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
This commit is contained in:
Grant Sanderson 2024-12-12 10:39:54 -06:00 committed by GitHub
parent 00b34f2020
commit 744e695340
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 209 additions and 353 deletions

View file

@ -74,7 +74,7 @@ Mathematical constant
PI = np.pi PI = np.pi
TAU = 2 * PI TAU = 2 * PI
DEGREES = TAU / 360 DEG = TAU / 360
Text Text
---- ----

View file

@ -34,7 +34,7 @@ InteractiveDevlopment
self.play(ReplacementTransform(square, circle)) self.play(ReplacementTransform(square, circle))
self.wait() self.wait()
self.play(circle.animate.stretch(4, 0)) 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)) self.play(circle.animate.shift(2 * RIGHT).scale(0.25))
text = Text(""" text = Text("""
@ -221,7 +221,7 @@ TexTransformExample
self.play( self.play(
TransformMatchingTex( TransformMatchingTex(
lines[0].copy(), lines[1], lines[0].copy(), lines[1],
path_arc=90 * DEGREES, path_arc=90 * DEG,
), ),
**play_kw **play_kw
) )
@ -599,8 +599,8 @@ SurfaceExample
# Set perspective # Set perspective
frame = self.camera.frame frame = self.camera.frame
frame.set_euler_angles( frame.set_euler_angles(
theta=-30 * DEGREES, theta=-30 * DEG,
phi=70 * DEGREES, phi=70 * DEG,
) )
surface = surfaces[0] surface = surfaces[0]
@ -624,8 +624,8 @@ SurfaceExample
self.play( self.play(
Transform(surface, surfaces[2]), Transform(surface, surfaces[2]),
# Move camera frame during the transition # Move camera frame during the transition
frame.animate.increment_phi(-10 * DEGREES), frame.animate.increment_phi(-10 * DEG),
frame.animate.increment_theta(-20 * DEGREES), frame.animate.increment_theta(-20 * DEG),
run_time=3 run_time=3
) )
# Add ambient rotation # Add ambient rotation

View file

@ -190,7 +190,7 @@ class TexTransformExample(Scene):
# to go to a non-equal substring from the target, # to go to a non-equal substring from the target,
# use the key map. # use the key map.
key_map={"+": "-"}, key_map={"+": "-"},
path_arc=90 * DEGREES, path_arc=90 * DEG,
), ),
) )
self.wait() self.wait()
@ -203,7 +203,7 @@ class TexTransformExample(Scene):
TransformMatchingStrings( TransformMatchingStrings(
lines[2].copy(), lines[3], lines[2].copy(), lines[3],
key_map={"2": R"\sqrt"}, key_map={"2": R"\sqrt"},
path_arc=-30 * DEGREES, path_arc=-30 * DEG,
), ),
) )
self.wait(2) self.wait(2)
@ -616,8 +616,8 @@ class SurfaceExample(ThreeDScene):
self.play( self.play(
Transform(surface, surfaces[2]), Transform(surface, surfaces[2]),
# Move camera frame during the transition # Move camera frame during the transition
self.frame.animate.increment_phi(-10 * DEGREES), self.frame.animate.increment_phi(-10 * DEG),
self.frame.animate.increment_theta(-20 * DEGREES), self.frame.animate.increment_theta(-20 * DEG),
run_time=3 run_time=3
) )
# Add ambient rotation # Add ambient rotation
@ -666,7 +666,7 @@ class InteractiveDevelopment(Scene):
self.play(ReplacementTransform(square, circle)) self.play(ReplacementTransform(square, circle))
self.wait() self.wait()
self.play(circle.animate.stretch(4, 0)) 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)) self.play(circle.animate.shift(2 * RIGHT).scale(0.25))
text = Text(""" text = Text("""

View file

@ -14,7 +14,7 @@ from manimlib.animation.transform import Transform
from manimlib.constants import FRAME_X_RADIUS, FRAME_Y_RADIUS from manimlib.constants import FRAME_X_RADIUS, FRAME_Y_RADIUS
from manimlib.constants import ORIGIN, RIGHT, UP from manimlib.constants import ORIGIN, RIGHT, UP
from manimlib.constants import SMALL_BUFF 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 TAU
from manimlib.constants import GREY, YELLOW from manimlib.constants import GREY, YELLOW
from manimlib.mobject.geometry import Circle from manimlib.mobject.geometry import Circle
@ -395,7 +395,7 @@ class WiggleOutThenIn(Animation):
class TurnInsideOut(Transform): 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) super().__init__(mobject, path_arc=path_arc, **kwargs)
def create_target(self) -> Mobject: def create_target(self) -> Mobject:

View file

@ -5,7 +5,7 @@ import inspect
import numpy as np import numpy as np
from manimlib.animation.animation import Animation from manimlib.animation.animation import Animation
from manimlib.constants import DEGREES from manimlib.constants import DEG
from manimlib.constants import OUT from manimlib.constants import OUT
from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Group
from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Mobject
@ -314,7 +314,7 @@ class ApplyComplexFunction(ApplyMethod):
class CyclicReplace(Transform): 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) super().__init__(Group(*mobjects), path_arc=path_arc, **kwargs)
def create_target(self) -> Mobject: def create_target(self) -> Mobject:

View file

@ -6,7 +6,7 @@ import warnings
import numpy as np import numpy as np
from scipy.spatial.transform import Rotation 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 FRAME_SHAPE
from manimlib.constants import DOWN, LEFT, ORIGIN, OUT, RIGHT, UP from manimlib.constants import DOWN, LEFT, ORIGIN, OUT, RIGHT, UP
from manimlib.constants import PI from manimlib.constants import PI
@ -26,7 +26,7 @@ class CameraFrame(Mobject):
frame_shape: tuple[float, float] = FRAME_SHAPE, frame_shape: tuple[float, float] = FRAME_SHAPE,
center_point: Vect3 = ORIGIN, center_point: Vect3 = ORIGIN,
# Field of view in the y direction # Field of view in the y direction
fovy: float = 45 * DEGREES, fovy: float = 45 * DEG,
euler_axes: str = "zxz", euler_axes: str = "zxz",
# This keeps it ordered first in a scene # This keeps it ordered first in a scene
z_index=-1, z_index=-1,
@ -181,7 +181,7 @@ class CameraFrame(Mobject):
Shortcut for set_euler_angles, defaulting to taking Shortcut for set_euler_angles, defaulting to taking
in angles in degrees 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: if center is not None:
self.move_to(np.array(center)) self.move_to(np.array(center))
if height is not None: if height is not None:
@ -209,7 +209,7 @@ class CameraFrame(Mobject):
self.increment_euler_angles(dgamma=dgamma, units=units) self.increment_euler_angles(dgamma=dgamma, units=units)
return self 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)) self.add_updater(lambda m, dt: m.increment_theta(angular_speed * dt))
return self return self

View file

@ -105,11 +105,6 @@ def parse_cli():
help="Scene will stay paused during wait calls until " + \ help="Scene will stay paused during wait calls until " + \
"space bar or right arrow is hit, like a slide show" "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( parser.add_argument(
"-i", "--gif", "-i", "--gif",
action="store_true", action="store_true",
@ -148,6 +143,12 @@ def parse_cli():
action="store_true", action="store_true",
help="Show the output file in finder", 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( parser.add_argument(
"--file_name", "--file_name",
help="Name for the movie or image file", 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 = config["file_writer"]
file_writer_config.update( file_writer_config.update(
write_to_movie=(not args.skip_animations and args.write_file), 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_last_frame=(args.skip_animations and args.write_file),
save_pngs=args.save_pngs,
png_mode=("RGBA" if args.transparent else "RGB"), png_mode=("RGBA" if args.transparent else "RGB"),
movie_file_extension=(get_file_ext(args)), movie_file_extension=(get_file_ext(args)),
output_directory=get_output_directory(args, config), output_directory=get_output_directory(args, config),

View file

@ -61,9 +61,10 @@ RIGHT_SIDE: Vect3 = FRAME_X_RADIUS * RIGHT
# Angles # Angles
PI: float = np.pi PI: float = np.pi
TAU: float = 2 * 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 # Nice to have a constant for readability
# when juxtaposed with expressions like 30 * DEGREES # when juxtaposed with expressions like 30 * DEG
RADIANS: float = 1 RADIANS: float = 1
# Related to Text # Related to Text

View file

@ -52,13 +52,6 @@ camera:
fps: 30 fps: 30
background_opacity: 1.0 background_opacity: 1.0
file_writer: 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 # What command to use for ffmpeg
ffmpeg_bin: "ffmpeg" ffmpeg_bin: "ffmpeg"
# Parameters to pass into ffmpeg # Parameters to pass into ffmpeg
@ -71,6 +64,9 @@ file_writer:
scene: scene:
show_animation_progress: False show_animation_progress: False
leave_progress_bars: 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 # How long does a scene pause on Scene.wait calls
default_wait_time: 1.0 default_wait_time: 1.0
vmobject: vmobject:
@ -104,6 +100,20 @@ sizes:
# Default buffers used in Mobject.next_to or Mobject.to_edge # Default buffers used in Mobject.next_to or Mobject.to_edge
default_mobject_to_edge_buff: 0.5 default_mobject_to_edge_buff: 0.5
default_mobject_to_mobject_buff: 0.25 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: colors:
blue_e: "#1C758A" blue_e: "#1C758A"
blue_d: "#29ABCA" blue_d: "#29ABCA"

View file

@ -15,6 +15,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
Module = importlib.util.types.ModuleType Module = importlib.util.types.ModuleType
from typing import Optional from typing import Optional
from addict import Dict
class BlankScene(InteractiveScene): class BlankScene(InteractiveScene):
@ -43,11 +44,7 @@ def prompt_user_for_choice(scene_classes):
print(f"{str(idx).zfill(max_digits)}: {name}") print(f"{str(idx).zfill(max_digits)}: {name}")
name_to_class[name] = scene_class name_to_class[name] = scene_class
try: try:
user_input = input( user_input = input("\nSelect which scene to render (by name or number): ")
"\nThat module has multiple scenes, " + \
"which ones would you like to render?" + \
"\nScene Name or Number: "
)
return [ return [
name_to_class[split_str] if not split_str.isnumeric() else scene_classes[int(split_str) - 1] 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(",") 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) 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 fw_config = manim_config.file_writer
if fw_config.write_to_movie and run_config.prerun: if fw_config.write_to_movie and run_config.prerun:
scene_config.file_writer_config.total_frames = compute_total_frames(scene_class, scene_config) scene_config.file_writer_config.total_frames = compute_total_frames(scene_class, scene_config)
return scene_class(**scene_config) return scene_class(**scene_config)
def get_scenes_to_render(all_scene_classes, scene_config, run_config): def note_missing_scenes(arg_names, module_names):
if run_config["write_all"]: for name in arg_names:
return [sc(**scene_config) for sc in all_scene_classes] 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): def get_scenes_to_render(all_scene_classes: list, scene_config: Dict, run_config: Dict):
log.error(f"No scene named {name} found") if run_config["write_all"] or len(all_scene_classes) == 1:
scene_names.remove(name) classes_to_run = all_scene_classes
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]]
else: 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) classes_to_run = prompt_user_for_choice(all_scene_classes)
return [ 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"): if hasattr(module, "SCENES_IN_ORDER"):
return module.SCENES_IN_ORDER return module.SCENES_IN_ORDER
else: else:
@ -162,24 +162,16 @@ def insert_embed_line_to_module(module: Module, line_number: int):
exec(code_object, module.__dict__) 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) module = ModuleLoader.get_module(file_name, is_reload)
if embed_line: if embed_line:
insert_embed_line_to_module(module, embed_line) insert_embed_line_to_module(module, embed_line)
return module return module
def main(scene_config, run_config): def main(scene_config: Dict, run_config: Dict):
module = get_scene_module( module = get_module(run_config.file_name, run_config.embed_line, run_config.is_reload)
run_config["file_name"], all_scene_classes = get_scene_classes(module)
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)
scenes = get_scenes_to_render(all_scene_classes, scene_config, run_config) scenes = get_scenes_to_render(all_scene_classes, scene_config, run_config)
if len(scenes) == 0: if len(scenes) == 0:
print("No scenes found to run") print("No scenes found to run")

View file

@ -7,7 +7,7 @@ import numpy as np
import itertools as it import itertools as it
from manimlib.constants import BLACK, BLUE, BLUE_D, BLUE_E, GREEN, GREY_A, WHITE, RED 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 DL, UL, DOWN, DR, LEFT, ORIGIN, OUT, RIGHT, UP
from manimlib.constants import FRAME_X_RADIUS, FRAME_Y_RADIUS from manimlib.constants import FRAME_X_RADIUS, FRAME_Y_RADIUS
from manimlib.constants import MED_SMALL_BUFF, SMALL_BUFF from manimlib.constants import MED_SMALL_BUFF, SMALL_BUFF
@ -307,7 +307,7 @@ class CoordinateSystem(ABC):
point = self.input_to_graph_point(x, graph) point = self.input_to_graph_point(x, graph)
angle = self.angle_of_tangent(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: if normal[1] < 0:
normal *= -1 normal *= -1
label.next_to(point, normal, buff=buff) label.next_to(point, normal, buff=buff)
@ -474,7 +474,7 @@ class Axes(VGroup, CoordinateSystem):
), ),
length=height, 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 # Add as a separate group in case various other
# mobjects are added to self, as for example in # mobjects are added to self, as for example in
# NumberPlane below # NumberPlane below

View file

@ -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 DL, DOWN, DR, LEFT, ORIGIN, OUT, RIGHT, UL, UP, UR
from manimlib.constants import GREY_A, RED, WHITE, BLACK from manimlib.constants import GREY_A, RED, WHITE, BLACK
from manimlib.constants import MED_SMALL_BUFF, SMALL_BUFF 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.mobject import Mobject
from manimlib.mobject.types.vectorized_mobject import DashedVMobject from manimlib.mobject.types.vectorized_mobject import DashedVMobject
from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VGroup
@ -983,7 +983,7 @@ class RegularPolygon(Polygon):
): ):
# Defaults to 0 for odd, 90 for even # Defaults to 0 for odd, 90 for even
if start_angle is None: 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) start_vect = rotate_vector(radius * RIGHT, start_angle)
vertices = compass_directions(n, start_vect) vertices = compass_directions(n, start_vect)
super().__init__(*vertices, **kwargs) super().__init__(*vertices, **kwargs)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from manimlib.constants import DOWN, LEFT, RIGHT, ORIGIN 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.numbers import DecimalNumber
from manimlib.mobject.svg.tex_mobject import Tex from manimlib.mobject.svg.tex_mobject import Tex
from manimlib.mobject.types.vectorized_mobject import VGroup from manimlib.mobject.types.vectorized_mobject import VGroup
@ -196,7 +196,7 @@ class Matrix(VMobject):
dots.set_width(hdots_width) dots.set_width(hdots_width)
self.swap_entry_for_dots(row[col_index], dots) self.swap_entry_for_dots(row[col_index], dots)
if use_vdots and use_hdots: if use_vdots and use_hdots:
rows[row_index][col_index].rotate(-45 * DEGREES) rows[row_index][col_index].rotate(-45 * DEG)
return self return self
def get_mob_matrix(self) -> VMobjectMatrixType: def get_mob_matrix(self) -> VMobjectMatrixType:

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import inspect import inspect
from manimlib.constants import DEGREES from manimlib.constants import DEG
from manimlib.constants import RIGHT from manimlib.constants import RIGHT
from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Mobject
from manimlib.utils.simple_functions import clip from manimlib.utils.simple_functions import clip
@ -71,7 +71,7 @@ def always_shift(
def always_rotate( def always_rotate(
mobject: Mobject, mobject: Mobject,
rate: float = 20 * DEGREES, rate: float = 20 * DEG,
**kwargs **kwargs
) -> Mobject: ) -> Mobject:
mobject.add_updater( mobject.add_updater(

View file

@ -20,6 +20,7 @@ from manimlib.mobject.svg.string_mobject import StringMobject
from manimlib.utils.cache import cache_on_disk from manimlib.utils.cache import cache_on_disk
from manimlib.utils.color import color_to_hex from manimlib.utils.color import color_to_hex
from manimlib.utils.color import int_to_hex from manimlib.utils.color import int_to_hex
from manimlib.utils.simple_functions import hash_string
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -74,34 +75,29 @@ def markup_to_svg(
pango_width = line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH pango_width = line_width / FRAME_WIDTH * DEFAULT_PIXEL_WIDTH
# Write the result to a temporary svg file, and return it's contents. # 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 temp_file = Path(tempfile.gettempdir(), hash_string(markup_str)).with_suffix(".svg")
# manimpango.MarkupUtils.text2svg(
# To avoid CAIRO_STATUS_WRITE_ERROR: b'error while writing to text=markup_str,
# output stream' on Windows, we need to pass 'delete=False'. font="", # Already handled
with tempfile.NamedTemporaryFile(suffix='.svg', mode='r+', delete=False) as tmp: slant="NORMAL", # Already handled
manimpango.MarkupUtils.text2svg( weight="NORMAL", # Already handled
text=markup_str, size=1, # Already handled
font="", # Already handled _=0, # Empty parameter
slant="NORMAL", # Already handled disable_liga=False,
weight="NORMAL", # Already handled file_name=str(temp_file),
size=1, # Already handled START_X=0,
_=0, # Empty parameter START_Y=0,
disable_liga=False, width=DEFAULT_CANVAS_WIDTH,
file_name=tmp.name, height=DEFAULT_CANVAS_HEIGHT,
START_X=0, justify=justify,
START_Y=0, indent=indent,
width=DEFAULT_CANVAS_WIDTH, line_spacing=None, # Already handled
height=DEFAULT_CANVAS_HEIGHT, alignment=alignment,
justify=justify, pango_width=pango_width
indent=indent, )
line_spacing=None, # Already handled result = temp_file.read_text()
alignment=alignment, os.remove(temp_file)
pango_width=pango_width return result
)
# Read the contents
tmp.seek(0)
return tmp.read()
class MarkupText(StringMobject): class MarkupText(StringMobject):

View file

@ -7,7 +7,7 @@ import numpy as np
from manimlib.constants import GREY_A, GREY_C, GREY_E from manimlib.constants import GREY_A, GREY_C, GREY_E
from manimlib.constants import BLACK from manimlib.constants import BLACK
from manimlib.constants import DEFAULT_STROKE_WIDTH 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 ORIGIN, OUT
from manimlib.constants import PI from manimlib.constants import PI
from manimlib.constants import TAU from manimlib.constants import TAU
@ -490,7 +490,7 @@ class VMobject(Mobject):
v1 = handle1 - last v1 = handle1 - last
v2 = anchor - handle2 v2 = anchor - handle2
angle = angle_between_vectors(v1, v2) 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] quad_approx = [last, find_intersection(last, v1, anchor, -v2), anchor]
else: else:
quad_approx = get_quadratic_approximation_of_cubic( quad_approx = get_quadratic_approximation_of_cubic(
@ -616,7 +616,7 @@ class VMobject(Mobject):
def subdivide_sharp_curves( def subdivide_sharp_curves(
self, self,
angle_threshold: float = 30 * DEGREES, angle_threshold: float = 30 * DEG,
recurse: bool = True recurse: bool = True
) -> Self: ) -> Self:
def tuple_to_subdivisions(b0, b1, b2): def tuple_to_subdivisions(b0, b1, b2):
@ -656,7 +656,7 @@ class VMobject(Mobject):
self.make_smooth(approx=approx) self.make_smooth(approx=approx)
return self 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]) angles = np.abs(self.get_joint_angles()[0::2])
return (angles < angle_tol).all() return (angles < angle_tol).all()

View file

@ -7,10 +7,11 @@ from IPython.core.getipython import get_ipython
from pyglet.window import key as PygletWindowKeys from pyglet.window import key as PygletWindowKeys
from manimlib.animation.fading import FadeIn 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 DL, DOWN, DR, LEFT, ORIGIN, RIGHT, UL, UP, UR
from manimlib.constants import FRAME_WIDTH, FRAME_HEIGHT, SMALL_BUFF from manimlib.constants import FRAME_WIDTH, FRAME_HEIGHT, SMALL_BUFF
from manimlib.constants import PI 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.constants import MANIM_COLORS, WHITE, GREY_A, GREY_C
from manimlib.mobject.geometry import Line from manimlib.mobject.geometry import Line
from manimlib.mobject.geometry import Rectangle from manimlib.mobject.geometry import Rectangle
@ -36,17 +37,16 @@ if TYPE_CHECKING:
from manimlib.typing import Vect3 from manimlib.typing import Vect3
SELECT_KEY = 's' SELECT_KEY = manim_config.key_bindings.select
UNSELECT_KEY = 'u' UNSELECT_KEY = manim_config.key_bindings.unselect
GRAB_KEY = 'g' GRAB_KEY = manim_config.key_bindings.grab
X_GRAB_KEY = 'h' X_GRAB_KEY = manim_config.key_bindings.x_grab
Y_GRAB_KEY = 'v' Y_GRAB_KEY = manim_config.key_bindings.y_grab
GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY] GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY]
RESIZE_KEY = 't' RESIZE_KEY = manim_config.key_bindings.resize # TODO
COLOR_KEY = 'c' COLOR_KEY = manim_config.key_bindings.color
INFORMATION_KEY = 'i' INFORMATION_KEY = manim_config.key_bindings.information
CURSOR_KEY = 'k' CURSOR_KEY = manim_config.key_bindings.cursor
COPY_FRAME_POSITION_KEY = 'p'
# For keyboard interactions # For keyboard interactions
@ -625,7 +625,7 @@ class InteractiveScene(Scene):
angles = frame.get_euler_angles() angles = frame.get_euler_angles()
call = f"reorient(" call = f"reorient("
theta, phi, gamma = (angles / DEGREES).astype(int) theta, phi, gamma = (angles / DEG).astype(int)
call += f"{theta}, {phi}, {gamma}" call += f"{theta}, {phi}, {gamma}"
if any(center != 0): if any(center != 0):
call += f", {tuple(np.round(center, 2))}" call += f", {tuple(np.round(center, 2))}"

View file

@ -48,12 +48,6 @@ if TYPE_CHECKING:
from manimlib.animation.animation import Animation from manimlib.animation.animation import Animation
PAN_3D_KEY = 'd'
FRAME_SHIFT_KEY = 'f'
RESET_FRAME_KEY = 'r'
QUIT_KEY = 'q'
class Scene(object): class Scene(object):
random_seed: int = 0 random_seed: int = 0
pan_sensitivity: float = 0.5 pan_sensitivity: float = 0.5
@ -77,6 +71,7 @@ class Scene(object):
end_at_animation_number: int | None = None, end_at_animation_number: int | None = None,
show_animation_progress: bool = False, show_animation_progress: bool = False,
leave_progress_bars: bool = False, leave_progress_bars: bool = False,
preview_while_skipping: bool = True,
presenter_mode: bool = False, presenter_mode: bool = False,
default_wait_time: float = 1.0, default_wait_time: float = 1.0,
): ):
@ -86,6 +81,7 @@ class Scene(object):
self.end_at_animation_number = end_at_animation_number self.end_at_animation_number = end_at_animation_number
self.show_animation_progress = show_animation_progress self.show_animation_progress = show_animation_progress
self.leave_progress_bars = leave_progress_bars self.leave_progress_bars = leave_progress_bars
self.preview_while_skipping = preview_while_skipping
self.presenter_mode = presenter_mode self.presenter_mode = presenter_mode
self.default_wait_time = default_wait_time self.default_wait_time = default_wait_time
@ -531,7 +527,7 @@ class Scene(object):
if not self.skip_animations: if not self.skip_animations:
self.file_writer.end_animation() 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 # Show some quick frames along the way
self.update_frame(dt=0, force_draw=True) self.update_frame(dt=0, force_draw=True)
@ -746,13 +742,13 @@ class Scene(object):
frame = self.camera.frame frame = self.camera.frame
# Handle perspective changes # 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 = frame.to_fixed_frame_point(d_point, relative=True)
ff_d_point *= self.pan_sensitivity ff_d_point *= self.pan_sensitivity
frame.increment_theta(-ff_d_point[0]) frame.increment_theta(-ff_d_point[0])
frame.increment_phi(ff_d_point[1]) frame.increment_phi(ff_d_point[1])
# Handle frame movements # 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) frame.shift(-d_point)
def on_mouse_drag( def on_mouse_drag(
@ -838,14 +834,14 @@ class Scene(object):
if propagate_event is not None and propagate_event is False: if propagate_event is not None and propagate_event is False:
return return
if char == RESET_FRAME_KEY: if char == manim_config.key_bindings.reset:
self.play(self.camera.frame.animate.to_default_state()) self.play(self.camera.frame.animate.to_default_state())
elif char == "z" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)): elif char == "z" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL)):
self.undo() self.undo()
elif char == "z" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL | PygletWindowKeys.MOD_SHIFT)): elif char == "z" and (modifiers & (PygletWindowKeys.MOD_COMMAND | PygletWindowKeys.MOD_CTRL | PygletWindowKeys.MOD_SHIFT)):
self.redo() self.redo()
# command + q # 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 self.quit_interaction = True
# Space or right arrow # Space or right arrow
elif char == " " or symbol == PygletWindowKeys.RIGHT: elif char == " " or symbol == PygletWindowKeys.RIGHT:

View file

@ -102,7 +102,7 @@ class InteractiveSceneEmbed:
"""Flash border, and potentially play sound, on exceptions""" """Flash border, and potentially play sound, on exceptions"""
def custom_exc(shell, etype, evalue, tb, tb_offset=None): def custom_exc(shell, etype, evalue, tb, tb_offset=None):
# Show the error don't just swallow it # 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 = FullScreenRectangle().set_stroke(RED, 30).set_fill(opacity=0)
rect.fix_in_frame() rect.fix_in_frame()
self.scene.play(VFadeInThenOut(rect, run_time=0.5)) self.scene.play(VFadeInThenOut(rect, run_time=0.5))

View file

@ -13,8 +13,6 @@ from pathlib import Path
from manimlib.logger import log from manimlib.logger import log
from manimlib.mobject.mobject import Mobject 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.file_ops import guarantee_existence
from manimlib.utils.sounds import get_full_sound_file_path from manimlib.utils.sounds import get_full_sound_file_path
@ -32,17 +30,15 @@ class SceneFileWriter(object):
self, self,
scene: Scene, scene: Scene,
write_to_movie: bool = False, write_to_movie: bool = False,
break_into_partial_movies: bool = False, subdivide_output: bool = False,
save_pngs: bool = False, # TODO, this currently does nothing
png_mode: str = "RGBA", png_mode: str = "RGBA",
save_last_frame: bool = False, save_last_frame: bool = False,
movie_file_extension: str = ".mp4", movie_file_extension: str = ".mp4",
# What python file is generating this scene # What python file is generating this scene
input_file_path: str = "", input_file_path: str = "",
# Where should this be written # Where should this be written
output_directory: str | None = None, output_directory: str = "",
file_name: str | None = None, file_name: str | None = None,
subdirectory_for_videos: bool = False,
open_file_upon_completion: bool = False, open_file_upon_completion: bool = False,
show_file_location_upon_completion: bool = False, show_file_location_upon_completion: bool = False,
quiet: bool = False, quiet: bool = False,
@ -57,8 +53,7 @@ class SceneFileWriter(object):
): ):
self.scene: Scene = scene self.scene: Scene = scene
self.write_to_movie = write_to_movie self.write_to_movie = write_to_movie
self.break_into_partial_movies = break_into_partial_movies self.subdivide_output = subdivide_output
self.save_pngs = save_pngs
self.png_mode = png_mode self.png_mode = png_mode
self.save_last_frame = save_last_frame self.save_last_frame = save_last_frame
self.movie_file_extension = movie_file_extension self.movie_file_extension = movie_file_extension
@ -66,7 +61,6 @@ class SceneFileWriter(object):
self.output_directory = output_directory self.output_directory = output_directory
self.file_name = file_name self.file_name = file_name
self.open_file_upon_completion = open_file_upon_completion 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.show_file_location_upon_completion = show_file_location_upon_completion
self.quiet = quiet self.quiet = quiet
self.total_frames = total_frames self.total_frames = total_frames
@ -81,40 +75,39 @@ class SceneFileWriter(object):
self.writing_process: sp.Popen | None = None self.writing_process: sp.Popen | None = None
self.progress_display: ProgressDisplay | None = None self.progress_display: ProgressDisplay | None = None
self.ended_with_interrupt: bool = False self.ended_with_interrupt: bool = False
self.init_output_directories() self.init_output_directories()
self.init_audio() self.init_audio()
# Output directories and files # Output directories and files
def init_output_directories(self) -> None: 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: if self.save_last_frame:
image_dir = guarantee_existence(os.path.join(out_dir, "images")) self.image_file_path = self.init_image_file_path()
image_file = add_extension_if_not_present(scene_name, ".png")
self.image_file_path = os.path.join(image_dir, image_file)
if self.write_to_movie: if self.write_to_movie:
if self.subdirectory_for_videos: self.movie_file_path = self.init_movie_file_path()
movie_dir = guarantee_existence(os.path.join(out_dir, "videos")) if self.subdivide_output:
else: self.partial_movie_directory = self.init_partial_movie_directory()
movie_dir = guarantee_existence(out_dir)
movie_file = add_extension_if_not_present(scene_name, self.movie_file_extension) def init_image_file_path(self) -> Path:
self.movie_file_path = os.path.join(movie_dir, movie_file) return self.get_output_file_rootname().with_suffix(".png")
if self.break_into_partial_movies:
self.partial_movie_directory = guarantee_existence(os.path.join( def init_movie_file_path(self) -> Path:
movie_dir, "partial_movie_files", scene_name, return self.get_output_file_rootname().with_suffix(self.movie_file_extension)
))
# A place to save mobjects def init_partial_movie_directory(self):
self.saved_mobject_directory = os.path.join( return guarantee_existence(self.get_output_file_rootname())
out_dir, "mobjects", str(self.scene)
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: def get_output_file_name(self) -> str:
path, _ = os.path.splitext(self.input_file_path) if self.file_name:
if path.startswith("_"): return self.file_name
path = path[1:] # Otherwise, use the name of the scene, potentially
return path # appending animation numbers
def get_default_scene_name(self) -> str:
name = str(self.scene) name = str(self.scene)
saan = self.scene.start_at_animation_number saan = self.scene.start_at_animation_number
eaan = self.scene.end_at_animation_number eaan = self.scene.end_at_animation_number
@ -124,26 +117,13 @@ class SceneFileWriter(object):
name += f"_{eaan}" name += f"_{eaan}"
return name 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 # Directory getters
def get_image_file_path(self) -> str: def get_image_file_path(self) -> str:
return self.image_file_path return self.image_file_path
def get_next_partial_movie_path(self) -> str: def get_next_partial_movie_path(self) -> str:
result = os.path.join( result = Path(self.partial_movie_directory, f"{self.scene.num_plays:05}")
self.partial_movie_directory, return result.with_suffix(self.movie_file_extension)
"{:05}{}".format(
self.scene.num_plays,
self.movie_file_extension,
)
)
return result
def get_movie_file_path(self) -> str: def get_movie_file_path(self) -> str:
return self.movie_file_path return self.movie_file_path
@ -199,23 +179,20 @@ class SceneFileWriter(object):
# Writers # Writers
def begin(self) -> None: 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()) self.open_movie_pipe(self.get_movie_file_path())
def begin_animation(self) -> None: 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()) self.open_movie_pipe(self.get_next_partial_movie_path())
def end_animation(self) -> None: 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() self.close_movie_pipe()
def finish(self) -> None: def finish(self) -> None:
if self.write_to_movie: if not self.subdivide_output and self.write_to_movie:
if self.break_into_partial_movies: self.close_movie_pipe()
self.combine_movie_files()
else:
self.close_movie_pipe()
if self.includes_sound: if self.includes_sound:
self.add_sound_to_video() self.add_sound_to_video()
self.print_file_ready_message(self.get_movie_file_path()) 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() width, height = self.scene.camera.get_pixel_shape()
vf_arg = 'vflip' vf_arg = 'vflip'
# if self.pixel_format.startswith("yuv"):
vf_arg += f',eq=saturation={self.saturation}:gamma={self.gamma}' vf_arg += f',eq=saturation={self.saturation}:gamma={self.gamma}'
command = [ command = [
@ -246,7 +222,7 @@ class SceneFileWriter(object):
'-r', str(fps), # frames per second '-r', str(fps), # frames per second
'-i', '-', # The input comes from a pipe '-i', '-', # The input comes from a pipe
'-vf', vf_arg, '-vf', vf_arg,
'-an', # Tells FFMPEG not to expect any audio '-an', # Tells ffmpeg not to expect any audio
'-loglevel', 'error', '-loglevel', 'error',
] ]
if self.video_codec: if self.video_codec:
@ -273,8 +249,8 @@ class SceneFileWriter(object):
movie_path = Path(self.get_movie_file_path()) movie_path = Path(self.get_movie_file_path())
scene_name = movie_path.stem scene_name = movie_path.stem
insert_dir = Path(movie_path.parent, "inserts") insert_dir = Path(movie_path.parent, "inserts")
guarantee_existence(str(insert_dir)) guarantee_existence(insert_dir)
return Path(insert_dir, f"{scene_name}_{index}{movie_path.suffix}") return Path(insert_dir, f"{scene_name}_{index}").with_suffix(self.movie_file_extension)
def begin_insert(self): def begin_insert(self):
# Begin writing process # Begin writing process
@ -283,7 +259,7 @@ class SceneFileWriter(object):
index = 0 index = 0
while (insert_path := self.get_insert_file_path(index)).exists(): while (insert_path := self.get_insert_file_path(index)).exists():
index += 1 index += 1
self.inserted_file_path = str(insert_path) self.inserted_file_path = insert_path
self.open_movie_pipe(self.inserted_file_path) self.open_movie_pipe(self.inserted_file_path)
def end_insert(self): def end_insert(self):
@ -327,54 +303,6 @@ class SceneFileWriter(object):
else: else:
self.movie_file_path = self.temp_file_path 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: def add_sound_to_video(self) -> None:
movie_file_path = self.get_movie_file_path() movie_file_path = self.get_movie_file_path()
stem, ext = os.path.splitext(movie_file_path) stem, ext = os.path.splitext(movie_file_path)

View file

@ -20,29 +20,3 @@ def merge_dicts_recursively(*dicts):
else: else:
result[key] = value result[key] = value
return result 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

View file

@ -17,25 +17,17 @@ if TYPE_CHECKING:
from typing import Iterable from typing import Iterable
def add_extension_if_not_present(file_name: str, extension: str) -> str: def guarantee_existence(path: str | Path) -> Path:
# This could conceivably be smarter about handling existing differing extensions path = Path(path)
if(file_name[-len(extension):] != extension): path.mkdir(parents=True, exist_ok=True)
return file_name + extension return path.absolute()
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 find_file( def find_file(
file_name: str, file_name: str,
directories: Iterable[str] | None = None, directories: Iterable[str] | None = None,
extensions: Iterable[str] | None = None extensions: Iterable[str] | None = None
) -> str: ) -> Path:
# Check if this is a file online first, and if so, download # Check if this is a file online first, and if so, download
# it to a temporary directory # it to a temporary directory
if validators.url(file_name): 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 # Check if what was passed in is already a valid path to a file
if os.path.exists(file_name): if os.path.exists(file_name):
return file_name return Path(file_name)
# Otherwise look in local file system # Otherwise look in local file system
directories = directories or [""] directories = directories or [""]
extensions = extensions or [""] extensions = extensions or [""]
possible_paths = ( possible_paths = (
os.path.join(directory, file_name + extension) Path(directory, file_name + extension)
for directory in directories for directory in directories
for extension in extensions for extension in extensions
) )
for path in possible_paths: for path in possible_paths:
if os.path.exists(path): if path.exists():
return path return path
raise IOError(f"{file_name} not Found") 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))

View file

@ -36,12 +36,6 @@ def get_num_args(function: Callable) -> int:
def get_parameters(function: Callable) -> Iterable[str]: def get_parameters(function: Callable) -> Iterable[str]:
return inspect.signature(function).parameters.keys() 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: def clip(a: float, min_a: float, max_a: float) -> float:
if a < min_a: 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: 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: if zero_over_zero_value is not None:
out = np.full_like(a, zero_over_zero_value) out = np.full_like(a, zero_over_zero_value)
where = np.logical_or(a != 0, b != 0) 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) return np.true_divide(a, b, out=out, where=where)
def binary_search(function: Callable[[float], float], def binary_search(
target: float, function: Callable[[float], float],
lower_bound: float, target: float,
upper_bound: float, lower_bound: float,
tolerance:float = 1e-4) -> float | None: upper_bound: float,
tolerance:float = 1e-4
) -> float | None:
lh = lower_bound lh = lower_bound
rh = upper_bound rh = upper_bound
mh = (lh + rh) / 2 mh = (lh + rh) / 2

View file

@ -1,40 +1,38 @@
from __future__ import annotations from __future__ import annotations
import re import re
from functools import lru_cache
from manimlib.utils.tex_to_symbol_count import TEX_TO_SYMBOL_COUNT from manimlib.utils.tex_to_symbol_count import TEX_TO_SYMBOL_COUNT
@lru_cache
def num_tex_symbols(tex: str) -> int: def num_tex_symbols(tex: str) -> int:
tex = remove_tex_environments(tex)
commands_pattern = r"""
(?P<sqrt>\\sqrt\[[0-9]+\])| # Special sqrt with number
(?P<cmd>\\[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 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] if match.group("sqrt"):
for substr in re.findall(r"\\sqrt\[[0-9]+\]", tex): total += len(match.group()) - 5
total += len(substr) - 5 # e.g. \sqrt[3] is 3 symbols else:
tex = tex.replace(substr, " ") total += TEX_TO_SYMBOL_COUNT.get(match.group(), 1)
pos = match.end()
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, " ")
# Count remaining characters # 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 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