2019-01-24 21:47:40 -08:00
|
|
|
import numpy as np
|
|
|
|
from pydub import AudioSegment
|
|
|
|
import shutil
|
2020-02-11 19:50:36 -08:00
|
|
|
import subprocess as sp
|
2019-01-24 21:47:40 -08:00
|
|
|
import os
|
2020-02-11 19:50:36 -08:00
|
|
|
import sys
|
|
|
|
import platform
|
2019-01-24 21:47:40 -08:00
|
|
|
|
2019-06-04 20:51:18 -07:00
|
|
|
import manimlib.constants as consts
|
2019-01-24 21:47:40 -08:00
|
|
|
from manimlib.constants import FFMPEG_BIN
|
|
|
|
from manimlib.utils.config_ops import digest_config
|
2019-06-03 23:41:05 -07:00
|
|
|
from manimlib.utils.file_ops import guarantee_existence
|
2019-01-24 21:47:40 -08:00
|
|
|
from manimlib.utils.file_ops import add_extension_if_not_present
|
|
|
|
from manimlib.utils.file_ops import get_sorted_integer_files
|
2019-01-24 22:24:01 -08:00
|
|
|
from manimlib.utils.sounds import get_full_sound_file_path
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
|
|
|
|
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",
|
2019-06-02 21:13:22 +02:00
|
|
|
"gif_file_extension": ".gif",
|
2019-01-24 21:47:40 -08:00
|
|
|
# Previous output_file_name
|
|
|
|
# TODO, address this in extract_scene et. al.
|
|
|
|
"file_name": None,
|
2020-02-04 15:26:09 -08:00
|
|
|
"input_file_path": "",
|
2019-01-24 21:47:40 -08:00
|
|
|
"output_directory": None,
|
2020-02-11 19:50:36 -08:00
|
|
|
"open_file_upon_completion": False,
|
|
|
|
"show_file_location_upon_completion": False,
|
|
|
|
"quiet": False,
|
2019-01-24 21:47:40 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
def __init__(self, scene, **kwargs):
|
|
|
|
digest_config(self, kwargs)
|
|
|
|
self.scene = scene
|
2019-01-24 22:24:01 -08:00
|
|
|
self.init_output_directories()
|
|
|
|
self.init_audio()
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
# Output directories and files
|
|
|
|
def init_output_directories(self):
|
2019-06-03 23:41:05 -07:00
|
|
|
module_directory = self.output_directory or self.get_default_module_directory()
|
|
|
|
scene_name = self.file_name or self.get_default_scene_name()
|
2019-01-24 21:47:40 -08:00
|
|
|
if self.save_last_frame:
|
2019-06-21 22:52:16 -07:00
|
|
|
if consts.VIDEO_DIR != "":
|
|
|
|
image_dir = guarantee_existence(os.path.join(
|
|
|
|
consts.VIDEO_DIR,
|
|
|
|
module_directory,
|
|
|
|
"images",
|
|
|
|
))
|
|
|
|
else:
|
|
|
|
image_dir = guarantee_existence(os.path.join(
|
|
|
|
consts.VIDEO_OUTPUT_DIR,
|
|
|
|
"images",
|
|
|
|
))
|
2019-01-24 21:47:40 -08:00
|
|
|
self.image_file_path = os.path.join(
|
|
|
|
image_dir,
|
2019-06-03 23:41:05 -07:00
|
|
|
add_extension_if_not_present(scene_name, ".png")
|
2019-01-24 21:47:40 -08:00
|
|
|
)
|
|
|
|
if self.write_to_movie:
|
2019-06-21 22:52:16 -07:00
|
|
|
if consts.VIDEO_DIR != "":
|
|
|
|
movie_dir = guarantee_existence(os.path.join(
|
|
|
|
consts.VIDEO_DIR,
|
|
|
|
module_directory,
|
|
|
|
self.get_resolution_directory(),
|
|
|
|
))
|
|
|
|
else:
|
|
|
|
movie_dir = guarantee_existence(consts.VIDEO_OUTPUT_DIR)
|
2019-01-24 21:47:40 -08:00
|
|
|
self.movie_file_path = os.path.join(
|
|
|
|
movie_dir,
|
|
|
|
add_extension_if_not_present(
|
2019-06-03 23:41:05 -07:00
|
|
|
scene_name, self.movie_file_extension
|
2019-01-24 21:47:40 -08:00
|
|
|
)
|
|
|
|
)
|
2019-06-02 21:13:22 +02:00
|
|
|
self.gif_file_path = os.path.join(
|
|
|
|
movie_dir,
|
|
|
|
add_extension_if_not_present(
|
2019-06-03 23:41:05 -07:00
|
|
|
scene_name, self.gif_file_extension
|
2019-06-02 21:13:22 +02:00
|
|
|
)
|
|
|
|
)
|
2019-06-03 23:41:05 -07:00
|
|
|
self.partial_movie_directory = guarantee_existence(os.path.join(
|
2019-01-24 21:47:40 -08:00
|
|
|
movie_dir,
|
2019-06-03 23:41:05 -07:00
|
|
|
"partial_movie_files",
|
|
|
|
scene_name,
|
2019-01-24 21:47:40 -08:00
|
|
|
))
|
|
|
|
|
2019-06-03 23:41:05 -07:00
|
|
|
def get_default_module_directory(self):
|
2019-05-01 01:16:56 -07:00
|
|
|
filename = os.path.basename(self.input_file_path)
|
2019-06-03 23:41:05 -07:00
|
|
|
root, _ = os.path.splitext(filename)
|
|
|
|
return root
|
2019-01-24 21:47:40 -08:00
|
|
|
|
2019-06-03 23:41:05 -07:00
|
|
|
def get_default_scene_name(self):
|
2019-05-24 15:05:20 -07:00
|
|
|
if self.file_name is None:
|
|
|
|
return self.scene.__class__.__name__
|
|
|
|
else:
|
|
|
|
return self.file_name
|
2019-01-24 21:47:40 -08:00
|
|
|
|
2019-06-03 23:41:05 -07:00
|
|
|
def get_resolution_directory(self):
|
2019-01-24 21:47:40 -08:00
|
|
|
pixel_height = self.scene.camera.pixel_height
|
2019-01-25 10:13:17 -08:00
|
|
|
frame_rate = self.scene.camera.frame_rate
|
2019-01-24 21:47:40 -08:00
|
|
|
return "{}p{}".format(
|
2019-01-25 10:13:17 -08:00
|
|
|
pixel_height, frame_rate
|
2019-01-24 21:47:40 -08:00
|
|
|
)
|
|
|
|
|
2019-01-24 22:24:01 -08:00
|
|
|
# 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
|
|
|
|
|
2019-01-24 21:47:40 -08:00
|
|
|
# Sound
|
|
|
|
def init_audio(self):
|
|
|
|
self.includes_sound = False
|
|
|
|
|
|
|
|
def create_audio_segment(self):
|
|
|
|
self.audio_segment = AudioSegment.silent()
|
|
|
|
|
2019-01-29 23:52:56 -08:00
|
|
|
def add_audio_segment(self, new_segment,
|
|
|
|
time=None,
|
|
|
|
gain_to_background=None):
|
2019-01-24 21:47:40 -08:00
|
|
|
if not self.includes_sound:
|
|
|
|
self.includes_sound = True
|
|
|
|
self.create_audio_segment()
|
|
|
|
segment = self.audio_segment
|
2019-01-24 22:24:01 -08:00
|
|
|
curr_end = segment.duration_seconds
|
|
|
|
if time is None:
|
|
|
|
time = curr_end
|
|
|
|
if time < 0:
|
2019-01-24 21:47:40 -08:00
|
|
|
raise Exception("Adding sound at timestamp < 0")
|
|
|
|
|
2019-01-24 22:24:01 -08:00
|
|
|
new_end = time + new_segment.duration_seconds
|
2019-01-24 21:47:40 -08:00
|
|
|
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(
|
2019-01-28 10:22:08 -08:00
|
|
|
new_segment,
|
|
|
|
position=int(1000 * time),
|
2019-01-29 23:52:56 -08:00
|
|
|
gain_during_overlay=gain_to_background,
|
2019-01-24 21:47:40 -08:00
|
|
|
)
|
|
|
|
|
2019-01-29 23:52:56 -08:00
|
|
|
def add_sound(self, sound_file, time=None, gain=None, **kwargs):
|
2019-01-24 22:24:01 -08:00
|
|
|
file_path = get_full_sound_file_path(sound_file)
|
|
|
|
new_segment = AudioSegment.from_file(file_path)
|
2019-01-29 23:52:56 -08:00
|
|
|
if gain:
|
|
|
|
new_segment = new_segment.apply_gain(gain)
|
|
|
|
self.add_audio_segment(new_segment, time, **kwargs)
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
# Writers
|
2020-02-11 19:50:36 -08:00
|
|
|
def begin_animation(self):
|
|
|
|
if self.write_to_movie:
|
2019-01-24 21:47:40 -08:00
|
|
|
self.open_movie_pipe()
|
|
|
|
|
2020-02-11 19:50:36 -08:00
|
|
|
def end_animation(self):
|
|
|
|
if self.write_to_movie:
|
2019-01-24 21:47:40 -08:00
|
|
|
self.close_movie_pipe()
|
|
|
|
|
2020-02-11 19:50:36 -08:00
|
|
|
def write_frame(self, camera):
|
2019-01-24 22:24:01 -08:00
|
|
|
if self.write_to_movie:
|
2020-02-11 19:50:36 -08:00
|
|
|
raw_bytes = camera.get_raw_fbo_data()
|
2020-02-04 15:26:09 -08:00
|
|
|
self.writing_process.stdin.write(raw_bytes)
|
2019-01-24 22:24:01 -08:00
|
|
|
|
2019-03-16 22:11:19 -07:00
|
|
|
def save_final_image(self, image):
|
2019-01-24 22:24:01 -08:00
|
|
|
file_path = self.get_image_file_path()
|
|
|
|
image.save(file_path)
|
|
|
|
self.print_file_ready_message(file_path)
|
|
|
|
|
2019-01-24 21:47:40 -08:00
|
|
|
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)
|
2019-03-16 22:11:19 -07:00
|
|
|
self.save_final_image(self.scene.get_image())
|
2020-02-11 19:50:36 -08:00
|
|
|
if self.should_open_file():
|
|
|
|
self.open_file()
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
def open_movie_pipe(self):
|
|
|
|
file_path = self.get_next_partial_movie_path()
|
2019-02-26 19:52:30 -05:00
|
|
|
temp_file_path = os.path.splitext(file_path)[0] + '_temp' + self.movie_file_extension
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
self.partial_movie_file_path = file_path
|
|
|
|
self.temp_partial_movie_file_path = temp_file_path
|
|
|
|
|
2019-01-25 10:13:17 -08:00
|
|
|
fps = self.scene.camera.frame_rate
|
2020-02-13 10:41:55 -08:00
|
|
|
width, height = self.scene.camera.get_pixel_shape()
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
command = [
|
|
|
|
FFMPEG_BIN,
|
|
|
|
'-y', # overwrite output file if it exists
|
|
|
|
'-f', 'rawvideo',
|
2020-02-13 10:41:55 -08:00
|
|
|
'-s', f'{width}x{height}', # size of one frame
|
2019-01-24 21:47:40 -08:00
|
|
|
'-pix_fmt', 'rgba',
|
|
|
|
'-r', str(fps), # frames per second
|
|
|
|
'-i', '-', # The imput comes from a pipe
|
2020-02-04 15:26:09 -08:00
|
|
|
'-vf', 'vflip',
|
2019-01-24 21:47:40 -08:00
|
|
|
'-an', # Tells FFMPEG not to expect any audio
|
|
|
|
'-loglevel', 'error',
|
|
|
|
]
|
2019-10-28 14:40:46 -07:00
|
|
|
# TODO, the test for a transparent background should not be based on
|
|
|
|
# the file extension.
|
2019-01-24 21:47:40 -08:00
|
|
|
if self.movie_file_extension == ".mov":
|
2019-10-28 14:40:46 -07:00
|
|
|
# This is if the background of the exported
|
|
|
|
# video should be transparent.
|
2019-01-24 21:47:40 -08:00
|
|
|
command += [
|
|
|
|
'-vcodec', 'qtrle',
|
|
|
|
]
|
|
|
|
else:
|
|
|
|
command += [
|
|
|
|
'-vcodec', 'libx264',
|
|
|
|
'-pix_fmt', 'yuv420p',
|
|
|
|
]
|
2020-02-04 15:26:09 -08:00
|
|
|
command += [temp_file_path]
|
2020-02-11 19:50:36 -08:00
|
|
|
self.writing_process = sp.Popen(command, stdin=sp.PIPE)
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
def close_movie_pipe(self):
|
|
|
|
self.writing_process.stdin.close()
|
|
|
|
self.writing_process.wait()
|
|
|
|
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:
|
2019-01-24 22:32:43 -08:00
|
|
|
kwargs["min_index"] = self.scene.start_at_animation_number
|
2019-01-24 21:47:40 -08:00
|
|
|
if self.scene.end_at_animation_number is not None:
|
2019-01-24 22:32:43 -08:00
|
|
|
kwargs["max_index"] = self.scene.end_at_animation_number
|
2019-01-24 21:47:40 -08:00
|
|
|
else:
|
|
|
|
kwargs["remove_indices_greater_than"] = self.scene.num_plays - 1
|
|
|
|
partial_movie_files = get_sorted_integer_files(
|
|
|
|
self.partial_movie_directory,
|
|
|
|
**kwargs
|
|
|
|
)
|
2019-02-04 14:15:41 -08:00
|
|
|
if len(partial_movie_files) == 0:
|
|
|
|
print("No animations in this scene")
|
|
|
|
return
|
|
|
|
|
2019-01-24 21:47:40 -08:00
|
|
|
# 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('\\', '/')
|
2020-06-18 16:26:04 -07:00
|
|
|
fp.write(f"file \'{pf_path}\'\n")
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
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,
|
|
|
|
'-loglevel', 'error',
|
2019-07-24 20:36:44 -07:00
|
|
|
'-c', 'copy',
|
|
|
|
movie_file_path
|
2019-01-24 21:47:40 -08:00
|
|
|
]
|
|
|
|
if not self.includes_sound:
|
|
|
|
commands.insert(-1, '-an')
|
|
|
|
|
2020-02-11 19:50:36 -08:00
|
|
|
combine_process = sp.Popen(commands)
|
2019-01-24 21:47:40 -08:00
|
|
|
combine_process.wait()
|
|
|
|
|
|
|
|
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))
|
2019-01-29 23:52:56 -08:00
|
|
|
self.audio_segment.export(
|
|
|
|
sound_file_path,
|
|
|
|
bitrate='312k',
|
|
|
|
)
|
2019-01-24 21:47:40 -08:00
|
|
|
temp_file_path = movie_file_path.replace(".", "_temp.")
|
2019-01-29 23:52:56 -08:00
|
|
|
commands = [
|
2019-01-24 21:47:40 -08:00
|
|
|
"ffmpeg",
|
|
|
|
"-i", movie_file_path,
|
|
|
|
"-i", sound_file_path,
|
|
|
|
'-y', # overwrite output file if it exists
|
2019-01-29 23:52:56 -08:00
|
|
|
"-c:v", "copy",
|
|
|
|
"-c:a", "aac",
|
|
|
|
"-b:a", "320k",
|
|
|
|
# select video stream from first file
|
|
|
|
"-map", "0:v:0",
|
|
|
|
# select audio stream from second file
|
|
|
|
"-map", "1:a:0",
|
2019-01-24 21:47:40 -08:00
|
|
|
'-loglevel', 'error',
|
2019-01-29 23:52:56 -08:00
|
|
|
# "-shortest",
|
2019-01-24 21:47:40 -08:00
|
|
|
temp_file_path,
|
|
|
|
]
|
2020-02-11 19:50:36 -08:00
|
|
|
sp.call(commands)
|
2019-01-24 21:47:40 -08:00
|
|
|
shutil.move(temp_file_path, movie_file_path)
|
2019-08-20 01:34:13 -05:00
|
|
|
os.remove(sound_file_path)
|
2019-01-24 21:47:40 -08:00
|
|
|
|
|
|
|
self.print_file_ready_message(movie_file_path)
|
|
|
|
|
|
|
|
def print_file_ready_message(self, file_path):
|
|
|
|
print("\nFile ready at {}\n".format(file_path))
|
2020-02-11 19:50:36 -08:00
|
|
|
|
|
|
|
def should_open_file(self):
|
|
|
|
return any([
|
|
|
|
self.show_file_location_upon_completion,
|
|
|
|
self.open_file_upon_completion,
|
|
|
|
])
|
|
|
|
|
|
|
|
def open_file(self):
|
|
|
|
if self.quiet:
|
|
|
|
curr_stdout = sys.stdout
|
|
|
|
sys.stdout = open(os.devnull, "w")
|
|
|
|
|
|
|
|
current_os = platform.system()
|
|
|
|
file_paths = []
|
|
|
|
|
|
|
|
if self.save_last_frame:
|
|
|
|
file_paths.append(self.get_image_file_path())
|
|
|
|
if self.write_to_movie:
|
|
|
|
file_paths.append(self.get_movie_file_path())
|
|
|
|
|
|
|
|
for file_path in file_paths:
|
|
|
|
if current_os == "Windows":
|
|
|
|
os.startfile(file_path)
|
|
|
|
else:
|
|
|
|
commands = []
|
|
|
|
if current_os == "Linux":
|
|
|
|
commands.append("xdg-open")
|
|
|
|
elif current_os.startswith("CYGWIN"):
|
|
|
|
commands.append("cygstart")
|
|
|
|
else: # Assume macOS
|
|
|
|
commands.append("open")
|
|
|
|
|
|
|
|
if self.show_file_location_upon_completion:
|
|
|
|
commands.append("-R")
|
|
|
|
|
|
|
|
commands.append(file_path)
|
|
|
|
|
|
|
|
FNULL = open(os.devnull, 'w')
|
|
|
|
sp.call(commands, stdout=FNULL, stderr=sp.STDOUT)
|
|
|
|
FNULL.close()
|
|
|
|
|
|
|
|
if self.quiet:
|
|
|
|
sys.stdout.close()
|
|
|
|
sys.stdout = curr_stdout
|