Merge pull request #1821 from 3b1b/video-work

Video work
This commit is contained in:
Grant Sanderson 2022-05-24 15:20:07 -07:00 committed by GitHub
commit 07a8274cb1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 535 additions and 366 deletions

View file

@ -18,7 +18,7 @@ Frame and pixel shape
DEFAULT_PIXEL_HEIGHT = 1080 DEFAULT_PIXEL_HEIGHT = 1080
DEFAULT_PIXEL_WIDTH = 1920 DEFAULT_PIXEL_WIDTH = 1920
DEFAULT_FRAME_RATE = 30 DEFAULT_FPS = 30
Buffs 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. ``--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 ``--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" ``--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 ``--color COLOR`` ``-c`` Background color
``--leave_progress_bars`` Leave progress bars displayed in terminal ``--leave_progress_bars`` Leave progress bars displayed in terminal
``--video_dir VIDEO_DIR`` Directory to write video ``--video_dir VIDEO_DIR`` Directory to write video

View file

@ -77,10 +77,6 @@ class AnimatingMethods(Scene):
# ".animate" syntax: # ".animate" syntax:
self.play(grid.animate.shift(LEFT)) 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 # Both of those will interpolate between the mobject's initial
# state and whatever happens when you apply that method. # state and whatever happens when you apply that method.
# For this example, calling grid.shift(LEFT) would shift the # 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.mobject.mobject import Mobject
from manimlib.utils.config_ops import digest_config from manimlib.utils.config_ops import digest_config
from manimlib.utils.rate_functions import smooth from manimlib.utils.rate_functions import smooth
from manimlib.utils.rate_functions import squish_rate_func
from manimlib.utils.simple_functions import clip from manimlib.utils.simple_functions import clip
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -23,6 +24,7 @@ DEFAULT_ANIMATION_LAG_RATIO = 0
class Animation(object): class Animation(object):
CONFIG = { CONFIG = {
"run_time": DEFAULT_ANIMATION_RUN_TIME, "run_time": DEFAULT_ANIMATION_RUN_TIME,
"time_span": None, # Tuple of times, between which the animation will run
"rate_func": smooth, "rate_func": smooth,
"name": None, "name": None,
# Does this animation add or remove a mobject form the screen # Does this animation add or remove a mobject form the screen
@ -53,6 +55,12 @@ class Animation(object):
# played. As much initialization as possible, # played. As much initialization as possible,
# especially any mobject copying, should live in # especially any mobject copying, should live in
# this method # 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.mobject.set_animating_status(True)
self.starting_mobject = self.create_starting_mobject() self.starting_mobject = self.create_starting_mobject()
if self.suspend_mobject_updating: if self.suspend_mobject_updating:
@ -166,6 +174,8 @@ class Animation(object):
return self return self
def get_run_time(self) -> float: def get_run_time(self) -> float:
if self.time_span:
return max(self.run_time, self.time_span[1])
return self.run_time return self.run_time
def set_rate_func(self, rate_func: Callable[[float], float]): def set_rate_func(self, rate_func: Callable[[float], float]):

View file

@ -68,6 +68,7 @@ class Transform(Animation):
self.target_copy = self.target_mobject.copy() self.target_copy = self.target_mobject.copy()
self.mobject.align_data_and_family(self.target_copy) self.mobject.align_data_and_family(self.target_copy)
super().begin() super().begin()
if not self.mobject.has_updaters:
self.mobject.lock_matching_data( self.mobject.lock_matching_data(
self.starting_mobject, self.starting_mobject,
self.target_copy, self.target_copy,

View file

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

View file

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

View file

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

View file

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

View file

@ -64,6 +64,7 @@ def get_scene_config(config):
"start_at_animation_number", "start_at_animation_number",
"end_at_animation_number", "end_at_animation_number",
"leave_progress_bars", "leave_progress_bars",
"show_animation_progress",
"preview", "preview",
"presenter_mode", "presenter_mode",
] ]
@ -87,7 +88,7 @@ def compute_total_frames(scene_class, scene_config):
pre_scene = scene_class(**pre_config) pre_scene = scene_class(**pre_config)
pre_scene.run() pre_scene.run()
total_time = pre_scene.time - pre_scene.skip_time 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): def get_scenes_to_render(scene_classes, scene_config, config):

View file

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

View file

@ -821,7 +821,7 @@ class FillArrow(Line):
def reset_points_around_ends(self): def reset_points_around_ends(self):
self.set_points_by_ends( 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 return self

View file

@ -286,7 +286,7 @@ class Mobject(object):
def are_points_touching( def are_points_touching(
self, self,
points: np.ndarray, points: np.ndarray,
buff: float = MED_SMALL_BUFF buff: float = 0
) -> bool: ) -> bool:
bb = self.get_bounding_box() bb = self.get_bounding_box()
mins = (bb[0] - buff) mins = (bb[0] - buff)
@ -296,7 +296,7 @@ class Mobject(object):
def is_point_touching( def is_point_touching(
self, self,
point: np.ndarray, point: np.ndarray,
buff: float = MED_SMALL_BUFF buff: float = 0
) -> bool: ) -> bool:
return self.are_points_touching(np.array(point, ndmin=2), buff)[0] return self.are_points_touching(np.array(point, ndmin=2), buff)[0]
@ -359,8 +359,9 @@ class Mobject(object):
if p not in excluded: if p not in excluded:
ancestors.append(p) ancestors.append(p)
to_process.append(p) to_process.append(p)
# Remove redundancies while preserving order # Ensure mobjects highest in the hierarchy show up first
ancestors.reverse() ancestors.reverse()
# Remove list redundancies while preserving order
return list(dict.fromkeys(ancestors)) return list(dict.fromkeys(ancestors))
def add(self, *mobjects: Mobject): def add(self, *mobjects: Mobject):
@ -475,6 +476,31 @@ class Mobject(object):
self.center() self.center()
return self 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( def sort(
self, self,
point_to_num_func: Callable[[np.ndarray], float] = lambda p: p[0], 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.depth_test = sm2.depth_test
sm1.render_primitive = sm2.render_primitive sm1.render_primitive = sm2.render_primitive
self.refresh_bounding_box(recurse_down=True) self.refresh_bounding_box(recurse_down=True)
self.match_updaters(mobject)
return self return self
def looks_identical(self, mobject: Mobject): def looks_identical(self, mobject: Mobject):
fam1 = self.get_family() fam1 = self.family_members_with_points()
fam2 = mobject.get_family() fam2 = mobject.family_members_with_points()
if len(fam1) != len(fam2): if len(fam1) != len(fam2):
return False return False
for m1, m2 in zip(fam1, fam2): for m1, m2 in zip(fam1, fam2):
@ -628,10 +655,12 @@ class Mobject(object):
if set(d1).difference(d2): if set(d1).difference(d2):
return False return False
for key in d1: for key in d1:
if isinstance(d1[key], np.ndarray): eq = (d1[key] == d2[key])
if not np.all(d1[key] == d2[key]): if isinstance(eq, bool):
if not eq:
return False return False
elif d1[key] != d2[key]: else:
if not eq.all():
return False return False
return True return True

View file

@ -34,21 +34,25 @@ class DecimalNumber(VMobject):
def __init__(self, number: float | complex = 0, **kwargs): def __init__(self, number: float | complex = 0, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.set_submobjects_from_number(number) self.set_submobjects_from_number(number)
self.init_colors()
def set_submobjects_from_number(self, number: float | complex) -> None: def set_submobjects_from_number(self, number: float | complex) -> None:
self.number = number self.number = number
self.set_submobjects([]) self.set_submobjects([])
string_to_mob_ = lambda s: self.string_to_mob(s, **self.text_config) self.text_config["font_size"] = self.get_font_size()
num_string = self.get_num_string(number) num_string = self.num_string = self.get_num_string(number)
self.add(*map(string_to_mob_, num_string)) self.add(*(
Text(ns, **self.text_config)
for ns in num_string
))
# Add non-numerical bits # Add non-numerical bits
if self.show_ellipsis: if self.show_ellipsis:
dots = string_to_mob_("...") dots = Text("...", **self.text_config)
dots.arrange(RIGHT, buff=2 * dots[0].get_width()) dots.arrange(RIGHT, buff=2 * dots[0].get_width())
self.add(dots) self.add(dots)
if self.unit is not None: 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.add(self.unit_sign)
self.arrange( self.arrange(
@ -91,12 +95,7 @@ class DecimalNumber(VMobject):
self.data["font_size"] = np.array([self.font_size], dtype=float) self.data["font_size"] = np.array([self.font_size], dtype=float)
def get_font_size(self) -> float: def get_font_size(self) -> float:
return self.data["font_size"][0] return int(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
def get_formatter(self, **kwargs) -> str: def get_formatter(self, **kwargs) -> str:
""" """
@ -117,13 +116,14 @@ class DecimalNumber(VMobject):
] ]
]) ])
config.update(kwargs) config.update(kwargs)
ndp = config["num_decimal_places"]
return "".join([ return "".join([
"{", "{",
config.get("field_name", ""), config.get("field_name", ""),
":", ":",
"+" if config["include_sign"] else "", "+" if config["include_sign"] else "",
"," if config["group_with_commas"] else "", "," if config["group_with_commas"] else "",
".", str(config["num_decimal_places"]), "f", f".{ndp}f",
"}", "}",
]) ])
@ -134,13 +134,15 @@ class DecimalNumber(VMobject):
"i" "i"
]) ])
def get_tex(self):
return self.num_string
def set_value(self, number: float | complex): def set_value(self, number: float | complex):
move_to_point = self.get_edge_center(self.edge_to_fix) 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.set_submobjects_from_number(number)
self.move_to(move_to_point, self.edge_to_fix) self.move_to(move_to_point, self.edge_to_fix)
for sm1, sm2 in zip(self.submobjects, old_submobjects): self.set_style(**style)
sm1.match_style(sm2)
return self return self
def _handle_scale_side_effects(self, scale_factor: float) -> None: def _handle_scale_side_effects(self, scale_factor: float) -> None:

View file

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

View file

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

View file

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

View file

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

View file

@ -203,36 +203,38 @@ class VMobject(Mobject):
shadow: float | None = None, shadow: float | None = None,
recurse: bool = True recurse: bool = True
): ):
for mob in self.get_family(recurse):
if fill_rgba is not None: if fill_rgba is not None:
self.data['fill_rgba'] = resize_with_interpolation(fill_rgba, len(fill_rgba)) mob.data['fill_rgba'] = resize_with_interpolation(fill_rgba, len(fill_rgba))
else: else:
self.set_fill( mob.set_fill(
color=fill_color, color=fill_color,
opacity=fill_opacity, opacity=fill_opacity,
recurse=recurse recurse=False
) )
if stroke_rgba is not None: if stroke_rgba is not None:
self.data['stroke_rgba'] = resize_with_interpolation(stroke_rgba, len(fill_rgba)) mob.data['stroke_rgba'] = resize_with_interpolation(stroke_rgba, len(fill_rgba))
self.set_stroke( mob.set_stroke(
width=stroke_width, width=stroke_width,
background=stroke_background, background=stroke_background,
recurse=False,
) )
else: else:
self.set_stroke( mob.set_stroke(
color=stroke_color, color=stroke_color,
width=stroke_width, width=stroke_width,
opacity=stroke_opacity, opacity=stroke_opacity,
recurse=recurse, recurse=False,
background=stroke_background, background=stroke_background,
) )
if reflectiveness is not None: if reflectiveness is not None:
self.set_reflectiveness(reflectiveness, recurse=recurse) mob.set_reflectiveness(reflectiveness, recurse=False)
if gloss is not None: if gloss is not None:
self.set_gloss(gloss, recurse=recurse) mob.set_gloss(gloss, recurse=False)
if shadow is not None: if shadow is not None:
self.set_shadow(shadow, recurse=recurse) mob.set_shadow(shadow, recurse=False)
return self return self
def get_style(self): def get_style(self):
@ -1202,17 +1204,17 @@ class VHighlight(VGroup):
def __init__( def __init__(
self, self,
vmobject: VMobject, vmobject: VMobject,
n_layers: int = 3, n_layers: int = 5,
color_bounds: tuple[ManimColor] = (GREY_C, GREY_E), 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 = vmobject.replicate(n_layers)
outline.set_fill(opacity=0) 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) colors = color_gradient(color_bounds, n_layers)
for part, added_width, color in zip(reversed(outline), added_widths, colors): for part, added_width, color in zip(reversed(outline), added_widths, colors):
for sm in part.family_members_with_points(): for sm in part.family_members_with_points():
part.set_stroke( sm.set_stroke(
width=sm.get_stroke_width() + added_width, width=sm.get_stroke_width() + added_width,
color=color, 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 COMMAND_MODIFIER, SHIFT_MODIFIER
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, SMALL_BUFF 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 Rectangle
from manimlib.mobject.geometry import Square from manimlib.mobject.geometry import Square
from manimlib.mobject.mobject import Group 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 VHighlight
from manimlib.mobject.types.vectorized_mobject import VMobject from manimlib.mobject.types.vectorized_mobject import VMobject
from manimlib.scene.scene import Scene 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.family_ops import extract_mobject_family_members
from manimlib.utils.space_ops import get_norm from manimlib.utils.space_ops import get_norm
from manimlib.utils.tex_file_writing import LatexError 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] GRAB_KEYS = [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY]
RESIZE_KEY = 't' RESIZE_KEY = 't'
COLOR_KEY = 'c' 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. # 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_color = WHITE
selection_rectangle_stroke_width = 1.0 selection_rectangle_stroke_width = 1.0
colors = MANIM_COLORS palette_colors = MANIM_COLORS
selection_nudge_size = 0.05 selection_nudge_size = 0.05
cursor_location_config = dict( cursor_location_config = dict(
font_size=14, font_size=24,
fill_color=GREY_C, fill_color=GREY_C,
num_decimal_places=3, 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): def setup(self):
self.selection = Group() self.selection = Group()
self.selection_highlight = Group() self.selection_highlight = self.get_selection_highlight()
self.selection_rectangle = self.get_selection_rectangle() 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.color_palette = self.get_color_palette()
self.cursor_location_label = self.get_cursor_location_label()
self.unselectables = [ self.unselectables = [
self.selection, self.selection,
self.selection_highlight, self.selection_highlight,
self.selection_rectangle, self.selection_rectangle,
self.cursor_location_label, self.crosshair,
self.information_label,
self.camera.frame self.camera.frame
] ]
self.select_top_level_mobs = True self.select_top_level_mobs = True
@ -94,6 +107,7 @@ class InteractiveScene(Scene):
self.is_selecting = False self.is_selecting = False
self.is_grabbing = False self.is_grabbing = False
self.add(self.selection_highlight) self.add(self.selection_highlight)
def get_selection_rectangle(self): def get_selection_rectangle(self):
@ -106,7 +120,7 @@ class InteractiveScene(Scene):
rect.add_updater(self.update_selection_rectangle) rect.add_updater(self.update_selection_rectangle)
return rect return rect
def update_selection_rectangle(self, rect): def update_selection_rectangle(self, rect: Rectangle):
p1 = rect.fixed_corner p1 = rect.fixed_corner
p2 = self.mouse_point.get_center() p2 = self.mouse_point.get_center()
rect.set_points_as_corners([ rect.set_points_as_corners([
@ -116,10 +130,50 @@ class InteractiveScene(Scene):
]) ])
return rect 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): def get_color_palette(self):
palette = VGroup(*( palette = VGroup(*(
Square(fill_color=color, fill_opacity=1, side_length=1) 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.set_stroke(width=0)
palette.arrange(RIGHT, buff=0.5) palette.arrange(RIGHT, buff=0.5)
@ -128,22 +182,51 @@ class InteractiveScene(Scene):
palette.fix_in_frame() palette.fix_in_frame()
return palette return palette
def get_cursor_location_label(self): def get_information_label(self):
decimals = VGroup(*( loc_label = VGroup(*(
DecimalNumber(**self.cursor_location_config) DecimalNumber(**self.cursor_location_config)
for n in range(3) for n in range(3)
)) ))
def update_coords(decimals): def update_coords(loc_label):
for mob, coord in zip(decimals, self.mouse_point.get_location()): for mob, coord in zip(loc_label, self.mouse_point.get_location()):
mob.set_value(coord) mob.set_value(coord)
decimals.arrange(RIGHT, buff=decimals.get_height()) loc_label.arrange(RIGHT, buff=loc_label.get_height())
decimals.to_corner(DR, buff=SMALL_BUFF) loc_label.to_corner(DR, buff=SMALL_BUFF)
decimals.fix_in_frame() loc_label.fix_in_frame()
return decimals return loc_label
decimals.add_updater(update_coords) loc_label.add_updater(update_coords)
return decimals
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 # Related to selection
@ -184,7 +267,6 @@ class InteractiveScene(Scene):
curr, exclude_pointless=True, curr, exclude_pointless=True,
) )
) )
self.refresh_selection_highlight()
def get_corner_dots(self, mobject: Mobject) -> Mobject: def get_corner_dots(self, mobject: Mobject) -> Mobject:
dots = DotCloud(**self.corner_dot_config) dots = DotCloud(**self.corner_dot_config)
@ -192,7 +274,7 @@ class InteractiveScene(Scene):
if mobject.get_depth() < 1e-2: if mobject.get_depth() < 1e-2:
vects = [DL, UL, UR, DR] vects = [DL, UL, UR, DR]
else: 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([ dots.add_updater(lambda d: d.set_points([
mobject.get_corner(v) + v * radius mobject.get_corner(v) + v * radius
for v in vects for v in vects
@ -201,27 +283,19 @@ class InteractiveScene(Scene):
def get_highlight(self, mobject: Mobject) -> Mobject: def get_highlight(self, mobject: Mobject) -> Mobject:
if isinstance(mobject, VMobject) and mobject.has_points() and not self.select_top_level_mobs: if isinstance(mobject, VMobject) and mobject.has_points() and not self.select_top_level_mobs:
result = VHighlight(mobject) length = max([mobject.get_height(), mobject.get_width()])
result.add_updater(lambda m: m.replace(mobject)) result = VHighlight(
mobject,
max_stroke_addition=min([50 * length, 10]),
)
result.add_updater(lambda m: m.replace(mobject, stretch=True))
return result return result
elif isinstance(mobject, DotCloud):
return Mobject()
else: else:
return self.get_corner_dots(mobject) return self.get_corner_dots(mobject)
def refresh_selection_highlight(self): def add_to_selection(self, *mobjects: Mobject):
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):
mobs = list(filter( mobs = list(filter(
lambda m: m not in self.unselectables and m not in self.selection, lambda m: m not in self.unselectables and m not in self.selection,
mobjects mobjects
@ -229,46 +303,34 @@ class InteractiveScene(Scene):
if len(mobs) == 0: if len(mobs) == 0:
return return
self.selection.add(*mobs) self.selection.add(*mobs)
self.refresh_selection_highlight() self.selection.set_animating_status(True)
for sm in mobs:
for mob in self.mobjects:
if sm in mob.get_family():
mob.set_animating_status(True)
self.refresh_static_mobjects()
def toggle_from_selection(self, *mobjects): def toggle_from_selection(self, *mobjects: Mobject):
for mob in mobjects: for mob in mobjects:
if mob in self.selection: if mob in self.selection:
self.selection.remove(mob) self.selection.remove(mob)
mob.set_animating_status(False) mob.set_animating_status(False)
else: else:
self.add_to_selection(mob) self.add_to_selection(mob)
self.refresh_selection_highlight() self.refresh_static_mobjects()
def clear_selection(self): def clear_selection(self):
for mob in self.selection: for mob in self.selection:
mob.set_animating_status(False) mob.set_animating_status(False)
self.selection.set_submobjects([]) self.selection.set_submobjects([])
self.selection_highlight.set_submobjects([])
self.refresh_static_mobjects() 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): def disable_interaction(self, *mobjects: Mobject):
for mob in mobjects: for mob in mobjects:
self.unselectables.append(mob) for sm in mob.get_family():
self.unselectables.append(sm)
self.regenerate_selection_search_set() self.regenerate_selection_search_set()
def enable_interaction(self, *mobjects: Mobject): def enable_interaction(self, *mobjects: Mobject):
for mob in mobjects: for mob in mobjects:
if mob in self.unselectables: for sm in mob.get_family():
self.unselectables.remove(mob) if sm in self.unselectables:
self.unselectables.remove(sm)
# Functions for keyboard actions # Functions for keyboard actions
@ -308,10 +370,6 @@ class InteractiveScene(Scene):
self.remove(*self.selection) self.remove(*self.selection)
self.clear_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): def enable_selection(self):
self.is_selecting = True self.is_selecting = True
self.add(self.selection_rectangle) self.add(self.selection_rectangle)
@ -352,6 +410,12 @@ class InteractiveScene(Scene):
else: else:
self.remove(self.color_palette) 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): def group_selection(self):
group = self.get_group(*self.selection) group = self.get_group(*self.selection)
self.add(group) self.add(group)
@ -393,8 +457,8 @@ class InteractiveScene(Scene):
self.prepare_resizing(about_corner=True) self.prepare_resizing(about_corner=True)
elif char == COLOR_KEY and modifiers == 0: elif char == COLOR_KEY and modifiers == 0:
self.toggle_color_palette() self.toggle_color_palette()
elif char == CURSOR_LOCATION_KEY and modifiers == 0: elif char == INFORMATION_KEY and modifiers == 0:
self.add(self.cursor_location_label) self.display_information()
elif char == "c" and modifiers == COMMAND_MODIFIER: elif char == "c" and modifiers == COMMAND_MODIFIER:
self.copy_selection() self.copy_selection()
elif char == "v" and modifiers == COMMAND_MODIFIER: elif char == "v" and modifiers == COMMAND_MODIFIER:
@ -420,6 +484,14 @@ class InteractiveScene(Scene):
vect=[LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)], vect=[LEFT, UP, RIGHT, DOWN][ARROW_SYMBOLS.index(symbol)],
large=(modifiers & SHIFT_MODIFIER), 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 # Conditions for saving state
if char in [GRAB_KEY, X_GRAB_KEY, Y_GRAB_KEY, RESIZE_KEY]: 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) super().on_key_release(symbol, modifiers)
if chr(symbol) == SELECT_KEY: if chr(symbol) == SELECT_KEY:
self.gather_new_selection() self.gather_new_selection()
# self.remove(self.crosshair)
if chr(symbol) in GRAB_KEYS: if chr(symbol) in GRAB_KEYS:
self.is_grabbing = False self.is_grabbing = False
elif chr(symbol) == CURSOR_LOCATION_KEY: elif chr(symbol) == INFORMATION_KEY:
self.remove(self.cursor_location_label) self.display_information(False)
elif symbol == SHIFT_SYMBOL and self.window.is_key_pressed(ord(RESIZE_KEY)): elif symbol == SHIFT_SYMBOL and self.window.is_key_pressed(ord(RESIZE_KEY)):
self.prepare_resizing(about_corner=False) self.prepare_resizing(about_corner=False)
@ -447,6 +520,8 @@ class InteractiveScene(Scene):
self.selection.set_y(diff[1]) self.selection.set_y(diff[1])
def handle_resizing(self, point: np.ndarray): def handle_resizing(self, point: np.ndarray):
if not hasattr(self, "scale_about_point"):
return
vect = point - self.scale_about_point vect = point - self.scale_about_point
if self.window.is_key_pressed(CTRL_SYMBOL): if self.window.is_key_pressed(CTRL_SYMBOL):
for i in (0, 1): for i in (0, 1):
@ -485,17 +560,9 @@ class InteractiveScene(Scene):
self.selection.set_color(mob.get_color()) self.selection.set_color(mob.get_color())
self.remove(self.color_palette) 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: def on_mouse_motion(self, point: np.ndarray, d_point: np.ndarray) -> None:
super().on_mouse_motion(point, d_point) super().on_mouse_motion(point, d_point)
self.crosshair.move_to(point)
if self.is_grabbing: if self.is_grabbing:
self.handle_grabbing(point) self.handle_grabbing(point)
elif self.window.is_key_pressed(ord(RESIZE_KEY)): elif self.window.is_key_pressed(ord(RESIZE_KEY)):
@ -507,7 +574,13 @@ class InteractiveScene(Scene):
super().on_mouse_release(point, button, mods) super().on_mouse_release(point, button, mods)
if self.color_palette in self.mobjects: if self.color_palette in self.mobjects:
self.choose_color(point) self.choose_color(point)
elif self.window.is_key_pressed(SHIFT_SYMBOL): return
self.toggle_clicked_mobject_from_selection(point) 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: else:
self.clear_selection() self.clear_selection()

View file

@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from collections import OrderedDict
from functools import wraps from functools import wraps
import inspect import inspect
import os import os
import platform import platform
import pyperclip
import random import random
import time import time
@ -11,7 +13,6 @@ import numpy as np
from tqdm import tqdm as ProgressDisplay from tqdm import tqdm as ProgressDisplay
from manimlib.animation.animation import prepare_animation from manimlib.animation.animation import prepare_animation
from manimlib.animation.transform import MoveToTarget
from manimlib.camera.camera import Camera from manimlib.camera.camera import Camera
from manimlib.constants import ARROW_SYMBOLS from manimlib.constants import ARROW_SYMBOLS
from manimlib.constants import DEFAULT_WAIT_TIME 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 import EVENT_DISPATCHER
from manimlib.event_handler.event_type import EventType from manimlib.event_handler.event_type import EventType
from manimlib.logger import log from manimlib.logger import log
from manimlib.mobject.mobject import _AnimationBuilder
from manimlib.mobject.mobject import Group from manimlib.mobject.mobject import Group
from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Mobject
from manimlib.mobject.mobject import Point from manimlib.mobject.mobject import Point
@ -61,7 +63,7 @@ class Scene(object):
"leave_progress_bars": False, "leave_progress_bars": False,
"preview": True, "preview": True,
"presenter_mode": False, "presenter_mode": False,
"linger_after_completion": True, "show_animation_progress": False,
"pan_sensitivity": 3, "pan_sensitivity": 3,
"max_num_saved_states": 50, "max_num_saved_states": 50,
} }
@ -72,7 +74,7 @@ class Scene(object):
from manimlib.window import Window from manimlib.window import Window
self.window = Window(scene=self, **self.window_config) self.window = Window(scene=self, **self.window_config)
self.camera_config["ctx"] = self.window.ctx 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.undo_stack = []
self.redo_stack = [] self.redo_stack = []
else: else:
@ -86,14 +88,19 @@ class Scene(object):
self.time: float = 0 self.time: float = 0
self.skip_time: float = 0 self.skip_time: float = 0
self.original_skipping_status: bool = self.skip_animations 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: if self.start_at_animation_number is not None:
self.skip_animations = True self.skip_animations = True
if self.file_writer.has_progress_display:
self.show_animation_progress = False
# Items associated with interaction # Items associated with interaction
self.mouse_point = Point() self.mouse_point = Point()
self.mouse_drag_point = Point() self.mouse_drag_point = Point()
self.hold_on_wait = self.presenter_mode self.hold_on_wait = self.presenter_mode
self.inside_embed = False self.inside_embed = False
self.quit_interaction = False
# Much nicer to work with deterministic scenes # Much nicer to work with deterministic scenes
if self.random_seed is not None: if self.random_seed is not None:
@ -111,8 +118,13 @@ class Scene(object):
self.setup() self.setup()
try: try:
self.construct() self.construct()
except EndSceneEarlyException: self.interact()
except EndScene:
pass pass
except KeyboardInterrupt:
# Get rid keyboard interupt symbols
print("", end="\r")
self.file_writer.ended_with_interrupt = True
self.tear_down() self.tear_down()
def setup(self) -> None: def setup(self) -> None:
@ -131,32 +143,31 @@ class Scene(object):
def tear_down(self) -> None: def tear_down(self) -> None:
self.stop_skipping() self.stop_skipping()
self.file_writer.finish() self.file_writer.finish()
if self.window and self.linger_after_completion: if self.window:
self.interact() self.window.destroy()
self.window = None
def interact(self) -> None: def interact(self) -> None:
# If there is a window, enter a loop # If there is a window, enter a loop
# which updates the frame while under # which updates the frame while under
# the hood calling the pyglet event loop # the hood calling the pyglet event loop
if self.window is None:
return
log.info( log.info(
"Tips: You are now in the interactive mode. Now you can use the keyboard" "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`" " and the mouse to interact with the scene. Just press `command + q` or `esc`"
" if you want to quit." " if you want to quit."
) )
self.quit_interaction = False self.skip_animations = False
self.refresh_static_mobjects() self.refresh_static_mobjects()
while not (self.window.is_closing or self.quit_interaction): while not self.is_window_closing():
self.update_frame(1 / self.camera.frame_rate) self.update_frame(1 / self.camera.fps)
if self.window.is_closing:
self.window.destroy()
def embed(self, close_scene_on_exit: bool = True) -> None: def embed(self, close_scene_on_exit: bool = True) -> None:
if not self.preview: if not self.preview:
# Ignore embed calls when there is no preview return # Embed is only relevant with a preview
return
self.inside_embed = True self.inside_embed = True
self.stop_skipping() self.stop_skipping()
self.linger_after_completion = False
self.update_frame() self.update_frame()
self.save_state() 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 # Enables gui interactions during the embed
def inputhook(context): def inputhook(context):
while not context.input_is_ready(): while not context.input_is_ready():
if not self.window.is_closing: if not self.is_window_closing():
self.update_frame(dt=0) self.update_frame(dt=0)
if self.is_window_closing():
shell.ask_exit()
pt_inputhooks.register("manim", inputhook) pt_inputhooks.register("manim", inputhook)
shell.enable_gui("manim") 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 # Operation to run after each ipython command
def post_cell_func(*args, **kwargs): def post_cell_func():
self.refresh_static_mobjects() self.refresh_static_mobjects()
if not self.is_window_closing():
self.update_frame(dt=0, ignore_skipping=True)
self.save_state() self.save_state()
shell.events.register("post_run_cell", post_cell_func) 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) shell(local_ns=local_ns, stack_depth=2)
self.inside_embed = False
# End scene when exiting an embed # End scene when exiting an embed
if close_scene_on_exit: if close_scene_on_exit:
raise EndSceneEarlyException() raise EndScene()
# Only these methods should touch the camera # Only these methods should touch the camera
@ -214,6 +266,9 @@ class Scene(object):
if self.skip_animations and not ignore_skipping: if self.skip_animations and not ignore_skipping:
return return
if self.is_window_closing():
raise EndScene()
if self.window: if self.window:
self.window.clear() self.window.clear()
self.camera.clear() self.camera.clear()
@ -396,7 +451,7 @@ class Scene(object):
self.stop_skipping() self.stop_skipping()
if self.end_at_animation_number is not None: if self.end_at_animation_number is not None:
if self.num_plays >= self.end_at_animation_number: if self.num_plays >= self.end_at_animation_number:
raise EndSceneEarlyException() raise EndScene()
def stop_skipping(self) -> None: def stop_skipping(self) -> None:
self.virtual_animation_start_time = self.time self.virtual_animation_start_time = self.time
@ -413,14 +468,13 @@ class Scene(object):
) -> list[float] | np.ndarray | ProgressDisplay: ) -> list[float] | np.ndarray | ProgressDisplay:
if self.skip_animations and not override_skip_animations: if self.skip_animations and not override_skip_animations:
return [run_time] return [run_time]
else:
step = 1 / self.camera.frame_rate times = np.arange(0, run_time, 1 / self.camera.fps)
times = np.arange(0, run_time, step)
if self.file_writer.has_progress_display: if self.file_writer.has_progress_display:
self.file_writer.set_progress_display_subdescription(desc) self.file_writer.set_progress_display_subdescription(desc)
return times
if self.show_animation_progress:
return ProgressDisplay( return ProgressDisplay(
times, times,
total=n_iterations, total=n_iterations,
@ -428,9 +482,11 @@ class Scene(object):
ascii=True if platform.system() == 'Windows' else None, ascii=True if platform.system() == 'Windows' else None,
desc=desc, desc=desc,
) )
else:
return times
def get_run_time(self, animations: Iterable[Animation]) -> float: 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( def get_animation_time_progression(
self, self,
@ -454,74 +510,16 @@ class Scene(object):
kw["override_skip_animations"] = True kw["override_skip_animations"] = True
return self.get_time_progression(duration, **kw) return self.get_time_progression(duration, **kw)
def anims_from_play_args(self, *args, **kwargs) -> list[Animation]: def prepare_animations(
""" self,
Each arg can either be an animation, or a mobject method proto_animations: list[Animation | _AnimationBuilder],
followed by that methods arguments (and potentially follow animation_config: dict,
by a dict of kwargs for that method). ):
This animation list is built by going through the args list, animations = list(map(prepare_animation, proto_animations))
and each animation is simply added, but when a mobject method for anim in animations:
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:
# This is where kwargs to play like run_time and rate_func # This is where kwargs to play like run_time and rate_func
# get applied to all animations # get applied to all animations
animation.update_config(**kwargs) anim.update_config(**animation_config)
return animations return animations
def handle_play_like_call(func): def handle_play_like_call(func):
@ -529,6 +527,8 @@ class Scene(object):
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
if self.inside_embed: if self.inside_embed:
self.save_state() self.save_state()
if self.presenter_mode and self.num_plays == 0:
self.hold_loop()
self.update_skipping_status() self.update_skipping_status()
should_write = not self.skip_animations should_write = not self.skip_animations
@ -548,6 +548,10 @@ class Scene(object):
if self.inside_embed: if self.inside_embed:
self.save_state() 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 self.num_plays += 1
return wrapper return wrapper
@ -587,11 +591,11 @@ class Scene(object):
self.update_mobjects(0) self.update_mobjects(0)
@handle_play_like_call @handle_play_like_call
def play(self, *args, **kwargs) -> None: def play(self, *proto_animations, **animation_config) -> None:
if len(args) == 0: if len(proto_animations) == 0:
log.warning("Called Scene.play with no animations") log.warning("Called Scene.play with no animations")
return return
animations = self.anims_from_play_args(*args, **kwargs) animations = self.prepare_animations(proto_animations, animation_config)
self.begin_animations(animations) self.begin_animations(animations)
self.progress_through_animations(animations) self.progress_through_animations(animations)
self.finish_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 self.presenter_mode and not self.skip_animations and not ignore_presenter_mode:
if note: if note:
log.info(note) log.info(note)
while self.hold_on_wait: self.hold_loop()
self.update_frame(dt=1 / self.camera.frame_rate)
self.hold_on_wait = True
else: else:
time_progression = self.get_wait_time_progression(duration, stop_condition) time_progression = self.get_wait_time_progression(duration, stop_condition)
last_t = 0 last_t = 0
@ -624,6 +626,11 @@ class Scene(object):
self.refresh_static_mobjects() self.refresh_static_mobjects()
return self 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( def wait_until(
self, self,
stop_condition: Callable[[], bool], stop_condition: Callable[[], bool],
@ -655,50 +662,52 @@ class Scene(object):
# Helpers for interactive development # Helpers for interactive development
def get_state(self) -> tuple[list[tuple[Mobject, Mobject]], int]: def get_state(self) -> SceneState:
if self.undo_stack: return SceneState(self)
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 restore_state(self, mobject_states: list[tuple[Mobject, Mobject]]): def restore_state(self, scene_state: SceneState):
self.mobjects = [mob.become(mob_copy) for mob, mob_copy in mobject_states] scene_state.restore_scene(self)
def save_state(self) -> None: def save_state(self) -> None:
if not self.preview: if not self.preview:
return return
state = self.get_state()
if self.undo_stack and state.mobjects_match(self.undo_stack[-1]):
return
self.redo_stack = [] self.redo_stack = []
state, n_changes = self.get_state()
if n_changes > 0:
self.undo_stack.append(state) self.undo_stack.append(state)
if len(self.undo_stack) > self.max_num_saved_states: if len(self.undo_stack) > self.max_num_saved_states:
self.undo_stack.pop(0) self.undo_stack.pop(0)
def undo(self): def undo(self):
if self.undo_stack: if self.undo_stack:
state, n_changes = self.get_state() self.redo_stack.append(self.get_state())
self.redo_stack.append(state)
self.restore_state(self.undo_stack.pop()) self.restore_state(self.undo_stack.pop())
self.refresh_static_mobjects() self.refresh_static_mobjects()
def redo(self): def redo(self):
if self.redo_stack: if self.redo_stack:
state, n_changes = self.get_state() self.undo_stack.append(self.get_state())
self.undo_stack.append(state)
self.restore_state(self.redo_stack.pop()) self.restore_state(self.redo_stack.pop())
self.refresh_static_mobjects() 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: def save_mobject_to_file(self, mobject: Mobject, file_path: str | None = None) -> None:
if file_path is None: if file_path is None:
file_path = self.file_writer.get_saved_mobject_path(mobject) file_path = self.file_writer.get_saved_mobject_path(mobject)
@ -714,6 +723,9 @@ class Scene(object):
path = os.path.join(directory, file_name) path = os.path.join(directory, file_name)
return Mobject.load(path) return Mobject.load(path)
def is_window_closing(self):
return self.window and (self.window.is_closing or self.quit_interaction)
# Event handling # Event handling
def on_mouse_motion( def on_mouse_motion(
@ -850,5 +862,49 @@ class Scene(object):
pass 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 pass

View file

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

View file

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

View file

@ -13,19 +13,12 @@ if TYPE_CHECKING:
S = TypeVar("S") 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 Used instead of list(set(l)) to maintain order
Keeps the last occurrence of each element Keeps the last occurrence of each element
""" """
reversed_result = [] return list(reversed(dict.fromkeys(reversed(lst))))
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
def list_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]: 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, Used instead of list(set(l1).update(l2)) to maintain order,
making sure duplicates are removed from l1, not l2. 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]: def list_difference_update(l1: Iterable[T], l2: Iterable[T]) -> list[T]:

View file

@ -90,11 +90,13 @@ def tex_to_dvi(tex_file: str) -> str:
if exit_code != 0: if exit_code != 0:
log_file = tex_file.replace(".tex", ".log") log_file = tex_file.replace(".tex", ".log")
log.error("LaTeX Error! Not a worry, it happens to the best of us.") log.error("LaTeX Error! Not a worry, it happens to the best of us.")
error_str = ""
with open(log_file, "r", encoding="utf-8") as file: with open(log_file, "r", encoding="utf-8") as file:
for line in file.readlines(): for line in file.readlines():
if line.startswith("!"): if line.startswith("!"):
log.debug(f"The error could be: `{line[2:-1]}`") error_str = line[2:-1]
raise LatexError() log.debug(f"The error could be: `{error_str}`")
raise LatexError(error_str)
return result return result