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_names.py b/active_projects/clacks/name_bump.py similarity index 97% rename from active_projects/clacks_names.py rename to active_projects/clacks/name_bump.py index ef0abbff..7ef69b59 100644 --- a/active_projects/clacks_names.py +++ b/active_projects/clacks/name_bump.py @@ -1,7 +1,7 @@ #!/usr/bin/env python from big_ol_pile_of_manim_imports import * -from active_projects.clacks import BlocksAndWallExample +from active_projects.clacks.question import BlocksAndWallExample class NameBump(BlocksAndWallExample): @@ -72,7 +72,6 @@ class NameBump(BlocksAndWallExample): block.label.set_fill(YELLOW, opacity=1) - # for name in names: # file_name = name.replace(".", "") # file_name += " Name Bump" diff --git a/active_projects/clacks.py b/active_projects/clacks/question.py similarity index 99% rename from active_projects/clacks.py rename to active_projects/clacks/question.py index 327abed7..71701e4b 100644 --- a/active_projects/clacks.py +++ b/active_projects/clacks/question.py @@ -346,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 diff --git a/active_projects/clacks/question/480p15/NameIntro.mp4 b/active_projects/clacks/question/480p15/NameIntro.mp4 new file mode 100644 index 00000000..ac3fb91e Binary files /dev/null and b/active_projects/clacks/question/480p15/NameIntro.mp4 differ diff --git a/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00000.mp4 b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00000.mp4 new file mode 100644 index 00000000..aca9e20f Binary files /dev/null and b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00000.mp4 differ diff --git a/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00001.mp4 b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00001.mp4 new file mode 100644 index 00000000..fde665b6 Binary files /dev/null and b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00001.mp4 differ diff --git a/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00002.mp4 b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00002.mp4 new file mode 100644 index 00000000..b94f67a7 Binary files /dev/null and b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00002.mp4 differ diff --git a/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00003.mp4 b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00003.mp4 new file mode 100644 index 00000000..4b87ccf1 Binary files /dev/null and b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00003.mp4 differ diff --git a/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/partial_movie_file_list.txt b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/partial_movie_file_list.txt new file mode 100644 index 00000000..d98edb46 --- /dev/null +++ b/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/partial_movie_file_list.txt @@ -0,0 +1,4 @@ +file '/Users/grant/cs/manim/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00000.mp4' +file '/Users/grant/cs/manim/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00001.mp4' +file '/Users/grant/cs/manim/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00002.mp4' +file '/Users/grant/cs/manim/active_projects/clacks/question/480p15/partial_movie_directory/NameIntro/00003.mp4' diff --git a/active_projects/clacks_solution1.py b/active_projects/clacks/solution1.py similarity index 99% rename from active_projects/clacks_solution1.py rename to active_projects/clacks/solution1.py index d67fda47..fdd139b2 100644 --- a/active_projects/clacks_solution1.py +++ b/active_projects/clacks/solution1.py @@ -1,5 +1,5 @@ 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 @@ -206,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( 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/manimlib/config.py b/manimlib/config.py index 96fbbe47..ee632759 100644 --- a/manimlib/config.py +++ b/manimlib/config.py @@ -33,7 +33,7 @@ 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", ), @@ -73,7 +73,7 @@ def parse_cli(): help="Write all the scenes from a file", ), parser.add_argument( - "-o", "--output_file_name", + "-o", "--file_name", help="Specify the name of the output file, if" "it should be different from the scene class name", ) @@ -155,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, @@ -241,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/scene/scene.py b/manimlib/scene/scene.py index e705a178..c0b0aed9 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,20 +23,12 @@ 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, } @@ -55,43 +36,36 @@ class Scene(Container): # 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 = [] 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) + 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 @@ -116,11 +90,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 +111,9 @@ 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) + # TODO, Scene file writer now handles sound # Only these methods should touch the camera - def set_camera(self, camera): self.camera = camera @@ -202,9 +140,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( @@ -522,8 +460,6 @@ class Scene(Container): @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 +494,11 @@ class Scene(Container): else: self.continual_update(0) - if self.livestreaming: - self.stream_lock = True - thread.start_new_thread(self.idle_stream, ()) return self + # TODO 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 +563,16 @@ 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()) - - # Display methods + for frame in frames: + self.file_writer.write_frame(frame) 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, 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..4cc56bef --- /dev/null +++ b/manimlib/scene/scene_file_writer.py @@ -0,0 +1,325 @@ +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 + + +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.init_audio() + self.init_output_directories() + self.stream_lock = False + + # 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( + 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_directory" + + # Sound + # TODO, make work with Scene + 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) + + # 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 + + # Writers + 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 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 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.start_at_animation_number + if self.scene.end_at_animation_number is not None: + kwargs["max_index"] = self.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 6f8cc674..c06bd148 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,38 +16,38 @@ 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_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_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, 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.get_output_file_name(), - ) - ) +# def get_partial_movie_output_directory(scene, 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.get_output_file_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 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 get_sorted_integer_files(directory, diff --git a/stage_scenes.py b/stage_scenes.py index 7f894f9a..65e055aa 100644 --- a/stage_scenes.py +++ b/stage_scenes.py @@ -8,8 +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_sorted_integer_files +from manimlib.utils.file_ops import get_movie_output_directory def get_sorted_scene_classes(module_name):