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
TAU = 2 * PI
DEGREES = TAU / 360
DEG = TAU / 360
Text
----

View file

@ -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

View file

@ -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("""

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 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:

View file

@ -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:

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -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"

View file

@ -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")

View file

@ -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

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 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)

View file

@ -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:

View file

@ -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(

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.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):

View file

@ -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()

View file

@ -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))}"

View file

@ -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:

View file

@ -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))

View file

@ -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)

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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>\\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
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