First pass at SceneFileWriter refactor

This commit is contained in:
Grant Sanderson 2019-01-24 21:47:40 -08:00
parent 77f0ca1ad7
commit 8ae0556394
18 changed files with 432 additions and 384 deletions

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

@ -1,7 +1,7 @@
#!/usr/bin/env python #!/usr/bin/env python
from big_ol_pile_of_manim_imports import * from big_ol_pile_of_manim_imports import *
from active_projects.clacks import BlocksAndWallExample from active_projects.clacks.question import BlocksAndWallExample
class NameBump(BlocksAndWallExample): class NameBump(BlocksAndWallExample):
@ -72,7 +72,6 @@ class NameBump(BlocksAndWallExample):
block.label.set_fill(YELLOW, opacity=1) block.label.set_fill(YELLOW, opacity=1)
# for name in names: # for name in names:
# file_name = name.replace(".", "") # file_name = name.replace(".", "")
# file_name += " Name Bump" # file_name += " Name Bump"

View file

@ -346,10 +346,7 @@ class BlocksAndWallScene(Scene):
self.counter_mob.set_value(n_clacks) self.counter_mob.set_value(n_clacks)
def create_sound_file(self, clack_data): def create_sound_file(self, clack_data):
directory = get_scene_output_directory(BlocksAndWallScene) clack_file = os.path.join(SOUND_DIR, self.collision_sound)
clack_file = os.path.join(
directory, 'sounds', self.collision_sound,
)
output_file = self.get_movie_file_path(extension='.wav') output_file = self.get_movie_file_path(extension='.wav')
times = [ times = [
time time

Binary file not shown.

View file

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

View file

@ -1,5 +1,5 @@
from big_ol_pile_of_manim_imports import * 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 from old_projects.div_curl import ShowTwoPopulations
@ -206,10 +206,7 @@ class AskAboutFindingNewVelocities(Scene):
self.show_value_on_equations() self.show_value_on_equations()
def add_clack_sound_file(self): def add_clack_sound_file(self):
self.clack_file = os.path.join( self.clack_file = os.path.join(SOUND_DIR, "clack.wav")
VIDEO_DIR, "active_projects",
"clacks", "sounds", "clack.wav"
)
def add_floor(self): def add_floor(self):
floor = self.floor = Line( floor = self.floor = Line(

View file

@ -88,7 +88,7 @@ from manimlib.utils.color import *
from manimlib.utils.config_ops import * from manimlib.utils.config_ops import *
from manimlib.utils.images import * from manimlib.utils.images import *
from manimlib.utils.iterables 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.paths import *
from manimlib.utils.rate_functions import * from manimlib.utils.rate_functions import *
from manimlib.utils.simple_functions import * from manimlib.utils.simple_functions import *

View file

@ -33,7 +33,7 @@ def parse_cli():
help="Render the scene as a movie file", help="Render the scene as a movie file",
), ),
parser.add_argument( parser.add_argument(
"-s", "--show_last_frame", "-s", "--save_last_frame",
action="store_true", action="store_true",
help="Save the last frame and open the image file", 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", help="Write all the scenes from a file",
), ),
parser.add_argument( parser.add_argument(
"-o", "--output_file_name", "-o", "--file_name",
help="Specify the name of the output file, if" help="Specify the name of the output file, if"
"it should be different from the scene class name", "it should be different from the scene class name",
) )
@ -155,36 +155,25 @@ def get_module(file_name):
def get_configuration(args): def get_configuration(args):
if args.output_file_name is not None: file_writer_config = {
output_file_name_root, output_file_name_ext = os.path.splitext( # By default, write to file
args.output_file_name) "write_to_movie": args.write_to_movie or not args.save_last_frame,
expected_ext = '.png' if args.show_last_frame else '.mp4' "save_last_frame": args.save_last_frame,
if output_file_name_ext not in ['', expected_ext]: "save_pngs": args.save_pngs,
print("WARNING: The output will be to (doubly-dotted) %s%s" % # If -t is passed in (for transparent), this will be RGBA
output_file_name_root % expected_ext) "png_mode": "RGBA" if args.transparent else "RGB",
output_file_name = args.output_file_name "movie_file_extension": ".mov" if args.transparent else ".mp4",
else: "file_name": args.file_name,
# 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
config = { config = {
"module": get_module(args.file), "module": get_module(args.file),
"scene_names": args.scene_names, "scene_names": args.scene_names,
"open_video_upon_completion": args.preview, "open_video_upon_completion": args.preview,
"show_file_in_finder": args.show_file_in_finder, "show_file_in_finder": args.show_file_in_finder,
# By default, write to file "file_writer_config": file_writer_config,
"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",
"quiet": args.quiet or args.write_all, "quiet": args.quiet or args.write_all,
"ignore_waits": args.preview, "ignore_waits": args.preview,
"write_all": args.write_all, "write_all": args.write_all,
"output_file_name": output_file_name,
"start_at_animation_number": args.start_at_animation_number, "start_at_animation_number": args.start_at_animation_number,
"end_at_animation_number": None, "end_at_animation_number": None,
"sound": args.sound, "sound": args.sound,
@ -241,7 +230,7 @@ def get_configuration(args):
config["start_at_animation_number"] = int(stan) config["start_at_animation_number"] = int(stan)
config["skip_animations"] = any([ 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"], config["start_at_animation_number"],
]) ])
return config 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") VIDEO_DIR = os.path.join(MEDIA_DIR, "videos")
RASTER_IMAGE_DIR = os.path.join(MEDIA_DIR, "designs", "raster_images") RASTER_IMAGE_DIR = os.path.join(MEDIA_DIR, "designs", "raster_images")
SVG_IMAGE_DIR = os.path.join(MEDIA_DIR, "designs", "svg_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__)) THIS_DIR = os.path.dirname(os.path.realpath(__file__))
FILE_DIR = os.path.join(THIS_DIR, "files") 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 import manimlib.constants
def handle_scene(scene, **config): def open_file_if_needed(file_writer, **config):
if config["quiet"]: if config["quiet"]:
curr_stdout = sys.stdout curr_stdout = sys.stdout
sys.stdout = open(os.devnull, "w") sys.stdout = open(os.devnull, "w")
if config["show_last_frame"]:
scene.save_image(mode=config["saved_image_mode"])
open_file = any([ open_file = any([
config["show_last_frame"],
config["open_video_upon_completion"], config["open_video_upon_completion"],
config["show_file_in_finder"] config["show_file_in_finder"]
]) ])
if open_file: if open_file:
current_os = platform.system() current_os = platform.system()
file_path = None file_paths = []
if config["show_last_frame"]: if config["file_writer_config"]["save_last_frame"]:
file_path = scene.get_image_file_path() file_paths.append(file_writer.get_image_file_path())
else: if config["file_writer_config"]["write_to_movie"]:
file_path = scene.get_movie_file_path() file_paths.append(file_writer.get_movie_file_path())
if current_os == "Windows": for file_path in file_paths:
os.startfile(file_path) if current_os == "Windows":
else: os.startfile(file_path)
commands = [] else:
commands = []
if (current_os == "Linux"):
commands.append("xdg-open")
else: # Assume macOS
commands.append("open")
if (current_os == "Linux"): if config["show_file_in_finder"]:
commands.append("xdg-open") commands.append("-R")
else: # Assume macOS
commands.append("open")
if config["show_file_in_finder"]: commands.append(file_path)
commands.append("-R")
commands.append(file_path) # commands.append("-g")
FNULL = open(os.devnull, 'w')
# commands.append("-g") sp.call(commands, stdout=FNULL, stderr=sp.STDOUT)
FNULL = open(os.devnull, 'w') FNULL.close()
sp.call(commands, stdout=FNULL, stderr=sp.STDOUT)
FNULL.close()
if config["quiet"]: if config["quiet"]:
sys.stdout.close() sys.stdout.close()
@ -128,23 +125,18 @@ def main(config):
"camera_config", "camera_config",
"frame_duration", "frame_duration",
"skip_animations", "skip_animations",
"write_to_movie", "file_writer_config",
"save_pngs",
"movie_file_extension",
"start_at_animation_number", "start_at_animation_number",
"end_at_animation_number", "end_at_animation_number",
"output_file_name",
"leave_progress_bars", "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): for SceneClass in get_scene_classes(scene_names_to_classes, config):
try: 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"]: if config["sound"]:
play_finish_sound() play_finish_sound()
except Exception: except Exception:

View file

@ -1,16 +1,9 @@
from time import sleep
import _thread as thread
import datetime
import inspect import inspect
import os
import random import random
import shutil
import subprocess
import warnings import warnings
from tqdm import tqdm as ProgressDisplay from tqdm import tqdm as ProgressDisplay
import numpy as np import numpy as np
from pydub import AudioSegment
from manimlib.animation.animation import Animation from manimlib.animation.animation import Animation
from manimlib.animation.creation import Write 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.continual_animation.continual_animation import ContinualAnimation
from manimlib.mobject.mobject import Mobject from manimlib.mobject.mobject import Mobject
from manimlib.mobject.svg.tex_mobject import TextMobject 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.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): class Scene(Container):
@ -34,20 +23,12 @@ class Scene(Container):
"camera_class": Camera, "camera_class": Camera,
"camera_config": {}, "camera_config": {},
"frame_duration": LOW_QUALITY_FRAME_DURATION, "frame_duration": LOW_QUALITY_FRAME_DURATION,
"construct_args": [], "file_writer_config": {},
"skip_animations": False, "skip_animations": False,
"write_to_movie": False,
"save_pngs": False,
"pngs_mode": "RGBA",
"movie_file_extension": ".mp4",
"always_continually_update": False, "always_continually_update": False,
"random_seed": 0, "random_seed": 0,
"start_at_animation_number": None, "start_at_animation_number": None,
"end_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, "leave_progress_bars": False,
} }
@ -55,43 +36,36 @@ class Scene(Container):
# Perhaps allow passing in a non-empty *mobjects parameter? # Perhaps allow passing in a non-empty *mobjects parameter?
Container.__init__(self, **kwargs) Container.__init__(self, **kwargs)
self.camera = self.camera_class(**self.camera_config) self.camera = self.camera_class(**self.camera_config)
self.file_writer = SceneFileWriter(
self, **self.file_writer_config,
)
self.mobjects = [] self.mobjects = []
self.continual_animations = [] self.continual_animations = []
self.foreground_mobjects = [] self.foreground_mobjects = []
self.num_plays = 0 self.num_plays = 0
self.frame_num = 0
self.time = 0 self.time = 0
self.original_skipping_status = self.skip_animations self.original_skipping_status = self.skip_animations
self.stream_lock = False
if self.random_seed is not None: if self.random_seed is not None:
random.seed(self.random_seed) random.seed(self.random_seed)
np.random.seed(self.random_seed) np.random.seed(self.random_seed)
self.init_audio()
self.setup() self.setup()
if self.livestreaming:
return None
try: try:
self.construct(*self.construct_args) self.construct()
except EndSceneEarlyException: except EndSceneEarlyException:
if hasattr(self, "writing_process"): pass
self.writing_process.terminate()
self.tear_down() self.tear_down()
self.file_writer.finish()
if self.write_to_movie:
self.combine_movie_files()
self.print_end_message() self.print_end_message()
def handle_play_like_call(func): def handle_play_like_call(func):
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
self.handle_animation_skipping() self.handle_animation_skipping()
should_write = self.write_to_movie and not self.skip_animations allow_write = not self.skip_animations
if should_write: self.file_writer.begin_animation(allow_write)
self.open_movie_pipe() func(self, *args, **kwargs)
func(self, *args, **kwargs) self.file_writer.end_animation(allow_write)
self.close_movie_pipe()
else:
func(self, *args, **kwargs)
self.num_plays += 1 self.num_plays += 1
return wrapper return wrapper
@ -116,11 +90,6 @@ class Scene(Container):
def __str__(self): def __str__(self):
return self.__class__.__name__ 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): def print_end_message(self):
print("Played {} animations".format(self.num_plays)) print("Played {} animations".format(self.num_plays))
@ -142,40 +111,9 @@ class Scene(Container):
def get_attrs(self, *keys): def get_attrs(self, *keys):
return [getattr(self, key) for key in keys] return [getattr(self, key) for key in keys]
# Sound # TODO, Scene file writer now handles 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 # Only these methods should touch the camera
def set_camera(self, camera): def set_camera(self, camera):
self.camera = camera self.camera = camera
@ -202,9 +140,9 @@ class Scene(Container):
mobjects=None, mobjects=None,
background=None, background=None,
include_submobjects=True, include_submobjects=True,
dont_update_when_skipping=True, ignore_skipping=True,
**kwargs): **kwargs):
if self.skip_animations and dont_update_when_skipping: if self.skip_animations and not ignore_skipping:
return return
if mobjects is None: if mobjects is None:
mobjects = list_update( mobjects = list_update(
@ -522,8 +460,6 @@ class Scene(Container):
@handle_play_like_call @handle_play_like_call
def play(self, *args, **kwargs): def play(self, *args, **kwargs):
if self.livestreaming:
self.stream_lock = False
if len(args) == 0: if len(args) == 0:
warnings.warn("Called Scene.play with no animations") warnings.warn("Called Scene.play with no animations")
return return
@ -558,22 +494,11 @@ class Scene(Container):
else: else:
self.continual_update(0) self.continual_update(0)
if self.livestreaming:
self.stream_lock = True
thread.start_new_thread(self.idle_stream, ())
return self return self
# TODO
def idle_stream(self): def idle_stream(self):
while(self.stream_lock): self.file_writer.idle_stream()
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 clean_up_animations(self, *animations): def clean_up_animations(self, *animations):
for animation in animations: for animation in animations:
@ -638,202 +563,16 @@ class Scene(Container):
return self return self
def add_frames(self, *frames): def add_frames(self, *frames):
self.increment_time(len(frames) * self.frame_duration)
if self.skip_animations: if self.skip_animations:
return return
self.increment_time(len(frames) * self.frame_duration) for frame in frames:
if self.write_to_movie: self.file_writer.write_frame(frame)
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
def show_frame(self): def show_frame(self):
self.update_frame(dont_update_when_skipping=False) self.update_frame(ignore_skipping=True)
self.get_image().show() 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 # TODO, this doesn't belong in Scene, but should be
# part of some more specialized subclass optimized # part of some more specialized subclass optimized
# for livestreaming # for livestreaming

View file

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

View file

@ -1,8 +1,6 @@
import os import os
import numpy as np import numpy as np
from manimlib.constants import VIDEO_DIR
def add_extension_if_not_present(file_name, extension): def add_extension_if_not_present(file_name, extension):
# This could conceivably be smarter about handling existing differing extensions # This could conceivably be smarter about handling existing differing extensions
@ -18,38 +16,38 @@ def guarantee_existance(path):
return os.path.abspath(path) return os.path.abspath(path)
def get_scene_output_directory(scene_class): # def get_scene_output_directory(scene_class):
return guarantee_existance(os.path.join( # return guarantee_existance(os.path.join(
VIDEO_DIR, # VIDEO_DIR,
scene_class.__module__.replace(".", os.path.sep) # scene_class.__module__.replace(".", os.path.sep)
)) # ))
def get_movie_output_directory(scene_class, camera_config, frame_duration): # def get_movie_output_directory(scene_class, camera_config, frame_duration):
directory = get_scene_output_directory(scene_class) # directory = get_scene_output_directory(scene_class)
sub_dir = "%dp%d" % ( # sub_dir = "%dp%d" % (
camera_config["pixel_height"], # camera_config["pixel_height"],
int(1.0 / frame_duration) # int(1.0 / frame_duration)
) # )
return guarantee_existance(os.path.join(directory, sub_dir)) # return guarantee_existance(os.path.join(directory, sub_dir))
def get_partial_movie_output_directory(scene, camera_config, frame_duration): # def get_partial_movie_output_directory(scene, camera_config, frame_duration):
directory = get_movie_output_directory( # directory = get_movie_output_directory(
scene.__class__, camera_config, frame_duration # scene.__class__, camera_config, frame_duration
) # )
return guarantee_existance( # return guarantee_existance(
os.path.join( # os.path.join(
directory, # directory,
"partial_movie_files", # "partial_movie_files",
scene.get_output_file_name(), # scene.get_output_file_name(),
) # )
) # )
def get_image_output_directory(scene_class, sub_dir="images"): # def get_image_output_directory(scene_class, sub_dir="images"):
directory = get_scene_output_directory(scene_class) # directory = get_scene_output_directory(scene_class)
return guarantee_existance(os.path.join(directory, sub_dir)) # return guarantee_existance(os.path.join(directory, sub_dir))
def get_sorted_integer_files(directory, def get_sorted_integer_files(directory,

View file

@ -8,8 +8,7 @@ from manimlib.constants import PRODUCTION_QUALITY_CAMERA_CONFIG
from manimlib.constants import PRODUCTION_QUALITY_FRAME_DURATION from manimlib.constants import PRODUCTION_QUALITY_FRAME_DURATION
from manimlib.config import get_module from manimlib.config import get_module
from manimlib.extract_scene import is_child_scene from manimlib.extract_scene import is_child_scene
from manimlib.utils.output_directory_getters import get_movie_output_directory from manimlib.utils.file_ops import get_movie_output_directory
from manimlib.utils.output_directory_getters import get_sorted_integer_files
def get_sorted_scene_classes(module_name): def get_sorted_scene_classes(module_name):