Resolve conflicts

This commit is contained in:
YishiMichael 2022-05-28 12:43:53 +08:00
commit 59eba943e5
No known key found for this signature in database
GPG key ID: EC615C0C5A86BC80
24 changed files with 529 additions and 362 deletions

View file

@ -18,7 +18,7 @@ Frame and pixel shape
DEFAULT_PIXEL_HEIGHT = 1080
DEFAULT_PIXEL_WIDTH = 1920
DEFAULT_FRAME_RATE = 30
DEFAULT_FPS = 30
Buffs
-----

View file

@ -56,7 +56,7 @@ flag abbr function
``--start_at_animation_number START_AT_ANIMATION_NUMBER`` ``-n`` Start rendering not from the first animation, but from another, specified by its index. If you passing two comma separated values, e.g. "3,6", it will end the rendering at the second value.
``--embed LINENO`` ``-e`` Takes a line number as an argument, and results in the scene being called as if the line ``self.embed()`` was inserted into the scene code at that line number
``--resolution RESOLUTION`` ``-r`` Resolution, passed as "WxH", e.g. "1920x1080"
``--frame_rate FRAME_RATE`` Frame rate, as an integer
``--fps FPS`` Frame rate, as an integer
``--color COLOR`` ``-c`` Background color
``--leave_progress_bars`` Leave progress bars displayed in terminal
``--video_dir VIDEO_DIR`` Directory to write video

View file

@ -77,10 +77,6 @@ class AnimatingMethods(Scene):
# ".animate" syntax:
self.play(grid.animate.shift(LEFT))
# Alternatively, you can use the older syntax by passing the
# method and then the arguments to the scene's "play" function:
self.play(grid.shift, LEFT)
# Both of those will interpolate between the mobject's initial
# state and whatever happens when you apply that method.
# For this example, calling grid.shift(LEFT) would shift the

View file

@ -6,6 +6,7 @@ from manimlib.mobject.mobject import _AnimationBuilder
from manimlib.mobject.mobject import Mobject
from manimlib.utils.config_ops import digest_config
from manimlib.utils.rate_functions import smooth
from manimlib.utils.rate_functions import squish_rate_func
from manimlib.utils.simple_functions import clip
from typing import TYPE_CHECKING
@ -23,6 +24,7 @@ DEFAULT_ANIMATION_LAG_RATIO = 0
class Animation(object):
CONFIG = {
"run_time": DEFAULT_ANIMATION_RUN_TIME,
"time_span": None, # Tuple of times, between which the animation will run
"rate_func": smooth,
"name": None,
# Does this animation add or remove a mobject form the screen
@ -53,6 +55,12 @@ class Animation(object):
# played. As much initialization as possible,
# especially any mobject copying, should live in
# this method
if self.time_span is not None:
start, end = self.time_span
self.run_time = max(end, self.run_time)
self.rate_func = squish_rate_func(
self.rate_func, start / self.run_time, end / self.run_time,
)
self.mobject.set_animating_status(True)
self.starting_mobject = self.create_starting_mobject()
if self.suspend_mobject_updating:
@ -166,6 +174,8 @@ class Animation(object):
return self
def get_run_time(self) -> float:
if self.time_span:
return max(self.run_time, self.time_span[1])
return self.run_time
def set_rate_func(self, rate_func: Callable[[float], float]):

View file

@ -68,10 +68,11 @@ class Transform(Animation):
self.target_copy = self.target_mobject.copy()
self.mobject.align_data_and_family(self.target_copy)
super().begin()
self.mobject.lock_matching_data(
self.starting_mobject,
self.target_copy,
)
if not self.mobject.has_updaters:
self.mobject.lock_matching_data(
self.starting_mobject,
self.target_copy,
)
def finish(self) -> None:
super().finish()

View file

@ -11,7 +11,7 @@ from scipy.spatial.transform import Rotation
from manimlib.constants import BLACK
from manimlib.constants import DEGREES, RADIANS
from manimlib.constants import DEFAULT_FRAME_RATE
from manimlib.constants import DEFAULT_FPS
from manimlib.constants import DEFAULT_PIXEL_HEIGHT, DEFAULT_PIXEL_WIDTH
from manimlib.constants import FRAME_HEIGHT, FRAME_WIDTH
from manimlib.constants import DOWN, LEFT, ORIGIN, OUT, RIGHT, UP
@ -170,7 +170,7 @@ class Camera(object):
"frame_config": {},
"pixel_width": DEFAULT_PIXEL_WIDTH,
"pixel_height": DEFAULT_PIXEL_HEIGHT,
"frame_rate": DEFAULT_FRAME_RATE,
"fps": DEFAULT_FPS,
# Note: frame height and width will be resized to match
# the pixel aspect ratio
"background_color": BLACK,

View file

@ -136,7 +136,7 @@ def parse_cli():
help="Resolution, passed as \"WxH\", e.g. \"1920x1080\"",
)
parser.add_argument(
"--frame_rate",
"--fps",
help="Frame rate, as an integer",
)
parser.add_argument(
@ -148,6 +148,11 @@ def parse_cli():
action="store_true",
help="Leave progress bars displayed in terminal",
)
parser.add_argument(
"--show_animation_progress",
action="store_true",
help="Show progress bar for each animation",
)
parser.add_argument(
"--video_dir",
help="Directory to write video",
@ -225,6 +230,8 @@ def insert_embed_line(file_name: str, scene_name: str, line_marker: str):
if len(line.strip()) > 0 and get_indent(line) < n_spaces:
prev_line_num = index - 2
break
if prev_line_num is None:
prev_line_num = len(lines) - 2
elif line_marker.isdigit():
# Treat the argument as a line number
prev_line_num = int(line_marker) - 1
@ -233,7 +240,7 @@ def insert_embed_line(file_name: str, scene_name: str, line_marker: str):
try:
prev_line_num = next(
i
for i in range(len(lines) - 1, scene_line_number, -1)
for i in range(scene_line_number, len(lines) - 1)
if line_marker in lines[i]
)
except StopIteration:
@ -330,6 +337,16 @@ def get_configuration(args):
else:
file_ext = ".mp4"
dir_config = custom_config["directories"]
output_directory = args.video_dir or dir_config["output"]
if dir_config["mirror_module_path"] and args.file:
to_cut = dir_config["removed_mirror_prefix"]
ext = os.path.abspath(args.file)
ext = ext.replace(to_cut, "").replace(".py", "")
if ext.startswith("_"):
ext = ext[1:]
output_directory = os.path.join(output_directory, ext)
file_writer_config = {
"write_to_movie": not args.skip_animations and write_file,
"break_into_partial_movies": custom_config["break_into_partial_movies"],
@ -338,8 +355,7 @@ def get_configuration(args):
# If -t is passed in (for transparent), this will be RGBA
"png_mode": "RGBA" if args.transparent else "RGB",
"movie_file_extension": file_ext,
"mirror_module_path": custom_config["directories"]["mirror_module_path"],
"output_directory": args.video_dir or custom_config["directories"]["output"],
"output_directory": output_directory,
"file_name": args.file_name,
"input_file_path": args.file or "",
"open_file_upon_completion": args.open,
@ -365,6 +381,7 @@ def get_configuration(args):
"preview": not write_file,
"presenter_mode": args.presenter_mode,
"leave_progress_bars": args.leave_progress_bars,
"show_animation_progress": args.show_animation_progress,
}
# Camera configuration
@ -398,31 +415,31 @@ def get_configuration(args):
def get_camera_configuration(args, custom_config):
camera_config = {}
camera_qualities = get_custom_config()["camera_qualities"]
camera_resolutions = get_custom_config()["camera_resolutions"]
if args.low_quality:
quality = camera_qualities["low"]
resolution = camera_resolutions["low"]
elif args.medium_quality:
quality = camera_qualities["medium"]
resolution = camera_resolutions["med"]
elif args.hd:
quality = camera_qualities["high"]
resolution = camera_resolutions["high"]
elif args.uhd:
quality = camera_qualities["ultra_high"]
resolution = camera_resolutions["4k"]
else:
quality = camera_qualities[camera_qualities["default_quality"]]
resolution = camera_resolutions[camera_resolutions["default_resolution"]]
if args.resolution:
quality["resolution"] = args.resolution
if args.frame_rate:
quality["frame_rate"] = int(args.frame_rate)
if args.fps:
fps = int(args.fps)
else:
fps = get_custom_config()["fps"]
width_str, height_str = quality["resolution"].split("x")
width_str, height_str = resolution.split("x")
width = int(width_str)
height = int(height_str)
camera_config.update({
"pixel_width": width,
"pixel_height": height,
"frame_rate": quality["frame_rate"],
"fps": fps,
})
try:

View file

@ -10,7 +10,7 @@ FRAME_X_RADIUS = FRAME_WIDTH / 2
DEFAULT_PIXEL_HEIGHT = 1080
DEFAULT_PIXEL_WIDTH = 1920
DEFAULT_FRAME_RATE = 30
DEFAULT_FPS = 30
SMALL_BUFF = 0.1
MED_SMALL_BUFF = 0.25

View file

@ -20,6 +20,7 @@ universal_import_line: "from manimlib import *"
style:
tex_font: "default"
font: "Consolas"
text_alignment: "LEFT"
background_color: "#333333"
# Set the position of preview window, you can use directions, e.g. UL/DR/OL/OO/...
# also, you can also specify the position(pixel) of the upper left corner of
@ -34,17 +35,10 @@ full_screen: False
# easier when working with the broken up scene, which
# effectively has cuts at all the places you might want.
break_into_partial_movies: False
camera_qualities:
low:
resolution: "854x480"
frame_rate: 15
medium:
resolution: "1280x720"
frame_rate: 30
high:
resolution: "1920x1080"
frame_rate: 30
ultra_high:
resolution: "3840x2160"
frame_rate: 60
default_quality: "high"
camera_resolutions:
low: "854x480"
med: "1280x720"
high: "1920x1080"
4k: "3840x2160"
default_resolution: "high"
fps: 30

View file

@ -64,6 +64,7 @@ def get_scene_config(config):
"start_at_animation_number",
"end_at_animation_number",
"leave_progress_bars",
"show_animation_progress",
"preview",
"presenter_mode",
]
@ -87,7 +88,7 @@ def compute_total_frames(scene_class, scene_config):
pre_scene = scene_class(**pre_config)
pre_scene.run()
total_time = pre_scene.time - pre_scene.skip_time
return int(total_time * scene_config["camera_config"]["frame_rate"])
return int(total_time * scene_config["camera_config"]["fps"])
def get_scenes_to_render(scene_classes, scene_config, config):

View file

@ -75,6 +75,7 @@ class ParametricCurve(VMobject):
if hasattr(self, "x_range"):
return self.x_range
class FunctionGraph(ParametricCurve):
CONFIG = {
"color": YELLOW,

View file

@ -821,7 +821,7 @@ class FillArrow(Line):
def reset_points_around_ends(self):
self.set_points_by_ends(
self.get_start(), self.get_end(), path_arc=self.path_arc
self.get_start().copy(), self.get_end().copy(), path_arc=self.path_arc
)
return self

View file

@ -286,7 +286,7 @@ class Mobject(object):
def are_points_touching(
self,
points: np.ndarray,
buff: float = MED_SMALL_BUFF
buff: float = 0
) -> bool:
bb = self.get_bounding_box()
mins = (bb[0] - buff)
@ -296,7 +296,7 @@ class Mobject(object):
def is_point_touching(
self,
point: np.ndarray,
buff: float = MED_SMALL_BUFF
buff: float = 0
) -> bool:
return self.are_points_touching(np.array(point, ndmin=2), buff)[0]
@ -359,8 +359,9 @@ class Mobject(object):
if p not in excluded:
ancestors.append(p)
to_process.append(p)
# Remove redundancies while preserving order
# Ensure mobjects highest in the hierarchy show up first
ancestors.reverse()
# Remove list redundancies while preserving order
return list(dict.fromkeys(ancestors))
def add(self, *mobjects: Mobject):
@ -475,6 +476,31 @@ class Mobject(object):
self.center()
return self
def arrange_to_fit_dim(self, length: float, dim: int, about_edge=ORIGIN):
ref_point = self.get_bounding_box_point(about_edge)
n_submobs = len(self.submobjects)
if n_submobs <= 1:
return
total_length = sum(sm.length_over_dim(dim) for sm in self.submobjects)
buff = (length - total_length) / (n_submobs - 1)
vect = np.zeros(self.dim)
vect[dim] = 1
x = 0
for submob in self.submobjects:
submob.set_coord(x, dim, -vect)
x += submob.length_over_dim(dim) + buff
self.move_to(ref_point, about_edge)
return self
def arrange_to_fit_width(self, width: float, about_edge=ORIGIN):
return self.arrange_to_fit_dim(width, 0, about_edge)
def arrange_to_fit_height(self, height: float, about_edge=ORIGIN):
return self.arrange_to_fit_dim(height, 1, about_edge)
def arrange_to_fit_depth(self, depth: float, about_edge=ORIGIN):
return self.arrange_to_fit_dim(depth, 2, about_edge)
def sort(
self,
point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0],
@ -616,11 +642,12 @@ class Mobject(object):
sm1.depth_test = sm2.depth_test
sm1.render_primitive = sm2.render_primitive
self.refresh_bounding_box(recurse_down=True)
self.match_updaters(mobject)
return self
def looks_identical(self, mobject: Mobject):
fam1 = self.get_family()
fam2 = mobject.get_family()
fam1 = self.family_members_with_points()
fam2 = mobject.family_members_with_points()
if len(fam1) != len(fam2):
return False
for m1, m2 in zip(fam1, fam2):
@ -628,11 +655,13 @@ class Mobject(object):
if set(d1).difference(d2):
return False
for key in d1:
if isinstance(d1[key], np.ndarray):
if not np.all(d1[key] == d2[key]):
eq = (d1[key] == d2[key])
if isinstance(eq, bool):
if not eq:
return False
else:
if not eq.all():
return False
elif d1[key] != d2[key]:
return False
return True
# Creating new Mobjects from this one

View file

@ -28,27 +28,31 @@ class DecimalNumber(VMobject):
"include_background_rectangle": False,
"edge_to_fix": LEFT,
"font_size": 48,
"text_config": {} # Do not pass in font_size here
"text_config": {} # Do not pass in font_size here
}
def __init__(self, number: float | complex = 0, **kwargs):
super().__init__(**kwargs)
self.set_submobjects_from_number(number)
self.init_colors()
def set_submobjects_from_number(self, number: float | complex) -> None:
self.number = number
self.set_submobjects([])
string_to_mob_ = lambda s: self.string_to_mob(s, **self.text_config)
num_string = self.get_num_string(number)
self.add(*map(string_to_mob_, num_string))
self.text_config["font_size"] = self.get_font_size()
num_string = self.num_string = self.get_num_string(number)
self.add(*(
Text(ns, **self.text_config)
for ns in num_string
))
# Add non-numerical bits
if self.show_ellipsis:
dots = string_to_mob_("...")
dots = Text("...", **self.text_config)
dots.arrange(RIGHT, buff=2 * dots[0].get_width())
self.add(dots)
if self.unit is not None:
self.unit_sign = self.string_to_mob(self.unit, SingleStringTex)
self.unit_sign = SingleStringTex(self.unit, font_size=self.get_font_size())
self.add(self.unit_sign)
self.arrange(
@ -91,12 +95,7 @@ class DecimalNumber(VMobject):
self.data["font_size"] = np.array([self.font_size], dtype=float)
def get_font_size(self) -> float:
return self.data["font_size"][0]
def string_to_mob(self, string: str, mob_class: Type[T] = Text, **kwargs) -> T:
mob = mob_class(string, font_size=1, **kwargs)
mob.scale(self.get_font_size())
return mob
return int(self.data["font_size"][0])
def get_formatter(self, **kwargs) -> str:
"""
@ -117,13 +116,14 @@ class DecimalNumber(VMobject):
]
])
config.update(kwargs)
ndp = config["num_decimal_places"]
return "".join([
"{",
config.get("field_name", ""),
":",
"+" if config["include_sign"] else "",
"," if config["group_with_commas"] else "",
".", str(config["num_decimal_places"]), "f",
f".{ndp}f",
"}",
])
@ -134,13 +134,15 @@ class DecimalNumber(VMobject):
"i"
])
def get_tex(self):
return self.num_string
def set_value(self, number: float | complex):
move_to_point = self.get_edge_center(self.edge_to_fix)
old_submobjects = list(self.submobjects)
style = self.family_members_with_points()[0].get_style()
self.set_submobjects_from_number(number)
self.move_to(move_to_point, self.edge_to_fix)
for sm1, sm2 in zip(self.submobjects, old_submobjects):
sm1.match_style(sm2)
self.set_style(**style)
return self
def _handle_scale_side_effects(self, scale_factor: float) -> None:

View file

@ -87,7 +87,7 @@ class Cross(VGroup):
Line(UL, DR),
Line(UR, DL),
)
self.insert_n_curves(2)
self.insert_n_curves(20)
self.replace(mobject, stretch=True)
self.set_stroke(self.stroke_color, width=self.stroke_width)

View file

@ -308,7 +308,7 @@ class Bubble(SVGMobject):
}
def __init__(self, **kwargs):
digest_config(self, kwargs, locals())
digest_config(self, kwargs)
if self.file_name is None:
raise Exception("Must invoke Bubble subclass")
SVGMobject.__init__(self, self.file_name, **kwargs)
@ -317,7 +317,11 @@ class Bubble(SVGMobject):
self.stretch_to_fit_width(self.width)
if self.direction[0] > 0:
self.flip()
self.direction_was_specified = ("direction" in kwargs)
if "direction" in kwargs:
self.direction = kwargs["direction"]
self.direction_was_specified = True
else:
self.direction_was_specified = False
self.content = Mobject()
self.refresh_triangulation()

View file

@ -33,7 +33,7 @@ class SingleStringTex(SVGMobject):
"fill_opacity": 1.0,
"stroke_width": 0,
"svg_default": {
"color": WHITE,
"fill_color": WHITE,
},
"path_string_config": {
"should_subdivide_sharp_curves": True,

View file

@ -68,7 +68,7 @@ class MarkupText(StringMobject):
"lsh": None,
"justify": False,
"indent": 0,
"alignment": "LEFT",
"alignment": "",
"line_width": None,
"font": "",
"slant": NORMAL,
@ -114,6 +114,8 @@ class MarkupText(StringMobject):
if not self.font:
self.font = get_customization()["style"]["font"]
if not self.alignment:
self.alignment = get_customization()["style"]["text_alignment"]
if self.is_markup:
self.validate_markup_string(text)

View file

@ -203,36 +203,38 @@ class VMobject(Mobject):
shadow: float | None = None,
recurse: bool = True
):
if fill_rgba is not None:
self.data['fill_rgba'] = resize_with_interpolation(fill_rgba, len(fill_rgba))
else:
self.set_fill(
color=fill_color,
opacity=fill_opacity,
recurse=recurse
)
for mob in self.get_family(recurse):
if fill_rgba is not None:
mob.data['fill_rgba'] = resize_with_interpolation(fill_rgba, len(fill_rgba))
else:
mob.set_fill(
color=fill_color,
opacity=fill_opacity,
recurse=False
)
if stroke_rgba is not None:
self.data['stroke_rgba'] = resize_with_interpolation(stroke_rgba, len(fill_rgba))
self.set_stroke(
width=stroke_width,
background=stroke_background,
)
else:
self.set_stroke(
color=stroke_color,
width=stroke_width,
opacity=stroke_opacity,
recurse=recurse,
background=stroke_background,
)
if stroke_rgba is not None:
mob.data['stroke_rgba'] = resize_with_interpolation(stroke_rgba, len(fill_rgba))
mob.set_stroke(
width=stroke_width,
background=stroke_background,
recurse=False,
)
else:
mob.set_stroke(
color=stroke_color,
width=stroke_width,
opacity=stroke_opacity,
recurse=False,
background=stroke_background,
)
if reflectiveness is not None:
self.set_reflectiveness(reflectiveness, recurse=recurse)
if gloss is not None:
self.set_gloss(gloss, recurse=recurse)
if shadow is not None:
self.set_shadow(shadow, recurse=recurse)
if reflectiveness is not None:
mob.set_reflectiveness(reflectiveness, recurse=False)
if gloss is not None:
mob.set_gloss(gloss, recurse=False)
if shadow is not None:
mob.set_shadow(shadow, recurse=False)
return self
def get_style(self):
@ -1202,17 +1204,17 @@ class VHighlight(VGroup):
def __init__(
self,
vmobject: VMobject,
n_layers: int = 3,
n_layers: int = 5,
color_bounds: tuple[ManimColor] = (GREY_C, GREY_E),
max_stroke_width: float = 10.0,
max_stroke_addition: float = 5.0,
):
outline = vmobject.replicate(n_layers)
outline.set_fill(opacity=0)
added_widths = np.linspace(0, max_stroke_width, n_layers + 1)[1:]
added_widths = np.linspace(0, max_stroke_addition, n_layers + 1)[1:]
colors = color_gradient(color_bounds, n_layers)
for part, added_width, color in zip(reversed(outline), added_widths, colors):
for sm in part.family_members_with_points():
part.set_stroke(
sm.set_stroke(
width=sm.get_stroke_width() + added_width,
color=color,
)

View file

@ -9,7 +9,9 @@ from manimlib.constants import ARROW_SYMBOLS, CTRL_SYMBOL, DELETE_SYMBOL, SHIFT_
from manimlib.constants import COMMAND_MODIFIER, SHIFT_MODIFIER
from manimlib.constants import DL, DOWN, DR, LEFT, ORIGIN, RIGHT, UL, UP, UR
from manimlib.constants import FRAME_WIDTH, SMALL_BUFF
from manimlib.constants import MANIM_COLORS, WHITE, GREY_C
from manimlib.constants import PI
from manimlib.constants import MANIM_COLORS, WHITE, GREY_A, GREY_C
from manimlib.mobject.geometry import Line
from manimlib.mobject.geometry import Rectangle
from manimlib.mobject.geometry import Square
from manimlib.mobject.mobject import Group
@ -22,6 +24,7 @@ from manimlib.mobject.types.vectorized_mobject import VGroup
from manimlib.mobject.types.vectorized_mobject import VHighlight
from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.scene.scene import Scene
from manimlib.scene.scene import SceneState
from manimlib.utils.family_ops import extract_mobject_family_members
from manimlib.utils.space_ops import get_norm
from manimlib.utils.tex_file_writing import LatexError
@ -34,7 +37,8 @@ Y_GRAB_KEY = 'v'
GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY]
RESIZE_KEY = 't'
COLOR_KEY = 'c'
CURSOR_LOCATION_KEY = 'l'
INFORMATION_KEY = 'i'
CURSOR_KEY = 'k'
# Note, a lot of the functionality here is still buggy and very much a work in progress.
@ -68,25 +72,34 @@ class InteractiveScene(Scene):
)
selection_rectangle_stroke_color = WHITE
selection_rectangle_stroke_width = 1.0
colors = MANIM_COLORS
palette_colors = MANIM_COLORS
selection_nudge_size = 0.05
cursor_location_config = dict(
font_size=14,
font_size=24,
fill_color=GREY_C,
num_decimal_places=3,
)
time_label_config = dict(
font_size=24,
fill_color=GREY_C,
num_decimal_places=1,
)
crosshair_width = 0.2
crosshair_color = GREY_A
def setup(self):
self.selection = Group()
self.selection_highlight = Group()
self.selection_highlight = self.get_selection_highlight()
self.selection_rectangle = self.get_selection_rectangle()
self.crosshair = self.get_crosshair()
self.information_label = self.get_information_label()
self.color_palette = self.get_color_palette()
self.cursor_location_label = self.get_cursor_location_label()
self.unselectables = [
self.selection,
self.selection_highlight,
self.selection_rectangle,
self.cursor_location_label,
self.crosshair,
self.information_label,
self.camera.frame
]
self.select_top_level_mobs = True
@ -94,6 +107,7 @@ class InteractiveScene(Scene):
self.is_selecting = False
self.is_grabbing = False
self.add(self.selection_highlight)
def get_selection_rectangle(self):
@ -106,7 +120,7 @@ class InteractiveScene(Scene):
rect.add_updater(self.update_selection_rectangle)
return rect
def update_selection_rectangle(self, rect):
def update_selection_rectangle(self, rect: Rectangle):
p1 = rect.fixed_corner
p2 = self.mouse_point.get_center()
rect.set_points_as_corners([
@ -116,10 +130,50 @@ class InteractiveScene(Scene):
])
return rect
def get_selection_highlight(self):
result = Group()
result.tracked_mobjects = []
result.add_updater(self.update_selection_highlight)
return result
def update_selection_highlight(self, highlight: Mobject):
if set(highlight.tracked_mobjects) == set(self.selection):
return
# Otherwise, refresh contents of highlight
highlight.tracked_mobjects = list(self.selection)
highlight.set_submobjects([
self.get_highlight(mob)
for mob in self.selection
])
try:
index = min((
i for i, mob in enumerate(self.mobjects)
for sm in self.selection
if sm in mob.get_family()
))
self.mobjects.remove(highlight)
self.mobjects.insert(index - 1, highlight)
except ValueError:
pass
def get_crosshair(self):
line = Line(LEFT, RIGHT)
line.insert_n_curves(1)
lines = line.replicate(2)
lines[1].rotate(PI / 2)
crosshair = VMobject()
crosshair.set_points([*lines[0].get_points(), *lines[1].get_points()])
crosshair.set_width(self.crosshair_width)
crosshair.set_stroke(self.crosshair_color, width=[2, 0, 2, 2, 0, 2])
crosshair.set_animating_status(True)
crosshair.fix_in_frame()
return crosshair
def get_color_palette(self):
palette = VGroup(*(
Square(fill_color=color, fill_opacity=1, side_length=1)
for color in self.colors
for color in self.palette_colors
))
palette.set_stroke(width=0)
palette.arrange(RIGHT, buff=0.5)
@ -128,22 +182,51 @@ class InteractiveScene(Scene):
palette.fix_in_frame()
return palette
def get_cursor_location_label(self):
decimals = VGroup(*(
def get_information_label(self):
loc_label = VGroup(*(
DecimalNumber(**self.cursor_location_config)
for n in range(3)
))
def update_coords(decimals):
for mob, coord in zip(decimals, self.mouse_point.get_location()):
def update_coords(loc_label):
for mob, coord in zip(loc_label, self.mouse_point.get_location()):
mob.set_value(coord)
decimals.arrange(RIGHT, buff=decimals.get_height())
decimals.to_corner(DR, buff=SMALL_BUFF)
decimals.fix_in_frame()
return decimals
loc_label.arrange(RIGHT, buff=loc_label.get_height())
loc_label.to_corner(DR, buff=SMALL_BUFF)
loc_label.fix_in_frame()
return loc_label
decimals.add_updater(update_coords)
return decimals
loc_label.add_updater(update_coords)
time_label = DecimalNumber(0, **self.time_label_config)
time_label.to_corner(DL, buff=SMALL_BUFF)
time_label.fix_in_frame()
time_label.add_updater(lambda m, dt: m.increment_value(dt))
return VGroup(loc_label, time_label)
# Overrides
def get_state(self):
return SceneState(self, ignore=[
self.selection_highlight,
self.selection_rectangle,
self.crosshair,
])
def restore_state(self, scene_state: SceneState):
super().restore_state(scene_state)
self.mobjects.insert(0, self.selection_highlight)
def add(self, *mobjects: Mobject):
super().add(*mobjects)
self.regenerate_selection_search_set()
def remove(self, *mobjects: Mobject):
super().remove(*mobjects)
self.regenerate_selection_search_set()
# def increment_time(self, dt: float) -> None:
# super().increment_time(dt)
# Related to selection
@ -184,7 +267,6 @@ class InteractiveScene(Scene):
curr, exclude_pointless=True,
)
)
self.refresh_selection_highlight()
def get_corner_dots(self, mobject: Mobject) -> Mobject:
dots = DotCloud(**self.corner_dot_config)
@ -192,7 +274,7 @@ class InteractiveScene(Scene):
if mobject.get_depth() < 1e-2:
vects = [DL, UL, UR, DR]
else:
vects = list(it.product(*3 * [[-1, 1]]))
vects = np.array(list(it.product(*3 * [[-1, 1]])))
dots.add_updater(lambda d: d.set_points([
mobject.get_corner(v) + v * radius
for v in vects
@ -201,27 +283,19 @@ class InteractiveScene(Scene):
def get_highlight(self, mobject: Mobject) -> Mobject:
if isinstance(mobject, VMobject) and mobject.has_points() and not self.select_top_level_mobs:
result = VHighlight(mobject)
result.add_updater(lambda m: m.replace(mobject))
length = max([mobject.get_height(), mobject.get_width()])
result = VHighlight(
mobject,
max_stroke_addition=min([50 * length, 10]),
)
result.add_updater(lambda m: m.replace(mobject, stretch=True))
return result
elif isinstance(mobject, DotCloud):
return Mobject()
else:
return self.get_corner_dots(mobject)
def refresh_selection_highlight(self):
if len(self.selection) > 0:
self.remove(self.selection_highlight)
self.selection_highlight.set_submobjects([
self.get_highlight(mob)
for mob in self.selection
])
index = min((
i for i, mob in enumerate(self.mobjects)
for sm in self.selection
if sm in mob.get_family()
))
self.mobjects.insert(index, self.selection_highlight)
def add_to_selection(self, *mobjects):
def add_to_selection(self, *mobjects: Mobject):
mobs = list(filter(
lambda m: m not in self.unselectables and m not in self.selection,
mobjects
@ -229,46 +303,34 @@ class InteractiveScene(Scene):
if len(mobs) == 0:
return
self.selection.add(*mobs)
self.refresh_selection_highlight()
for sm in mobs:
for mob in self.mobjects:
if sm in mob.get_family():
mob.set_animating_status(True)
self.refresh_static_mobjects()
self.selection.set_animating_status(True)
def toggle_from_selection(self, *mobjects):
def toggle_from_selection(self, *mobjects: Mobject):
for mob in mobjects:
if mob in self.selection:
self.selection.remove(mob)
mob.set_animating_status(False)
else:
self.add_to_selection(mob)
self.refresh_selection_highlight()
self.refresh_static_mobjects()
def clear_selection(self):
for mob in self.selection:
mob.set_animating_status(False)
self.selection.set_submobjects([])
self.selection_highlight.set_submobjects([])
self.refresh_static_mobjects()
def add(self, *new_mobjects: Mobject):
super().add(*new_mobjects)
self.regenerate_selection_search_set()
def remove(self, *mobjects: Mobject):
super().remove(*mobjects)
self.regenerate_selection_search_set()
def disable_interaction(self, *mobjects: Mobject):
for mob in mobjects:
self.unselectables.append(mob)
for sm in mob.get_family():
self.unselectables.append(sm)
self.regenerate_selection_search_set()
def enable_interaction(self, *mobjects: Mobject):
for mob in mobjects:
if mob in self.unselectables:
self.unselectables.remove(mob)
for sm in mob.get_family():
if sm in self.unselectables:
self.unselectables.remove(sm)
# Functions for keyboard actions
@ -308,10 +370,6 @@ class InteractiveScene(Scene):
self.remove(*self.selection)
self.clear_selection()
def restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]):
super().restore_state(mobject_states)
self.refresh_selection_highlight()
def enable_selection(self):
self.is_selecting = True
self.add(self.selection_rectangle)
@ -352,6 +410,12 @@ class InteractiveScene(Scene):
else:
self.remove(self.color_palette)
def display_information(self, show=True):
if show:
self.add(self.information_label)
else:
self.remove(self.information_label)
def group_selection(self):
group = self.get_group(*self.selection)
self.add(group)
@ -393,8 +457,8 @@ class InteractiveScene(Scene):
self.prepare_resizing(about_corner=True)
elif char == COLOR_KEY and modifiers == 0:
self.toggle_color_palette()
elif char == CURSOR_LOCATION_KEY and modifiers == 0:
self.add(self.cursor_location_label)
elif char == INFORMATION_KEY and modifiers == 0:
self.display_information()
elif char == "c" and modifiers == COMMAND_MODIFIER:
self.copy_selection()
elif char == "v" and modifiers == COMMAND_MODIFIER:
@ -420,6 +484,14 @@ class InteractiveScene(Scene):
vect=[LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)],
large=(modifiers & SHIFT_MODIFIER),
)
# Adding crosshair
if char == CURSOR_KEY:
if self.crosshair in self.mobjects:
self.remove(self.crosshair)
else:
self.add(self.crosshair)
if char == SELECT_KEY:
self.add(self.crosshair)
# Conditions for saving state
if char in [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY, RESIZE_KEY]:
@ -429,10 +501,11 @@ class InteractiveScene(Scene):
super().on_key_release(symbol, modifiers)
if chr(symbol) == SELECT_KEY:
self.gather_new_selection()
# self.remove(self.crosshair)
if chr(symbol) in GRAB_KEYS:
self.is_grabbing = False
elif chr(symbol) == CURSOR_LOCATION_KEY:
self.remove(self.cursor_location_label)
elif chr(symbol) == INFORMATION_KEY:
self.display_information(False)
elif symbol == SHIFT_SYMBOL and self.window.is_key_pressed(ord(RESIZE_KEY)):
self.prepare_resizing(about_corner=False)
@ -447,6 +520,8 @@ class InteractiveScene(Scene):
self.selection.set_y(diff[1])
def handle_resizing(self, point: np.ndarray):
if not hasattr(self, "scale_about_point"):
return
vect = point - self.scale_about_point
if self.window.is_key_pressed(CTRL_SYMBOL):
for i in (0, 1):
@ -485,17 +560,9 @@ class InteractiveScene(Scene):
self.selection.set_color(mob.get_color())
self.remove(self.color_palette)
def toggle_clicked_mobject_from_selection(self, point: np.ndarray):
mob = self.point_to_mobject(
point,
search_set=self.get_selection_search_set(),
buff=SMALL_BUFF
)
if mob is not None:
self.toggle_from_selection(mob)
def on_mouse_motion(self, point: np.ndarray, d_point: np.ndarray) -> None:
super().on_mouse_motion(point, d_point)
self.crosshair.move_to(point)
if self.is_grabbing:
self.handle_grabbing(point)
elif self.window.is_key_pressed(ord(RESIZE_KEY)):
@ -507,7 +574,13 @@ class InteractiveScene(Scene):
super().on_mouse_release(point, button, mods)
if self.color_palette in self.mobjects:
self.choose_color(point)
elif self.window.is_key_pressed(SHIFT_SYMBOL):
self.toggle_clicked_mobject_from_selection(point)
return
mobject = self.point_to_mobject(
point,
search_set=self.get_selection_search_set(),
buff=1e-4,
)
if mobject is not None:
self.toggle_from_selection(mobject)
else:
self.clear_selection()

View file

@ -1,9 +1,11 @@
from __future__ import annotations
from collections import OrderedDict
from functools import wraps
import inspect
import os
import platform
import pyperclip
import random
import time
@ -11,7 +13,6 @@ import numpy as np
from tqdm import tqdm as ProgressDisplay
from manimlib.animation.animation import prepare_animation
from manimlib.animation.transform import MoveToTarget
from manimlib.camera.camera import Camera
from manimlib.constants import ARROW_SYMBOLS
from manimlib.constants import DEFAULT_WAIT_TIME
@ -20,6 +21,7 @@ from manimlib.constants import SHIFT_MODIFIER
from manimlib.event_handler import EVENT_DISPATCHER
from manimlib.event_handler.event_type import EventType
from manimlib.logger import log
from manimlib.mobject.mobject import _AnimationBuilder
from manimlib.mobject.mobject import Group
from manimlib.mobject.mobject import Mobject
from manimlib.mobject.mobject import Point
@ -61,7 +63,7 @@ class Scene(object):
"leave_progress_bars": False,
"preview": True,
"presenter_mode": False,
"linger_after_completion": True,
"show_animation_progress": False,
"pan_sensitivity": 3,
"max_num_saved_states": 50,
}
@ -72,7 +74,7 @@ class Scene(object):
from manimlib.window import Window
self.window = Window(scene=self, **self.window_config)
self.camera_config["ctx"] = self.window.ctx
self.camera_config["frame_rate"] = 30 # Where's that 30 from?
self.camera_config["fps"] = 30 # Where's that 30 from?
self.undo_stack = []
self.redo_stack = []
else:
@ -86,14 +88,19 @@ class Scene(object):
self.time: float = 0
self.skip_time: float = 0
self.original_skipping_status: bool = self.skip_animations
self.checkpoint_states: dict[str, list[tuple[Mobject, Mobject]]] = dict()
if self.start_at_animation_number is not None:
self.skip_animations = True
if self.file_writer.has_progress_display:
self.show_animation_progress = False
# Items associated with interaction
self.mouse_point = Point()
self.mouse_drag_point = Point()
self.hold_on_wait = self.presenter_mode
self.inside_embed = False
self.quit_interaction = False
# Much nicer to work with deterministic scenes
if self.random_seed is not None:
@ -111,8 +118,13 @@ class Scene(object):
self.setup()
try:
self.construct()
except EndSceneEarlyException:
self.interact()
except EndScene:
pass
except KeyboardInterrupt:
# Get rid keyboard interupt symbols
print("", end="\r")
self.file_writer.ended_with_interrupt = True
self.tear_down()
def setup(self) -> None:
@ -131,32 +143,31 @@ class Scene(object):
def tear_down(self) -> None:
self.stop_skipping()
self.file_writer.finish()
if self.window and self.linger_after_completion:
self.interact()
if self.window:
self.window.destroy()
self.window = None
def interact(self) -> None:
# If there is a window, enter a loop
# which updates the frame while under
# the hood calling the pyglet event loop
if self.window is None:
return
log.info(
"Tips: You are now in the interactive mode. Now you can use the keyboard"
" and the mouse to interact with the scene. Just press `command + q` or `esc`"
" if you want to quit."
)
self.quit_interaction = False
self.skip_animations = False
self.refresh_static_mobjects()
while not (self.window.is_closing or self.quit_interaction):
self.update_frame(1 / self.camera.frame_rate)
if self.window.is_closing:
self.window.destroy()
while not self.is_window_closing():
self.update_frame(1 / self.camera.fps)
def embed(self, close_scene_on_exit: bool = True) -> None:
if not self.preview:
# Ignore embed calls when there is no preview
return
return # Embed is only relevant with a preview
self.inside_embed = True
self.stop_skipping()
self.linger_after_completion = False
self.update_frame()
self.save_state()
@ -175,29 +186,70 @@ class Scene(object):
]
})
# This is useful if one wants to re-run a block of scene
# code, while developing, tweaking it each time.
# As long as the copied selection starts with a comment,
# this will revert to the state of the scene at the first
# point of running.
def checkpoint_paste(skip=False, show_progress=True):
pasted = pyperclip.paste()
line0 = pasted.lstrip().split("\n")[0]
if line0.startswith("#"):
if line0 not in self.checkpoint_states:
self.checkpoint(line0)
else:
self.revert_to_checkpoint(line0)
self.update_frame(dt=0)
if skip:
originally_skip = self.skip_animations
self.skip_animations = True
if show_progress:
originally_show_animation_progress = self.show_animation_progress
self.show_animation_progress = True
shell.run_line_magic("paste", "")
if skip:
self.skip_animations = originally_skip
if show_progress:
self.show_animation_progress = originally_show_animation_progress
local_ns['checkpoint_paste'] = checkpoint_paste
# Enables gui interactions during the embed
def inputhook(context):
while not context.input_is_ready():
if not self.window.is_closing:
if not self.is_window_closing():
self.update_frame(dt=0)
if self.is_window_closing():
shell.ask_exit()
pt_inputhooks.register("manim", inputhook)
shell.enable_gui("manim")
# This is hacky, but there's an issue with ipython which is that
# when you define lambda's or list comprehensions during a shell session,
# they are not aware of local variables in the surrounding scope. Because
# That comes up a fair bit during scene construction, to get around this,
# we (admittedly sketchily) update the global namespace to match the local
# namespace, since this is just a shell session anyway.
shell.events.register(
"pre_run_cell",
lambda: shell.user_global_ns.update(shell.user_ns)
)
# Operation to run after each ipython command
def post_cell_func(*args, **kwargs):
def post_cell_func():
self.refresh_static_mobjects()
if not self.is_window_closing():
self.update_frame(dt=0, ignore_skipping=True)
self.save_state()
shell.events.register("post_run_cell", post_cell_func)
# Launch shell, with stack_depth=2 indicating we should use caller globals/locals
shell(local_ns=local_ns, stack_depth=2)
self.inside_embed = False
# End scene when exiting an embed
if close_scene_on_exit:
raise EndSceneEarlyException()
raise EndScene()
# Only these methods should touch the camera
@ -214,6 +266,9 @@ class Scene(object):
if self.skip_animations and not ignore_skipping:
return
if self.is_window_closing():
raise EndScene()
if self.window:
self.window.clear()
self.camera.clear()
@ -396,7 +451,7 @@ class Scene(object):
self.stop_skipping()
if self.end_at_animation_number is not None:
if self.num_plays >= self.end_at_animation_number:
raise EndSceneEarlyException()
raise EndScene()
def stop_skipping(self) -> None:
self.virtual_animation_start_time = self.time
@ -413,24 +468,25 @@ class Scene(object):
) -> list[float] | np.ndarray | ProgressDisplay:
if self.skip_animations and not override_skip_animations:
return [run_time]
else:
step = 1 / self.camera.frame_rate
times = np.arange(0, run_time, step)
times = np.arange(0, run_time, 1 / self.camera.fps)
if self.file_writer.has_progress_display:
self.file_writer.set_progress_display_subdescription(desc)
if self.show_animation_progress:
return ProgressDisplay(
times,
total=n_iterations,
leave=self.leave_progress_bars,
ascii=True if platform.system() == 'Windows' else None,
desc=desc,
)
else:
return times
return ProgressDisplay(
times,
total=n_iterations,
leave=self.leave_progress_bars,
ascii=True if platform.system() == 'Windows' else None,
desc=desc,
)
def get_run_time(self, animations: Iterable[Animation]) -> float:
return np.max([animation.run_time for animation in animations])
return np.max([animation.get_run_time() for animation in animations])
def get_animation_time_progression(
self,
@ -454,74 +510,16 @@ class Scene(object):
kw["override_skip_animations"] = True
return self.get_time_progression(duration, **kw)
def anims_from_play_args(self, *args, **kwargs) -> list[Animation]:
"""
Each arg can either be an animation, or a mobject method
followed by that methods arguments (and potentially follow
by a dict of kwargs for that method).
This animation list is built by going through the args list,
and each animation is simply added, but when a mobject method
s hit, a MoveToTarget animation is built using the args that
follow up until either another animation is hit, another method
is hit, or the args list runs out.
"""
animations = []
state = {
"curr_method": None,
"last_method": None,
"method_args": [],
}
def compile_method(state):
if state["curr_method"] is None:
return
mobject = state["curr_method"].__self__
if state["last_method"] and state["last_method"].__self__ is mobject:
animations.pop()
# method should already have target then.
else:
mobject.generate_target()
#
if len(state["method_args"]) > 0 and isinstance(state["method_args"][-1], dict):
method_kwargs = state["method_args"].pop()
else:
method_kwargs = {}
state["curr_method"].__func__(
mobject.target,
*state["method_args"],
**method_kwargs
)
animations.append(MoveToTarget(mobject))
state["last_method"] = state["curr_method"]
state["curr_method"] = None
state["method_args"] = []
for arg in args:
if inspect.ismethod(arg):
compile_method(state)
state["curr_method"] = arg
elif state["curr_method"] is not None:
state["method_args"].append(arg)
elif isinstance(arg, Mobject):
raise Exception("""
I think you may have invoked a method
you meant to pass in as a Scene.play argument
""")
else:
try:
anim = prepare_animation(arg)
except TypeError:
raise TypeError(f"Unexpected argument {arg} passed to Scene.play()")
compile_method(state)
animations.append(anim)
compile_method(state)
for animation in animations:
def prepare_animations(
self,
proto_animations: list[Animation | _AnimationBuilder],
animation_config: dict,
):
animations = list(map(prepare_animation, proto_animations))
for anim in animations:
# This is where kwargs to play like run_time and rate_func
# get applied to all animations
animation.update_config(**kwargs)
anim.update_config(**animation_config)
return animations
def handle_play_like_call(func):
@ -529,6 +527,8 @@ class Scene(object):
def wrapper(self, *args, **kwargs):
if self.inside_embed:
self.save_state()
if self.presenter_mode and self.num_plays == 0:
self.hold_loop()
self.update_skipping_status()
should_write = not self.skip_animations
@ -548,6 +548,10 @@ class Scene(object):
if self.inside_embed:
self.save_state()
if self.skip_animations and self.window is not None:
# Show some quick frames along the way
self.update_frame(dt=0, ignore_skipping=True)
self.num_plays += 1
return wrapper
@ -587,11 +591,11 @@ class Scene(object):
self.update_mobjects(0)
@handle_play_like_call
def play(self, *args, **kwargs) -> None:
if len(args) == 0:
def play(self, *proto_animations, **animation_config) -> None:
if len(proto_animations) == 0:
log.warning("Called Scene.play with no animations")
return
animations = self.anims_from_play_args(*args, **kwargs)
animations = self.prepare_animations(proto_animations, animation_config)
self.begin_animations(animations)
self.progress_through_animations(animations)
self.finish_animations(animations)
@ -608,9 +612,7 @@ class Scene(object):
if self.presenter_mode and not self.skip_animations and not ignore_presenter_mode:
if note:
log.info(note)
while self.hold_on_wait:
self.update_frame(dt=1 / self.camera.frame_rate)
self.hold_on_wait = True
self.hold_loop()
else:
time_progression = self.get_wait_time_progression(duration, stop_condition)
last_t = 0
@ -624,6 +626,11 @@ class Scene(object):
self.refresh_static_mobjects()
return self
def hold_loop(self):
while self.hold_on_wait:
self.update_frame(dt=1 / self.camera.fps)
self.hold_on_wait = True
def wait_until(
self,
stop_condition: Callable[[], bool],
@ -655,50 +662,52 @@ class Scene(object):
# Helpers for interactive development
def get_state(self) -> tuple[list[tuple[Mobject, Mobject]], int]:
if self.undo_stack:
last_state = dict(self.undo_stack[-1])
else:
last_state = {}
result = []
n_changes = 0
for mob in self.mobjects:
# If it hasn't changed since the last state, just point to the
# same copy as before
if mob in last_state and last_state[mob].looks_identical(mob):
result.append((mob, last_state[mob]))
else:
result.append((mob, mob.copy()))
n_changes += 1
return result, n_changes
def get_state(self) -> SceneState:
return SceneState(self)
def restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]):
self.mobjects = [mob.become(mob_copy) for mob, mob_copy in mobject_states]
def restore_state(self, scene_state: SceneState):
scene_state.restore_scene(self)
def save_state(self) -> None:
if not self.preview:
return
state = self.get_state()
if self.undo_stack and state.mobjects_match(self.undo_stack[-1]):
return
self.redo_stack = []
state, n_changes = self.get_state()
if n_changes > 0:
self.undo_stack.append(state)
if len(self.undo_stack) > self.max_num_saved_states:
self.undo_stack.pop(0)
self.undo_stack.append(state)
if len(self.undo_stack) > self.max_num_saved_states:
self.undo_stack.pop(0)
def undo(self):
if self.undo_stack:
state, n_changes = self.get_state()
self.redo_stack.append(state)
self.redo_stack.append(self.get_state())
self.restore_state(self.undo_stack.pop())
self.refresh_static_mobjects()
def redo(self):
if self.redo_stack:
state, n_changes = self.get_state()
self.undo_stack.append(state)
self.undo_stack.append(self.get_state())
self.restore_state(self.redo_stack.pop())
self.refresh_static_mobjects()
def checkpoint(self, key: str):
self.checkpoint_states[key] = self.get_state()
def revert_to_checkpoint(self, key: str):
if key not in self.checkpoint_states:
log.error(f"No checkpoint at {key}")
return
all_keys = list(self.checkpoint_states.keys())
index = all_keys.index(key)
for later_key in all_keys[index + 1:]:
self.checkpoint_states.pop(later_key)
self.restore_state(self.checkpoint_states[key])
def clear_checkpoints(self):
self.checkpoint_states = dict()
def save_mobject_to_file(self, mobject: Mobject, file_path: str | None = None) -> None:
if file_path is None:
file_path = self.file_writer.get_saved_mobject_path(mobject)
@ -714,6 +723,9 @@ class Scene(object):
path = os.path.join(directory, file_name)
return Mobject.load(path)
def is_window_closing(self):
return self.window and (self.window.is_closing or self.quit_interaction)
# Event handling
def on_mouse_motion(
@ -850,5 +862,49 @@ class Scene(object):
pass
class EndSceneEarlyException(Exception):
class SceneState():
def __init__(self, scene: Scene, ignore: list[Mobject] | None = None):
self.time = scene.time
self.num_plays = scene.num_plays
self.mobjects_to_copies = OrderedDict.fromkeys(scene.mobjects)
if ignore:
for mob in ignore:
self.mobjects_to_copies.pop(mob, None)
last_m2c = scene.undo_stack[-1].mobjects_to_copies if scene.undo_stack else dict()
for mob in self.mobjects_to_copies:
# If it hasn't changed since the last state, just point to the
# same copy as before
if mob in last_m2c and last_m2c[mob].looks_identical(mob):
self.mobjects_to_copies[mob] = last_m2c[mob]
else:
self.mobjects_to_copies[mob] = mob.copy()
def __eq__(self, state: SceneState):
return all((
self.time == state.time,
self.num_plays == state.num_plays,
self.mobjects_to_copies == state.mobjects_to_copies
))
def mobjects_match(self, state: SceneState):
return self.mobjects_to_copies == state.mobjects_to_copies
def n_changes(self, state: SceneState):
m2c = state.mobjects_to_copies
return sum(
1 - int(mob in m2c and mob.looks_identical(m2c[mob]))
for mob in self.mobjects_to_copies
)
def restore_scene(self, scene: Scene):
scene.time = self.time
scene.num_plays = self.num_plays
scene.mobjects = [
mob.become(mob_copy)
for mob, mob_copy in self.mobjects_to_copies.items()
]
class EndScene(Exception):
pass

View file

@ -37,9 +37,6 @@ class SceneFileWriter(object):
"png_mode": "RGBA",
"save_last_frame": False,
"movie_file_extension": ".mp4",
# Should the path of output files mirror the directory
# structure of the module holding the scene?
"mirror_module_path": False,
# What python file is generating this scene
"input_file_path": "",
# Where should this be written
@ -57,16 +54,13 @@ class SceneFileWriter(object):
self.scene: Scene = scene
self.writing_process: sp.Popen | None = None
self.has_progress_display: bool = False
self.ended_with_interrupt: bool = False
self.init_output_directories()
self.init_audio()
# Output directories and files
def init_output_directories(self) -> None:
out_dir = self.output_directory or ""
if self.mirror_module_path:
module_dir = self.get_default_module_directory()
out_dir = os.path.join(out_dir, module_dir)
scene_name = self.file_name or self.get_default_scene_name()
if self.save_last_frame:
image_dir = guarantee_existence(os.path.join(out_dir, "images"))
@ -81,7 +75,9 @@ class SceneFileWriter(object):
movie_dir, "partial_movie_files", scene_name,
))
# A place to save mobjects
self.saved_mobject_directory = os.path.join(out_dir, "mobjects")
self.saved_mobject_directory = os.path.join(
out_dir, "mobjects", str(self.scene)
)
def get_default_module_directory(self) -> str:
path, _ = os.path.splitext(self.input_file_path)
@ -101,9 +97,9 @@ class SceneFileWriter(object):
def get_resolution_directory(self) -> str:
pixel_height = self.scene.camera.pixel_height
frame_rate = self.scene.camera.frame_rate
fps = self.scene.camera.fps
return "{}p{}".format(
pixel_height, frame_rate
pixel_height, fps
)
# Directory getters
@ -124,10 +120,7 @@ class SceneFileWriter(object):
return self.movie_file_path
def get_saved_mobject_directory(self) -> str:
return guarantee_existence(os.path.join(
self.saved_mobject_directory,
str(self.scene),
))
return guarantee_existence(self.saved_mobject_directory)
def get_saved_mobject_path(self, mobject: Mobject) -> str | None:
directory = self.get_saved_mobject_directory()
@ -241,7 +234,7 @@ class SceneFileWriter(object):
self.final_file_path = file_path
self.temp_file_path = stem + "_temp" + ext
fps = self.scene.camera.frame_rate
fps = self.scene.camera.fps
width, height = self.scene.camera.get_pixel_shape()
command = [
@ -305,7 +298,11 @@ class SceneFileWriter(object):
self.writing_process.terminate()
if self.has_progress_display:
self.progress_display.close()
shutil.move(self.temp_file_path, self.final_file_path)
if not self.ended_with_interrupt:
shutil.move(self.temp_file_path, self.final_file_path)
else:
self.movie_file_path = self.temp_file_path
def combine_movie_files(self) -> None:
kwargs = {

View file

@ -52,25 +52,14 @@ def init_customization() -> None:
"window_monitor": 0,
"full_screen": False,
"break_into_partial_movies": False,
"camera_qualities": {
"low": {
"resolution": "854x480",
"frame_rate": 15,
},
"medium": {
"resolution": "1280x720",
"frame_rate": 30,
},
"high": {
"resolution": "1920x1080",
"frame_rate": 60,
},
"ultra_high": {
"resolution": "3840x2160",
"frame_rate": 60,
},
"default_quality": "",
}
"camera_resolutions": {
"low": "854x480",
"medium": "1280x720",
"high": "1920x1080",
"4k": "3840x2160",
"default_resolution": "high",
},
"fps": 30,
}
console = Console()

View file

@ -13,19 +13,12 @@ if TYPE_CHECKING:
S = TypeVar("S")
def remove_list_redundancies(l: Sequence[T]) -> list[T]:
def remove_list_redundancies(lst: Sequence[T]) -> list[T]:
"""
Used instead of list(set(l)) to maintain order
Keeps the last occurrence of each element
"""
reversed_result = []
used = set()
for x in reversed(l):
if x not in used:
reversed_result.append(x)
used.add(x)
reversed_result.reverse()
return reversed_result
return list(reversed(dict.fromkeys(reversed(lst))))
def list_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]:
@ -33,7 +26,7 @@ def list_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]:
Used instead of list(set(l1).update(l2)) to maintain order,
making sure duplicates are removed from l1, not l2.
"""
return [e for e in l1 if e not in l2] + list(l2)
return remove_list_redundancies([*l1, *l2])
def list_difference_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]: