Merge pull request #418 from 3b1b/scene-file-writer-refactor

Scene file writer refactor
This commit is contained in:
Grant Sanderson 2019-01-24 22:38:37 -08:00 committed by GitHub
commit 503ede97af
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1439 additions and 548 deletions

View file

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

View file

@ -0,0 +1,8 @@
from active_projects import clacks
output_directory = "clacks_question"
all_scenes = [
clacks.NameIntro,
clacks.MathAndPhysicsConspiring,
clacks.LightBouncing,
]

View file

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

View file

@ -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)
self.add(question)

View file

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

View file

@ -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 <number> to skip ahead to the n'th animation of a scene.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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