manim 0.18.1__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.

Files changed (129) hide show
  1. manim/__main__.py +45 -12
  2. manim/_config/__init__.py +2 -2
  3. manim/_config/cli_colors.py +8 -4
  4. manim/_config/default.cfg +0 -2
  5. manim/_config/logger_utils.py +5 -0
  6. manim/_config/utils.py +29 -38
  7. manim/animation/animation.py +148 -8
  8. manim/animation/composition.py +16 -13
  9. manim/animation/creation.py +184 -8
  10. manim/animation/fading.py +5 -8
  11. manim/animation/indication.py +93 -26
  12. manim/animation/movement.py +21 -3
  13. manim/animation/rotation.py +2 -1
  14. manim/animation/specialized.py +3 -5
  15. manim/animation/speedmodifier.py +3 -3
  16. manim/animation/transform.py +4 -5
  17. manim/animation/updaters/mobject_update_utils.py +17 -14
  18. manim/camera/camera.py +2 -2
  19. manim/cli/__init__.py +17 -0
  20. manim/cli/cfg/group.py +52 -36
  21. manim/cli/checkhealth/checks.py +92 -76
  22. manim/cli/checkhealth/commands.py +12 -5
  23. manim/cli/default_group.py +148 -24
  24. manim/cli/init/commands.py +28 -23
  25. manim/cli/plugins/commands.py +13 -3
  26. manim/cli/render/commands.py +47 -42
  27. manim/cli/render/global_options.py +43 -9
  28. manim/cli/render/render_options.py +84 -19
  29. manim/constants.py +11 -4
  30. manim/mobject/frame.py +0 -1
  31. manim/mobject/geometry/arc.py +109 -75
  32. manim/mobject/geometry/boolean_ops.py +20 -17
  33. manim/mobject/geometry/labeled.py +300 -77
  34. manim/mobject/geometry/line.py +120 -60
  35. manim/mobject/geometry/polygram.py +109 -25
  36. manim/mobject/geometry/shape_matchers.py +35 -15
  37. manim/mobject/geometry/tips.py +36 -27
  38. manim/mobject/graph.py +48 -40
  39. manim/mobject/graphing/coordinate_systems.py +110 -45
  40. manim/mobject/graphing/functions.py +16 -10
  41. manim/mobject/graphing/number_line.py +23 -9
  42. manim/mobject/graphing/probability.py +2 -10
  43. manim/mobject/graphing/scale.py +6 -5
  44. manim/mobject/matrix.py +17 -19
  45. manim/mobject/mobject.py +149 -103
  46. manim/mobject/opengl/opengl_geometry.py +4 -8
  47. manim/mobject/opengl/opengl_mobject.py +506 -343
  48. manim/mobject/opengl/opengl_point_cloud_mobject.py +3 -7
  49. manim/mobject/opengl/opengl_surface.py +1 -2
  50. manim/mobject/opengl/opengl_vectorized_mobject.py +27 -65
  51. manim/mobject/svg/brace.py +61 -13
  52. manim/mobject/svg/svg_mobject.py +2 -1
  53. manim/mobject/table.py +11 -12
  54. manim/mobject/text/code_mobject.py +186 -550
  55. manim/mobject/text/numbers.py +7 -7
  56. manim/mobject/text/tex_mobject.py +22 -13
  57. manim/mobject/text/text_mobject.py +29 -20
  58. manim/mobject/three_d/polyhedra.py +98 -1
  59. manim/mobject/three_d/three_dimensions.py +59 -31
  60. manim/mobject/types/image_mobject.py +37 -23
  61. manim/mobject/types/point_cloud_mobject.py +103 -67
  62. manim/mobject/types/vectorized_mobject.py +387 -214
  63. manim/mobject/value_tracker.py +2 -1
  64. manim/mobject/vector_field.py +2 -4
  65. manim/opengl/__init__.py +3 -3
  66. manim/plugins/__init__.py +2 -3
  67. manim/plugins/plugins_flags.py +3 -3
  68. manim/renderer/cairo_renderer.py +11 -11
  69. manim/renderer/opengl_renderer.py +19 -20
  70. manim/renderer/shader.py +2 -3
  71. manim/renderer/shader_wrapper.py +3 -2
  72. manim/scene/moving_camera_scene.py +23 -0
  73. manim/scene/scene.py +72 -41
  74. manim/scene/scene_file_writer.py +313 -164
  75. manim/scene/section.py +15 -15
  76. manim/scene/three_d_scene.py +8 -15
  77. manim/scene/vector_space_scene.py +3 -6
  78. manim/typing.py +326 -66
  79. manim/utils/bezier.py +1658 -381
  80. manim/utils/caching.py +11 -5
  81. manim/utils/color/AS2700.py +2 -0
  82. manim/utils/color/BS381.py +2 -0
  83. manim/utils/color/DVIPSNAMES.py +96 -0
  84. manim/utils/color/SVGNAMES.py +179 -0
  85. manim/utils/color/X11.py +3 -0
  86. manim/utils/color/XKCD.py +2 -0
  87. manim/utils/color/__init__.py +8 -5
  88. manim/utils/color/core.py +818 -301
  89. manim/utils/color/manim_colors.py +7 -9
  90. manim/utils/commands.py +40 -19
  91. manim/utils/config_ops.py +18 -13
  92. manim/utils/debug.py +8 -6
  93. manim/utils/deprecation.py +92 -43
  94. manim/utils/docbuild/autoaliasattr_directive.py +45 -8
  95. manim/utils/docbuild/autocolor_directive.py +12 -13
  96. manim/utils/docbuild/manim_directive.py +35 -29
  97. manim/utils/docbuild/module_parsing.py +74 -27
  98. manim/utils/family.py +3 -3
  99. manim/utils/family_ops.py +12 -4
  100. manim/utils/file_ops.py +22 -16
  101. manim/utils/hashing.py +7 -7
  102. manim/utils/images.py +10 -4
  103. manim/utils/ipython_magic.py +12 -8
  104. manim/utils/iterables.py +161 -119
  105. manim/utils/module_ops.py +55 -19
  106. manim/utils/opengl.py +68 -23
  107. manim/utils/parameter_parsing.py +3 -2
  108. manim/utils/paths.py +11 -5
  109. manim/utils/polylabel.py +168 -0
  110. manim/utils/qhull.py +218 -0
  111. manim/utils/rate_functions.py +69 -32
  112. manim/utils/simple_functions.py +24 -15
  113. manim/utils/sounds.py +7 -1
  114. manim/utils/space_ops.py +48 -37
  115. manim/utils/testing/_frames_testers.py +13 -8
  116. manim/utils/testing/_show_diff.py +5 -3
  117. manim/utils/testing/_test_class_makers.py +33 -18
  118. manim/utils/testing/frames_comparison.py +20 -14
  119. manim/utils/tex.py +4 -2
  120. manim/utils/tex_file_writing.py +45 -45
  121. manim/utils/tex_templates.py +1 -1
  122. manim/utils/unit.py +6 -5
  123. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/METADATA +16 -9
  124. manim-0.19.0.dist-info/RECORD +221 -0
  125. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/WHEEL +1 -1
  126. manim-0.18.1.dist-info/RECORD +0 -217
  127. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/LICENSE +0 -0
  128. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/LICENSE.community +0 -0
  129. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/entry_points.txt +0 -0
@@ -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 subprocess
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__(self, renderer, scene_name, **kwargs):
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", type=DefaultSectionType.NORMAL, skip_animations=False
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 bein set (doesn't work on Windows), everyone likes defensive programming, right?
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, type: str, skip_animations: bool) -> None:
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
 
@@ -195,7 +224,7 @@ class SceneFileWriter:
195
224
 
196
225
  self.sections.append(
197
226
  Section(
198
- type,
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
- new_segment = AudioSegment.from_file(file_path)
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(self, allow_write: bool = False, file_path=None):
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.open_movie_pipe(file_path=file_path)
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.close_movie_pipe()
413
+ self.close_partial_movie_stream()
375
414
 
376
- def write_frame(self, frame_or_renderer: np.ndarray | OpenGLRenderer):
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
421
+
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
- self.writing_process.stdin.write(
398
- renderer.get_raw_frame_buffer_object_data(),
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
- renderer.get_image(),
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 open_movie_pipe(self, file_path=None):
471
- """
472
- Used internally by Manim to initialise
473
- FFMPEG and begin writing to FFMPEG's input
474
- buffer.
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["frame_rate"]
481
- if fps == int(fps): # fps is integer
482
- fps = int(fps)
483
- if config.renderer == RendererType.OPENGL:
484
- width, height = self.renderer.get_pixel_shape()
485
- else:
486
- height = config["pixel_height"]
487
- width = config["pixel_width"]
488
-
489
- command = [
490
- config.ffmpeg_executable,
491
- "-y", # overwrite output file if it exists
492
- "-f",
493
- "rawvideo",
494
- "-s",
495
- "%dx%d" % (width, height), # size of one frame
496
- "-pix_fmt",
497
- "rgba",
498
- "-r",
499
- str(fps), # frames per second
500
- "-i",
501
- "-", # The input comes from a pipe
502
- "-an", # Tells FFMPEG not to expect any audio
503
- "-loglevel",
504
- config["ffmpeg_loglevel"].lower(),
505
- "-metadata",
506
- f"comment=Rendered with Manim Community v{__version__}",
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
- def close_movie_pipe(self):
521
- """
522
- Used internally by Manim to gracefully stop writing to FFMPEG's input buffer
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.writing_process.stdin.close()
525
- self.writing_process.wait()
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
- commands += [
588
- "-vf",
589
- f"fps={np.clip(config['frame_rate'], 1, 50)},split[s0][s1];[s0]palettegen=stats_mode=diff[p];[s1][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
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
- commands += ["-c", "copy"]
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
- if not includes_sound:
595
- commands += ["-an"]
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
- commands += [str(output_file)]
712
+ # We need to assign the packet to the new stream.
713
+ packet.stream = output_stream
714
+ output_container.mux(packet)
598
715
 
599
- combine_process = subprocess.Popen(commands)
600
- combine_process.wait()
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
- commands = [
638
- config.ffmpeg_executable,
639
- "-i",
640
- str(movie_file_path),
641
- "-i",
642
- str(sound_file_path),
643
- "-y", # overwrite output file if it exists
644
- "-c:v",
645
- "copy",
646
- "-c:a",
647
- "aac",
648
- "-b:a",
649
- "320k",
650
- # select video stream from first file
651
- "-map",
652
- "0:v:0",
653
- # select audio stream from second file
654
- "-map",
655
- "1:a:0",
656
- "-loglevel",
657
- config.ffmpeg_loglevel.lower(),
658
- "-metadata",
659
- f"comment=Rendered with Manim Community v{__version__}",
660
- # "-shortest",
661
- str(temp_file_path),
662
- ]
663
- subprocess.call(commands)
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: