diff --git a/README.md b/README.md index 72846d99..69300a70 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Set MEDIA_DIR environment variable to determine where image and animation files Look through the old_projects folder to see the code for previous 3b1b videos. Note, however, that developments are often made to the library without considering backwards compatibility on those old_projects. To run them with a guarantee that they will work, you will have to go back to the commit which complete that project. -While developing a scene, the `-s` flag is helpful to just see what things look like at the end without having to generate the full animation. It can also be helpful to use the `-n` flag to skip over some number of animations. +While developing a scene, the `-sp` flags are helpful to just see what things look like at the end without having to generate the full animation. It can also be helpful to use the `-n` flag to skip over some number of animations. ### Documentation Documentation is in progress at [manim.readthedocs.io](https://manim.readthedocs.io). diff --git a/active_projects/clacks/all_questions_scenes.py b/active_projects/clacks/all_questions_scenes.py new file mode 100644 index 00000000..c29d03ce --- /dev/null +++ b/active_projects/clacks/all_questions_scenes.py @@ -0,0 +1,8 @@ +from active_projects import clacks + +output_directory = "clacks_question" +all_scenes = [ + clacks.NameIntro, + clacks.MathAndPhysicsConspiring, + clacks.LightBouncing, +] diff --git a/active_projects/clacks/name_bump.py b/active_projects/clacks/name_bump.py new file mode 100644 index 00000000..7ef69b59 --- /dev/null +++ b/active_projects/clacks/name_bump.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +from big_ol_pile_of_manim_imports import * + +from active_projects.clacks.question import BlocksAndWallExample + + +class NameBump(BlocksAndWallExample): + CONFIG = { + "name": "Magnus Lysfjord", + "sliding_blocks_config": { + "block1_config": { + "mass": 1e6, + "velocity": -0.5, + "distance": 7, + }, + "block2_config": {}, + }, + "wait_time": 25, + } + + def setup(self): + names = self.name.split(" ") + n = len(names) + if n == 1: + names = 2 * [names[0]] + elif n > 2: + names = [ + " ".join(names[:n // 2]), + " ".join(names[n // 2:]), + ] + # Swap, to show first name on the left + names = [names[1], names[0]] + + name_mobs = VGroup(*map(TextMobject, names)) + name_mobs.set_stroke(BLACK, 3, background=True) + name_mobs.set_fill(LIGHT_GREY, 1) + name_mobs.set_sheen(3, UL) + name_mobs.scale(2) + configs = [ + self.sliding_blocks_config["block1_config"], + self.sliding_blocks_config["block2_config"], + ] + for name_mob, config in zip(name_mobs, configs): + config["width"] = name_mob.get_width() + self.name_mobs = name_mobs + + super().setup() + + def add_blocks(self): + super().add_blocks() + blocks = self.blocks + name_mobs = self.name_mobs + + blocks.fade(1) + + def update_name_mobs(name_mobs): + for name_mob, block in zip(name_mobs, self.blocks): + name_mob.move_to(block) + target_y = block.get_bottom()[1] + SMALL_BUFF + curr_y = name_mob[0].get_bottom()[1] + name_mob.shift((target_y - curr_y) * UP) + + name_mobs.add_updater(update_name_mobs) + self.add(name_mobs) + + clack_y = self.name_mobs[1].get_center()[1] + for location, time in self.clack_data: + location[1] = clack_y + + for block, name_mob in zip(blocks, name_mobs): + block.label.next_to(name_mob, UP) + block.label.set_fill(YELLOW, opacity=1) + + +# for name in names: +# file_name = name.replace(".", "") +# file_name += " Name Bump" +# scene = NameBump( +# name=name, +# write_to_movie=True, +# output_file_name=file_name, +# camera_config=PRODUCTION_QUALITY_CAMERA_CONFIG, +# frame_duration=PRODUCTION_QUALITY_FRAME_DURATION, +# ) diff --git a/active_projects/clacks.py b/active_projects/clacks/question.py similarity index 97% rename from active_projects/clacks.py rename to active_projects/clacks/question.py index ec80ed76..17dab588 100644 --- a/active_projects/clacks.py +++ b/active_projects/clacks/question.py @@ -27,6 +27,8 @@ class Block(Square): self.fill_color = self.mass_to_color(self.mass) if self.label_text is None: self.label_text = self.mass_to_label_text(self.mass) + if "width" in kwargs: + kwargs.pop("width") Square.__init__(self, side_length=self.width, **kwargs) self.label = self.get_label() self.add(self.label) @@ -130,8 +132,8 @@ class SlidingBlocks(VGroup): def update_blocks_from_phase_space_point_tracker(self): block1, block2 = self.block1, self.block2 - ps_point = self.phase_space_point_tracker.get_location() + theta = np.arctan(np.sqrt(self.mass_ratio)) ps_point_angle = angle_of_vector(ps_point) n_clacks = int(ps_point_angle / theta) @@ -274,7 +276,6 @@ class BlocksAndWallScene(Scene): "collision_sound": "clack.wav", "show_flash_animations": True, "min_time_between_sounds": 0.004, - "allow_sound": True, } def setup(self): @@ -345,10 +346,7 @@ class BlocksAndWallScene(Scene): self.counter_mob.set_value(n_clacks) def create_sound_file(self, clack_data): - directory = get_scene_output_directory(BlocksAndWallScene) - clack_file = os.path.join( - directory, 'sounds', self.collision_sound, - ) + clack_file = os.path.join(SOUND_DIR, self.collision_sound) output_file = self.get_movie_file_path(extension='.wav') times = [ time @@ -377,6 +375,8 @@ class BlocksAndWallScene(Scene): clacks.export(output_file, format="wav") return output_file + # TODO, this no longer works + # should use Scene.add_sound instead def combine_movie_files(self): Scene.combine_movie_files(self) if self.include_sound: @@ -848,9 +848,14 @@ class BlocksAndWallExampleMass1e10(BlocksAndWallExample): class DigitsOfPi(Scene): + CONFIG = {"n_digits": 9} + def construct(self): + nd = self.n_digits + pow10 = int(10**nd) + rounded_pi = int(pow10 * PI) / pow10 equation = TexMobject( - "\\pi = 3.14159265..." + ("\\pi = {:." + str(nd) + "f}...").format(rounded_pi) ) equation.set_color(YELLOW) pi_creature = Randolph(color=YELLOW) @@ -858,9 +863,11 @@ class DigitsOfPi(Scene): pi_creature.scale(1.4) pi_creature.move_to(equation[0], DOWN) self.add(pi_creature, equation[1]) - for digit in equation[2:]: - self.add(digit) - self.wait(0.1) + self.play(ShowIncreasingSubsets( + equation[2:], + rate_func=None, + run_time=1, + )) self.play(Blink(pi_creature)) self.wait() @@ -1553,7 +1560,7 @@ class EndScreen(Scene): ) -class Thumbnail(BlocksAndWallExample): +class Thumbnail(BlocksAndWallExample, MovingCameraScene): CONFIG = { "sliding_blocks_config": { "block1_config": { @@ -1565,19 +1572,34 @@ class Thumbnail(BlocksAndWallExample): "wait_time": 0, "count_clacks": False, "show_flash_animations": False, - "floor_y": -3, + "floor_y": -3.0, } + def setup(self): + MovingCameraScene.setup(self) + BlocksAndWallExample.setup(self) + def construct(self): + self.camera_frame.shift(0.9 * UP) + self.thicken_lines() + self.grow_labels() + self.add_vector() + self.add_text() + + def thicken_lines(self): self.floor.set_stroke(WHITE, 10) self.wall.set_stroke(WHITE, 10) self.wall[1:].set_stroke(WHITE, 4) + + def grow_labels(self): blocks = self.blocks for block in blocks.block1, blocks.block2: block.remove(block.label) block.label.scale(2.5, about_point=block.get_top()) self.add(block.label) + def add_vector(self): + blocks = self.blocks arrow = Vector( 2.5 * LEFT, color=RED, @@ -1590,9 +1612,10 @@ class Thumbnail(BlocksAndWallExample): ) self.add(arrow) + def add_text(self): question = TextMobject("How many\\\\collisions?") question.scale(2.5) question.to_edge(UP) question.set_color(YELLOW) question.set_stroke(RED, 2, background=True) - self.add(question) \ No newline at end of file + self.add(question) diff --git a/active_projects/clacks_solution1.py b/active_projects/clacks/solution1.py similarity index 77% rename from active_projects/clacks_solution1.py rename to active_projects/clacks/solution1.py index 83088a20..fdd139b2 100644 --- a/active_projects/clacks_solution1.py +++ b/active_projects/clacks/solution1.py @@ -1,8 +1,8 @@ from big_ol_pile_of_manim_imports import * -from active_projects.clacks import * +from active_projects.clacks.question import * +from old_projects.div_curl import ShowTwoPopulations -# TODO, add solution image class FromPuzzleToSolution(MovingCameraScene): def construct(self): big_rect = FullScreenFadeRectangle() @@ -20,7 +20,7 @@ class FromPuzzleToSolution(MovingCameraScene): images = Group( ImageMobject("BlocksAndWallExampleMass16"), - ImageMobject("SphereSurfaceProof2"), # TODO + ImageMobject("AnalyzeCircleGeometry"), ) for title, rect, image in zip(titles, rects, images): title.scale(1.5) @@ -138,6 +138,42 @@ class BlocksAndWallExampleMassTrillion(BlocksAndWallExample): } +class First6DigitsOfPi(DigitsOfPi): + CONFIG = {"n_digits": 6} + + +class FavoritesInDescription(Scene): + def construct(self): + words = TextMobject("(See the description for \\\\ some favorites)") + words.scale(1.5) + self.add(words) + + +class V1EqualsV2Line(Scene): + def construct(self): + line = Line(LEFT, 7 * RIGHT) + eq = TexMobject("v_1", "=", "v_2") + eq.set_color_by_tex("v_", RED) + eq.next_to(RIGHT, UR, SMALL_BUFF) + self.play( + Write(eq, run_time=1), + ShowCreation(line), + ) + self.wait() + + +class PhaseSpaceTitle(Scene): + def construct(self): + title = TextMobject("Phase space") + title.scale(1.5) + title.to_edge(UP) + rect = ScreenRectangle(height=6) + rect.next_to(title, DOWN) + self.add(rect) + self.play(Write(title, run_time=1)) + self.wait() + + class AskAboutFindingNewVelocities(Scene): CONFIG = { "floor_y": -3, @@ -170,10 +206,7 @@ class AskAboutFindingNewVelocities(Scene): self.show_value_on_equations() def add_clack_sound_file(self): - self.clack_file = os.path.join( - VIDEO_DIR, "active_projects", - "clacks", "sounds", "clack.wav" - ) + self.clack_file = os.path.join(SOUND_DIR, "clack.wav") def add_floor(self): floor = self.floor = Line( @@ -267,6 +300,9 @@ class AskAboutFindingNewVelocities(Scene): self.halt() self.play(randy.look_at, velocity_labels[-1]) self.play(Blink(randy)) + self.play(randy.change, "confused") + self.play(Blink(randy)) + self.wait() self.play( FadeInFrom(energy_words, RIGHT), FadeInFromDown(energy_expression), @@ -409,7 +445,8 @@ class AskAboutFindingNewVelocities(Scene): v_decimals.add_updater(update_v_decimals) self.add(v_decimals) self.unhalt() - for x in range(4): + self.vps_point.save_state() + for x in range(8): self.go_through_next_collision() energy_decimal.clear_updaters() momentum_decimal.set_value(get_momentum()) @@ -427,13 +464,14 @@ class AskAboutFindingNewVelocities(Scene): ], ]) self.unhalt() + self.vps_point.restore() momentum_decimal.add_updater( lambda m: m.set_value(get_momentum()) ) momentum_decimal.add_updater( lambda m: m.next_to(momentum_const_brace, UP, SMALL_BUFF) ) - for x in range(4): + for x in range(9): self.go_through_next_collision() self.wait(10) @@ -596,6 +634,7 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): "unit_size": 0.7, }, }, + "momentum_line_scale_factor": 4, } def construct(self): @@ -747,9 +786,10 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): ellipse = self.ellipse = Circle(color=YELLOW) ellipse.set_stroke(BLACK, 5, background=True) ellipse.rotate(PI) + mass_ratio = self.block1.mass / self.block2.mass ellipse.replace( Polygon(*[ - axes.coords_to_point(x, y * np.sqrt(10)) + axes.coords_to_point(x, y * np.sqrt(mass_ratio)) for x, y in [(1, 0), (0, 1), (-1, 0), (0, -1)] ]), stretch=True @@ -776,8 +816,9 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): lambda m: m.move_to(ellipse.get_left()) ) + mass_ratio = self.block1.mass / self.block2.mass brief_circle = ellipse.copy() - brief_circle.stretch(np.sqrt(10), 0) + brief_circle.stretch(np.sqrt(mass_ratio), 0) brief_circle.set_stroke(WHITE, 2) xy_equation = self.xy_equation = TexMobject( @@ -892,7 +933,7 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): m1 = self.block1.mass m2 = self.block2.mass line = Line(np.sqrt(m2) * LEFT, np.sqrt(m1) * DOWN) - line.scale(4) + line.scale(self.momentum_line_scale_factor) line.set_stroke(GREEN, 3) line.move_to(vps_dot) @@ -989,6 +1030,20 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): ) self.wait() + group = VGroup( + self.ellipse, + self.lines[-1], + self.vps_dot.copy().clear_updaters() + ) + for x in range(2): + self.play( + Rotate( + group, PI, RIGHT, + about_point=self.axes.coords_to_point(0, 0) + ), + ) + self.remove(group[-1]) + def show_remaining_collisions(self): line = self.momentum_line # slope_group = self.slope_group @@ -1019,6 +1074,8 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): # Helpers def add_update_line(self, func): + if not hasattr(self, "lines"): + self.lines = VGroup() if hasattr(self, "vps_dot"): old_vps_point = self.vps_dot.get_center() func() @@ -1027,6 +1084,7 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): line = Line(old_vps_point, new_vps_point) line.set_stroke(WHITE, 2) self.add(line) + self.lines.add(line) else: func() @@ -1037,17 +1095,98 @@ class IntroduceVelocityPhaseSpace(AskAboutFindingNewVelocities): self.add_update_line(super().reflect_block2) +class IntroduceVelocityPhaseSpaceWith16(IntroduceVelocityPhaseSpace): + CONFIG = { + "block1_config": { + "mass": 16, + "velocity": -0.5, + }, + "momentum_line_scale_factor": 0, + } + + +class SimpleRect(Scene): + def construct(self): + self.add(Rectangle(width=6, height=2, color=WHITE)) + + +class SurprisedRandy(Scene): + def construct(self): + randy = Randolph() + self.play(FadeIn(randy)) + self.play(randy.change, "surprised", 3 * UR) + self.play(Blink(randy)) + self.wait() + self.play(randy.change, "pondering", 3 * UR) + self.play(Blink(randy)) + self.wait(2) + self.play(FadeOut(randy)) + + +class HuntForPi(TeacherStudentsScene): + def construct(self): + self.student_says( + "Hunt for $\\pi$!", + bubble_kwargs={"direction": LEFT}, + target_mode="hooray" + ) + self.change_all_student_modes( + "hooray", + added_anims=[self.teacher.change, "happy"] + ) + self.wait() + + +class StretchBySqrt10(Scene): + def construct(self): + arrow = DoubleArrow(2 * LEFT, 2 * RIGHT) + arrow.tip[1].shift(0.05 * LEFT) + value = TexMobject("\\sqrt{10}") + value.next_to(arrow, UP) + arrow.save_state() + arrow.stretch(0, 0) + self.play( + Restore(arrow), + Write(value, run_time=1), + ) + self.wait() + + +class XCoordNegative(Scene): + def construct(self): + rect = Rectangle(height=4, width=4) + rect.set_stroke(width=0) + rect.set_fill(RED, 0.5) + rect.save_state() + rect.stretch(0, 0, about_edge=RIGHT) + self.play(Restore(rect)) + self.wait() + + +class YCoordZero(Scene): + def construct(self): + rect = Rectangle(height=4, width=8) + rect.set_stroke(width=0) + rect.set_fill(WHITE, 0.5) + rect.save_state() + self.play( + rect.stretch, 0.01, 1, + rect.set_fill, {"opacity": 1} + ) + self.wait() + + class CircleDiagramFromSlidingBlocks(Scene): CONFIG = { "BlocksAndWallSceneClass": BlocksAndWallExampleMass1e1, "circle_config": { "radius": 2, "stroke_color": YELLOW, - "stroke_width": 2, + "stroke_width": 3, }, "lines_style": { "stroke_color": WHITE, - "stroke_width": 1, + "stroke_width": 2, }, "axes_config": { "style": { @@ -1097,6 +1236,7 @@ class CircleDiagramFromSlidingBlocks(Scene): last_time = time dot.move_to(line.get_end()) self.add(line, dot) + self.wait() def get_circle(self): circle = Circle(**self.circle_config) @@ -1348,8 +1488,13 @@ class AnalyzeCircleGeometry(CircleDiagramFromSlidingBlocks, MovingCameraScene): for n, arc in enumerate(all_arcs): count_mob = Integer(n + 1) count_mob.scale(0.75) + buff = SMALL_BUFF + if len(all_arcs) > 100: + count_mob.scale(0.1) + count_mob.set_stroke(WHITE, 0.25) + buff = 0.4 * SMALL_BUFF point = arc.point_from_proportion(0.5) - count_mob.next_to(point, normalize(point), SMALL_BUFF) + count_mob.next_to(point, normalize(point), buff) arc_counts.add(count_mob) self.play( @@ -1616,9 +1761,9 @@ class InscribedAngleTheorem(Scene): self.add(circle, center_dot) angle_trackers = self.angle_trackers = VGroup( - ValueTracker(TAU / 8), + ValueTracker(TAU / 4), ValueTracker(PI), - ValueTracker(-TAU / 8), + ValueTracker(-TAU / 4), ) def get_point(angle): @@ -1719,11 +1864,6 @@ class InscribedAngleTheorem(Scene): Write(theta_label), ) self.wait() - self.play( - TransformFromCopy(lines, center_lines), - TransformFromCopy(theta_label, two_theta_label), - ) - self.wait() # Add updaters labels.add_updater(update_labels) @@ -1733,10 +1873,24 @@ class InscribedAngleTheorem(Scene): theta_label.add_updater(lambda m: m.become(get_theta_label())) two_theta_label.add_updater(lambda m: m.become(get_2theta_label())) - self.add( - labels, lines, center_lines, dots, - theta_label, two_theta_label, + self.add(labels, lines, dots, theta_label) + # Further animations + self.play( + angle_trackers[0].set_value, TAU / 8, ) + self.play( + angle_trackers[2].set_value, -TAU / 8, + ) + self.wait() + center_lines.update() + two_theta_label.update() + self.play( + TransformFromCopy(lines.copy().clear_updaters(), center_lines), + TransformFromCopy(theta_label.copy().clear_updaters(), two_theta_label), + ) + self.wait() + + self.add(center_lines, two_theta_label) def let_point_vary(self): p1_tracker, p2_tracker, p3_tracker = self.angle_trackers @@ -1765,6 +1919,22 @@ class InscribedAngleTheorem(Scene): self.wait() +class SimpleSlopeLabel(Scene): + def construct(self): + label = TexMobject( + "\\text{Slope}", "=", + "-\\frac{\\sqrt{m_1}}{\\sqrt{m_2}}" + ) + vector = Vector(DOWN + 2 * LEFT, color=WHITE) + vector.move_to(label[0].get_bottom(), UR) + vector.shift(SMALL_BUFF * DOWN) + self.play( + Write(label), + GrowArrow(vector), + ) + self.wait() + + class AddTwoThetaManyTimes(Scene): def construct(self): expression = TexMobject( @@ -1822,35 +1992,17 @@ class AddTwoThetaManyTimes(Scene): return result def show_example(self): + equation = self.get_changable_equation(0.01, n_decimal_places=2) expression, brace, question = self.central_question_group - N_mob = Integer(1) - N_mob.match_height(expression[0]) - N_mob.set_color(BLUE) - dot_theta_eq = TexMobject("\\cdot", "(0.01)", "=") - rhs = DecimalNumber(0, num_decimal_places=2) - rhs.set_color(RED) - dot_theta_eq.move_to(expression[1:4]) - dot_theta_eq.shift(2 * DOWN) + N_mob, dot_theta_eq, rhs, comp_pi = equation - def align_value(mob): - mob.align_to(dot_theta_eq[1][1:-1], DOWN) - - N_mob.add_updater( - lambda m: m.next_to(dot_theta_eq, LEFT, SMALL_BUFF) - ) - N_mob.add_updater(align_value) - rhs.add_updater( - lambda m: m.set_value(0.01 * N_mob.get_value()) - ) - rhs.add_updater( - lambda m: m.next_to(dot_theta_eq, RIGHT, 2 * SMALL_BUFF) - ) - rhs.add_updater(align_value) + equation.next_to(expression, DOWN, 2, aligned_edge=LEFT) self.play( TransformFromCopy(expression[0], N_mob), TransformFromCopy(expression[1:4], dot_theta_eq), TransformFromCopy(expression[4], rhs), + TransformFromCopy(expression[4], comp_pi), ) self.wait() self.play( @@ -1863,6 +2015,49 @@ class AddTwoThetaManyTimes(Scene): self.wait() self.play(ShowCreationThenFadeAround(N_mob)) + # + def get_changable_equation(self, value, tex_string=None, n_decimal_places=10): + int_mob = Integer(1) + int_mob.set_color(BLUE) + formatter = "({:0." + str(n_decimal_places) + "f})" + tex_string = tex_string or formatter.format(value) + tex_mob = TexMobject("\\cdot", tex_string, "=") + rhs = DecimalNumber(value, num_decimal_places=n_decimal_places) + + def align_number(mob): + y0 = mob[0].get_center()[1] + y1 = tex_mob[1][1:-1].get_center()[1] + mob.shift((y1 - y0) * UP) + + int_mob.add_updater( + lambda m: m.next_to(tex_mob, LEFT, SMALL_BUFF) + ) + int_mob.add_updater(align_number) + rhs.add_updater( + lambda m: m.set_value(value * int_mob.get_value()) + ) + rhs.add_updater( + lambda m: m.next_to(tex_mob, RIGHT, SMALL_BUFF) + ) + rhs.add_updater(align_number) + + def get_comp_pi(): + if rhs.get_value() < np.pi: + result = TexMobject("< \\pi") + result.set_color(GREEN) + elif rhs.get_value() > np.pi: + result = TexMobject("> \\pi") + result.set_color(RED) + else: + result = TexMobject("= \\pi") + result.next_to(rhs, RIGHT, 2 * SMALL_BUFF) + result[1].scale(1.5, about_edge=LEFT) + return result + + comp_pi = updating_mobject_from_func(get_comp_pi) + + return VGroup(int_mob, tex_mob, rhs, comp_pi) + class AskAboutTheta(TeacherStudentsScene): def construct(self): @@ -2029,11 +2224,13 @@ class ComputeThetaFor1e4(AnalyzeCircleGeometry): sm = movers.submobjects sm[-1], sm[-2] = sm[-2], sm[-1] self.play(LaggedStart( - Transform, movers, + Transform, movers[:-1], lambda m: (m, m.target), - lag_ratio=0.3, - run_time=2, + lag_ratio=1, + run_time=1, + path_arc=PI / 6, )) + self.play(MoveToTarget(movers[-1])) self.remove(movers) self.add(arctan_equation) self.play(ShowCreationThenFadeAround(arctan_equation)) @@ -2056,7 +2253,8 @@ class ThetaChart(Scene): ]) titles.scale(1.5) titles.arrange_submobjects(RIGHT, buff=1.5) - titles[-1].shift(MED_SMALL_BUFF * RIGHT) + titles[1].shift(MED_SMALL_BUFF * LEFT) + titles[2].shift(MED_SMALL_BUFF * RIGHT) titles.to_corner(UL) lines = VGroup() @@ -2087,14 +2285,14 @@ class ThetaChart(Scene): entries = [ ( "$m_1$ : $m_2$", - "$\\arctan(\\sqrt{m2} / \\sqrt{m1})$", + "$\\arctan(\\sqrt{m_2} / \\sqrt{m_1})$", "" ) ] + [ ( "{:,} : 1".format(10**(2 * exp)), "$\\arctan(1 / {:,})$".format(10**exp), - "{:0.10f}".format(np.arctan(10**(-exp))) + self.get_theta_decimal(exp), ) for exp in [1, 2, 3, 4, 5] ] @@ -2119,9 +2317,11 @@ class ThetaChart(Scene): self.play( LaggedStart( FadeInFromDown, - VGroup(*[em[:2] for em in entry_mobs]) + VGroup(*[em[:2] for em in entry_mobs]), ), - LaggedStart(ShowCreation, h_lines[1:]) + LaggedStart(ShowCreation, h_lines[1:]), + lag_ratio=0.1, + run_time=5, ) self.entry_mobs = entry_mobs @@ -2148,6 +2348,7 @@ class ThetaChart(Scene): rhs.to_edge(RIGHT, buff=MED_SMALL_BUFF) value.generate_target() value.target.set_fill(opacity=1) + value.target.scale(0.9) value.target.next_to(rhs, LEFT, SMALL_BUFF) self.play( @@ -2163,6 +2364,11 @@ class ThetaChart(Scene): self.wait() value.add(rhs) + def get_theta_decimal(self, exp): + theta = np.arctan(10**(-exp)) + rounded_theta = np.floor(1e10 * theta) / 1e10 + return "{:0.10f}\\dots".format(rounded_theta) + class CentralQuestionFor1e2(AddTwoThetaManyTimes): CONFIG = { @@ -2211,7 +2417,7 @@ class CentralQuestionFor1e2(AddTwoThetaManyTimes): self.wait() self.play(*[ - ChangeDecimalToValue(int_mob, max_count, run_time=3) + ChangeDecimalToValue(int_mob, max_count, run_time=8) for int_mob in int_mobs ]) self.wait() @@ -2227,49 +2433,6 @@ class CentralQuestionFor1e2(AddTwoThetaManyTimes): self.play(ShowCreationThenFadeAround(int_mobs[1])) self.wait() - # - def get_changable_equation(self, value, tex_string=None, n_decimal_places=10): - int_mob = Integer(1) - int_mob.set_color(BLUE) - formatter = "({:0." + str(n_decimal_places) + "f})" - tex_string = tex_string or formatter.format(value) - tex_mob = TexMobject("\\cdot", tex_string, "=") - rhs = DecimalNumber(value, num_decimal_places=n_decimal_places) - - def align_number(mob): - y0 = mob[0].get_center()[1] - y1 = tex_mob[1][1:-1].get_center()[1] - mob.shift((y1 - y0) * UP) - - int_mob.add_updater( - lambda m: m.next_to(tex_mob, LEFT, SMALL_BUFF) - ) - int_mob.add_updater(align_number) - rhs.add_updater( - lambda m: m.set_value(value * int_mob.get_value()) - ) - rhs.add_updater( - lambda m: m.next_to(tex_mob, RIGHT, SMALL_BUFF) - ) - rhs.add_updater(align_number) - - def get_comp_pi(): - if rhs.get_value() < np.pi: - result = TexMobject("< \\pi") - result.set_color(GREEN) - elif rhs.get_value() > np.pi: - result = TexMobject("> \\pi") - result.set_color(RED) - else: - result = TexMobject("= \\pi") - result.next_to(rhs, RIGHT, 2 * SMALL_BUFF) - result[1].scale(1.5, about_edge=LEFT) - return result - - comp_pi = updating_mobject_from_func(get_comp_pi) - - return VGroup(int_mob, tex_mob, rhs, comp_pi) - class AnalyzeCircleGeometry1e2(AnalyzeCircleGeometry): CONFIG = { @@ -2303,7 +2466,7 @@ class AskAboutArctanOfSmallValues(TeacherStudentsScene): "erm", "sassy", "confused" ) ) - self.wait() + self.look_at(3 * UL) self.play(equation1.shift, UP) self.play( TransformFromCopy( @@ -2317,20 +2480,110 @@ class AskAboutArctanOfSmallValues(TeacherStudentsScene): VGroup(*[equation2[i] for i in (2, 3, 5)]), ), self.get_student_changes( - "confused", "erm", "sassy" + "confused", "erm", "sassy", ), ) - self.wait() - self.student_says("Why?", target_mode="maybe") + self.look_at(3 * UL) self.wait(3) + # self.student_says("Why?", target_mode="maybe") + # self.wait(3) def add_title(self): title = TextMobject("For small $x$") subtitle = TextMobject("(e.g. $x = 0.001$)") - title.scale(1.5) - title.to_edge(UP, buff=MED_SMALL_BUFF) + subtitle.scale(0.75) subtitle.next_to(title, DOWN) - self.add(title, subtitle) + title.add(subtitle) + # title.scale(1.5) + # title.to_edge(UP, buff=MED_SMALL_BUFF) + title.move_to(self.hold_up_spot) + title.to_edge(UP) + self.add(title) + + +class ActanAndTanGraphs(GraphScene): + CONFIG = { + "x_min": -PI / 8, + "x_max": 5 * PI / 8, + "y_min": -PI / 8, + "y_max": 4 * PI / 8, + "x_tick_frequency": PI / 8, + "x_leftmost_tick": -PI / 8, + "y_tick_frequency": PI / 8, + "y_leftmost_tick": -PI / 8, + "x_axis_width": 10, + "y_axis_height": 7, + "graph_origin": 2.5 * DOWN + 5 * LEFT, + "num_graph_anchor_points": 500, + } + + def construct(self): + self.setup_axes() + axes = self.axes + labels = VGroup( + TexMobject("\\pi / 8"), + TexMobject("\\pi / 4"), + TexMobject("3\\pi / 8"), + TexMobject("\\pi / 2"), + ) + for n, label in zip(it.count(1), labels): + label.scale(0.75) + label.next_to(self.coords_to_point(n * PI / 8, 0), DOWN) + self.add(label) + + id_graph = self.get_graph(lambda x: x, x_max=1.5) + arctan_graph = self.get_graph(np.arctan, x_max=1.5) + tan_graph = self.get_graph(np.tan, x_max=1.5) + graphs = VGroup(id_graph, arctan_graph, tan_graph) + + id_label = TexMobject("f(x) = x") + arctan_label = TexMobject("\\arctan(x)") + tan_label = TexMobject("\\tan(x)") + labels = VGroup(id_label, arctan_label, tan_label) + for label, graph in zip(labels, graphs): + label.match_color(graph) + label.next_to(graph.points[-1], RIGHT) + if label.get_bottom()[1] > FRAME_HEIGHT / 2: + label.next_to(graph.point_from_proportion(0.75), LEFT) + + arctan_x_tracker = ValueTracker(3 * PI / 8) + arctan_v_line = updating_mobject_from_func( + lambda: self.get_vertical_line_to_graph( + arctan_x_tracker.get_value(), + arctan_graph, + line_class=DashedLine, + color=WHITE, + ) + ) + tan_x_tracker = ValueTracker(2 * PI / 8) + tan_v_line = updating_mobject_from_func( + lambda: self.get_vertical_line_to_graph( + tan_x_tracker.get_value(), + tan_graph, + line_class=DashedLine, + color=WHITE, + ) + ) + + self.add(axes) + self.play( + ShowCreation(id_graph), + Write(id_label) + ) + self.play( + ShowCreation(arctan_graph), + Write(arctan_label) + ) + self.add(arctan_v_line) + self.play(arctan_x_tracker.set_value, 0, run_time=2) + self.wait() + self.play( + TransformFromCopy(arctan_graph, tan_graph), + TransformFromCopy(arctan_label, tan_label), + ) + self.add(tan_v_line) + self.play(tan_x_tracker.set_value, 0, run_time=2) + self.wait() class UnitCircleIntuition(Scene): @@ -2523,6 +2776,413 @@ class UnitCircleIntuition(Scene): self.wait() -class TangentTaylorSeries(Scene): +class TangentTaylorSeries(TeacherStudentsScene): def construct(self): + series = TexMobject( + "\\tan", "(", "\\theta", ")", "=", "\\theta", "+", + "\\frac{1}{3}", "\\theta", "^3", "+", + "\\frac{2}{15}", "\\theta", "^5", "+", "\\cdots", + tex_to_color_map={"\\theta": YELLOW}, + ) + series.move_to(2 * UP) + series.move_to(self.hold_up_spot, DOWN) + series_error = series[7:] + series_error_rect = SurroundingRectangle(series_error) + + example = TexMobject( + "\\tan", "\\left(", "\\frac{1}{100}", "\\right)", + "=", "\\frac{1}{100}", "+", + "\\frac{1}{3}", "\\left(", + "\\frac{1}{1{,}000{,}000}", + "\\right)", "+", + "\\frac{2}{15}", "\\left(", + "\\frac{1}{10{,}000{,}000{,}000}", + "\\right)", "+", "\\cdots", + ) + example.set_color_by_tex("\\frac{1}{1", BLUE) + example.set_width(FRAME_WIDTH - 1) + example.next_to(self.students, UP, buff=2) + example.shift_onto_screen() + error = example[7:] + error_rect = SurroundingRectangle(error) + error_rect.set_color(RED) + error_decimal = DecimalNumber( + np.tan(0.01) - 0.01, + num_decimal_places=15, + ) + error_decimal.next_to(error_rect, DOWN) + approx = TexMobject("\\approx") + approx.next_to(error_decimal, LEFT) + error_decimal.add(approx) + error_decimal.match_color(error_rect) + + self.play( + FadeInFromDown(series), + self.teacher.change, "raise_right_hand", + ) + self.play( + ShowCreation(series_error_rect), + self.get_student_changes(*3 * ["pondering"]) + ) + self.play(FadeOut(series_error_rect)) + self.play( + series.center, series.to_edge, UP, + ) + self.look_at(series) + self.play( + TransformFromCopy(series[:8], example[:8]), + TransformFromCopy(series[8], example[9]), + TransformFromCopy(series[10:12], example[11:13]), + TransformFromCopy(series[12], example[14]), + TransformFromCopy(series[14:], example[16:]), + *map(GrowFromCenter, [example[i] for i in (8, 10, 13, 15)]) + ) + self.change_student_modes("happy", "confused", "sad") + self.play(ShowCreation(error_rect)) + self.play(ShowIncreasingSubsets(error_decimal)) + self.change_all_student_modes("hooray") + self.wait(3) + + +class AnalyzeCircleGeometry1e4(AnalyzeCircleGeometry): + CONFIG = { + "mass_ratio": 10000, + } + + +class SumUpWrapper(Scene): + def construct(self): + title = TextMobject("To sum up:") + title.scale(1.5) + title.to_edge(UP) + screen_rect = ScreenRectangle(height=6) + screen_rect.set_fill(BLACK, 1) + screen_rect.next_to(title, DOWN) + self.add(FullScreenFadeRectangle( + fill_color=DARK_GREY, + fill_opacity=0.5 + )) + self.play( + FadeInFromDown(title), + FadeIn(screen_rect), + ) + self.wait() + + +class ConservationLawSummary(Scene): + def construct(self): + energy_eq = TexMobject( + "\\frac{1}{2}", "m_1", "(", "v_1", ")", "^2", "+", + "\\frac{1}{2}", "m_2", "(", "v_2", ")", "^2", "=", + "\\text{const.}", + ) + energy_word = TextMobject("Energy") + energy_word.scale(2) + circle = Circle(color=YELLOW, radius=2) + energy_group = VGroup(energy_word, energy_eq, circle) + momentum_eq = TexMobject( + "m_1", "v_1", "+", "m_2", "v_2", "=", + "\\text{const.}", + ) + momentum_word = TextMobject("Momentum") + momentum_word.scale(2) + line = Line(ORIGIN, RIGHT + np.sqrt(10) * DOWN) + line.set_color(GREEN) + momentum_group = VGroup(momentum_word, momentum_eq, line) + + equations = VGroup(energy_eq, momentum_eq) + words = VGroup(energy_word, momentum_word) + + for equation in equations: + equation.set_color_by_tex("m_", BLUE) + equation.set_color_by_tex("v_", RED) + + words.arrange_submobjects( + DOWN, buff=3, + ) + words.to_edge(LEFT, buff=1.5) + + for group in energy_group, momentum_group: + arrow = Arrow( + LEFT, 2 * RIGHT, + rectangular_stem_width=0.1, + tip_length=0.5, + color=WHITE + ) + arrow.next_to(group[0], RIGHT) + group[1].next_to(group[0], DOWN) + group[2].next_to(arrow, RIGHT) + group[2].set_stroke(width=6) + group.add(arrow) + # line.scale(4, about_edge=DR) + red_energy_word = energy_word.copy() + red_energy_word.set_fill(opacity=0) + red_energy_word.set_stroke(RED, 2) + + self.add(energy_group, momentum_group) + self.wait() + self.play( + LaggedStart( + ShowCreationThenDestruction, + red_energy_word + ), + ) + for color in [RED, BLUE, PINK, YELLOW]: + self.play(ShowCreation( + circle.copy().set_color(color), + )) + + +class FinalCommentsOnPhaseSpace(Scene): + def construct(self): + self.add_title() + self.show_related_fields() + self.state_to_point() + self.puzzle_as_remnant() + + def add_title(self): + title = self.title = TextMobject("Phase space") + title.scale(2) + title.to_edge(UP) + title.set_color(YELLOW) + + self.play(Write(title)) + + def show_related_fields(self): + title = self.title + + images = Group( + ImageMobject("ClacksThumbnail"), + ImageMobject("PictoralODE"), + # ImageMobject("DoublePendulumStart"), + ImageMobject("MobiusStrip"), + ) + colors = [BLUE_D, GREY_BROWN, BLUE_C] + for image, color in zip(images, colors): + image.set_height(2.5) + image.add(SurroundingRectangle( + image, + color=color, + stroke_width=5, + buff=0, + )) + images.arrange_submobjects(RIGHT) + images.move_to(DOWN) + + arrows = VGroup(*[ + Arrow( + title.get_bottom(), image.get_top(), + color=WHITE, + ) + for image in images + ]) + + for image, arrow in zip(images, arrows): + self.play( + GrowArrow(arrow), + GrowFromPoint(image, title.get_bottom()), + ) + self.wait() + self.wait() + + self.to_fade = Group(images, arrows) + + def state_to_point(self): + state = TextMobject("State") + arrow = Arrow( + 2 * LEFT, 2 * RIGHT, + color=WHITE, + rectangular_stem_width=0.1, + tip_length=0.5 + ) + point = TextMobject("Point") + dynamics = TextMobject("Dynamics") + geometry = TextMobject("Geometry") + words = VGroup(state, point, dynamics, geometry) + for word in words: + word.scale(2) + + group = VGroup(state, arrow, point) + group.arrange_submobjects(RIGHT, buff=MED_LARGE_BUFF) + group.move_to(2.5 * DOWN) + + dynamics.move_to(state, RIGHT) + geometry.move_to(point, LEFT) + + self.play( + FadeOutAndShift(self.to_fade, UP), + FadeInFrom(state, UP) + ) + self.play( + GrowArrow(arrow), + FadeInFrom(point, LEFT) + ) + self.wait(2) + for w1, w2 in [(state, dynamics), (point, geometry)]: + self.play( + FadeOutAndShift(w1, UP), + FadeInFrom(w2, DOWN), + ) + self.wait() + self.wait() + + def puzzle_as_remnant(self): pass + + +class AltShowTwoPopulations(ShowTwoPopulations): + CONFIG = { + "count_word_scale_val": 2, + } + + +class SimpleTeacherHolding(TeacherStudentsScene): + def construct(self): + self.play(self.teacher.change, "raise_right_hand") + self.change_all_student_modes("pondering") + self.wait(3) + + +class EndScreen(PatreonEndScreen): + CONFIG = { + "specific_patrons": [ + "Juan Benet", + "Vassili Philippov", + "Burt Humburg", + "Matt Russell", + "soekul", + "Richard Barthel", + "Nathan Jessurun", + "Ali Yahya", + "dave nicponski", + "Yu Jun", + "Kaustuv DeBiswas", + "Yana Chernobilsky", + "Lukas Biewald", + "Arthur Zey", + "Roy Larson", + "Joseph Kelly", + "Peter Mcinerney", + "Scott Walter, Ph.D.", + "Magnus Lysfjord", + "Evan Phillips", + "Graham", + "Mauricio Collares", + "Quantopian", + "Jordan Scales", + "Lukas -krtek.net- Novy", + "John Shaughnessy", + "Joseph John Cox", + "Ryan Atallah", + "Britt Selvitelle", + "Jonathan Wilson", + "Randy C. Will", + "Magnus Dahlström", + "David Gow", + "J", + "Luc Ritchie", + "Rish Kundalia", + "Bob Sanderson", + "Mathew Bramson", + "Mustafa Mahdi", + "Robert Teed", + "Cooper Jones", + "Jeff Linse", + "John Haley", + "Boris Veselinovich", + "Andrew Busey", + "Awoo", + "Linh Tran", + "Ripta Pasay", + "David Clark", + "Mathias Jansson", + "Clark Gaebel", + "Bernd Sing", + "Jason Hise", + "Ankalagon", + "Dave B", + "Ted Suzman", + "Chris Connett", + "Eric Younge", + "1stViewMaths", + "Jacob Magnuson", + "Jonathan Eppele", + "Delton Ding", + "James Hughes", + "Stevie Metke", + "Yaw Etse", + "John Griffith", + "Magister Mugit", + "Ludwig Schubert", + "Giovanni Filippi", + "Matt Langford", + "Matt Roveto", + "Jameel Syed", + "Richard Burgmann", + "Solara570", + "Alexis Olson", + "Jeff Straathof", + "John V Wertheim", + "Sindre Reino Trosterud", + "Song Gao", + "Peter Ehrnstrom", + "Valeriy Skobelev", + "Art Ianuzzi", + "Michael Faust", + "Omar Zrien", + "Adrian Robinson", + "Federico Lebron", + "Kai-Siang Ang", + "Michael Hardel", + "Nero Li", + "Ryan Williams", + "Charles Southerland", + "Devarsh Desai", + "Hal Hildebrand", + "Jan Pijpers", + "L0j1k", + "Mark B Bahu", + "Márton Vaitkus", + "Richard Comish", + "Zach Cardwell", + "Brian Staroselsky", + "Matthew Cocke", + "Christian Kaiser", + "Danger Dai", + "Dave Kester", + "eaglle", + "Florian Chudigiewitsch", + "Roobie", + "Xavier Bernard", + "YinYangBalance.Asia", + "Eryq Ouithaqueue", + "Kanan Gill", + "j eduardo perez", + "Antonio Juarez", + "Owen Campbell-Moore", + ], + } + + +class SolutionThumbnail(Thumbnail): + CONFIG = { + "sliding_blocks_config": { + "block1_config": { + "label_text": "$100^{d}$ kg", + }, + "collect_clack_data": False, + }, + } + + def add_text(self): + word = TextMobject("Solution") + question = TextMobject("How many collisions?") + word.set_width(7) + question.match_width(word) + question.next_to(word, UP) + group = VGroup(word, question) + group.to_edge(UP, buff=MED_LARGE_BUFF) + word.set_color(RED) + question.set_color(YELLOW) + group.set_stroke(RED, 2, background=True) + self.add(group) diff --git a/big_ol_pile_of_manim_imports.py b/big_ol_pile_of_manim_imports.py index a4c43f3d..71358dd4 100644 --- a/big_ol_pile_of_manim_imports.py +++ b/big_ol_pile_of_manim_imports.py @@ -88,7 +88,7 @@ from manimlib.utils.color import * from manimlib.utils.config_ops import * from manimlib.utils.images import * from manimlib.utils.iterables import * -from manimlib.utils.output_directory_getters import * +from manimlib.utils.file_ops import * from manimlib.utils.paths import * from manimlib.utils.rate_functions import * from manimlib.utils.simple_functions import * diff --git a/example_scenes.py b/example_scenes.py index d57ad5e5..d05e6595 100644 --- a/example_scenes.py +++ b/example_scenes.py @@ -7,8 +7,9 @@ from big_ol_pile_of_manim_imports import * # # Use the flat -l for a faster rendering at a lower # quality. -# Use -s to skip to the end and just show the final frame -# Use the -p to have the animation pop up once done. +# Use -s to skip to the end and just save the final frame +# Use the -p to have the animation (or image, if -s was +# used) pop up once done. # Use -n to skip ahead to the n'th animation of a scene. diff --git a/manimlib/animation/creation.py b/manimlib/animation/creation.py index 42f4f92f..dd058c05 100644 --- a/manimlib/animation/creation.py +++ b/manimlib/animation/creation.py @@ -246,11 +246,10 @@ class GrowFromPoint(Transform): def __init__(self, mobject, point, **kwargs): digest_config(self, kwargs) target = mobject.copy() - point_mob = VectorizedPoint(point) + mobject.scale(0) + mobject.move_to(point) if self.point_color: - point_mob.set_color(self.point_color) - mobject.replace(point_mob) - mobject.set_color(point_mob.get_color()) + mobject.set_color(self.point_color) Transform.__init__(self, mobject, target, **kwargs) diff --git a/manimlib/animation/indication.py b/manimlib/animation/indication.py index 6b870378..f4f52bb6 100644 --- a/manimlib/animation/indication.py +++ b/manimlib/animation/indication.py @@ -191,10 +191,7 @@ class ShowCreationThenDestructionAround(AnimationOnSurroundingRectangle): class ShowCreationThenFadeAround(AnimationOnSurroundingRectangle): CONFIG = { - "rect_to_animation": lambda rect: Succession( - ShowCreation, rect, - FadeOut, rect, - ) + "rect_to_animation": ShowCreationThenFadeOut } diff --git a/manimlib/config.py b/manimlib/config.py index 19063713..db415cef 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -25,7 +25,7 @@ def parse_cli(): parser.add_argument( "-p", "--preview", action="store_true", - help="Automatically open movie file once its done", + help="Automatically open the saved file once its done", ), parser.add_argument( "-w", "--write_to_movie", @@ -33,9 +33,9 @@ def parse_cli(): help="Render the scene as a movie file", ), parser.add_argument( - "-s", "--show_last_frame", + "-s", "--save_last_frame", action="store_true", - help="Save the last frame and open the image file", + help="Save the last frame", ), parser.add_argument( "-l", "--low_quality", @@ -73,8 +73,7 @@ def parse_cli(): help="Write all the scenes from a file", ), parser.add_argument( - "-o", "--output_file_name", - nargs=1, + "-o", "--file_name", help="Specify the name of the output file, if" "it should be different from the scene class name", ) @@ -156,36 +155,25 @@ def get_module(file_name): def get_configuration(args): - if args.output_file_name is not None: - output_file_name_root, output_file_name_ext = os.path.splitext( - args.output_file_name) - expected_ext = '.png' if args.show_last_frame else '.mp4' - if output_file_name_ext not in ['', expected_ext]: - print("WARNING: The output will be to (doubly-dotted) %s%s" % - output_file_name_root % expected_ext) - output_file_name = args.output_file_name - else: - # If anyone wants .mp4.mp4 and is surprised to only get .mp4, or such... Well, too bad. - output_file_name = output_file_name_root - else: - output_file_name = args.output_file_name - + file_writer_config = { + # By default, write to file + "write_to_movie": args.write_to_movie or not args.save_last_frame, + "save_last_frame": args.save_last_frame, + "save_pngs": args.save_pngs, + # If -t is passed in (for transparent), this will be RGBA + "png_mode": "RGBA" if args.transparent else "RGB", + "movie_file_extension": ".mov" if args.transparent else ".mp4", + "file_name": args.file_name, + } config = { "module": get_module(args.file), "scene_names": args.scene_names, "open_video_upon_completion": args.preview, "show_file_in_finder": args.show_file_in_finder, - # By default, write to file - "write_to_movie": args.write_to_movie or not args.show_last_frame, - "show_last_frame": args.show_last_frame, - "save_pngs": args.save_pngs, - # If -t is passed in (for transparent), this will be RGBA - "saved_image_mode": "RGBA" if args.transparent else "RGB", - "movie_file_extension": ".mov" if args.transparent else ".mp4", + "file_writer_config": file_writer_config, "quiet": args.quiet or args.write_all, "ignore_waits": args.preview, "write_all": args.write_all, - "output_file_name": output_file_name, "start_at_animation_number": args.start_at_animation_number, "end_at_animation_number": None, "sound": args.sound, @@ -242,7 +230,7 @@ def get_configuration(args): config["start_at_animation_number"] = int(stan) config["skip_animations"] = any([ - config["show_last_frame"] and not config["write_to_movie"], + file_writer_config["save_last_frame"], config["start_at_animation_number"], ]) return config diff --git a/manimlib/constants.py b/manimlib/constants.py index 8dcc14d1..fe17b959 100644 --- a/manimlib/constants.py +++ b/manimlib/constants.py @@ -25,6 +25,7 @@ with open("media_dir.txt", 'w') as media_file: VIDEO_DIR = os.path.join(MEDIA_DIR, "videos") RASTER_IMAGE_DIR = os.path.join(MEDIA_DIR, "designs", "raster_images") SVG_IMAGE_DIR = os.path.join(MEDIA_DIR, "designs", "svg_images") +SOUND_DIR = os.path.join(MEDIA_DIR, "designs", "sounds") ### THIS_DIR = os.path.dirname(os.path.realpath(__file__)) FILE_DIR = os.path.join(THIS_DIR, "files") diff --git a/manimlib/extract_scene.py b/manimlib/extract_scene.py index c2265db0..94c327e6 100644 --- a/manimlib/extract_scene.py +++ b/manimlib/extract_scene.py @@ -12,46 +12,43 @@ from manimlib.utils.sounds import play_finish_sound import manimlib.constants -def handle_scene(scene, **config): +def open_file_if_needed(file_writer, **config): if config["quiet"]: curr_stdout = sys.stdout sys.stdout = open(os.devnull, "w") - if config["show_last_frame"]: - scene.save_image(mode=config["saved_image_mode"]) open_file = any([ - config["show_last_frame"], config["open_video_upon_completion"], config["show_file_in_finder"] ]) if open_file: current_os = platform.system() - file_path = None + file_paths = [] - if config["show_last_frame"]: - file_path = scene.get_image_file_path() - else: - file_path = scene.get_movie_file_path() + if config["file_writer_config"]["save_last_frame"]: + file_paths.append(file_writer.get_image_file_path()) + if config["file_writer_config"]["write_to_movie"]: + file_paths.append(file_writer.get_movie_file_path()) - if current_os == "Windows": - os.startfile(file_path) - else: - commands = [] + for file_path in file_paths: + if current_os == "Windows": + os.startfile(file_path) + else: + commands = [] + if (current_os == "Linux"): + commands.append("xdg-open") + else: # Assume macOS + commands.append("open") - if (current_os == "Linux"): - commands.append("xdg-open") - else: # Assume macOS - commands.append("open") + if config["show_file_in_finder"]: + commands.append("-R") - if config["show_file_in_finder"]: - commands.append("-R") + commands.append(file_path) - commands.append(file_path) - - # commands.append("-g") - FNULL = open(os.devnull, 'w') - sp.call(commands, stdout=FNULL, stderr=sp.STDOUT) - FNULL.close() + # commands.append("-g") + FNULL = open(os.devnull, 'w') + sp.call(commands, stdout=FNULL, stderr=sp.STDOUT) + FNULL.close() if config["quiet"]: sys.stdout.close() @@ -128,23 +125,18 @@ def main(config): "camera_config", "frame_duration", "skip_animations", - "write_to_movie", - "save_pngs", - "movie_file_extension", + "file_writer_config", "start_at_animation_number", "end_at_animation_number", - "output_file_name", "leave_progress_bars", ] ]) - if config["save_pngs"]: - print("We are going to save a PNG sequence as well...") - scene_kwargs["save_pngs"] = True - scene_kwargs["pngs_mode"] = config["saved_image_mode"] for SceneClass in get_scene_classes(scene_names_to_classes, config): try: - handle_scene(SceneClass(**scene_kwargs), **config) + # By invoking, this renders the full scene + scene = SceneClass(**scene_kwargs) + open_file_if_needed(scene.file_writer, **config) if config["sound"]: play_finish_sound() except Exception: diff --git a/manimlib/for_3b1b_videos/common_scenes.py b/manimlib/for_3b1b_videos/common_scenes.py index be041d29..4eb1241e 100644 --- a/manimlib/for_3b1b_videos/common_scenes.py +++ b/manimlib/for_3b1b_videos/common_scenes.py @@ -293,3 +293,82 @@ class TODOStub(Scene): def construct(self): self.add(TextMobject("TODO: %s" % self.message)) self.wait() + + +class Banner(Scene): + CONFIG = { + "camera_config": { + "pixel_height": 1440, + "pixel_width": 2560, + }, + "pi_height": 1.25, + "pi_bottom": 0.25 * DOWN, + "use_date": False, + "date": "Sunday, February 3rd", + "message_scale_val": 0.9, + "add_supporter_note": False, + } + + def __init__(self, **kwargs): + # Force these dimensions + self.camera_config = { + "pixel_height": 1440, + "pixel_width": 2560, + } + Scene.__init__(self, **kwargs) + + def construct(self): + pis = self.get_pis() + pis.set_height(self.pi_height) + pis.arrange_submobjects(RIGHT, aligned_edge=DOWN) + pis.move_to(self.pi_bottom, DOWN) + self.add(pis) + + if self.use_date: + message = self.get_date_message() + else: + message = self.get_probabalistic_message() + message.scale(self.message_scale_val) + message.next_to(pis, DOWN) + self.add(message) + + if self.add_supporter_note: + note = self.get_supporter_note() + note.scale(0.5) + message.shift((MED_SMALL_BUFF - SMALL_BUFF) * UP) + note.next_to(message, DOWN, SMALL_BUFF) + self.add(note) + + yellow_parts = [sm for sm in message if sm.get_color() == YELLOW] + for pi in pis: + if yellow_parts: + pi.look_at(yellow_parts[-1]) + else: + pi.look_at(message) + + def get_pis(self): + return VGroup( + Randolph(color=BLUE_E, mode="pondering"), + Randolph(color=BLUE_D, mode="hooray"), + Randolph(color=BLUE_C, mode="sassy"), + Mortimer(color=GREY_BROWN, mode="thinking") + ) + + def get_probabalistic_message(self): + return TextMobject( + "New video every", "Sunday", + "(with probability 0.3)", + tex_to_color_map={"Sunday": YELLOW}, + ) + + def get_date_message(self): + return TextMobject( + "Next video on ", self.date, + tex_to_color_map={self.date: YELLOW}, + ) + + def get_supporter_note(self): + return TextMobject( + "(Available to supporters for review now)", + color="#F96854", + ) diff --git a/manimlib/scene/graph_scene.py b/manimlib/scene/graph_scene.py index 0f23ad8b..868e2858 100644 --- a/manimlib/scene/graph_scene.py +++ b/manimlib/scene/graph_scene.py @@ -20,7 +20,9 @@ from manimlib.utils.color import invert_color from manimlib.utils.space_ops import angle_of_vector # TODO, this should probably reimplemented entirely, especially so as to -# better reuse code from mobject/coordinate_systems +# better reuse code from mobject/coordinate_systems. +# Also, I really dislike how the configuration is set up, this +# is way too messy to work with. class GraphScene(Scene): diff --git a/manimlib/scene/scene.py b/manimlib/scene/scene.py index 41e7ee57..009bfd98 100644 --- a/manimlib/scene/scene.py +++ b/manimlib/scene/scene.py @@ -1,16 +1,9 @@ -from time import sleep -import _thread as thread -import datetime import inspect -import os import random -import shutil -import subprocess import warnings from tqdm import tqdm as ProgressDisplay import numpy as np -from pydub import AudioSegment from manimlib.animation.animation import Animation from manimlib.animation.creation import Write @@ -21,12 +14,8 @@ from manimlib.container.container import Container from manimlib.continual_animation.continual_animation import ContinualAnimation from manimlib.mobject.mobject import Mobject from manimlib.mobject.svg.tex_mobject import TextMobject +from manimlib.scene.scene_file_writer import SceneFileWriter from manimlib.utils.iterables import list_update -from manimlib.utils.output_directory_getters import add_extension_if_not_present -from manimlib.utils.output_directory_getters import get_image_output_directory -from manimlib.utils.output_directory_getters import get_movie_output_directory -from manimlib.utils.output_directory_getters import get_partial_movie_output_directory -from manimlib.utils.output_directory_getters import get_sorted_integer_files class Scene(Container): @@ -34,67 +23,42 @@ class Scene(Container): "camera_class": Camera, "camera_config": {}, "frame_duration": LOW_QUALITY_FRAME_DURATION, - "construct_args": [], + "file_writer_config": {}, "skip_animations": False, - "write_to_movie": False, - "save_pngs": False, - "pngs_mode": "RGBA", - "movie_file_extension": ".mp4", "always_continually_update": False, "random_seed": 0, "start_at_animation_number": None, "end_at_animation_number": None, - "livestreaming": False, - "to_twitch": False, - "twitch_key": None, - "output_file_name": None, "leave_progress_bars": False, } def __init__(self, **kwargs): - # Perhaps allow passing in a non-empty *mobjects parameter? Container.__init__(self, **kwargs) self.camera = self.camera_class(**self.camera_config) + self.file_writer = SceneFileWriter( + self, **self.file_writer_config, + ) + self.mobjects = [] self.continual_animations = [] + # TODO, remove need for foreground mobjects self.foreground_mobjects = [] self.num_plays = 0 - self.frame_num = 0 self.time = 0 self.original_skipping_status = self.skip_animations - self.stream_lock = False if self.random_seed is not None: random.seed(self.random_seed) np.random.seed(self.random_seed) - self.init_audio() self.setup() - if self.livestreaming: - return None try: - self.construct(*self.construct_args) + self.construct() except EndSceneEarlyException: - if hasattr(self, "writing_process"): - self.writing_process.terminate() + pass self.tear_down() - - if self.write_to_movie: - self.combine_movie_files() + self.file_writer.finish() self.print_end_message() - def handle_play_like_call(func): - def wrapper(self, *args, **kwargs): - self.handle_animation_skipping() - should_write = self.write_to_movie and not self.skip_animations - if should_write: - self.open_movie_pipe() - func(self, *args, **kwargs) - self.close_movie_pipe() - else: - func(self, *args, **kwargs) - self.num_plays += 1 - return wrapper - def setup(self): """ This is meant to be implement by any scenes which @@ -116,11 +80,6 @@ class Scene(Container): def __str__(self): return self.__class__.__name__ - def get_output_file_name(self): - if self.output_file_name is not None: - return self.output_file_name - return str(self) - def print_end_message(self): print("Played {} animations".format(self.num_plays)) @@ -142,40 +101,7 @@ class Scene(Container): def get_attrs(self, *keys): return [getattr(self, key) for key in keys] - # Sound - def init_audio(self): - self.includes_sound = False - - def create_audio_segment(self): - self.audio_segment = AudioSegment.silent() - - def add_audio_segment(self, new_segment, time_offset=0): - if not self.includes_sound: - self.includes_sound = True - self.create_audio_segment() - segment = self.audio_segment - overly_time = self.get_time() + time_offset - if overly_time < 0: - raise Exception("Adding sound at timestamp < 0") - - curr_end = segment.duration_seconds - new_end = overly_time + new_segment.duration_seconds - diff = new_end - curr_end - if diff > 0: - segment = segment.append( - AudioSegment.silent(int(np.ceil(diff * 1000))), - crossfade=0, - ) - self.audio_segment = segment.overlay( - new_segment, position=int(1000 * overly_time) - ) - - def add_sound(self, sound_file, time_offset=0): - new_segment = AudioSegment.from_file(sound_file) - self.add_audio_segment(new_segment, 0) - # Only these methods should touch the camera - def set_camera(self, camera): self.camera = camera @@ -202,9 +128,9 @@ class Scene(Container): mobjects=None, background=None, include_submobjects=True, - dont_update_when_skipping=True, + ignore_skipping=True, **kwargs): - if self.skip_animations and dont_update_when_skipping: + if self.skip_animations and not ignore_skipping: return if mobjects is None: mobjects = list_update( @@ -511,7 +437,7 @@ class Scene(Container): compile_method(state) return animations - def handle_animation_skipping(self): + def update_skipping_status(self): if self.start_at_animation_number: if self.num_plays == self.start_at_animation_number: self.skip_animations = False @@ -520,10 +446,18 @@ class Scene(Container): self.skip_animations = True raise EndSceneEarlyException() + def handle_play_like_call(func): + def wrapper(self, *args, **kwargs): + self.update_skipping_status() + allow_write = not self.skip_animations + self.file_writer.begin_animation(allow_write) + func(self, *args, **kwargs) + self.file_writer.end_animation(allow_write) + self.num_plays += 1 + return wrapper + @handle_play_like_call def play(self, *args, **kwargs): - if self.livestreaming: - self.stream_lock = False if len(args) == 0: warnings.warn("Called Scene.play with no animations") return @@ -558,22 +492,10 @@ class Scene(Container): else: self.continual_update(0) - if self.livestreaming: - self.stream_lock = True - thread.start_new_thread(self.idle_stream, ()) return self def idle_stream(self): - while(self.stream_lock): - a = datetime.datetime.now() - self.update_frame() - n_frames = 1 - frame = self.get_frame() - self.add_frames(*[frame] * n_frames) - b = datetime.datetime.now() - time_diff = (b - a).total_seconds() - if time_diff < self.frame_duration: - sleep(self.frame_duration - time_diff) + self.file_writer.idle_stream() def clean_up_animations(self, *animations): for animation in animations: @@ -638,202 +560,20 @@ class Scene(Container): return self def add_frames(self, *frames): + self.increment_time(len(frames) * self.frame_duration) if self.skip_animations: return - self.increment_time(len(frames) * self.frame_duration) - if self.write_to_movie: - for frame in frames: - if self.save_pngs: - self.save_image( - "frame" + str(self.frame_num), self.pngs_mode, True - ) - self.frame_num = self.frame_num + 1 - self.writing_process.stdin.write(frame.tostring()) + for frame in frames: + self.file_writer.write_frame(frame) - # Display methods + def add_sound(self, sound_file, time_offset=0): + time = self.get_time() + time_offset + self.file_writer.add_sound(sound_file, time) def show_frame(self): - self.update_frame(dont_update_when_skipping=False) + self.update_frame(ignore_skipping=True) self.get_image().show() - def get_image_file_path(self, name=None, dont_update=False): - sub_dir = "images" - output_file_name = self.get_output_file_name() - if dont_update: - sub_dir = output_file_name - path = get_image_output_directory(self.__class__, sub_dir) - file_name = add_extension_if_not_present( - name or output_file_name, ".png" - ) - return os.path.join(path, file_name) - - def save_image(self, name=None, mode="RGB", dont_update=False): - path = self.get_image_file_path(name, dont_update) - if not dont_update: - self.update_frame(dont_update_when_skipping=False) - image = self.get_image() - image = image.convert(mode) - image.save(path) - - def get_movie_file_path(self, name=None, extension=None): - directory = get_movie_output_directory( - self.__class__, self.camera_config, self.frame_duration - ) - if extension is None: - extension = self.movie_file_extension - if name is None: - name = self.get_output_file_name() - file_path = os.path.join(directory, name) - if not file_path.endswith(extension): - file_path += extension - return file_path - - def get_partial_movie_directory(self): - return get_partial_movie_output_directory( - self.__class__, self.camera_config, self.frame_duration - ) - - def open_movie_pipe(self): - directory = self.get_partial_movie_directory() - file_path = os.path.join( - directory, "{}{}".format( - self.num_plays, - self.movie_file_extension, - ) - ) - temp_file_path = file_path.replace(".", "_temp.") - - self.movie_file_path = file_path - self.temp_movie_file_path = temp_file_path - - fps = int(1 / self.frame_duration) - height = self.camera.get_pixel_height() - width = self.camera.get_pixel_width() - - command = [ - FFMPEG_BIN, - '-y', # overwrite output file if it exists - '-f', 'rawvideo', - '-s', '%dx%d' % (width, height), # size of one frame - '-pix_fmt', 'rgba', - '-r', str(fps), # frames per second - '-i', '-', # The imput comes from a pipe - '-c:v', 'h264_nvenc', - '-an', # Tells FFMPEG not to expect any audio - '-loglevel', 'error', - ] - if self.movie_file_extension == ".mov": - # This is if the background of the exported video - # should be transparent. - command += [ - '-vcodec', 'qtrle', - # '-vcodec', 'png', - ] - else: - command += [ - '-vcodec', 'libx264', - '-pix_fmt', 'yuv420p', - ] - if self.livestreaming: - if self.to_twitch: - command += ['-f', 'flv'] - command += ['rtmp://live.twitch.tv/app/' + self.twitch_key] - else: - command += ['-f', 'mpegts'] - command += [STREAMING_PROTOCOL + '://' + STREAMING_IP + ':' + STREAMING_PORT] - else: - command += [temp_file_path] - self.writing_process = subprocess.Popen(command, stdin=subprocess.PIPE) - - def close_movie_pipe(self): - self.writing_process.stdin.close() - self.writing_process.wait() - if self.livestreaming: - return True - shutil.move( - self.temp_movie_file_path, - self.movie_file_path, - ) - - def combine_movie_files(self): - # Manim renders the scene as many smaller movie files - # which are then concatenated to a larger one. The reason - # for this is that sometimes video-editing is made easier when - # one works with the broken up scene, which effectively has - # cuts at all the places you might want. But for viewing - # the scene as a whole, one of course wants to see it as a - # single piece. - partial_movie_file_directory = self.get_partial_movie_directory() - kwargs = { - "remove_non_integer_files": True, - "extension": self.movie_file_extension, - } - if self.start_at_animation_number is not None: - kwargs["min_index"] = self.start_at_animation_number - if self.end_at_animation_number is not None: - kwargs["max_index"] = self.end_at_animation_number - else: - kwargs["remove_indices_greater_than"] = self.num_plays - 1 - partial_movie_files = get_sorted_integer_files( - partial_movie_file_directory, - **kwargs - ) - # Write a file partial_file_list.txt containing all - # partial movie files - file_list = os.path.join( - partial_movie_file_directory, - "partial_movie_file_list.txt" - ) - with open(file_list, 'w') as fp: - for pf_path in partial_movie_files: - if os.name == 'nt': - pf_path = pf_path.replace('\\', '/') - fp.write("file \'{}\'\n".format(pf_path)) - - movie_file_path = self.get_movie_file_path() - commands = [ - FFMPEG_BIN, - '-y', # overwrite output file if it exists - '-f', 'concat', - '-safe', '0', - '-i', file_list, - '-c', 'copy', - '-loglevel', 'error', - movie_file_path - ] - if not self.includes_sound: - commands.insert(-1, '-an') - - combine_process = subprocess.Popen(commands) - combine_process.wait() - # os.remove(file_list) - - if self.includes_sound: - sound_file_path = movie_file_path.replace( - self.movie_file_extension, ".wav" - ) - # Makes sure sound file length will match video file - self.add_audio_segment(AudioSegment.silent(0)) - self.audio_segment.export(sound_file_path) - temp_file_path = movie_file_path.replace(".", "_temp.") - commands = commands = [ - "ffmpeg", - "-i", movie_file_path, - "-i", sound_file_path, - '-y', # overwrite output file if it exists - "-c:v", "copy", "-c:a", "aac", - '-loglevel', 'error', - "-shortest", - "-strict", "experimental", - temp_file_path, - ] - subprocess.call(commands) - shutil.move(temp_file_path, movie_file_path) - # subprocess.call(["rm", self.temp_movie_file_path]) - subprocess.call(["rm", sound_file_path]) - - print("\nAnimation ready at {}\n".format(movie_file_path)) - # TODO, this doesn't belong in Scene, but should be # part of some more specialized subclass optimized # for livestreaming diff --git a/manimlib/scene/scene_file_writer.py b/manimlib/scene/scene_file_writer.py new file mode 100644 index 00000000..e19afc48 --- /dev/null +++ b/manimlib/scene/scene_file_writer.py @@ -0,0 +1,327 @@ +import numpy as np +from pydub import AudioSegment +import shutil +import subprocess +import os +import _thread as thread +from time import sleep +import datetime + +from manimlib.constants import FFMPEG_BIN +from manimlib.constants import STREAMING_IP +from manimlib.constants import STREAMING_PORT +from manimlib.constants import STREAMING_PROTOCOL +from manimlib.constants import VIDEO_DIR +from manimlib.utils.config_ops import digest_config +from manimlib.utils.file_ops import guarantee_existance +from manimlib.utils.file_ops import add_extension_if_not_present +from manimlib.utils.file_ops import get_sorted_integer_files +from manimlib.utils.sounds import get_full_sound_file_path + + +class SceneFileWriter(object): + CONFIG = { + "write_to_movie": False, + # TODO, save_pngs is doing nothing + "save_pngs": False, + "png_mode": "RGBA", + "save_last_frame": False, + "movie_file_extension": ".mp4", + "livestreaming": False, + "to_twitch": False, + "twitch_key": None, + # Previous output_file_name + # TODO, address this in extract_scene et. al. + "file_name": None, + "output_directory": None, + } + + def __init__(self, scene, **kwargs): + digest_config(self, kwargs) + self.scene = scene + self.stream_lock = False + self.init_output_directories() + self.init_audio() + + # Output directories and files + def init_output_directories(self): + output_directory = self.output_directory or self.get_default_output_directory() + file_name = self.file_name or self.get_default_file_name() + if self.save_last_frame: + image_dir = guarantee_existance(os.path.join( + VIDEO_DIR, + output_directory, + self.get_image_directory(), + )) + self.image_file_path = os.path.join( + image_dir, + add_extension_if_not_present(file_name, ".png") + ) + if self.write_to_movie: + movie_dir = guarantee_existance(os.path.join( + VIDEO_DIR, + output_directory, + self.get_movie_directory(), + )) + self.movie_file_path = os.path.join( + movie_dir, + add_extension_if_not_present( + file_name, self.movie_file_extension + ) + ) + self.partial_movie_directory = guarantee_existance(os.path.join( + movie_dir, + self.get_partial_movie_directory(), + file_name, + )) + + def get_default_output_directory(self): + scene_module = self.scene.__class__.__module__ + return scene_module.replace(".", os.path.sep) + + def get_default_file_name(self): + return self.scene.__class__.__name__ + + def get_movie_directory(self): + pixel_height = self.scene.camera.pixel_height + frame_duration = self.scene.frame_duration + return "{}p{}".format( + pixel_height, int(1.0 / frame_duration) + ) + + def get_image_directory(self): + return "images" + + def get_partial_movie_directory(self): + return "partial_movie_files" + + # Directory getters + def get_image_file_path(self): + return self.image_file_path + + def get_next_partial_movie_path(self): + result = os.path.join( + self.partial_movie_directory, + "{:05}{}".format( + self.scene.num_plays, + self.movie_file_extension, + ) + ) + return result + + def get_movie_file_path(self): + return self.movie_file_path + + # Sound + def init_audio(self): + self.includes_sound = False + + def create_audio_segment(self): + self.audio_segment = AudioSegment.silent() + + def add_audio_segment(self, new_segment, time=None): + if not self.includes_sound: + self.includes_sound = True + self.create_audio_segment() + segment = self.audio_segment + curr_end = segment.duration_seconds + if time is None: + time = curr_end + if time < 0: + raise Exception("Adding sound at timestamp < 0") + + new_end = time + new_segment.duration_seconds + diff = new_end - curr_end + if diff > 0: + segment = segment.append( + AudioSegment.silent(int(np.ceil(diff * 1000))), + crossfade=0, + ) + self.audio_segment = segment.overlay( + new_segment, position=int(1000 * time) + ) + + def add_sound(self, sound_file, time): + file_path = get_full_sound_file_path(sound_file) + new_segment = AudioSegment.from_file(file_path) + self.add_audio_segment(new_segment, time) + + # Writers + def begin_animation(self, allow_write=False): + if self.write_to_movie and allow_write: + self.open_movie_pipe() + if self.livestreaming: + self.stream_lock = False + + def end_animation(self, allow_write=False): + if self.write_to_movie and allow_write: + self.close_movie_pipe() + if self.livestreaming: + self.stream_lock = True + thread.start_new_thread(self.idle_stream, ()) + + def write_frame(self, frame): + if self.write_to_movie: + self.writing_process.stdin.write(frame.tostring()) + + def save_image(self, image): + file_path = self.get_image_file_path() + image.save(file_path) + self.print_file_ready_message(file_path) + + def idle_stream(self): + while self.stream_lock: + a = datetime.datetime.now() + self.update_frame() + n_frames = 1 + frame = self.get_frame() + self.add_frames(*[frame] * n_frames) + b = datetime.datetime.now() + time_diff = (b - a).total_seconds() + if time_diff < self.frame_duration: + sleep(self.frame_duration - time_diff) + + def finish(self): + if self.write_to_movie: + if hasattr(self, "writing_process"): + self.writing_process.terminate() + self.combine_movie_files() + if self.save_last_frame: + self.scene.update_frame(ignore_skipping=True) + self.save_image(self.scene.get_image()) + + def open_movie_pipe(self): + file_path = self.get_next_partial_movie_path() + temp_file_path = file_path.replace(".", "_temp.") + + self.partial_movie_file_path = file_path + self.temp_partial_movie_file_path = temp_file_path + + fps = int(1 / self.scene.frame_duration) + height = self.scene.camera.get_pixel_height() + width = self.scene.camera.get_pixel_width() + + command = [ + FFMPEG_BIN, + '-y', # overwrite output file if it exists + '-f', 'rawvideo', + '-s', '%dx%d' % (width, height), # size of one frame + '-pix_fmt', 'rgba', + '-r', str(fps), # frames per second + '-i', '-', # The imput comes from a pipe + '-c:v', 'h264_nvenc', + '-an', # Tells FFMPEG not to expect any audio + '-loglevel', 'error', + ] + if self.movie_file_extension == ".mov": + # This is if the background of the exported video + # should be transparent. + command += [ + '-vcodec', 'qtrle', + # '-vcodec', 'png', + ] + else: + command += [ + '-vcodec', 'libx264', + '-pix_fmt', 'yuv420p', + ] + if self.livestreaming: + if self.to_twitch: + command += ['-f', 'flv'] + command += ['rtmp://live.twitch.tv/app/' + self.twitch_key] + else: + command += ['-f', 'mpegts'] + command += [STREAMING_PROTOCOL + '://' + STREAMING_IP + ':' + STREAMING_PORT] + else: + command += [temp_file_path] + self.writing_process = subprocess.Popen(command, stdin=subprocess.PIPE) + + def close_movie_pipe(self): + self.writing_process.stdin.close() + self.writing_process.wait() + if self.livestreaming: + return True + shutil.move( + self.temp_partial_movie_file_path, + self.partial_movie_file_path, + ) + + def combine_movie_files(self): + # Manim renders the scene as many smaller movie files + # which are then concatenated to a larger one. The reason + # for this is that sometimes video-editing is made easier when + # one works with the broken up scene, which effectively has + # cuts at all the places you might want. But for viewing + # the scene as a whole, one of course wants to see it as a + # single piece. + kwargs = { + "remove_non_integer_files": True, + "extension": self.movie_file_extension, + } + if self.scene.start_at_animation_number is not None: + kwargs["min_index"] = self.scene.start_at_animation_number + if self.scene.end_at_animation_number is not None: + kwargs["max_index"] = self.scene.end_at_animation_number + else: + kwargs["remove_indices_greater_than"] = self.scene.num_plays - 1 + partial_movie_files = get_sorted_integer_files( + self.partial_movie_directory, + **kwargs + ) + # Write a file partial_file_list.txt containing all + # partial movie files + file_list = os.path.join( + self.partial_movie_directory, + "partial_movie_file_list.txt" + ) + with open(file_list, 'w') as fp: + for pf_path in partial_movie_files: + if os.name == 'nt': + pf_path = pf_path.replace('\\', '/') + fp.write("file \'{}\'\n".format(pf_path)) + + movie_file_path = self.get_movie_file_path() + commands = [ + FFMPEG_BIN, + '-y', # overwrite output file if it exists + '-f', 'concat', + '-safe', '0', + '-i', file_list, + '-c', 'copy', + '-loglevel', 'error', + movie_file_path + ] + if not self.includes_sound: + commands.insert(-1, '-an') + + combine_process = subprocess.Popen(commands) + combine_process.wait() + # os.remove(file_list) + + if self.includes_sound: + sound_file_path = movie_file_path.replace( + self.movie_file_extension, ".wav" + ) + # Makes sure sound file length will match video file + self.add_audio_segment(AudioSegment.silent(0)) + self.audio_segment.export(sound_file_path) + temp_file_path = movie_file_path.replace(".", "_temp.") + commands = commands = [ + "ffmpeg", + "-i", movie_file_path, + "-i", sound_file_path, + '-y', # overwrite output file if it exists + "-c:v", "copy", "-c:a", "aac", + '-loglevel', 'error', + "-shortest", + "-strict", "experimental", + temp_file_path, + ] + subprocess.call(commands) + shutil.move(temp_file_path, movie_file_path) + subprocess.call(["rm", sound_file_path]) + + self.print_file_ready_message(movie_file_path) + + def print_file_ready_message(self, file_path): + print("\nFile ready at {}\n".format(file_path)) diff --git a/manimlib/utils/output_directory_getters.py b/manimlib/utils/file_ops.py similarity index 61% rename from manimlib/utils/output_directory_getters.py rename to manimlib/utils/file_ops.py index a3695249..36a21e4e 100644 --- a/manimlib/utils/output_directory_getters.py +++ b/manimlib/utils/file_ops.py @@ -1,8 +1,6 @@ import os import numpy as np -from manimlib.constants import VIDEO_DIR - def add_extension_if_not_present(file_name, extension): # This could conceivably be smarter about handling existing differing extensions @@ -18,36 +16,16 @@ def guarantee_existance(path): return os.path.abspath(path) -def get_scene_output_directory(scene_class): - return guarantee_existance(os.path.join( - VIDEO_DIR, - scene_class.__module__.replace(".", os.path.sep) - )) - - -def get_movie_output_directory(scene_class, camera_config, frame_duration): - directory = get_scene_output_directory(scene_class) - sub_dir = "%dp%d" % ( - camera_config["pixel_height"], - int(1.0 / frame_duration) - ) - return guarantee_existance(os.path.join(directory, sub_dir)) - - -def get_partial_movie_output_directory(scene_class, camera_config, frame_duration): - directory = get_movie_output_directory(scene_class, camera_config, frame_duration) - return guarantee_existance( - os.path.join( - directory, - "partial_movie_files", - scene_class.__name__ - ) - ) - - -def get_image_output_directory(scene_class, sub_dir="images"): - directory = get_scene_output_directory(scene_class) - return guarantee_existance(os.path.join(directory, sub_dir)) +def seek_full_path_from_defaults(file_name, default_dir, extensions): + possible_paths = [file_name] + possible_paths += [ + os.path.join(default_dir, file_name + extension) + for extension in ["", *extensions] + ] + for path in possible_paths: + if os.path.exists(path): + return path + raise IOError("File {} not Found".format(file_name)) def get_sorted_integer_files(directory, diff --git a/manimlib/utils/images.py b/manimlib/utils/images.py index 57d089b4..63dcc58b 100644 --- a/manimlib/utils/images.py +++ b/manimlib/utils/images.py @@ -4,20 +4,15 @@ import os from PIL import Image from manimlib.constants import RASTER_IMAGE_DIR +from manimlib.utils.file_ops import seek_full_path_from_defaults def get_full_raster_image_path(image_file_name): - possible_paths = [ + return seek_full_path_from_defaults( image_file_name, - os.path.join(RASTER_IMAGE_DIR, image_file_name), - os.path.join(RASTER_IMAGE_DIR, image_file_name + ".jpg"), - os.path.join(RASTER_IMAGE_DIR, image_file_name + ".png"), - os.path.join(RASTER_IMAGE_DIR, image_file_name + ".gif"), - ] - for path in possible_paths: - if os.path.exists(path): - return path - raise IOError("File %s not Found" % image_file_name) + default_dir=RASTER_IMAGE_DIR, + extensions=[".jpg", ".png", ".gif"] + ) def drag_pixels(frames): diff --git a/manimlib/utils/sounds.py b/manimlib/utils/sounds.py index e9d3dc7e..17a185ce 100644 --- a/manimlib/utils/sounds.py +++ b/manimlib/utils/sounds.py @@ -1,4 +1,6 @@ import os +from manimlib.constants import SOUND_DIR +from manimlib.utils.file_ops import seek_full_path_from_defaults def play_chord(*nums): @@ -28,3 +30,11 @@ def play_error_sound(): def play_finish_sound(): play_chord(12, 9, 5, 2) + + +def get_full_sound_file_path(sound_file_name): + return seek_full_path_from_defaults( + sound_file_name, + default_dir=SOUND_DIR, + extensions=[".wav", ".mp3"] + ) diff --git a/old_projects/div_curl.py b/old_projects/div_curl.py index e6e55675..5a43966c 100644 --- a/old_projects/div_curl.py +++ b/old_projects/div_curl.py @@ -2998,6 +2998,7 @@ class ShowTwoPopulations(Scene): "start_num_rabbits": 20, "animal_height": 0.5, "final_wait_time": 30, + "count_word_scale_val": 1, } def construct(self): @@ -3093,9 +3094,13 @@ class ShowTwoPopulations(Scene): # Add counts for foxes and rabbits labels = self.get_pop_labels() num_foxes = Integer(10) + num_foxes.scale(self.count_word_scale_val) num_foxes.next_to(labels[0], RIGHT) + num_foxes.align_to(labels[0][1], DOWN) num_rabbits = Integer(10) + num_rabbits.scale(self.count_word_scale_val) num_rabbits.next_to(labels[1], RIGHT) + num_rabbits.align_to(labels[1][1], DOWN) self.add(ContinualChangingDecimal( num_foxes, lambda a: get_num_foxes() @@ -3156,6 +3161,8 @@ class ShowTwoPopulations(Scene): TextMobject("\\# Foxes: "), TextMobject("\\# Rabbits: "), ) + for label in labels: + label.scale(self.count_word_scale_val) labels.arrange_submobjects(RIGHT, buff=2) labels.to_edge(UP) return labels diff --git a/stage_scenes.py b/stage_scenes.py index 53b099d7..65e055aa 100644 --- a/stage_scenes.py +++ b/stage_scenes.py @@ -8,9 +8,7 @@ from manimlib.constants import PRODUCTION_QUALITY_CAMERA_CONFIG from manimlib.constants import PRODUCTION_QUALITY_FRAME_DURATION from manimlib.config import get_module from manimlib.extract_scene import is_child_scene -from manimlib.utils.output_directory_getters import get_movie_output_directory -from manimlib.utils.output_directory_getters import get_partial_movie_output_directory -from manimlib.utils.output_directory_getters import get_sorted_integer_files +from manimlib.utils.file_ops import get_movie_output_directory def get_sorted_scene_classes(module_name): @@ -48,26 +46,29 @@ def stage_animations(module_name): sorted_files = [] for scene_class in scene_classes: scene_name = scene_class.__name__ + clips = [f for f in files if f.startswith(scene_name + ".")] + for clip in clips: + sorted_files.append(os.path.join(animation_dir, clip)) # Partial movie file directory - pmf_dir = get_partial_movie_output_directory( - scene_class, **output_directory_kwargs - ) - if os.path.exists(pmf_dir): - for extension in [".mov", ".mp4"]: - int_files = get_sorted_integer_files( - pmf_dir, extension=extension - ) - for file in int_files: - sorted_files.append(os.path.join(pmf_dir, file)) - else: - for clip in [f for f in files if f.startswith(scene_name + ".")]: - sorted_files.append(os.path.join(animation_dir, clip)) + # movie_dir = get_movie_output_directory( + # scene_class, **output_directory_kwargs + # ) + # if os.path.exists(movie_dir): + # for extension in [".mov", ".mp4"]: + # int_files = get_sorted_integer_files( + # pmf_dir, extension=extension + # ) + # for file in int_files: + # sorted_files.append(os.path.join(pmf_dir, file)) + # else: - animation_subdir = os.path.dirname(animation_dir) + # animation_subdir = os.path.dirname(animation_dir) count = 0 while True: staged_scenes_dir = os.path.join( - animation_subdir, "staged_scenes_{}".format(count) + animation_dir, + os.pardir, + "staged_scenes_{}".format(count) ) if not os.path.exists(staged_scenes_dir): os.makedirs(staged_scenes_dir) @@ -82,8 +83,7 @@ def stage_animations(module_name): symlink_name = os.path.join( staged_scenes_dir, "Scene_{:03}_{}".format( - count, - "".join(f.split(os.sep)[-2:]) + count, f.split(os.sep)[-1] ) ) os.symlink(f, symlink_name)