Skip to content

Commit 5788f81

Browse files
achille-fouilleulbehacklpre-commit-ci[bot]
authored
Set AAC codec for audio in mp4 files, add transcoding utility (#3956)
* scene_file_writer: convert frame_rate to fraction * Set audio codec to AAC when format=mp4 * refactor: change import uv.utils.Fraction -> fractions.Fraction * use config as single source of truth for container format * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: Benjamin Hackl <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 0a96aac commit 5788f81

File tree

2 files changed

+62
-36
lines changed

2 files changed

+62
-36
lines changed

manim/scene/scene_file_writer.py

Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import json
88
import shutil
9+
from fractions import Fraction
910
from pathlib import Path
1011
from queue import Queue
1112
from tempfile import NamedTemporaryFile
@@ -40,6 +41,38 @@
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+
4376
class 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}"

tests/test_scene_rendering/test_file_writer.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import sys
2+
from fractions import Fraction
23
from pathlib import Path
34

45
import av
56
import numpy as np
67
import pytest
78

89
from manim import DR, Circle, Create, Scene, Star, tempconfig
10+
from manim.scene.scene_file_writer import to_av_frame_rate
911
from manim.utils.commands import capture, get_video_metadata
1012

1113

@@ -175,3 +177,11 @@ def test_unicode_partial_movie(tmpdir, simple_scenes_path):
175177

176178
_, err, exit_code = capture(command)
177179
assert exit_code == 0, err
180+
181+
182+
def test_frame_rates():
183+
assert to_av_frame_rate(25) == Fraction(25, 1)
184+
assert to_av_frame_rate(24.0) == Fraction(24, 1)
185+
assert to_av_frame_rate(23.976) == Fraction(24 * 1000, 1001)
186+
assert to_av_frame_rate(23.98) == Fraction(24 * 1000, 1001)
187+
assert to_av_frame_rate(59.94) == Fraction(60 * 1000, 1001)

0 commit comments

Comments
 (0)