manim 0.18.0.post0__py3-none-any.whl → 0.19.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of manim might be problematic. Click here for more details.
- manim/__init__.py +3 -6
- manim/__main__.py +61 -20
- manim/_config/__init__.py +6 -3
- manim/_config/cli_colors.py +16 -8
- manim/_config/default.cfg +1 -3
- manim/_config/logger_utils.py +14 -8
- manim/_config/utils.py +651 -472
- manim/animation/animation.py +152 -5
- manim/animation/composition.py +80 -39
- manim/animation/creation.py +196 -14
- manim/animation/fading.py +5 -9
- manim/animation/indication.py +103 -47
- manim/animation/movement.py +22 -5
- manim/animation/rotation.py +3 -2
- manim/animation/specialized.py +4 -6
- manim/animation/speedmodifier.py +10 -5
- manim/animation/transform.py +4 -5
- manim/animation/transform_matching_parts.py +1 -1
- manim/animation/updaters/mobject_update_utils.py +17 -14
- manim/camera/camera.py +15 -6
- manim/cli/__init__.py +17 -0
- manim/cli/cfg/group.py +70 -44
- manim/cli/checkhealth/checks.py +93 -75
- manim/cli/checkhealth/commands.py +14 -5
- manim/cli/default_group.py +157 -25
- manim/cli/init/commands.py +32 -24
- manim/cli/plugins/commands.py +16 -3
- manim/cli/render/commands.py +72 -60
- manim/cli/render/ease_of_access_options.py +4 -3
- manim/cli/render/global_options.py +51 -15
- manim/cli/render/output_options.py +6 -5
- manim/cli/render/render_options.py +97 -32
- manim/constants.py +65 -19
- manim/gui/gui.py +2 -0
- manim/mobject/frame.py +0 -1
- manim/mobject/geometry/arc.py +112 -78
- manim/mobject/geometry/boolean_ops.py +32 -25
- manim/mobject/geometry/labeled.py +300 -77
- manim/mobject/geometry/line.py +132 -64
- manim/mobject/geometry/polygram.py +126 -30
- manim/mobject/geometry/shape_matchers.py +35 -15
- manim/mobject/geometry/tips.py +38 -29
- manim/mobject/graph.py +414 -133
- manim/mobject/graphing/coordinate_systems.py +126 -64
- manim/mobject/graphing/functions.py +25 -15
- manim/mobject/graphing/number_line.py +24 -10
- manim/mobject/graphing/probability.py +2 -10
- manim/mobject/graphing/scale.py +6 -5
- manim/mobject/matrix.py +17 -19
- manim/mobject/mobject.py +314 -165
- manim/mobject/opengl/opengl_compatibility.py +2 -0
- manim/mobject/opengl/opengl_geometry.py +30 -9
- manim/mobject/opengl/opengl_image_mobject.py +2 -0
- manim/mobject/opengl/opengl_mobject.py +509 -343
- manim/mobject/opengl/opengl_point_cloud_mobject.py +5 -7
- manim/mobject/opengl/opengl_surface.py +3 -2
- manim/mobject/opengl/opengl_three_dimensions.py +2 -0
- manim/mobject/opengl/opengl_vectorized_mobject.py +46 -79
- manim/mobject/svg/brace.py +63 -13
- manim/mobject/svg/svg_mobject.py +4 -3
- manim/mobject/table.py +11 -13
- manim/mobject/text/code_mobject.py +186 -548
- manim/mobject/text/numbers.py +9 -7
- manim/mobject/text/tex_mobject.py +23 -14
- manim/mobject/text/text_mobject.py +70 -24
- manim/mobject/three_d/polyhedra.py +98 -1
- manim/mobject/three_d/three_d_utils.py +4 -4
- manim/mobject/three_d/three_dimensions.py +62 -34
- manim/mobject/types/image_mobject.py +42 -24
- manim/mobject/types/point_cloud_mobject.py +105 -67
- manim/mobject/types/vectorized_mobject.py +496 -228
- manim/mobject/value_tracker.py +5 -4
- manim/mobject/vector_field.py +5 -5
- manim/opengl/__init__.py +3 -3
- manim/plugins/__init__.py +14 -1
- manim/plugins/plugins_flags.py +14 -8
- manim/renderer/cairo_renderer.py +20 -10
- manim/renderer/opengl_renderer.py +21 -23
- manim/renderer/opengl_renderer_window.py +2 -0
- manim/renderer/shader.py +2 -3
- manim/renderer/shader_wrapper.py +5 -2
- manim/renderer/vectorized_mobject_rendering.py +5 -0
- manim/scene/moving_camera_scene.py +23 -0
- manim/scene/scene.py +90 -43
- manim/scene/scene_file_writer.py +316 -165
- manim/scene/section.py +17 -15
- manim/scene/three_d_scene.py +13 -21
- manim/scene/vector_space_scene.py +22 -9
- manim/typing.py +830 -70
- manim/utils/bezier.py +1667 -399
- manim/utils/caching.py +13 -5
- manim/utils/color/AS2700.py +2 -0
- manim/utils/color/BS381.py +3 -0
- manim/utils/color/DVIPSNAMES.py +96 -0
- manim/utils/color/SVGNAMES.py +179 -0
- manim/utils/color/X11.py +3 -0
- manim/utils/color/XKCD.py +3 -0
- manim/utils/color/__init__.py +8 -5
- manim/utils/color/core.py +844 -309
- manim/utils/color/manim_colors.py +7 -9
- manim/utils/commands.py +48 -20
- manim/utils/config_ops.py +18 -13
- manim/utils/debug.py +8 -7
- manim/utils/deprecation.py +90 -40
- manim/utils/docbuild/__init__.py +17 -0
- manim/utils/docbuild/autoaliasattr_directive.py +234 -0
- manim/utils/docbuild/autocolor_directive.py +21 -17
- manim/utils/docbuild/manim_directive.py +50 -35
- manim/utils/docbuild/module_parsing.py +245 -0
- manim/utils/exceptions.py +6 -0
- manim/utils/family.py +5 -3
- manim/utils/family_ops.py +17 -4
- manim/utils/file_ops.py +26 -16
- manim/utils/hashing.py +9 -7
- manim/utils/images.py +10 -4
- manim/utils/ipython_magic.py +14 -8
- manim/utils/iterables.py +161 -119
- manim/utils/module_ops.py +57 -19
- manim/utils/opengl.py +83 -24
- manim/utils/parameter_parsing.py +32 -0
- manim/utils/paths.py +21 -23
- manim/utils/polylabel.py +168 -0
- manim/utils/qhull.py +218 -0
- manim/utils/rate_functions.py +74 -39
- manim/utils/simple_functions.py +24 -15
- manim/utils/sounds.py +7 -1
- manim/utils/space_ops.py +125 -69
- manim/utils/testing/__init__.py +17 -0
- manim/utils/testing/_frames_testers.py +13 -8
- manim/utils/testing/_show_diff.py +5 -3
- manim/utils/testing/_test_class_makers.py +33 -18
- manim/utils/testing/frames_comparison.py +27 -19
- manim/utils/tex.py +127 -197
- manim/utils/tex_file_writing.py +47 -45
- manim/utils/tex_templates.py +2 -1
- manim/utils/unit.py +6 -5
- {manim-0.18.0.post0.dist-info → manim-0.19.0.dist-info}/LICENSE.community +1 -1
- {manim-0.18.0.post0.dist-info → manim-0.19.0.dist-info}/METADATA +40 -39
- manim-0.19.0.dist-info/RECORD +221 -0
- {manim-0.18.0.post0.dist-info → manim-0.19.0.dist-info}/WHEEL +1 -1
- manim/cli/new/__init__.py +0 -0
- manim/cli/new/group.py +0 -189
- manim/plugins/import_plugins.py +0 -43
- manim-0.18.0.post0.dist-info/RECORD +0 -217
- {manim-0.18.0.post0.dist-info → manim-0.19.0.dist-info}/LICENSE +0 -0
- {manim-0.18.0.post0.dist-info → manim-0.19.0.dist-info}/entry_points.txt +0 -0
manim/scene/scene_file_writer.py
CHANGED
|
@@ -5,18 +5,22 @@ from __future__ import annotations
|
|
|
5
5
|
__all__ = ["SceneFileWriter"]
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
-
import os
|
|
9
8
|
import shutil
|
|
10
|
-
import
|
|
9
|
+
from fractions import Fraction
|
|
11
10
|
from pathlib import Path
|
|
11
|
+
from queue import Queue
|
|
12
|
+
from tempfile import NamedTemporaryFile
|
|
13
|
+
from threading import Thread
|
|
12
14
|
from typing import TYPE_CHECKING, Any
|
|
13
15
|
|
|
16
|
+
import av
|
|
14
17
|
import numpy as np
|
|
15
18
|
import srt
|
|
16
19
|
from PIL import Image
|
|
17
20
|
from pydub import AudioSegment
|
|
18
21
|
|
|
19
22
|
from manim import __version__
|
|
23
|
+
from manim.typing import PixelArray, StrPath
|
|
20
24
|
|
|
21
25
|
from .. import config, logger
|
|
22
26
|
from .._config.logger_utils import set_file_logger
|
|
@@ -24,11 +28,9 @@ from ..constants import RendererType
|
|
|
24
28
|
from ..utils.file_ops import (
|
|
25
29
|
add_extension_if_not_present,
|
|
26
30
|
add_version_before_extension,
|
|
27
|
-
ensure_executable,
|
|
28
31
|
guarantee_existence,
|
|
29
32
|
is_gif_format,
|
|
30
33
|
is_png_format,
|
|
31
|
-
is_webm_format,
|
|
32
34
|
modify_atime,
|
|
33
35
|
write_to_movie,
|
|
34
36
|
)
|
|
@@ -36,9 +38,42 @@ from ..utils.sounds import get_full_sound_file_path
|
|
|
36
38
|
from .section import DefaultSectionType, Section
|
|
37
39
|
|
|
38
40
|
if TYPE_CHECKING:
|
|
41
|
+
from manim.renderer.cairo_renderer import CairoRenderer
|
|
39
42
|
from manim.renderer.opengl_renderer import OpenGLRenderer
|
|
40
43
|
|
|
41
44
|
|
|
45
|
+
def to_av_frame_rate(fps):
|
|
46
|
+
epsilon1 = 1e-4
|
|
47
|
+
epsilon2 = 0.02
|
|
48
|
+
|
|
49
|
+
if isinstance(fps, int):
|
|
50
|
+
(num, denom) = (fps, 1)
|
|
51
|
+
elif abs(fps - round(fps)) < epsilon1:
|
|
52
|
+
(num, denom) = (round(fps), 1)
|
|
53
|
+
else:
|
|
54
|
+
denom = 1001
|
|
55
|
+
num = round(fps * denom / 1000) * 1000
|
|
56
|
+
if abs(fps - num / denom) >= epsilon2:
|
|
57
|
+
raise ValueError("invalid frame rate")
|
|
58
|
+
|
|
59
|
+
return Fraction(num, denom)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def convert_audio(input_path: Path, output_path: Path, codec_name: str):
|
|
63
|
+
with (
|
|
64
|
+
av.open(input_path) as input_audio,
|
|
65
|
+
av.open(output_path, "w") as output_audio,
|
|
66
|
+
):
|
|
67
|
+
input_audio_stream = input_audio.streams.audio[0]
|
|
68
|
+
output_audio_stream = output_audio.add_stream(codec_name)
|
|
69
|
+
for frame in input_audio.decode(input_audio_stream):
|
|
70
|
+
for packet in output_audio_stream.encode(frame):
|
|
71
|
+
output_audio.mux(packet)
|
|
72
|
+
|
|
73
|
+
for packet in output_audio_stream.encode():
|
|
74
|
+
output_audio.mux(packet)
|
|
75
|
+
|
|
76
|
+
|
|
42
77
|
class SceneFileWriter:
|
|
43
78
|
"""
|
|
44
79
|
SceneFileWriter is the object that actually writes the animations
|
|
@@ -70,7 +105,12 @@ class SceneFileWriter:
|
|
|
70
105
|
|
|
71
106
|
force_output_as_scene_name = False
|
|
72
107
|
|
|
73
|
-
def __init__(
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
renderer: CairoRenderer | OpenGLRenderer,
|
|
111
|
+
scene_name: StrPath,
|
|
112
|
+
**kwargs: Any,
|
|
113
|
+
) -> None:
|
|
74
114
|
self.renderer = renderer
|
|
75
115
|
self.init_output_directories(scene_name)
|
|
76
116
|
self.init_audio()
|
|
@@ -81,18 +121,10 @@ class SceneFileWriter:
|
|
|
81
121
|
# first section gets automatically created for convenience
|
|
82
122
|
# if you need the first section to be skipped, add a first section by hand, it will replace this one
|
|
83
123
|
self.next_section(
|
|
84
|
-
name="autocreated",
|
|
124
|
+
name="autocreated", type_=DefaultSectionType.NORMAL, skip_animations=False
|
|
85
125
|
)
|
|
86
|
-
# fail fast if ffmpeg is not found
|
|
87
|
-
if not ensure_executable(Path(config.ffmpeg_executable)):
|
|
88
|
-
raise RuntimeError(
|
|
89
|
-
"Manim could not find ffmpeg, which is required for generating video output.\n"
|
|
90
|
-
"For installing ffmpeg please consult https://docs.manim.community/en/stable/installation.html\n"
|
|
91
|
-
"Make sure to either add ffmpeg to the PATH environment variable\n"
|
|
92
|
-
"or set path to the ffmpeg executable under the ffmpeg header in Manim's configuration."
|
|
93
|
-
)
|
|
94
126
|
|
|
95
|
-
def init_output_directories(self, scene_name):
|
|
127
|
+
def init_output_directories(self, scene_name: StrPath) -> None:
|
|
96
128
|
"""Initialise output directories.
|
|
97
129
|
|
|
98
130
|
Notes
|
|
@@ -105,10 +137,7 @@ class SceneFileWriter:
|
|
|
105
137
|
if config["dry_run"]: # in dry-run mode there is no output
|
|
106
138
|
return
|
|
107
139
|
|
|
108
|
-
if config["input_file"]
|
|
109
|
-
module_name = config.get_dir("input_file").stem
|
|
110
|
-
else:
|
|
111
|
-
module_name = ""
|
|
140
|
+
module_name = config.get_dir("input_file").stem if config["input_file"] else ""
|
|
112
141
|
|
|
113
142
|
if SceneFileWriter.force_output_as_scene_name:
|
|
114
143
|
self.output_name = Path(scene_name)
|
|
@@ -137,7 +166,7 @@ class SceneFileWriter:
|
|
|
137
166
|
self.output_name, config["movie_file_extension"]
|
|
138
167
|
)
|
|
139
168
|
|
|
140
|
-
# TODO: /dev/null would be good in case sections_output_dir is used without
|
|
169
|
+
# TODO: /dev/null would be good in case sections_output_dir is used without being set (doesn't work on Windows), everyone likes defensive programming, right?
|
|
141
170
|
self.sections_output_dir = Path("")
|
|
142
171
|
if config.save_sections:
|
|
143
172
|
self.sections_output_dir = guarantee_existence(
|
|
@@ -177,7 +206,7 @@ class SceneFileWriter:
|
|
|
177
206
|
if len(self.sections) and self.sections[-1].is_empty():
|
|
178
207
|
self.sections.pop()
|
|
179
208
|
|
|
180
|
-
def next_section(self, name: str,
|
|
209
|
+
def next_section(self, name: str, type_: str, skip_animations: bool) -> None:
|
|
181
210
|
"""Create segmentation cut here."""
|
|
182
211
|
self.finish_last_section()
|
|
183
212
|
|
|
@@ -191,11 +220,11 @@ class SceneFileWriter:
|
|
|
191
220
|
and not skip_animations
|
|
192
221
|
):
|
|
193
222
|
# relative to index file
|
|
194
|
-
section_video = f"{self.output_name}_{len(self.sections):04}{config.movie_file_extension}"
|
|
223
|
+
section_video = f"{self.output_name}_{len(self.sections):04}_{name}{config.movie_file_extension}"
|
|
195
224
|
|
|
196
225
|
self.sections.append(
|
|
197
226
|
Section(
|
|
198
|
-
|
|
227
|
+
type_,
|
|
199
228
|
section_video,
|
|
200
229
|
name,
|
|
201
230
|
skip_animations,
|
|
@@ -258,15 +287,11 @@ class SceneFileWriter:
|
|
|
258
287
|
|
|
259
288
|
# Sound
|
|
260
289
|
def init_audio(self):
|
|
261
|
-
"""
|
|
262
|
-
Preps the writer for adding audio to the movie.
|
|
263
|
-
"""
|
|
290
|
+
"""Preps the writer for adding audio to the movie."""
|
|
264
291
|
self.includes_sound = False
|
|
265
292
|
|
|
266
293
|
def create_audio_segment(self):
|
|
267
|
-
"""
|
|
268
|
-
Creates an empty, silent, Audio Segment.
|
|
269
|
-
"""
|
|
294
|
+
"""Creates an empty, silent, Audio Segment."""
|
|
270
295
|
self.audio_segment = AudioSegment.silent()
|
|
271
296
|
|
|
272
297
|
def add_audio_segment(
|
|
@@ -341,13 +366,27 @@ class SceneFileWriter:
|
|
|
341
366
|
|
|
342
367
|
"""
|
|
343
368
|
file_path = get_full_sound_file_path(sound_file)
|
|
344
|
-
|
|
369
|
+
# we assume files with .wav / .raw suffix are actually
|
|
370
|
+
# .wav and .raw files, respectively.
|
|
371
|
+
if file_path.suffix not in (".wav", ".raw"):
|
|
372
|
+
# we need to pass delete=False to work on Windows
|
|
373
|
+
# TODO: figure out a way to cache the wav file generated (benchmark needed)
|
|
374
|
+
with NamedTemporaryFile(suffix=".wav", delete=False) as wav_file_path:
|
|
375
|
+
convert_audio(file_path, wav_file_path, "pcm_s16le")
|
|
376
|
+
new_segment = AudioSegment.from_file(wav_file_path.name)
|
|
377
|
+
logger.info(f"Automatically converted {file_path} to .wav")
|
|
378
|
+
Path(wav_file_path.name).unlink()
|
|
379
|
+
else:
|
|
380
|
+
new_segment = AudioSegment.from_file(file_path)
|
|
381
|
+
|
|
345
382
|
if gain:
|
|
346
383
|
new_segment = new_segment.apply_gain(gain)
|
|
347
384
|
self.add_audio_segment(new_segment, time, **kwargs)
|
|
348
385
|
|
|
349
386
|
# Writers
|
|
350
|
-
def begin_animation(
|
|
387
|
+
def begin_animation(
|
|
388
|
+
self, allow_write: bool = False, file_path: StrPath | None = None
|
|
389
|
+
) -> None:
|
|
351
390
|
"""
|
|
352
391
|
Used internally by manim to stream the animation to FFMPEG for
|
|
353
392
|
displaying or writing to a file.
|
|
@@ -358,9 +397,9 @@ class SceneFileWriter:
|
|
|
358
397
|
Whether or not to write to a video file.
|
|
359
398
|
"""
|
|
360
399
|
if write_to_movie() and allow_write:
|
|
361
|
-
self.
|
|
400
|
+
self.open_partial_movie_stream(file_path=file_path)
|
|
362
401
|
|
|
363
|
-
def end_animation(self, allow_write: bool = False):
|
|
402
|
+
def end_animation(self, allow_write: bool = False) -> None:
|
|
364
403
|
"""
|
|
365
404
|
Internally used by Manim to stop streaming to
|
|
366
405
|
FFMPEG gracefully.
|
|
@@ -371,9 +410,36 @@ class SceneFileWriter:
|
|
|
371
410
|
Whether or not to write to a video file.
|
|
372
411
|
"""
|
|
373
412
|
if write_to_movie() and allow_write:
|
|
374
|
-
self.
|
|
413
|
+
self.close_partial_movie_stream()
|
|
414
|
+
|
|
415
|
+
def listen_and_write(self):
|
|
416
|
+
"""For internal use only: blocks until new frame is available on the queue."""
|
|
417
|
+
while True:
|
|
418
|
+
num_frames, frame_data = self.queue.get()
|
|
419
|
+
if frame_data is None:
|
|
420
|
+
break
|
|
375
421
|
|
|
376
|
-
|
|
422
|
+
self.encode_and_write_frame(frame_data, num_frames)
|
|
423
|
+
|
|
424
|
+
def encode_and_write_frame(self, frame: PixelArray, num_frames: int) -> None:
|
|
425
|
+
"""
|
|
426
|
+
For internal use only: takes a given frame in ``np.ndarray`` format and
|
|
427
|
+
write it to the stream
|
|
428
|
+
"""
|
|
429
|
+
for _ in range(num_frames):
|
|
430
|
+
# Notes: precomputing reusing packets does not work!
|
|
431
|
+
# I.e., you cannot do `packets = encode(...)`
|
|
432
|
+
# and reuse it, as it seems that `mux(...)`
|
|
433
|
+
# consumes the packet.
|
|
434
|
+
# The same issue applies for `av_frame`,
|
|
435
|
+
# reusing it renders weird-looking frames.
|
|
436
|
+
av_frame = av.VideoFrame.from_ndarray(frame, format="rgba")
|
|
437
|
+
for packet in self.video_stream.encode(av_frame):
|
|
438
|
+
self.video_container.mux(packet)
|
|
439
|
+
|
|
440
|
+
def write_frame(
|
|
441
|
+
self, frame_or_renderer: np.ndarray | OpenGLRenderer, num_frames: int = 1
|
|
442
|
+
):
|
|
377
443
|
"""
|
|
378
444
|
Used internally by Manim to write a frame to
|
|
379
445
|
the FFMPEG input buffer.
|
|
@@ -382,41 +448,34 @@ class SceneFileWriter:
|
|
|
382
448
|
----------
|
|
383
449
|
frame_or_renderer
|
|
384
450
|
Pixel array of the frame.
|
|
451
|
+
num_frames
|
|
452
|
+
The number of times to write frame.
|
|
385
453
|
"""
|
|
386
|
-
if config.renderer == RendererType.OPENGL:
|
|
387
|
-
self.write_opengl_frame(frame_or_renderer)
|
|
388
|
-
elif config.renderer == RendererType.CAIRO:
|
|
389
|
-
frame = frame_or_renderer
|
|
390
|
-
if write_to_movie():
|
|
391
|
-
self.writing_process.stdin.write(frame.tobytes())
|
|
392
|
-
if is_png_format() and not config["dry_run"]:
|
|
393
|
-
self.output_image_from_array(frame)
|
|
394
|
-
|
|
395
|
-
def write_opengl_frame(self, renderer: OpenGLRenderer):
|
|
396
454
|
if write_to_movie():
|
|
397
|
-
|
|
398
|
-
|
|
455
|
+
frame: np.ndarray = (
|
|
456
|
+
frame_or_renderer.get_frame()
|
|
457
|
+
if config.renderer == RendererType.OPENGL
|
|
458
|
+
else frame_or_renderer
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
msg = (num_frames, frame)
|
|
462
|
+
self.queue.put(msg)
|
|
463
|
+
|
|
464
|
+
if is_png_format() and not config["dry_run"]:
|
|
465
|
+
image: Image = (
|
|
466
|
+
frame_or_renderer.get_image()
|
|
467
|
+
if config.renderer == RendererType.OPENGL
|
|
468
|
+
else Image.fromarray(frame_or_renderer)
|
|
399
469
|
)
|
|
400
|
-
elif is_png_format() and not config["dry_run"]:
|
|
401
470
|
target_dir = self.image_file_path.parent / self.image_file_path.stem
|
|
402
471
|
extension = self.image_file_path.suffix
|
|
403
472
|
self.output_image(
|
|
404
|
-
|
|
473
|
+
image,
|
|
405
474
|
target_dir,
|
|
406
475
|
extension,
|
|
407
476
|
config["zero_pad"],
|
|
408
477
|
)
|
|
409
478
|
|
|
410
|
-
def output_image_from_array(self, frame_data):
|
|
411
|
-
target_dir = self.image_file_path.parent / self.image_file_path.stem
|
|
412
|
-
extension = self.image_file_path.suffix
|
|
413
|
-
self.output_image(
|
|
414
|
-
Image.fromarray(frame_data),
|
|
415
|
-
target_dir,
|
|
416
|
-
extension,
|
|
417
|
-
config["zero_pad"],
|
|
418
|
-
)
|
|
419
|
-
|
|
420
479
|
def output_image(self, image: Image.Image, target_dir, ext, zero_pad: bool):
|
|
421
480
|
if zero_pad:
|
|
422
481
|
image.save(f"{target_dir}{str(self.frame_count).zfill(zero_pad)}{ext}")
|
|
@@ -442,7 +501,7 @@ class SceneFileWriter:
|
|
|
442
501
|
image.save(self.image_file_path)
|
|
443
502
|
self.print_file_ready_message(self.image_file_path)
|
|
444
503
|
|
|
445
|
-
def finish(self):
|
|
504
|
+
def finish(self) -> None:
|
|
446
505
|
"""
|
|
447
506
|
Finishes writing to the FFMPEG buffer or writing images
|
|
448
507
|
to output directory.
|
|
@@ -452,8 +511,6 @@ class SceneFileWriter:
|
|
|
452
511
|
frame in the default image directory.
|
|
453
512
|
"""
|
|
454
513
|
if write_to_movie():
|
|
455
|
-
if hasattr(self, "writing_process"):
|
|
456
|
-
self.writing_process.terminate()
|
|
457
514
|
self.combine_to_movie()
|
|
458
515
|
if config.save_sections:
|
|
459
516
|
self.combine_to_section_videos()
|
|
@@ -467,62 +524,66 @@ class SceneFileWriter:
|
|
|
467
524
|
if self.subcaptions:
|
|
468
525
|
self.write_subcaption_file()
|
|
469
526
|
|
|
470
|
-
def
|
|
471
|
-
"""
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
527
|
+
def open_partial_movie_stream(self, file_path=None) -> None:
|
|
528
|
+
"""Open a container holding a video stream.
|
|
529
|
+
|
|
530
|
+
This is used internally by Manim initialize the container holding
|
|
531
|
+
the video stream of a partial movie file.
|
|
475
532
|
"""
|
|
476
533
|
if file_path is None:
|
|
477
534
|
file_path = self.partial_movie_files[self.renderer.num_plays]
|
|
478
535
|
self.partial_movie_file_path = file_path
|
|
479
536
|
|
|
480
|
-
fps = config
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
"-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
"
|
|
497
|
-
"
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
if config.renderer == RendererType.OPENGL:
|
|
509
|
-
command += ["-vf", "vflip"]
|
|
510
|
-
if is_webm_format():
|
|
511
|
-
command += ["-vcodec", "libvpx-vp9", "-auto-alt-ref", "0"]
|
|
512
|
-
# .mov format
|
|
513
|
-
elif config["transparent"]:
|
|
514
|
-
command += ["-vcodec", "qtrle"]
|
|
515
|
-
else:
|
|
516
|
-
command += ["-vcodec", "libx264", "-pix_fmt", "yuv420p"]
|
|
517
|
-
command += [file_path]
|
|
518
|
-
self.writing_process = subprocess.Popen(command, stdin=subprocess.PIPE)
|
|
537
|
+
fps = to_av_frame_rate(config.frame_rate)
|
|
538
|
+
|
|
539
|
+
partial_movie_file_codec = "libx264"
|
|
540
|
+
partial_movie_file_pix_fmt = "yuv420p"
|
|
541
|
+
av_options = {
|
|
542
|
+
"an": "1", # ffmpeg: -an, no audio
|
|
543
|
+
"crf": "23", # ffmpeg: -crf, constant rate factor (improved bitrate)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if config.movie_file_extension == ".webm":
|
|
547
|
+
partial_movie_file_codec = "libvpx-vp9"
|
|
548
|
+
av_options["-auto-alt-ref"] = "1"
|
|
549
|
+
if config.transparent:
|
|
550
|
+
partial_movie_file_pix_fmt = "yuva420p"
|
|
551
|
+
|
|
552
|
+
elif config.transparent:
|
|
553
|
+
partial_movie_file_codec = "qtrle"
|
|
554
|
+
partial_movie_file_pix_fmt = "argb"
|
|
555
|
+
|
|
556
|
+
with av.open(file_path, mode="w") as video_container:
|
|
557
|
+
stream = video_container.add_stream(
|
|
558
|
+
partial_movie_file_codec,
|
|
559
|
+
rate=fps,
|
|
560
|
+
options=av_options,
|
|
561
|
+
)
|
|
562
|
+
stream.pix_fmt = partial_movie_file_pix_fmt
|
|
563
|
+
stream.width = config.pixel_width
|
|
564
|
+
stream.height = config.pixel_height
|
|
519
565
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
566
|
+
self.video_container = video_container
|
|
567
|
+
self.video_stream = stream
|
|
568
|
+
|
|
569
|
+
self.queue: Queue[tuple[int, PixelArray | None]] = Queue()
|
|
570
|
+
self.writer_thread = Thread(target=self.listen_and_write, args=())
|
|
571
|
+
self.writer_thread.start()
|
|
572
|
+
|
|
573
|
+
def close_partial_movie_stream(self) -> None:
|
|
574
|
+
"""Close the currently opened video container.
|
|
575
|
+
|
|
576
|
+
Used internally by Manim to first flush the remaining packages
|
|
577
|
+
in the video stream holding a partial file, and then close
|
|
578
|
+
the corresponding container.
|
|
523
579
|
"""
|
|
524
|
-
self.
|
|
525
|
-
self.
|
|
580
|
+
self.queue.put((-1, None))
|
|
581
|
+
self.writer_thread.join()
|
|
582
|
+
|
|
583
|
+
for packet in self.video_stream.encode():
|
|
584
|
+
self.video_container.mux(packet)
|
|
585
|
+
|
|
586
|
+
self.video_container.close()
|
|
526
587
|
|
|
527
588
|
logger.info(
|
|
528
589
|
f"Animation {self.renderer.num_plays} : Partial movie file written in %(path)s",
|
|
@@ -567,37 +628,93 @@ class SceneFileWriter:
|
|
|
567
628
|
for pf_path in input_files:
|
|
568
629
|
pf_path = Path(pf_path).as_posix()
|
|
569
630
|
fp.write(f"file 'file:{pf_path}'\n")
|
|
570
|
-
commands = [
|
|
571
|
-
config.ffmpeg_executable,
|
|
572
|
-
"-y", # overwrite output file if it exists
|
|
573
|
-
"-f",
|
|
574
|
-
"concat",
|
|
575
|
-
"-safe",
|
|
576
|
-
"0",
|
|
577
|
-
"-i",
|
|
578
|
-
str(file_list),
|
|
579
|
-
"-loglevel",
|
|
580
|
-
config.ffmpeg_loglevel.lower(),
|
|
581
|
-
"-metadata",
|
|
582
|
-
f"comment=Rendered with Manim Community v{__version__}",
|
|
583
|
-
"-nostdin",
|
|
584
|
-
]
|
|
585
631
|
|
|
632
|
+
av_options = {
|
|
633
|
+
"safe": "0", # needed to read files
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if not includes_sound:
|
|
637
|
+
av_options["an"] = "1"
|
|
638
|
+
|
|
639
|
+
partial_movies_input = av.open(
|
|
640
|
+
str(file_list), options=av_options, format="concat"
|
|
641
|
+
)
|
|
642
|
+
partial_movies_stream = partial_movies_input.streams.video[0]
|
|
643
|
+
output_container = av.open(str(output_file), mode="w")
|
|
644
|
+
output_container.metadata["comment"] = (
|
|
645
|
+
f"Rendered with Manim Community v{__version__}"
|
|
646
|
+
)
|
|
647
|
+
output_stream = output_container.add_stream(
|
|
648
|
+
codec_name="gif" if create_gif else None,
|
|
649
|
+
template=partial_movies_stream if not create_gif else None,
|
|
650
|
+
)
|
|
651
|
+
if config.transparent and config.movie_file_extension == ".webm":
|
|
652
|
+
output_stream.pix_fmt = "yuva420p"
|
|
586
653
|
if create_gif:
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
654
|
+
"""
|
|
655
|
+
The following solution was largely inspired from this comment
|
|
656
|
+
https://github.com/imageio/imageio/issues/995#issuecomment-1580533018,
|
|
657
|
+
and the following code
|
|
658
|
+
https://github.com/imageio/imageio/blob/65d79140018bb7c64c0692ea72cb4093e8d632a0/imageio/plugins/pyav.py#L927-L996.
|
|
659
|
+
"""
|
|
660
|
+
output_stream.pix_fmt = "rgb8"
|
|
661
|
+
if config.transparent:
|
|
662
|
+
output_stream.pix_fmt = "pal8"
|
|
663
|
+
output_stream.width = config.pixel_width
|
|
664
|
+
output_stream.height = config.pixel_height
|
|
665
|
+
output_stream.rate = to_av_frame_rate(config.frame_rate)
|
|
666
|
+
graph = av.filter.Graph()
|
|
667
|
+
input_buffer = graph.add_buffer(template=partial_movies_stream)
|
|
668
|
+
split = graph.add("split")
|
|
669
|
+
palettegen = graph.add("palettegen", "stats_mode=diff")
|
|
670
|
+
paletteuse = graph.add(
|
|
671
|
+
"paletteuse", "dither=bayer:bayer_scale=5:diff_mode=rectangle"
|
|
672
|
+
)
|
|
673
|
+
output_sink = graph.add("buffersink")
|
|
674
|
+
|
|
675
|
+
input_buffer.link_to(split)
|
|
676
|
+
split.link_to(palettegen, 0, 0) # 1st input of split -> input of palettegen
|
|
677
|
+
split.link_to(paletteuse, 1, 0) # 2nd output of split -> 1st input
|
|
678
|
+
palettegen.link_to(paletteuse, 0, 1) # output of palettegen -> 2nd input
|
|
679
|
+
paletteuse.link_to(output_sink)
|
|
680
|
+
|
|
681
|
+
graph.configure()
|
|
682
|
+
|
|
683
|
+
for frame in partial_movies_input.decode(video=0):
|
|
684
|
+
graph.push(frame)
|
|
685
|
+
|
|
686
|
+
graph.push(None) # EOF: https://github.com/PyAV-Org/PyAV/issues/886.
|
|
687
|
+
|
|
688
|
+
frames_written = 0
|
|
689
|
+
while True:
|
|
690
|
+
try:
|
|
691
|
+
frame = graph.pull()
|
|
692
|
+
if output_stream.codec_context.time_base is not None:
|
|
693
|
+
frame.time_base = output_stream.codec_context.time_base
|
|
694
|
+
frame.pts = frames_written
|
|
695
|
+
frames_written += 1
|
|
696
|
+
output_container.mux(output_stream.encode(frame))
|
|
697
|
+
except av.error.EOFError:
|
|
698
|
+
break
|
|
699
|
+
|
|
700
|
+
for packet in output_stream.encode():
|
|
701
|
+
output_container.mux(packet)
|
|
702
|
+
|
|
591
703
|
else:
|
|
592
|
-
|
|
704
|
+
for packet in partial_movies_input.demux(partial_movies_stream):
|
|
705
|
+
# We need to skip the "flushing" packets that `demux` generates.
|
|
706
|
+
if packet.dts is None:
|
|
707
|
+
continue
|
|
593
708
|
|
|
594
|
-
|
|
595
|
-
|
|
709
|
+
packet.dts = None # This seems to be needed, as dts from consecutive
|
|
710
|
+
# files may not be monotically increasing, so we let libav compute it.
|
|
596
711
|
|
|
597
|
-
|
|
712
|
+
# We need to assign the packet to the new stream.
|
|
713
|
+
packet.stream = output_stream
|
|
714
|
+
output_container.mux(packet)
|
|
598
715
|
|
|
599
|
-
|
|
600
|
-
|
|
716
|
+
partial_movies_input.close()
|
|
717
|
+
output_container.close()
|
|
601
718
|
|
|
602
719
|
def combine_to_movie(self):
|
|
603
720
|
"""Used internally by Manim to combine the separate
|
|
@@ -614,6 +731,11 @@ class SceneFileWriter:
|
|
|
614
731
|
movie_file_path = self.movie_file_path
|
|
615
732
|
if is_gif_format():
|
|
616
733
|
movie_file_path = self.gif_file_path
|
|
734
|
+
|
|
735
|
+
if len(partial_movie_files) == 0: # Prevent calling concat on empty list
|
|
736
|
+
logger.info("No animations are contained in this scene.")
|
|
737
|
+
return
|
|
738
|
+
|
|
617
739
|
logger.info("Combining to Movie file.")
|
|
618
740
|
self.combine_files(
|
|
619
741
|
partial_movie_files,
|
|
@@ -623,44 +745,72 @@ class SceneFileWriter:
|
|
|
623
745
|
)
|
|
624
746
|
|
|
625
747
|
# handle sound
|
|
626
|
-
if self.includes_sound:
|
|
748
|
+
if self.includes_sound and config.format != "gif":
|
|
627
749
|
sound_file_path = movie_file_path.with_suffix(".wav")
|
|
628
750
|
# Makes sure sound file length will match video file
|
|
629
751
|
self.add_audio_segment(AudioSegment.silent(0))
|
|
630
752
|
self.audio_segment.export(
|
|
631
753
|
sound_file_path,
|
|
754
|
+
format="wav",
|
|
632
755
|
bitrate="312k",
|
|
633
756
|
)
|
|
757
|
+
# Audio added to a VP9 encoded (webm) video file needs
|
|
758
|
+
# to be encoded as vorbis or opus. Directly exporting
|
|
759
|
+
# self.audio_segment with such a codec works in principle,
|
|
760
|
+
# but tries to call ffmpeg via its CLI -- which we want
|
|
761
|
+
# to avoid. This is why we need to do the conversion
|
|
762
|
+
# manually.
|
|
763
|
+
if config.movie_file_extension == ".webm":
|
|
764
|
+
ogg_sound_file_path = sound_file_path.with_suffix(".ogg")
|
|
765
|
+
convert_audio(sound_file_path, ogg_sound_file_path, "libvorbis")
|
|
766
|
+
sound_file_path = ogg_sound_file_path
|
|
767
|
+
elif config.movie_file_extension == ".mp4":
|
|
768
|
+
# Similarly, pyav may reject wav audio in an .mp4 file;
|
|
769
|
+
# convert to AAC.
|
|
770
|
+
aac_sound_file_path = sound_file_path.with_suffix(".aac")
|
|
771
|
+
convert_audio(sound_file_path, aac_sound_file_path, "aac")
|
|
772
|
+
sound_file_path = aac_sound_file_path
|
|
773
|
+
|
|
634
774
|
temp_file_path = movie_file_path.with_name(
|
|
635
775
|
f"{movie_file_path.stem}_temp{movie_file_path.suffix}"
|
|
636
776
|
)
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
"
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
777
|
+
av_options = {
|
|
778
|
+
"shortest": "1",
|
|
779
|
+
"metadata": f"comment=Rendered with Manim Community v{__version__}",
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
with (
|
|
783
|
+
av.open(movie_file_path) as video_input,
|
|
784
|
+
av.open(sound_file_path) as audio_input,
|
|
785
|
+
):
|
|
786
|
+
video_stream = video_input.streams.video[0]
|
|
787
|
+
audio_stream = audio_input.streams.audio[0]
|
|
788
|
+
output_container = av.open(
|
|
789
|
+
str(temp_file_path), mode="w", options=av_options
|
|
790
|
+
)
|
|
791
|
+
output_video_stream = output_container.add_stream(template=video_stream)
|
|
792
|
+
output_audio_stream = output_container.add_stream(template=audio_stream)
|
|
793
|
+
|
|
794
|
+
for packet in video_input.demux(video_stream):
|
|
795
|
+
# We need to skip the "flushing" packets that `demux` generates.
|
|
796
|
+
if packet.dts is None:
|
|
797
|
+
continue
|
|
798
|
+
|
|
799
|
+
# We need to assign the packet to the new stream.
|
|
800
|
+
packet.stream = output_video_stream
|
|
801
|
+
output_container.mux(packet)
|
|
802
|
+
|
|
803
|
+
for packet in audio_input.demux(audio_stream):
|
|
804
|
+
# We need to skip the "flushing" packets that `demux` generates.
|
|
805
|
+
if packet.dts is None:
|
|
806
|
+
continue
|
|
807
|
+
|
|
808
|
+
# We need to assign the packet to the new stream.
|
|
809
|
+
packet.stream = output_audio_stream
|
|
810
|
+
output_container.mux(packet)
|
|
811
|
+
|
|
812
|
+
output_container.close()
|
|
813
|
+
|
|
664
814
|
shutil.move(str(temp_file_path), str(movie_file_path))
|
|
665
815
|
sound_file_path.unlink()
|
|
666
816
|
|
|
@@ -672,7 +822,6 @@ class SceneFileWriter:
|
|
|
672
822
|
|
|
673
823
|
def combine_to_section_videos(self) -> None:
|
|
674
824
|
"""Concatenate partial movie files for each section."""
|
|
675
|
-
|
|
676
825
|
self.finish_last_section()
|
|
677
826
|
sections_index: list[dict[str, Any]] = []
|
|
678
827
|
for section in self.sections:
|
|
@@ -725,6 +874,8 @@ class SceneFileWriter:
|
|
|
725
874
|
|
|
726
875
|
def write_subcaption_file(self):
|
|
727
876
|
"""Writes the subcaption file."""
|
|
877
|
+
if config.output_file is None:
|
|
878
|
+
return
|
|
728
879
|
subcaption_file = Path(config.output_file).with_suffix(".srt")
|
|
729
880
|
subcaption_file.write_text(srt.compose(self.subcaptions), encoding="utf-8")
|
|
730
881
|
logger.info(f"Subcaption file has been written as {subcaption_file}")
|