manim 0.17.0__py3-none-any.whl → 0.19.1__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.
Files changed (163) hide show
  1. manim/__init__.py +11 -6
  2. manim/__main__.py +62 -19
  3. manim/_config/__init__.py +10 -9
  4. manim/_config/cli_colors.py +26 -9
  5. manim/_config/default.cfg +1 -3
  6. manim/_config/logger_utils.py +23 -13
  7. manim/_config/utils.py +662 -468
  8. manim/animation/animation.py +164 -18
  9. manim/animation/changing.py +34 -23
  10. manim/animation/composition.py +265 -67
  11. manim/animation/creation.py +208 -26
  12. manim/animation/fading.py +16 -18
  13. manim/animation/growing.py +35 -15
  14. manim/animation/indication.py +150 -76
  15. manim/animation/movement.py +56 -22
  16. manim/animation/numbers.py +64 -6
  17. manim/animation/rotation.py +78 -7
  18. manim/animation/specialized.py +6 -7
  19. manim/animation/speedmodifier.py +13 -10
  20. manim/animation/transform.py +14 -11
  21. manim/animation/transform_matching_parts.py +3 -4
  22. manim/animation/updaters/mobject_update_utils.py +152 -30
  23. manim/animation/updaters/update.py +10 -7
  24. manim/camera/camera.py +182 -118
  25. manim/camera/mapping_camera.py +34 -3
  26. manim/camera/moving_camera.py +95 -74
  27. manim/camera/multi_camera.py +23 -15
  28. manim/camera/three_d_camera.py +70 -52
  29. manim/cli/__init__.py +17 -0
  30. manim/cli/cfg/group.py +76 -44
  31. manim/cli/checkhealth/checks.py +192 -0
  32. manim/cli/checkhealth/commands.py +90 -0
  33. manim/cli/default_group.py +158 -25
  34. manim/cli/init/commands.py +33 -25
  35. manim/cli/plugins/commands.py +16 -3
  36. manim/cli/render/commands.py +72 -60
  37. manim/cli/render/ease_of_access_options.py +4 -3
  38. manim/cli/render/global_options.py +59 -17
  39. manim/cli/render/output_options.py +6 -5
  40. manim/cli/render/render_options.py +98 -33
  41. manim/constants.py +109 -59
  42. manim/data_structures.py +31 -0
  43. manim/mobject/frame.py +8 -5
  44. manim/mobject/geometry/__init__.py +1 -0
  45. manim/mobject/geometry/arc.py +277 -135
  46. manim/mobject/geometry/boolean_ops.py +32 -31
  47. manim/mobject/geometry/labeled.py +376 -0
  48. manim/mobject/geometry/line.py +192 -87
  49. manim/mobject/geometry/polygram.py +224 -58
  50. manim/mobject/geometry/shape_matchers.py +61 -25
  51. manim/mobject/geometry/tips.py +122 -48
  52. manim/mobject/graph.py +1027 -419
  53. manim/mobject/graphing/coordinate_systems.py +533 -278
  54. manim/mobject/graphing/functions.py +53 -32
  55. manim/mobject/graphing/number_line.py +123 -65
  56. manim/mobject/graphing/probability.py +88 -62
  57. manim/mobject/graphing/scale.py +33 -19
  58. manim/mobject/logo.py +118 -28
  59. manim/mobject/matrix.py +87 -83
  60. manim/mobject/mobject.py +912 -442
  61. manim/mobject/opengl/dot_cloud.py +16 -5
  62. manim/mobject/opengl/opengl_compatibility.py +4 -2
  63. manim/mobject/opengl/opengl_geometry.py +254 -153
  64. manim/mobject/opengl/opengl_image_mobject.py +3 -1
  65. manim/mobject/opengl/opengl_mobject.py +779 -482
  66. manim/mobject/opengl/opengl_point_cloud_mobject.py +41 -14
  67. manim/mobject/opengl/opengl_surface.py +14 -92
  68. manim/mobject/opengl/opengl_three_dimensions.py +12 -8
  69. manim/mobject/opengl/opengl_vectorized_mobject.py +98 -100
  70. manim/mobject/svg/brace.py +173 -41
  71. manim/mobject/svg/svg_mobject.py +139 -53
  72. manim/mobject/table.py +61 -68
  73. manim/mobject/text/code_mobject.py +193 -539
  74. manim/mobject/text/numbers.py +81 -34
  75. manim/mobject/text/tex_mobject.py +130 -78
  76. manim/mobject/text/text_mobject.py +288 -164
  77. manim/mobject/three_d/polyhedra.py +111 -13
  78. manim/mobject/three_d/three_d_utils.py +17 -8
  79. manim/mobject/three_d/three_dimensions.py +239 -106
  80. manim/mobject/types/image_mobject.py +50 -30
  81. manim/mobject/types/point_cloud_mobject.py +120 -75
  82. manim/mobject/types/vectorized_mobject.py +841 -408
  83. manim/mobject/value_tracker.py +105 -38
  84. manim/mobject/vector_field.py +50 -31
  85. manim/opengl/__init__.py +3 -3
  86. manim/plugins/__init__.py +14 -1
  87. manim/plugins/plugins_flags.py +10 -14
  88. manim/renderer/cairo_renderer.py +65 -50
  89. manim/renderer/opengl_renderer.py +89 -69
  90. manim/renderer/opengl_renderer_window.py +39 -18
  91. manim/renderer/shader.py +123 -87
  92. manim/renderer/shader_wrapper.py +44 -28
  93. manim/renderer/vectorized_mobject_rendering.py +38 -10
  94. manim/scene/moving_camera_scene.py +32 -3
  95. manim/scene/scene.py +507 -242
  96. manim/scene/scene_file_writer.py +371 -220
  97. manim/scene/section.py +20 -16
  98. manim/scene/three_d_scene.py +14 -22
  99. manim/scene/vector_space_scene.py +223 -129
  100. manim/scene/zoomed_scene.py +46 -41
  101. manim/typing.py +990 -0
  102. manim/utils/bezier.py +1823 -371
  103. manim/utils/caching.py +12 -5
  104. manim/utils/color/AS2700.py +236 -0
  105. manim/utils/color/BS381.py +318 -0
  106. manim/utils/color/DVIPSNAMES.py +96 -0
  107. manim/utils/color/SVGNAMES.py +179 -0
  108. manim/utils/color/X11.py +533 -0
  109. manim/utils/color/XKCD.py +952 -0
  110. manim/utils/color/__init__.py +61 -0
  111. manim/utils/color/core.py +1667 -0
  112. manim/utils/color/manim_colors.py +218 -0
  113. manim/utils/commands.py +48 -20
  114. manim/utils/config_ops.py +39 -19
  115. manim/utils/debug.py +8 -7
  116. manim/utils/deprecation.py +86 -39
  117. manim/utils/docbuild/__init__.py +17 -0
  118. manim/utils/docbuild/autoaliasattr_directive.py +236 -0
  119. manim/utils/docbuild/autocolor_directive.py +99 -0
  120. manim/utils/docbuild/manim_directive.py +94 -41
  121. manim/utils/docbuild/module_parsing.py +245 -0
  122. manim/utils/exceptions.py +6 -0
  123. manim/utils/family.py +5 -3
  124. manim/utils/family_ops.py +17 -4
  125. manim/utils/file_ops.py +27 -17
  126. manim/utils/hashing.py +55 -45
  127. manim/utils/images.py +13 -7
  128. manim/utils/ipython_magic.py +13 -7
  129. manim/utils/iterables.py +163 -120
  130. manim/utils/module_ops.py +66 -24
  131. manim/utils/opengl.py +77 -24
  132. manim/utils/parameter_parsing.py +32 -0
  133. manim/utils/paths.py +30 -33
  134. manim/utils/polylabel.py +235 -0
  135. manim/utils/qhull.py +218 -0
  136. manim/utils/rate_functions.py +98 -32
  137. manim/utils/simple_functions.py +25 -33
  138. manim/utils/sounds.py +7 -1
  139. manim/utils/space_ops.py +188 -115
  140. manim/utils/testing/__init__.py +17 -0
  141. manim/utils/testing/_frames_testers.py +13 -8
  142. manim/utils/testing/_show_diff.py +5 -3
  143. manim/utils/testing/_test_class_makers.py +34 -18
  144. manim/utils/testing/frames_comparison.py +37 -19
  145. manim/utils/tex.py +130 -198
  146. manim/utils/tex_file_writing.py +77 -47
  147. manim/utils/tex_templates.py +2 -1
  148. manim/utils/unit.py +6 -5
  149. {manim-0.17.0.dist-info → manim-0.19.1.dist-info}/METADATA +64 -65
  150. manim-0.19.1.dist-info/RECORD +220 -0
  151. {manim-0.17.0.dist-info → manim-0.19.1.dist-info}/WHEEL +1 -1
  152. manim-0.19.1.dist-info/entry_points.txt +3 -0
  153. {manim-0.17.0.dist-info → manim-0.19.1.dist-info/licenses}/LICENSE.community +1 -1
  154. manim/cli/new/group.py +0 -189
  155. manim/communitycolors.py +0 -9
  156. manim/gui/__init__.py +0 -0
  157. manim/gui/gui.py +0 -82
  158. manim/plugins/import_plugins.py +0 -43
  159. manim/utils/color.py +0 -552
  160. manim-0.17.0.dist-info/RECORD +0 -206
  161. manim-0.17.0.dist-info/entry_points.txt +0 -4
  162. /manim/cli/{new → checkhealth}/__init__.py +0 -0
  163. {manim-0.17.0.dist-info → manim-0.19.1.dist-info/licenses}/LICENSE +0 -0
@@ -5,12 +5,15 @@ 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, _TemporaryFileWrapper
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
@@ -24,11 +27,9 @@ from ..constants import RendererType
24
27
  from ..utils.file_ops import (
25
28
  add_extension_if_not_present,
26
29
  add_version_before_extension,
27
- ensure_executable,
28
30
  guarantee_existence,
29
31
  is_gif_format,
30
32
  is_png_format,
31
- is_webm_format,
32
33
  modify_atime,
33
34
  write_to_movie,
34
35
  )
@@ -36,12 +37,50 @@ from ..utils.sounds import get_full_sound_file_path
36
37
  from .section import DefaultSectionType, Section
37
38
 
38
39
  if TYPE_CHECKING:
40
+ from av.container.output import OutputContainer
41
+ from av.stream import Stream
42
+
43
+ from manim.renderer.cairo_renderer import CairoRenderer
39
44
  from manim.renderer.opengl_renderer import OpenGLRenderer
45
+ from manim.typing import PixelArray, StrPath
46
+
47
+
48
+ def to_av_frame_rate(fps: float) -> Fraction:
49
+ epsilon1 = 1e-4
50
+ epsilon2 = 0.02
51
+
52
+ if isinstance(fps, int):
53
+ (num, denom) = (fps, 1)
54
+ elif abs(fps - round(fps)) < epsilon1:
55
+ (num, denom) = (round(fps), 1)
56
+ else:
57
+ denom = 1001
58
+ num = round(fps * denom / 1000) * 1000
59
+ if abs(fps - num / denom) >= epsilon2:
60
+ raise ValueError("invalid frame rate")
61
+
62
+ return Fraction(num, denom)
63
+
64
+
65
+ def convert_audio(
66
+ input_path: Path, output_path: Path | _TemporaryFileWrapper[bytes], codec_name: str
67
+ ) -> None:
68
+ with (
69
+ av.open(input_path) as input_audio,
70
+ av.open(output_path, "w") as output_audio,
71
+ ):
72
+ input_audio_stream = input_audio.streams.audio[0]
73
+ output_audio_stream = output_audio.add_stream(codec_name)
74
+ for frame in input_audio.decode(input_audio_stream):
75
+ for packet in output_audio_stream.encode(frame):
76
+ output_audio.mux(packet)
77
+
78
+ for packet in output_audio_stream.encode():
79
+ output_audio.mux(packet)
40
80
 
41
81
 
42
82
  class SceneFileWriter:
43
- """
44
- SceneFileWriter is the object that actually writes the animations
83
+ """SceneFileWriter is the object that actually writes the animations
45
84
  played, into video files, using FFMPEG.
46
85
  This is mostly for Manim's internal use. You will rarely, if ever,
47
86
  have to use the methods for this class, unless tinkering with the very
@@ -70,29 +109,26 @@ class SceneFileWriter:
70
109
 
71
110
  force_output_as_scene_name = False
72
111
 
73
- def __init__(self, renderer, scene_name, **kwargs):
112
+ def __init__(
113
+ self,
114
+ renderer: CairoRenderer | OpenGLRenderer,
115
+ scene_name: str,
116
+ **kwargs: Any,
117
+ ) -> None:
74
118
  self.renderer = renderer
75
119
  self.init_output_directories(scene_name)
76
120
  self.init_audio()
77
121
  self.frame_count = 0
78
- self.partial_movie_files: list[str] = []
122
+ self.partial_movie_files: list[str | None] = []
79
123
  self.subcaptions: list[srt.Subtitle] = []
80
124
  self.sections: list[Section] = []
81
125
  # first section gets automatically created for convenience
82
126
  # if you need the first section to be skipped, add a first section by hand, it will replace this one
83
127
  self.next_section(
84
- name="autocreated", type=DefaultSectionType.NORMAL, skip_animations=False
128
+ name="autocreated", type_=DefaultSectionType.NORMAL, skip_animations=False
85
129
  )
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
130
 
95
- def init_output_directories(self, scene_name):
131
+ def init_output_directories(self, scene_name: str) -> None:
96
132
  """Initialise output directories.
97
133
 
98
134
  Notes
@@ -105,10 +141,7 @@ class SceneFileWriter:
105
141
  if config["dry_run"]: # in dry-run mode there is no output
106
142
  return
107
143
 
108
- if config["input_file"]:
109
- module_name = config.get_dir("input_file").stem
110
- else:
111
- module_name = ""
144
+ module_name = config.get_dir("input_file").stem if config["input_file"] else ""
112
145
 
113
146
  if SceneFileWriter.force_output_as_scene_name:
114
147
  self.output_name = Path(scene_name)
@@ -137,7 +170,7 @@ class SceneFileWriter:
137
170
  self.output_name, config["movie_file_extension"]
138
171
  )
139
172
 
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?
173
+ # 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
174
  self.sections_output_dir = Path("")
142
175
  if config.save_sections:
143
176
  self.sections_output_dir = guarantee_existence(
@@ -177,7 +210,7 @@ class SceneFileWriter:
177
210
  if len(self.sections) and self.sections[-1].is_empty():
178
211
  self.sections.pop()
179
212
 
180
- def next_section(self, name: str, type: str, skip_animations: bool) -> None:
213
+ def next_section(self, name: str, type_: str, skip_animations: bool) -> None:
181
214
  """Create segmentation cut here."""
182
215
  self.finish_last_section()
183
216
 
@@ -191,20 +224,23 @@ class SceneFileWriter:
191
224
  and not skip_animations
192
225
  ):
193
226
  # relative to index file
194
- section_video = f"{self.output_name}_{len(self.sections):04}{config.movie_file_extension}"
227
+ section_video = f"{self.output_name}_{len(self.sections):04}_{name}{config.movie_file_extension}"
195
228
 
196
229
  self.sections.append(
197
230
  Section(
198
- type,
231
+ type_,
199
232
  section_video,
200
233
  name,
201
234
  skip_animations,
202
235
  ),
203
236
  )
204
237
 
205
- def add_partial_movie_file(self, hash_animation: str):
206
- """Adds a new partial movie file path to `scene.partial_movie_files` and current section from a hash.
207
- This method will compute the path from the hash. In addition to that it adds the new animation to the current section.
238
+ def add_partial_movie_file(self, hash_animation: str | None) -> None:
239
+ """Adds a new partial movie file path to ``scene.partial_movie_files``
240
+ and current section from a hash.
241
+
242
+ This method will compute the path from the hash. In addition to that it
243
+ adds the new animation to the current section.
208
244
 
209
245
  Parameters
210
246
  ----------
@@ -227,7 +263,7 @@ class SceneFileWriter:
227
263
  self.partial_movie_files.append(new_partial_movie_file)
228
264
  self.sections[-1].partial_movie_files.append(new_partial_movie_file)
229
265
 
230
- def get_resolution_directory(self):
266
+ def get_resolution_directory(self) -> str:
231
267
  """Get the name of the resolution directory directly containing
232
268
  the video file.
233
269
 
@@ -243,9 +279,11 @@ class SceneFileWriter:
243
279
  |--Tex
244
280
  |--texts
245
281
  |--videos
246
- |--<name_of_file_containing_scene>
247
- |--<height_in_pixels_of_video>p<frame_rate>
248
- |--<scene_name>.mp4
282
+ |--<name_of_file_containing_scene>
283
+ |--<height_in_pixels_of_video>p<frame_rate>
284
+ |--partial_movie_files
285
+ |--<scene_name>.mp4
286
+ |--<scene_name>.srt
249
287
 
250
288
  Returns
251
289
  -------
@@ -257,16 +295,12 @@ class SceneFileWriter:
257
295
  return f"{pixel_height}p{frame_rate}"
258
296
 
259
297
  # Sound
260
- def init_audio(self):
261
- """
262
- Preps the writer for adding audio to the movie.
263
- """
298
+ def init_audio(self) -> None:
299
+ """Preps the writer for adding audio to the movie."""
264
300
  self.includes_sound = False
265
301
 
266
- def create_audio_segment(self):
267
- """
268
- Creates an empty, silent, Audio Segment.
269
- """
302
+ def create_audio_segment(self) -> None:
303
+ """Creates an empty, silent, Audio Segment."""
270
304
  self.audio_segment = AudioSegment.silent()
271
305
 
272
306
  def add_audio_segment(
@@ -274,10 +308,9 @@ class SceneFileWriter:
274
308
  new_segment: AudioSegment,
275
309
  time: float | None = None,
276
310
  gain_to_background: float | None = None,
277
- ):
278
- """
279
- This method adds an audio segment from an
280
- AudioSegment type object and suitable parameters.
311
+ ) -> None:
312
+ """This method adds an audio segment from an AudioSegment type object
313
+ and suitable parameters.
281
314
 
282
315
  Parameters
283
316
  ----------
@@ -285,8 +318,7 @@ class SceneFileWriter:
285
318
  The audio segment to add
286
319
 
287
320
  time
288
- the timestamp at which the
289
- sound should be added.
321
+ the timestamp at which the sound should be added.
290
322
 
291
323
  gain_to_background
292
324
  The gain of the segment from the background.
@@ -316,13 +348,12 @@ class SceneFileWriter:
316
348
 
317
349
  def add_sound(
318
350
  self,
319
- sound_file: str,
351
+ sound_file: StrPath,
320
352
  time: float | None = None,
321
353
  gain: float | None = None,
322
- **kwargs,
323
- ):
324
- """
325
- This method adds an audio segment from a sound file.
354
+ **kwargs: Any,
355
+ ) -> None:
356
+ """This method adds an audio segment from a sound file.
326
357
 
327
358
  Parameters
328
359
  ----------
@@ -341,15 +372,28 @@ class SceneFileWriter:
341
372
 
342
373
  """
343
374
  file_path = get_full_sound_file_path(sound_file)
344
- new_segment = AudioSegment.from_file(file_path)
375
+ # we assume files with .wav / .raw suffix are actually
376
+ # .wav and .raw files, respectively.
377
+ if file_path.suffix not in (".wav", ".raw"):
378
+ # we need to pass delete=False to work on Windows
379
+ # TODO: figure out a way to cache the wav file generated (benchmark needed)
380
+ with NamedTemporaryFile(suffix=".wav", delete=False) as wav_file_path:
381
+ convert_audio(file_path, wav_file_path, "pcm_s16le")
382
+ new_segment = AudioSegment.from_file(wav_file_path.name)
383
+ logger.info(f"Automatically converted {file_path} to .wav")
384
+ Path(wav_file_path.name).unlink()
385
+ else:
386
+ new_segment = AudioSegment.from_file(file_path)
387
+
345
388
  if gain:
346
389
  new_segment = new_segment.apply_gain(gain)
347
390
  self.add_audio_segment(new_segment, time, **kwargs)
348
391
 
349
392
  # Writers
350
- def begin_animation(self, allow_write: bool = False, file_path=None):
351
- """
352
- Used internally by manim to stream the animation to FFMPEG for
393
+ def begin_animation(
394
+ self, allow_write: bool = False, file_path: StrPath | None = None
395
+ ) -> None:
396
+ """Used internally by manim to stream the animation to FFMPEG for
353
397
  displaying or writing to a file.
354
398
 
355
399
  Parameters
@@ -358,12 +402,10 @@ class SceneFileWriter:
358
402
  Whether or not to write to a video file.
359
403
  """
360
404
  if write_to_movie() and allow_write:
361
- self.open_movie_pipe(file_path=file_path)
405
+ self.open_partial_movie_stream(file_path=file_path)
362
406
 
363
- def end_animation(self, allow_write: bool = False):
364
- """
365
- Internally used by Manim to stop streaming to
366
- FFMPEG gracefully.
407
+ def end_animation(self, allow_write: bool = False) -> None:
408
+ """Internally used by Manim to stop streaming to FFMPEG gracefully.
367
409
 
368
410
  Parameters
369
411
  ----------
@@ -371,63 +413,86 @@ class SceneFileWriter:
371
413
  Whether or not to write to a video file.
372
414
  """
373
415
  if write_to_movie() and allow_write:
374
- self.close_movie_pipe()
416
+ self.close_partial_movie_stream()
375
417
 
376
- def write_frame(self, frame_or_renderer: np.ndarray | OpenGLRenderer):
418
+ def listen_and_write(self) -> None:
419
+ """For internal use only: blocks until new frame is available on the queue."""
420
+ while True:
421
+ num_frames, frame_data = self.queue.get()
422
+ if frame_data is None:
423
+ break
424
+
425
+ self.encode_and_write_frame(frame_data, num_frames)
426
+
427
+ def encode_and_write_frame(self, frame: PixelArray, num_frames: int) -> None:
428
+ """For internal use only: takes a given frame in ``np.ndarray`` format and
429
+ writes it to the stream
377
430
  """
378
- Used internally by Manim to write a frame to
379
- the FFMPEG input buffer.
431
+ for _ in range(num_frames):
432
+ # Notes: precomputing reusing packets does not work!
433
+ # I.e., you cannot do `packets = encode(...)`
434
+ # and reuse it, as it seems that `mux(...)`
435
+ # consumes the packet.
436
+ # The same issue applies for `av_frame`,
437
+ # reusing it renders weird-looking frames.
438
+ av_frame = av.VideoFrame.from_ndarray(frame, format="rgba")
439
+ for packet in self.video_stream.encode(av_frame):
440
+ self.video_container.mux(packet)
441
+
442
+ def write_frame(
443
+ self, frame_or_renderer: PixelArray | OpenGLRenderer, num_frames: int = 1
444
+ ) -> None:
445
+ """Used internally by Manim to write a frame to the FFMPEG input buffer.
380
446
 
381
447
  Parameters
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(),
399
- )
400
- elif is_png_format() and not config["dry_run"]:
455
+ if isinstance(frame_or_renderer, np.ndarray):
456
+ frame = frame_or_renderer
457
+ else:
458
+ frame = (
459
+ frame_or_renderer.get_frame()
460
+ if config.renderer == RendererType.OPENGL
461
+ else frame_or_renderer
462
+ )
463
+
464
+ msg = (num_frames, frame)
465
+ self.queue.put(msg)
466
+
467
+ if is_png_format() and not config["dry_run"]:
468
+ if isinstance(frame_or_renderer, np.ndarray):
469
+ image = Image.fromarray(frame_or_renderer)
470
+ else:
471
+ image = (
472
+ frame_or_renderer.get_image()
473
+ if config.renderer == RendererType.OPENGL
474
+ else Image.fromarray(frame_or_renderer)
475
+ )
401
476
  target_dir = self.image_file_path.parent / self.image_file_path.stem
402
477
  extension = self.image_file_path.suffix
403
478
  self.output_image(
404
- renderer.get_image(),
479
+ image,
405
480
  target_dir,
406
481
  extension,
407
482
  config["zero_pad"],
408
483
  )
409
484
 
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
- def output_image(self, image: Image.Image, target_dir, ext, zero_pad: bool):
485
+ def output_image(
486
+ self, image: Image.Image, target_dir: StrPath, ext: str, zero_pad: int
487
+ ) -> None:
421
488
  if zero_pad:
422
489
  image.save(f"{target_dir}{str(self.frame_count).zfill(zero_pad)}{ext}")
423
490
  else:
424
491
  image.save(f"{target_dir}{self.frame_count}{ext}")
425
492
  self.frame_count += 1
426
493
 
427
- def save_final_image(self, image: np.ndarray):
428
- """
429
- The name is a misnomer. This method saves the image
430
- passed to it as an in the default image directory.
494
+ def save_image(self, image: Image.Image) -> None:
495
+ """This method saves the image passed to it in the default image directory.
431
496
 
432
497
  Parameters
433
498
  ----------
@@ -442,18 +507,12 @@ class SceneFileWriter:
442
507
  image.save(self.image_file_path)
443
508
  self.print_file_ready_message(self.image_file_path)
444
509
 
445
- def finish(self):
446
- """
447
- Finishes writing to the FFMPEG buffer or writing images
448
- to output directory.
449
- Combines the partial movie files into the
450
- whole scene.
451
- If save_last_frame is True, saves the last
452
- frame in the default image directory.
510
+ def finish(self) -> None:
511
+ """Finishes writing to the FFMPEG buffer or writing images to output directory.
512
+ Combines the partial movie files into the whole scene.
513
+ If save_last_frame is True, saves the last frame in the default image directory.
453
514
  """
454
515
  if write_to_movie():
455
- if hasattr(self, "writing_process"):
456
- self.writing_process.terminate()
457
516
  self.combine_to_movie()
458
517
  if config.save_sections:
459
518
  self.combine_to_section_videos()
@@ -467,69 +526,73 @@ class SceneFileWriter:
467
526
  if self.subcaptions:
468
527
  self.write_subcaption_file()
469
528
 
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.
529
+ def open_partial_movie_stream(self, file_path: StrPath | None = None) -> None:
530
+ """Open a container holding a video stream.
531
+
532
+ This is used internally by Manim initialize the container holding
533
+ the video stream of a partial movie file.
475
534
  """
476
535
  if file_path is None:
477
536
  file_path = self.partial_movie_files[self.renderer.num_plays]
478
537
  self.partial_movie_file_path = file_path
479
538
 
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)
539
+ fps = to_av_frame_rate(config.frame_rate)
540
+
541
+ partial_movie_file_codec = "libx264"
542
+ partial_movie_file_pix_fmt = "yuv420p"
543
+ av_options = {
544
+ "an": "1", # ffmpeg: -an, no audio
545
+ "crf": "23", # ffmpeg: -crf, constant rate factor (improved bitrate)
546
+ }
547
+
548
+ if config.movie_file_extension == ".webm":
549
+ partial_movie_file_codec = "libvpx-vp9"
550
+ av_options["-auto-alt-ref"] = "1"
551
+ if config.transparent:
552
+ partial_movie_file_pix_fmt = "yuva420p"
553
+
554
+ elif config.transparent:
555
+ partial_movie_file_codec = "qtrle"
556
+ partial_movie_file_pix_fmt = "argb"
557
+
558
+ with av.open(file_path, mode="w") as video_container:
559
+ stream = video_container.add_stream(
560
+ partial_movie_file_codec,
561
+ rate=fps,
562
+ options=av_options,
563
+ )
564
+ stream.pix_fmt = partial_movie_file_pix_fmt
565
+ stream.width = config.pixel_width
566
+ stream.height = config.pixel_height
519
567
 
520
- def close_movie_pipe(self):
521
- """
522
- Used internally by Manim to gracefully stop writing to FFMPEG's input buffer
568
+ self.video_container: OutputContainer = video_container
569
+ self.video_stream: Stream = stream
570
+
571
+ self.queue: Queue[tuple[int, PixelArray | None]] = Queue()
572
+ self.writer_thread = Thread(target=self.listen_and_write, args=())
573
+ self.writer_thread.start()
574
+
575
+ def close_partial_movie_stream(self) -> None:
576
+ """Close the currently opened video container.
577
+
578
+ Used internally by Manim to first flush the remaining packages
579
+ in the video stream holding a partial file, and then close
580
+ the corresponding container.
523
581
  """
524
- self.writing_process.stdin.close()
525
- self.writing_process.wait()
582
+ self.queue.put((-1, None))
583
+ self.writer_thread.join()
584
+
585
+ for packet in self.video_stream.encode():
586
+ self.video_container.mux(packet)
587
+
588
+ self.video_container.close()
526
589
 
527
590
  logger.info(
528
591
  f"Animation {self.renderer.num_plays} : Partial movie file written in %(path)s",
529
592
  {"path": f"'{self.partial_movie_file_path}'"},
530
593
  )
531
594
 
532
- def is_already_cached(self, hash_invocation: str):
595
+ def is_already_cached(self, hash_invocation: str) -> bool:
533
596
  """Will check if a file named with `hash_invocation` exists.
534
597
 
535
598
  Parameters
@@ -554,9 +617,9 @@ class SceneFileWriter:
554
617
  self,
555
618
  input_files: list[str],
556
619
  output_file: Path,
557
- create_gif=False,
558
- includes_sound=False,
559
- ):
620
+ create_gif: bool = False,
621
+ includes_sound: bool = False,
622
+ ) -> None:
560
623
  file_list = self.partial_movie_directory / "partial_movie_file_list.txt"
561
624
  logger.debug(
562
625
  f"Partial movie files to combine ({len(input_files)} files): %(p)s",
@@ -567,39 +630,94 @@ class SceneFileWriter:
567
630
  for pf_path in input_files:
568
631
  pf_path = Path(pf_path).as_posix()
569
632
  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
633
 
634
+ av_options = {
635
+ "safe": "0", # needed to read files
636
+ }
637
+
638
+ if not includes_sound:
639
+ av_options["an"] = "1"
640
+
641
+ partial_movies_input = av.open(
642
+ str(file_list), options=av_options, format="concat"
643
+ )
644
+ partial_movies_stream = partial_movies_input.streams.video[0]
645
+ output_container = av.open(str(output_file), mode="w")
646
+ output_container.metadata["comment"] = (
647
+ f"Rendered with Manim Community v{__version__}"
648
+ )
649
+ output_stream = output_container.add_stream(
650
+ codec_name="gif" if create_gif else None,
651
+ template=partial_movies_stream if not create_gif else None,
652
+ )
653
+ if config.transparent and config.movie_file_extension == ".webm":
654
+ output_stream.pix_fmt = "yuva420p"
586
655
  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
- ]
656
+ """The following solution was largely inspired from this comment
657
+ https://github.com/imageio/imageio/issues/995#issuecomment-1580533018,
658
+ and the following code
659
+ https://github.com/imageio/imageio/blob/65d79140018bb7c64c0692ea72cb4093e8d632a0/imageio/plugins/pyav.py#L927-L996.
660
+ """
661
+ output_stream.pix_fmt = "rgb8"
662
+ if config.transparent:
663
+ output_stream.pix_fmt = "pal8"
664
+ output_stream.width = config.pixel_width
665
+ output_stream.height = config.pixel_height
666
+ output_stream.rate = to_av_frame_rate(config.frame_rate)
667
+ graph = av.filter.Graph()
668
+ input_buffer = graph.add_buffer(template=partial_movies_stream)
669
+ split = graph.add("split")
670
+ palettegen = graph.add("palettegen", "stats_mode=diff")
671
+ paletteuse = graph.add(
672
+ "paletteuse", "dither=bayer:bayer_scale=5:diff_mode=rectangle"
673
+ )
674
+ output_sink = graph.add("buffersink")
675
+
676
+ input_buffer.link_to(split)
677
+ split.link_to(palettegen, 0, 0) # 1st input of split -> input of palettegen
678
+ split.link_to(paletteuse, 1, 0) # 2nd output of split -> 1st input
679
+ palettegen.link_to(paletteuse, 0, 1) # output of palettegen -> 2nd input
680
+ paletteuse.link_to(output_sink)
681
+
682
+ graph.configure()
683
+
684
+ for frame in partial_movies_input.decode(video=0):
685
+ graph.push(frame)
686
+
687
+ graph.push(None) # EOF: https://github.com/PyAV-Org/PyAV/issues/886.
688
+
689
+ frames_written = 0
690
+ while True:
691
+ try:
692
+ frame = graph.pull()
693
+ if output_stream.codec_context.time_base is not None:
694
+ frame.time_base = output_stream.codec_context.time_base
695
+ frame.pts = frames_written
696
+ frames_written += 1
697
+ output_container.mux(output_stream.encode(frame))
698
+ except av.error.EOFError:
699
+ break
700
+
701
+ for packet in output_stream.encode():
702
+ output_container.mux(packet)
703
+
591
704
  else:
592
- commands += ["-c", "copy"]
705
+ for packet in partial_movies_input.demux(partial_movies_stream):
706
+ # We need to skip the "flushing" packets that `demux` generates.
707
+ if packet.dts is None:
708
+ continue
593
709
 
594
- if not includes_sound:
595
- commands += ["-an"]
710
+ packet.dts = None # This seems to be needed, as dts from consecutive
711
+ # files may not be monotically increasing, so we let libav compute it.
596
712
 
597
- commands += [str(output_file)]
713
+ # We need to assign the packet to the new stream.
714
+ packet.stream = output_stream
715
+ output_container.mux(packet)
598
716
 
599
- combine_process = subprocess.Popen(commands)
600
- combine_process.wait()
717
+ partial_movies_input.close()
718
+ output_container.close()
601
719
 
602
- def combine_to_movie(self):
720
+ def combine_to_movie(self) -> None:
603
721
  """Used internally by Manim to combine the separate
604
722
  partial movie files that make up a Scene into a single
605
723
  video file for that Scene.
@@ -614,6 +732,11 @@ class SceneFileWriter:
614
732
  movie_file_path = self.movie_file_path
615
733
  if is_gif_format():
616
734
  movie_file_path = self.gif_file_path
735
+
736
+ if len(partial_movie_files) == 0: # Prevent calling concat on empty list
737
+ logger.info("No animations are contained in this scene.")
738
+ return
739
+
617
740
  logger.info("Combining to Movie file.")
618
741
  self.combine_files(
619
742
  partial_movie_files,
@@ -623,44 +746,72 @@ class SceneFileWriter:
623
746
  )
624
747
 
625
748
  # handle sound
626
- if self.includes_sound:
749
+ if self.includes_sound and config.format != "gif":
627
750
  sound_file_path = movie_file_path.with_suffix(".wav")
628
751
  # Makes sure sound file length will match video file
629
752
  self.add_audio_segment(AudioSegment.silent(0))
630
753
  self.audio_segment.export(
631
754
  sound_file_path,
755
+ format="wav",
632
756
  bitrate="312k",
633
757
  )
758
+ # Audio added to a VP9 encoded (webm) video file needs
759
+ # to be encoded as vorbis or opus. Directly exporting
760
+ # self.audio_segment with such a codec works in principle,
761
+ # but tries to call ffmpeg via its CLI -- which we want
762
+ # to avoid. This is why we need to do the conversion
763
+ # manually.
764
+ if config.movie_file_extension == ".webm":
765
+ ogg_sound_file_path = sound_file_path.with_suffix(".ogg")
766
+ convert_audio(sound_file_path, ogg_sound_file_path, "libvorbis")
767
+ sound_file_path = ogg_sound_file_path
768
+ elif config.movie_file_extension == ".mp4":
769
+ # Similarly, pyav may reject wav audio in an .mp4 file;
770
+ # convert to AAC.
771
+ aac_sound_file_path = sound_file_path.with_suffix(".aac")
772
+ convert_audio(sound_file_path, aac_sound_file_path, "aac")
773
+ sound_file_path = aac_sound_file_path
774
+
634
775
  temp_file_path = movie_file_path.with_name(
635
776
  f"{movie_file_path.stem}_temp{movie_file_path.suffix}"
636
777
  )
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)
778
+ av_options = {
779
+ "shortest": "1",
780
+ "metadata": f"comment=Rendered with Manim Community v{__version__}",
781
+ }
782
+
783
+ with (
784
+ av.open(movie_file_path) as video_input,
785
+ av.open(sound_file_path) as audio_input,
786
+ ):
787
+ video_stream = video_input.streams.video[0]
788
+ audio_stream = audio_input.streams.audio[0]
789
+ output_container = av.open(
790
+ str(temp_file_path), mode="w", options=av_options
791
+ )
792
+ output_video_stream = output_container.add_stream(template=video_stream)
793
+ output_audio_stream = output_container.add_stream(template=audio_stream)
794
+
795
+ for packet in video_input.demux(video_stream):
796
+ # We need to skip the "flushing" packets that `demux` generates.
797
+ if packet.dts is None:
798
+ continue
799
+
800
+ # We need to assign the packet to the new stream.
801
+ packet.stream = output_video_stream
802
+ output_container.mux(packet)
803
+
804
+ for packet in audio_input.demux(audio_stream):
805
+ # We need to skip the "flushing" packets that `demux` generates.
806
+ if packet.dts is None:
807
+ continue
808
+
809
+ # We need to assign the packet to the new stream.
810
+ packet.stream = output_audio_stream
811
+ output_container.mux(packet)
812
+
813
+ output_container.close()
814
+
664
815
  shutil.move(str(temp_file_path), str(movie_file_path))
665
816
  sound_file_path.unlink()
666
817
 
@@ -672,7 +823,6 @@ class SceneFileWriter:
672
823
 
673
824
  def combine_to_section_videos(self) -> None:
674
825
  """Concatenate partial movie files for each section."""
675
-
676
826
  self.finish_last_section()
677
827
  sections_index: list[dict[str, Any]] = []
678
828
  for section in self.sections:
@@ -687,7 +837,7 @@ class SceneFileWriter:
687
837
  with (self.sections_output_dir / f"{self.output_name}.json").open("w") as file:
688
838
  json.dump(sections_index, file, indent=4)
689
839
 
690
- def clean_cache(self):
840
+ def clean_cache(self) -> None:
691
841
  """Will clean the cache by removing the oldest partial_movie_files."""
692
842
  cached_partial_movies = [
693
843
  (self.partial_movie_directory / file_name)
@@ -700,9 +850,8 @@ class SceneFileWriter:
700
850
  )
701
851
  oldest_files_to_delete = sorted(
702
852
  cached_partial_movies,
703
- key=os.path.getatime,
853
+ key=lambda path: path.stat().st_atime,
704
854
  )[:number_files_to_delete]
705
- # oldest_file_path = min(cached_partial_movies, key=os.path.getatime)
706
855
  for file_to_delete in oldest_files_to_delete:
707
856
  file_to_delete.unlink()
708
857
  logger.info(
@@ -710,7 +859,7 @@ class SceneFileWriter:
710
859
  " You can change this behaviour by changing max_files_cached in config.",
711
860
  )
712
861
 
713
- def flush_cache_directory(self):
862
+ def flush_cache_directory(self) -> None:
714
863
  """Delete all the cached partial movie files"""
715
864
  cached_partial_movies = [
716
865
  self.partial_movie_directory / file_name
@@ -724,13 +873,15 @@ class SceneFileWriter:
724
873
  {"par_dir": self.partial_movie_directory},
725
874
  )
726
875
 
727
- def write_subcaption_file(self):
876
+ def write_subcaption_file(self) -> None:
728
877
  """Writes the subcaption file."""
878
+ if config.output_file is None:
879
+ return
729
880
  subcaption_file = Path(config.output_file).with_suffix(".srt")
730
- subcaption_file.write_text(srt.compose(self.subcaptions))
881
+ subcaption_file.write_text(srt.compose(self.subcaptions), encoding="utf-8")
731
882
  logger.info(f"Subcaption file has been written as {subcaption_file}")
732
883
 
733
- def print_file_ready_message(self, file_path):
884
+ def print_file_ready_message(self, file_path: StrPath) -> None:
734
885
  """Prints the "File Ready" message to STDOUT."""
735
886
  config["output_file"] = file_path
736
887
  logger.info("\nFile ready at %(file_path)s\n", {"file_path": f"'{file_path}'"})