66
77import json
88import shutil
9+ from fractions import Fraction
910from pathlib import Path
1011from queue import Queue
1112from tempfile import NamedTemporaryFile
4041 from manim .renderer .opengl_renderer import OpenGLRenderer
4142
4243
44+ def to_av_frame_rate (fps ):
45+ epsilon1 = 1e-4
46+ epsilon2 = 0.02
47+
48+ if isinstance (fps , int ):
49+ (num , denom ) = (fps , 1 )
50+ elif abs (fps - round (fps )) < epsilon1 :
51+ (num , denom ) = (round (fps ), 1 )
52+ else :
53+ denom = 1001
54+ num = round (fps * denom / 1000 ) * 1000
55+ if abs (fps - num / denom ) >= epsilon2 :
56+ raise ValueError ("invalid frame rate" )
57+
58+ return Fraction (num , denom )
59+
60+
61+ def convert_audio (input_path : Path , output_path : Path , codec_name : str ):
62+ with (
63+ av .open (input_path ) as input_audio ,
64+ av .open (output_path , "w" ) as output_audio ,
65+ ):
66+ input_audio_stream = input_audio .streams .audio [0 ]
67+ output_audio_stream = output_audio .add_stream (codec_name )
68+ for frame in input_audio .decode (input_audio_stream ):
69+ for packet in output_audio_stream .encode (frame ):
70+ output_audio .mux (packet )
71+
72+ for packet in output_audio_stream .encode ():
73+ output_audio .mux (packet )
74+
75+
4376class SceneFileWriter :
4477 """
4578 SceneFileWriter is the object that actually writes the animations
@@ -333,19 +366,7 @@ def add_sound(
333366 # we need to pass delete=False to work on Windows
334367 # TODO: figure out a way to cache the wav file generated (benchmark needed)
335368 wav_file_path = NamedTemporaryFile (suffix = ".wav" , delete = False )
336- with (
337- av .open (file_path ) as input_container ,
338- av .open (wav_file_path , "w" , format = "wav" ) as output_container ,
339- ):
340- for audio_stream in input_container .streams .audio :
341- output_stream = output_container .add_stream ("pcm_s16le" )
342- for frame in input_container .decode (audio_stream ):
343- for packet in output_stream .encode (frame ):
344- output_container .mux (packet )
345-
346- for packet in output_stream .encode ():
347- output_container .mux (packet )
348-
369+ convert_audio (file_path , wav_file_path , "pcm_s16le" )
349370 new_segment = AudioSegment .from_file (wav_file_path .name )
350371 logger .info (f"Automatically converted { file_path } to .wav" )
351372 wav_file_path .close ()
@@ -506,9 +527,7 @@ def open_partial_movie_stream(self, file_path=None) -> None:
506527 file_path = self .partial_movie_files [self .renderer .num_plays ]
507528 self .partial_movie_file_path = file_path
508529
509- fps = config ["frame_rate" ]
510- if fps == int (fps ): # fps is integer
511- fps = int (fps )
530+ fps = to_av_frame_rate (config .frame_rate )
512531
513532 partial_movie_file_codec = "libx264"
514533 partial_movie_file_pix_fmt = "yuv420p"
@@ -517,7 +536,7 @@ def open_partial_movie_stream(self, file_path=None) -> None:
517536 "crf" : "23" , # ffmpeg: -crf, constant rate factor (improved bitrate)
518537 }
519538
520- if config .format == "webm" :
539+ if config .movie_file_extension == ". webm" :
521540 partial_movie_file_codec = "libvpx-vp9"
522541 av_options ["-auto-alt-ref" ] = "1"
523542 if config .transparent :
@@ -530,7 +549,7 @@ def open_partial_movie_stream(self, file_path=None) -> None:
530549 with av .open (file_path , mode = "w" ) as video_container :
531550 stream = video_container .add_stream (
532551 partial_movie_file_codec ,
533- rate = config . frame_rate ,
552+ rate = fps ,
534553 options = av_options ,
535554 )
536555 stream .pix_fmt = partial_movie_file_pix_fmt
@@ -622,7 +641,7 @@ def combine_files(
622641 codec_name = "gif" if create_gif else None ,
623642 template = partial_movies_stream if not create_gif else None ,
624643 )
625- if config .transparent and config .format == "webm" :
644+ if config .transparent and config .movie_file_extension == ". webm" :
626645 output_stream .pix_fmt = "yuva420p"
627646 if create_gif :
628647 """
@@ -636,7 +655,7 @@ def combine_files(
636655 output_stream .pix_fmt = "pal8"
637656 output_stream .width = config .pixel_width
638657 output_stream .height = config .pixel_height
639- output_stream .rate = config .frame_rate
658+ output_stream .rate = to_av_frame_rate ( config .frame_rate )
640659 graph = av .filter .Graph ()
641660 input_buffer = graph .add_buffer (template = partial_movies_stream )
642661 split = graph .add ("split" )
@@ -663,7 +682,8 @@ def combine_files(
663682 while True :
664683 try :
665684 frame = graph .pull ()
666- frame .time_base = output_stream .codec_context .time_base
685+ if output_stream .codec_context .time_base is not None :
686+ frame .time_base = output_stream .codec_context .time_base
667687 frame .pts = frames_written
668688 frames_written += 1
669689 output_container .mux (output_stream .encode (frame ))
@@ -704,6 +724,7 @@ def combine_to_movie(self):
704724 movie_file_path = self .movie_file_path
705725 if is_gif_format ():
706726 movie_file_path = self .gif_file_path
727+
707728 if len (partial_movie_files ) == 0 : # Prevent calling concat on empty list
708729 logger .info ("No animations are contained in this scene." )
709730 return
@@ -732,21 +753,16 @@ def combine_to_movie(self):
732753 # but tries to call ffmpeg via its CLI -- which we want
733754 # to avoid. This is why we need to do the conversion
734755 # manually.
735- if config .format == "webm" :
736- with (
737- av .open (sound_file_path ) as wav_audio ,
738- av .open (sound_file_path .with_suffix (".ogg" ), "w" ) as opus_audio ,
739- ):
740- wav_audio_stream = wav_audio .streams .audio [0 ]
741- opus_audio_stream = opus_audio .add_stream ("libvorbis" )
742- for frame in wav_audio .decode (wav_audio_stream ):
743- for packet in opus_audio_stream .encode (frame ):
744- opus_audio .mux (packet )
745-
746- for packet in opus_audio_stream .encode ():
747- opus_audio .mux (packet )
748-
749- sound_file_path = sound_file_path .with_suffix (".ogg" )
756+ if config .movie_file_extension == ".webm" :
757+ ogg_sound_file_path = sound_file_path .with_suffix (".ogg" )
758+ convert_audio (sound_file_path , ogg_sound_file_path , "libvorbis" )
759+ sound_file_path = ogg_sound_file_path
760+ elif config .movie_file_extension == ".mp4" :
761+ # Similarly, pyav may reject wav audio in an .mp4 file;
762+ # convert to AAC.
763+ aac_sound_file_path = sound_file_path .with_suffix (".aac" )
764+ convert_audio (sound_file_path , aac_sound_file_path , "aac" )
765+ sound_file_path = aac_sound_file_path
750766
751767 temp_file_path = movie_file_path .with_name (
752768 f"{ movie_file_path .stem } _temp{ movie_file_path .suffix } "
0 commit comments