mirror of
https://github.com/ManimCommunity/manim.git
synced 2026-06-22 10:01:47 +00:00
Merge branch 'main' of https://github.com/ManimCommunity/manim into experimental
This commit is contained in:
commit
d7fa8f051c
33 changed files with 458 additions and 197 deletions
|
|
@ -1,9 +1,9 @@
|
|||
default_stages: [commit, push]
|
||||
default_stages: [pre-commit, pre-push]
|
||||
fail_fast: false
|
||||
exclude: ^(manim/grpc/gen/|docs/i18n/)
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.6.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-ast
|
||||
name: Validate Python
|
||||
|
|
@ -13,7 +13,7 @@ repos:
|
|||
- id: check-toml
|
||||
name: Validate Poetry
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.6.2
|
||||
rev: v0.7.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: ruff lint
|
||||
|
|
@ -22,7 +22,7 @@ repos:
|
|||
- id: ruff-format
|
||||
types: [python]
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.11.2
|
||||
rev: v1.12.1
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@ consider this slightly contrived function:
|
|||
|
||||
.. code-block:: python
|
||||
|
||||
def shift_mobject(mob: Mobject, direction: Vector3D, scale_factor: float = 1) -> mob:
|
||||
M = TypeVar("M", bound=Mobject) # allow any mobject
|
||||
def shift_mobject(mob: M, direction: Vector3D, scale_factor: float = 1) -> M:
|
||||
return mob.shift(direction * scale_factor)
|
||||
|
||||
Here we see an important example of the difference. ``direction`` can not, and
|
||||
|
|
@ -129,6 +130,6 @@ There are several representations of images in Manim. The most common is
|
|||
the representation as a NumPy array of floats representing the pixels of an image.
|
||||
This is especially common when it comes to the OpenGL renderer.
|
||||
|
||||
This is the use case of the :class:`~.typing.Image` type hint. Sometimes, Manim may use ``PIL.Image``,
|
||||
in which case one should use that type hint instead.
|
||||
This is the use case of the :class:`~.typing.PixelArray` type hint. Sometimes, Manim may use ``PIL.Image.Image``,
|
||||
which is not the same as :class:`~.typing.PixelArray`. In this case, use the ``PIL.Image.Image`` typehint.
|
||||
Of course, if a more specific type of image is needed, it can be annotated as such.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ __all__ = ["FileWriter"]
|
|||
|
||||
import json
|
||||
import shutil
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from queue import Queue
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
|
@ -35,7 +36,43 @@ from manim.utils.file_ops import (
|
|||
from manim.utils.sounds import get_full_sound_file_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from manim.typing import PixelArray
|
||||
from manim.typing import PixelArray, StrOrBytesPath
|
||||
|
||||
|
||||
def to_av_frame_rate(fps: float) -> Fraction:
|
||||
epsilon1 = 1e-4
|
||||
epsilon2 = 0.02
|
||||
|
||||
if isinstance(fps, int):
|
||||
(num, denom) = (fps, 1)
|
||||
elif abs(fps - round(fps)) < epsilon1:
|
||||
(num, denom) = (round(fps), 1)
|
||||
else:
|
||||
denom = 1001
|
||||
num = round(fps * denom / 1000) * 1000
|
||||
if abs(fps - num / denom) >= epsilon2:
|
||||
raise ValueError("invalid frame rate")
|
||||
|
||||
return Fraction(num, denom)
|
||||
|
||||
|
||||
def convert_audio(
|
||||
input_path: StrOrBytesPath,
|
||||
output_path: StrOrBytesPath,
|
||||
codec_name: str,
|
||||
) -> None:
|
||||
with (
|
||||
av.open(input_path) as input_audio,
|
||||
av.open(output_path, "w") as output_audio,
|
||||
):
|
||||
input_audio_stream = input_audio.streams.audio[0]
|
||||
output_audio_stream = output_audio.add_stream(codec_name)
|
||||
for frame in input_audio.decode(input_audio_stream):
|
||||
for packet in output_audio_stream.encode(frame):
|
||||
output_audio.mux(packet)
|
||||
|
||||
for packet in output_audio_stream.encode():
|
||||
output_audio.mux(packet)
|
||||
|
||||
|
||||
class FileWriter(FileWriterProtocol):
|
||||
|
|
@ -334,23 +371,10 @@ class FileWriter(FileWriterProtocol):
|
|||
if file_path.suffix not in (".wav", ".raw"):
|
||||
# we need to pass delete=False to work on Windows
|
||||
# TODO: figure out a way to cache the wav file generated (benchmark needed)
|
||||
wav_file_path = NamedTemporaryFile(suffix=".wav", delete=False)
|
||||
with (
|
||||
av.open(file_path) as input_container,
|
||||
av.open(wav_file_path, "w", format="wav") as output_container,
|
||||
):
|
||||
for audio_stream in input_container.streams.audio:
|
||||
output_stream = output_container.add_stream("pcm_s16le")
|
||||
for frame in input_container.decode(audio_stream):
|
||||
for packet in output_stream.encode(frame):
|
||||
output_container.mux(packet)
|
||||
|
||||
for packet in output_stream.encode():
|
||||
output_container.mux(packet)
|
||||
|
||||
new_segment = AudioSegment.from_file(wav_file_path.name)
|
||||
logger.info(f"Automatically converted {file_path} to .wav")
|
||||
wav_file_path.close()
|
||||
with NamedTemporaryFile(suffix=".wav", delete=False) as wav_file_path:
|
||||
convert_audio(file_path, wav_file_path, "pcm_s16le")
|
||||
new_segment = AudioSegment.from_file(wav_file_path.name)
|
||||
logger.info(f"Automatically converted {file_path} to .wav")
|
||||
Path(wav_file_path.name).unlink()
|
||||
else:
|
||||
new_segment = AudioSegment.from_file(file_path)
|
||||
|
|
@ -499,9 +523,7 @@ class FileWriter(FileWriterProtocol):
|
|||
file_path = self.partial_movie_files[self.num_plays]
|
||||
self.partial_movie_file_path = file_path
|
||||
|
||||
fps = config.frame_rate
|
||||
if fps == int(fps):
|
||||
fps = int(fps)
|
||||
fps = to_av_frame_rate(config.frame_rate)
|
||||
|
||||
partial_movie_file_codec = "libx264"
|
||||
partial_movie_file_pix_fmt = "yuv420p"
|
||||
|
|
@ -510,7 +532,7 @@ class FileWriter(FileWriterProtocol):
|
|||
"crf": "23", # ffmpeg: -crf, constant rate factor (improved bitrate)
|
||||
}
|
||||
|
||||
if config.format == "webm":
|
||||
if config.movie_file_extension == ".webm":
|
||||
partial_movie_file_codec = "libvpx-vp9"
|
||||
av_options["-auto-alt-ref"] = "1"
|
||||
if config.transparent:
|
||||
|
|
@ -523,7 +545,7 @@ class FileWriter(FileWriterProtocol):
|
|||
with av.open(file_path, mode="w") as video_container:
|
||||
stream = video_container.add_stream(
|
||||
partial_movie_file_codec,
|
||||
rate=config.frame_rate,
|
||||
rate=fps,
|
||||
options=av_options,
|
||||
)
|
||||
stream.pix_fmt = partial_movie_file_pix_fmt
|
||||
|
|
@ -615,7 +637,7 @@ class FileWriter(FileWriterProtocol):
|
|||
codec_name="gif" if create_gif else None,
|
||||
template=partial_movies_stream if not create_gif else None,
|
||||
)
|
||||
if config.transparent and config.format == "webm":
|
||||
if config.transparent and config.movie_file_extension == ".webm":
|
||||
output_stream.pix_fmt = "yuva420p"
|
||||
if create_gif:
|
||||
"""
|
||||
|
|
@ -629,7 +651,7 @@ class FileWriter(FileWriterProtocol):
|
|||
output_stream.pix_fmt = "pal8"
|
||||
output_stream.width = config.pixel_width
|
||||
output_stream.height = config.pixel_height
|
||||
output_stream.rate = config.frame_rate
|
||||
output_stream.rate = to_av_frame_rate(config.frame_rate)
|
||||
graph = av.filter.Graph()
|
||||
input_buffer = graph.add_buffer(template=partial_movies_stream)
|
||||
split = graph.add("split")
|
||||
|
|
@ -656,7 +678,8 @@ class FileWriter(FileWriterProtocol):
|
|||
while True:
|
||||
try:
|
||||
frame = graph.pull()
|
||||
frame.time_base = output_stream.codec_context.time_base
|
||||
if output_stream.codec_context.time_base is not None:
|
||||
frame.time_base = output_stream.codec_context.time_base
|
||||
frame.pts = frames_written
|
||||
frames_written += 1
|
||||
output_container.mux(output_stream.encode(frame))
|
||||
|
|
@ -697,6 +720,7 @@ class FileWriter(FileWriterProtocol):
|
|||
movie_file_path = self.movie_file_path
|
||||
if is_gif_format():
|
||||
movie_file_path = self.gif_file_path
|
||||
|
||||
if len(partial_movie_files) == 0: # Prevent calling concat on empty list
|
||||
logger.info("No animations are contained in this scene.")
|
||||
return
|
||||
|
|
@ -725,21 +749,16 @@ class FileWriter(FileWriterProtocol):
|
|||
# but tries to call ffmpeg via its CLI -- which we want
|
||||
# to avoid. This is why we need to do the conversion
|
||||
# manually.
|
||||
if config.format == "webm":
|
||||
with (
|
||||
av.open(sound_file_path) as wav_audio,
|
||||
av.open(sound_file_path.with_suffix(".ogg"), "w") as opus_audio,
|
||||
):
|
||||
wav_audio_stream = wav_audio.streams.audio[0]
|
||||
opus_audio_stream = opus_audio.add_stream("libvorbis")
|
||||
for frame in wav_audio.decode(wav_audio_stream):
|
||||
for packet in opus_audio_stream.encode(frame):
|
||||
opus_audio.mux(packet)
|
||||
|
||||
for packet in opus_audio_stream.encode():
|
||||
opus_audio.mux(packet)
|
||||
|
||||
sound_file_path = sound_file_path.with_suffix(".ogg")
|
||||
if config.movie_file_extension == ".webm":
|
||||
ogg_sound_file_path = sound_file_path.with_suffix(".ogg")
|
||||
convert_audio(sound_file_path, ogg_sound_file_path, "libvorbis")
|
||||
sound_file_path = ogg_sound_file_path
|
||||
elif config.movie_file_extension == ".mp4":
|
||||
# Similarly, pyav may reject wav audio in an .mp4 file;
|
||||
# convert to AAC.
|
||||
aac_sound_file_path = sound_file_path.with_suffix(".aac")
|
||||
convert_audio(sound_file_path, aac_sound_file_path, "aac")
|
||||
sound_file_path = aac_sound_file_path
|
||||
|
||||
temp_file_path = movie_file_path.with_name(
|
||||
f"{movie_file_path.stem}_temp{movie_file_path.suffix}"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
|
|||
from manim.mobject.types.vectorized_mobject import VMobject
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from manim.typing import Point2D, Point3D
|
||||
from typing_extensions import Self
|
||||
|
||||
from manim.typing import Point3D
|
||||
|
||||
from manim.utils.color import YELLOW
|
||||
|
||||
|
|
@ -103,7 +105,7 @@ class ParametricFunction(VMobject, metaclass=ConvertToOpenGL):
|
|||
def __init__(
|
||||
self,
|
||||
function: Callable[[float], Point3D],
|
||||
t_range: Point2D | Point3D = (0, 1),
|
||||
t_range: tuple[float, float] | tuple[float, float, float] = (0, 1),
|
||||
scaling: _ScaleBase = LinearBase(),
|
||||
dt: float = 1e-8,
|
||||
discontinuities: Iterable[float] | None = None,
|
||||
|
|
@ -112,9 +114,8 @@ class ParametricFunction(VMobject, metaclass=ConvertToOpenGL):
|
|||
**kwargs,
|
||||
):
|
||||
self.function = function
|
||||
t_range = (0, 1, 0.01) if t_range is None else t_range
|
||||
if len(t_range) == 2:
|
||||
t_range = np.array([*t_range, 0.01])
|
||||
t_range = (*t_range, 0.01)
|
||||
|
||||
self.scaling = scaling
|
||||
|
||||
|
|
@ -126,13 +127,13 @@ class ParametricFunction(VMobject, metaclass=ConvertToOpenGL):
|
|||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def get_function(self):
|
||||
def get_function(self) -> Callable[[float], Point3D]:
|
||||
return self.function
|
||||
|
||||
def get_point_from_function(self, t):
|
||||
def get_point_from_function(self, t: float) -> Point3D:
|
||||
return self.function(t)
|
||||
|
||||
def generate_points(self):
|
||||
def generate_points(self) -> Self:
|
||||
if self.discontinuities is not None:
|
||||
discontinuities = filter(
|
||||
lambda t: self.t_min <= t <= self.t_max,
|
||||
|
|
|
|||
|
|
@ -406,14 +406,14 @@ class Mobject:
|
|||
"""Sets :attr:`points` to be an empty array."""
|
||||
self.points = np.zeros((0, self.dim))
|
||||
|
||||
def init_colors(self) -> None:
|
||||
def init_colors(self) -> object:
|
||||
"""Initializes the colors.
|
||||
|
||||
Gets called upon creation. This is an empty method that can be implemented by
|
||||
subclasses.
|
||||
"""
|
||||
|
||||
def generate_points(self) -> None:
|
||||
def generate_points(self) -> object:
|
||||
"""Initializes :attr:`points` and therefore the shape.
|
||||
|
||||
Gets called upon creation. This is an empty method that can be implemented by
|
||||
|
|
@ -1496,7 +1496,7 @@ class Mobject:
|
|||
tex_top.to_edge(UP)
|
||||
tex_side = Tex("I am moving to the side!")
|
||||
c = Circle().shift(2*DOWN)
|
||||
self.add(tex_top, tex_side)
|
||||
self.add(tex_top, tex_side, c)
|
||||
tex_side.to_edge(LEFT)
|
||||
c.to_edge(RIGHT, buff=0)
|
||||
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ class OpenGLMobject:
|
|||
"""
|
||||
self.set_color(self.color, self.opacity)
|
||||
|
||||
def init_points(self):
|
||||
def init_points(self) -> object:
|
||||
"""Initializes :attr:`points` and therefore the shape.
|
||||
|
||||
Gets called upon creation. This is an empty method that can be implemented by
|
||||
|
|
|
|||
|
|
@ -894,6 +894,12 @@ class Line3D(Cylinder):
|
|||
The thickness of the line.
|
||||
color
|
||||
The color of the line.
|
||||
resolution
|
||||
The resolution of the line.
|
||||
By default this value is the number of points the line will sampled at.
|
||||
If you want the line to also come out checkered, use a tuple.
|
||||
For example, for a line made of 24 points with 4 checker points on each
|
||||
cylinder, pass the tuple (4, 24).
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
|
@ -914,9 +920,11 @@ class Line3D(Cylinder):
|
|||
end: np.ndarray = RIGHT,
|
||||
thickness: float = 0.02,
|
||||
color: ParsableManimColor | None = None,
|
||||
resolution: int | Sequence[int] = 24,
|
||||
**kwargs,
|
||||
):
|
||||
self.thickness = thickness
|
||||
self.resolution = (2, resolution) if isinstance(resolution, int) else resolution
|
||||
self.set_start_and_end_attrs(start, end, **kwargs)
|
||||
if color is not None:
|
||||
self.set_color(color)
|
||||
|
|
@ -950,6 +958,7 @@ class Line3D(Cylinder):
|
|||
height=np.linalg.norm(self.vect),
|
||||
radius=self.thickness,
|
||||
direction=self.direction,
|
||||
resolution=self.resolution,
|
||||
**kwargs,
|
||||
)
|
||||
self.shift((self.start + self.end) / 2)
|
||||
|
|
@ -1121,6 +1130,8 @@ class Arrow3D(Line3D):
|
|||
The base radius of the conical tip.
|
||||
color
|
||||
The color of the arrow.
|
||||
resolution
|
||||
The resolution of the arrow line.
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
|
@ -1147,10 +1158,16 @@ class Arrow3D(Line3D):
|
|||
height: float = 0.3,
|
||||
base_radius: float = 0.08,
|
||||
color: ParsableManimColor = WHITE,
|
||||
resolution: int | Sequence[int] = 24,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
start=start, end=end, thickness=thickness, color=color, **kwargs
|
||||
start=start,
|
||||
end=end,
|
||||
thickness=thickness,
|
||||
color=color,
|
||||
resolution=resolution,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.length = np.linalg.norm(self.vect)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from manim import config
|
|||
from manim.constants import *
|
||||
from manim.mobject.mobject import Mobject
|
||||
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
|
||||
from manim.mobject.opengl.opengl_mobject import OpenGLMobject
|
||||
from manim.mobject.opengl.opengl_vectorized_mobject import OpenGLVMobject
|
||||
from manim.mobject.three_d.three_d_utils import (
|
||||
get_3d_vmob_gradient_start_and_end_points,
|
||||
|
|
@ -2071,7 +2072,11 @@ class VGroup(VMobject, metaclass=ConvertToOpenGL):
|
|||
|
||||
"""
|
||||
|
||||
def __init__(self, *vmobjects, **kwargs):
|
||||
def __init__(
|
||||
self,
|
||||
*vmobjects: VMobject | Iterable[VMobject],
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.add(*vmobjects)
|
||||
|
||||
|
|
@ -2090,7 +2095,7 @@ class VGroup(VMobject, metaclass=ConvertToOpenGL):
|
|||
Parameters
|
||||
----------
|
||||
vmobjects
|
||||
List of VMobject to add
|
||||
List or iterable of VMobjects to add
|
||||
|
||||
Returns
|
||||
-------
|
||||
|
|
@ -2099,10 +2104,13 @@ class VGroup(VMobject, metaclass=ConvertToOpenGL):
|
|||
Raises
|
||||
------
|
||||
TypeError
|
||||
If one element of the list is not an instance of VMobject
|
||||
If one element of the list, or iterable is not an instance of VMobject
|
||||
|
||||
Examples
|
||||
--------
|
||||
The following example shows how to add individual or multiple `VMobject` instances through the `VGroup`
|
||||
constructor and its `.add()` method.
|
||||
|
||||
.. manim:: AddToVGroup
|
||||
|
||||
class AddToVGroup(Scene):
|
||||
|
|
@ -2131,9 +2139,65 @@ class VGroup(VMobject, metaclass=ConvertToOpenGL):
|
|||
self.play( # Animate group without component
|
||||
(gr-circle_red).animate.shift(RIGHT)
|
||||
)
|
||||
|
||||
A `VGroup` can be created using iterables as well. Keep in mind that all generated values from an
|
||||
iterable must be an instance of `VMobject`. This is demonstrated below:
|
||||
|
||||
.. manim:: AddIterableToVGroupExample
|
||||
:save_last_frame:
|
||||
|
||||
class AddIterableToVGroupExample(Scene):
|
||||
def construct(self):
|
||||
v = VGroup(
|
||||
Square(), # Singular VMobject instance
|
||||
[Circle(), Triangle()], # List of VMobject instances
|
||||
Dot(),
|
||||
(Dot() for _ in range(2)), # Iterable that generates VMobjects
|
||||
)
|
||||
v.arrange()
|
||||
self.add(v)
|
||||
|
||||
To facilitate this, the iterable is unpacked before its individual instances are added to the `VGroup`.
|
||||
As a result, when you index a `VGroup`, you will never get back an iterable.
|
||||
Instead, you will always receive `VMobject` instances, including those
|
||||
that were part of the iterable/s that you originally added to the `VGroup`.
|
||||
"""
|
||||
# leave here because the docstring is useful
|
||||
return super().add(*vmobjects)
|
||||
|
||||
def get_type_error_message(invalid_obj, invalid_indices):
|
||||
return (
|
||||
f"Only values of type {vmobject_render_type.__name__} can be added "
|
||||
"as submobjects of VGroup, but the value "
|
||||
f"{repr(invalid_obj)} (at index {invalid_indices[1]} of "
|
||||
f"parameter {invalid_indices[0]}) is of type "
|
||||
f"{type(invalid_obj).__name__}."
|
||||
)
|
||||
|
||||
vmobject_render_type = (
|
||||
OpenGLVMobject if config.renderer == RendererType.OPENGL else VMobject
|
||||
)
|
||||
valid_vmobjects = []
|
||||
|
||||
for i, vmobject in enumerate(vmobjects):
|
||||
if isinstance(vmobject, vmobject_render_type):
|
||||
valid_vmobjects.append(vmobject)
|
||||
elif isinstance(vmobject, Iterable) and not isinstance(
|
||||
vmobject, (Mobject, OpenGLMobject)
|
||||
):
|
||||
for j, subvmobject in enumerate(vmobject):
|
||||
if not isinstance(subvmobject, vmobject_render_type):
|
||||
raise TypeError(get_type_error_message(subvmobject, (i, j)))
|
||||
valid_vmobjects.append(subvmobject)
|
||||
elif isinstance(vmobject, Iterable) and isinstance(
|
||||
vmobject, (Mobject, OpenGLMobject)
|
||||
):
|
||||
raise TypeError(
|
||||
f"{get_type_error_message(vmobject, (i, 0))} "
|
||||
"You can try adding this value into a Group instead."
|
||||
)
|
||||
else:
|
||||
raise TypeError(get_type_error_message(vmobject, (i, 0)))
|
||||
|
||||
return super().add(*valid_vmobjects)
|
||||
|
||||
def __add__(self, vmobject: VMobject) -> Self:
|
||||
return VGroup(*self.submobjects, vmobject)
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ from typing import TYPE_CHECKING, Any, Callable, overload
|
|||
|
||||
import numpy as np
|
||||
|
||||
from manim.typing import PointDType
|
||||
from manim.utils.simple_functions import choose
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -36,6 +35,7 @@ if TYPE_CHECKING:
|
|||
from manim.typing import (
|
||||
BezierPoints,
|
||||
BezierPoints_Array,
|
||||
ColVector,
|
||||
MatrixMN,
|
||||
Point3D,
|
||||
Point3D_Array,
|
||||
|
|
@ -46,47 +46,127 @@ if TYPE_CHECKING:
|
|||
# ruff: noqa: E741
|
||||
|
||||
|
||||
@overload
|
||||
def bezier(
|
||||
points: Sequence[Point3D] | Point3D_Array,
|
||||
) -> Callable[[float], Point3D]:
|
||||
"""Classic implementation of a bezier curve.
|
||||
points: BezierPoints,
|
||||
) -> Callable[[float | ColVector], Point3D | Point3D_Array]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def bezier(
|
||||
points: Sequence[Point3D_Array],
|
||||
) -> Callable[[float | ColVector], Point3D_Array]: ...
|
||||
|
||||
|
||||
def bezier(points):
|
||||
"""Classic implementation of a Bézier curve.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
points
|
||||
points defining the desired bezier curve.
|
||||
:math:`(d+1, 3)`-shaped array of :math:`d+1` control points defining a single Bézier
|
||||
curve of degree :math:`d`. Alternatively, for vectorization purposes, ``points`` can
|
||||
also be a :math:`(d+1, M, 3)`-shaped sequence of :math:`d+1` arrays of :math:`M`
|
||||
control points each, which define `M` Bézier curves instead.
|
||||
|
||||
Returns
|
||||
-------
|
||||
function describing the bezier curve.
|
||||
You can pass a t value between 0 and 1 to get the corresponding point on the curve.
|
||||
"""
|
||||
n = len(points) - 1
|
||||
# Cubic Bezier curve
|
||||
if n == 3:
|
||||
return lambda t: np.asarray(
|
||||
(1 - t) ** 3 * points[0]
|
||||
+ 3 * t * (1 - t) ** 2 * points[1]
|
||||
+ 3 * (1 - t) * t**2 * points[2]
|
||||
+ t**3 * points[3],
|
||||
dtype=PointDType,
|
||||
)
|
||||
# Quadratic Bezier curve
|
||||
if n == 2:
|
||||
return lambda t: np.asarray(
|
||||
(1 - t) ** 2 * points[0] + 2 * t * (1 - t) * points[1] + t**2 * points[2],
|
||||
dtype=PointDType,
|
||||
)
|
||||
bezier_func : :class:`typing.Callable` [[:class:`float` | :class:`~.ColVector`], :class:`~.Point3D` | :class:`~.Point3D_Array`]
|
||||
Function describing the Bézier curve. The behaviour of this function depends on
|
||||
the shape of ``points``:
|
||||
|
||||
return lambda t: np.asarray(
|
||||
np.asarray(
|
||||
[
|
||||
(((1 - t) ** (n - k)) * (t**k) * choose(n, k) * point)
|
||||
for k, point in enumerate(points)
|
||||
],
|
||||
dtype=PointDType,
|
||||
).sum(axis=0)
|
||||
)
|
||||
* If ``points`` was a :math:`(d+1, 3)` array representing a single Bézier curve,
|
||||
then ``bezier_func`` can receive either:
|
||||
|
||||
* a :class:`float` ``t``, in which case it returns a
|
||||
single :math:`(1, 3)`-shaped :class:`~.Point3D` representing the evaluation
|
||||
of the Bézier at ``t``, or
|
||||
* an :math:`(n, 1)`-shaped :class:`~.ColVector`
|
||||
containing :math:`n` values to evaluate the Bézier curve at, returning instead
|
||||
an :math:`(n, 3)`-shaped :class:`~.Point3D_Array` containing the points
|
||||
resulting from evaluating the Bézier at each of the :math:`n` values.
|
||||
.. warning::
|
||||
If passing a vector of :math:`t`-values to ``bezier_func``, it **must**
|
||||
be a column vector/matrix of shape :math:`(n, 1)`. Passing an 1D array of
|
||||
shape :math:`(n,)` is not supported and **will result in undefined behaviour**.
|
||||
|
||||
* If ``points`` was a :math:`(d+1, M, 3)` array describing :math:`M` Bézier curves,
|
||||
then ``bezier_func`` can receive either:
|
||||
|
||||
* a :class:`float` ``t``, in which case it returns an
|
||||
:math:`(M, 3)`-shaped :class:`~.Point3D_Array` representing the evaluation
|
||||
of the :math:`M` Bézier curves at the same value ``t``, or
|
||||
* an :math:`(M, 1)`-shaped
|
||||
:class:`~.ColVector` containing :math:`M` values, such that the :math:`i`-th
|
||||
Bézier curve defined by ``points`` is evaluated at the corresponding :math:`i`-th
|
||||
value in ``t``, returning again an :math:`(M, 3)`-shaped :class:`~.Point3D_Array`
|
||||
containing those :math:`M` evaluations.
|
||||
.. warning::
|
||||
Unlike the previous case, if you pass a :class:`~.ColVector` to ``bezier_func``,
|
||||
it **must** contain exactly :math:`M` values, each value for each of the :math:`M`
|
||||
Bézier curves defined by ``points``. Any array of shape other than :math:`(M, 1)`
|
||||
**will result in undefined behaviour**.
|
||||
"""
|
||||
P = np.asarray(points)
|
||||
degree = P.shape[0] - 1
|
||||
|
||||
if degree == 0:
|
||||
|
||||
def zero_bezier(t):
|
||||
return np.ones_like(t) * P[0]
|
||||
|
||||
return zero_bezier
|
||||
|
||||
if degree == 1:
|
||||
|
||||
def linear_bezier(t):
|
||||
return P[0] + t * (P[1] - P[0])
|
||||
|
||||
return linear_bezier
|
||||
|
||||
if degree == 2:
|
||||
|
||||
def quadratic_bezier(t):
|
||||
t2 = t * t
|
||||
mt = 1 - t
|
||||
mt2 = mt * mt
|
||||
return mt2 * P[0] + 2 * t * mt * P[1] + t2 * P[2]
|
||||
|
||||
return quadratic_bezier
|
||||
|
||||
if degree == 3:
|
||||
|
||||
def cubic_bezier(t):
|
||||
t2 = t * t
|
||||
t3 = t2 * t
|
||||
mt = 1 - t
|
||||
mt2 = mt * mt
|
||||
mt3 = mt2 * mt
|
||||
return mt3 * P[0] + 3 * t * mt2 * P[1] + 3 * t2 * mt * P[2] + t3 * P[3]
|
||||
|
||||
return cubic_bezier
|
||||
|
||||
def nth_grade_bezier(t):
|
||||
is_scalar = not isinstance(t, np.ndarray)
|
||||
if is_scalar:
|
||||
B = np.empty((1, *P.shape))
|
||||
else:
|
||||
t = t.reshape(-1, *[1 for dim in P.shape])
|
||||
B = np.empty((t.shape[0], *P.shape))
|
||||
B[:] = P
|
||||
|
||||
for i in range(degree):
|
||||
# After the i-th iteration (i in [0, ..., d-1]) there are evaluations at t
|
||||
# of (d-i) Bezier curves of grade (i+1), stored in the first d-i slots of B
|
||||
B[:, : degree - i] += t * (B[:, 1 : degree - i + 1] - B[:, : degree - i])
|
||||
|
||||
# In the end, there shall be the evaluation at t of a single Bezier curve of
|
||||
# grade d, stored in the first slot of B
|
||||
if is_scalar:
|
||||
return B[0, 0]
|
||||
return B[:, 0]
|
||||
|
||||
return nth_grade_bezier
|
||||
|
||||
|
||||
def partial_bezier_points(points: BezierPoints, a: float, b: float) -> BezierPoints:
|
||||
|
|
@ -875,9 +955,10 @@ def bezier_remap(
|
|||
An array of multiple Bézier curves of degree :math:`d` to be remapped. The shape of this array
|
||||
must be ``(current_number_of_curves, nppc, dim)``, where:
|
||||
|
||||
* ``current_number_of_curves`` is the current amount of curves in the array ``bezier_tuples``,
|
||||
* ``nppc`` is the amount of points per curve, such that their degree is ``nppc-1``, and
|
||||
* ``dim`` is the dimension of the points, usually :math:`3`.
|
||||
* ``current_number_of_curves`` is the current amount of curves in the array ``bezier_tuples``,
|
||||
* ``nppc`` is the amount of points per curve, such that their degree is ``nppc-1``, and
|
||||
* ``dim`` is the dimension of the points, usually :math:`3`.
|
||||
|
||||
new_number_of_curves
|
||||
The number of curves that the output will contain. This needs to be higher than the current number.
|
||||
|
||||
|
|
@ -927,14 +1008,47 @@ def bezier_remap(
|
|||
def interpolate(start: float, end: float, alpha: float) -> float: ...
|
||||
|
||||
|
||||
@overload
|
||||
def interpolate(start: float, end: float, alpha: ColVector) -> ColVector: ...
|
||||
|
||||
|
||||
@overload
|
||||
def interpolate(start: Point3D, end: Point3D, alpha: float) -> Point3D: ...
|
||||
|
||||
|
||||
def interpolate(
|
||||
start: int | float | Point3D, end: int | float | Point3D, alpha: float | Point3D
|
||||
) -> float | Point3D:
|
||||
return (1 - alpha) * start + alpha * end
|
||||
@overload
|
||||
def interpolate(start: Point3D, end: Point3D, alpha: ColVector) -> Point3D_Array: ...
|
||||
|
||||
|
||||
def interpolate(start, end, alpha):
|
||||
"""Linearly interpolates between two values ``start`` and ``end``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
start
|
||||
The start of the range.
|
||||
end
|
||||
The end of the range.
|
||||
alpha
|
||||
A float between 0 and 1, or an :math:`(n, 1)` column vector containing
|
||||
:math:`n` floats between 0 and 1 to interpolate in a vectorized fashion.
|
||||
|
||||
Returns
|
||||
-------
|
||||
:class:`float` | :class:`~.ColVector` | :class:`~.Point3D` | :class:`~.Point3D_Array`
|
||||
The result of the linear interpolation.
|
||||
|
||||
* If ``start`` and ``end`` are of type :class:`float`, and:
|
||||
|
||||
* ``alpha`` is also a :class:`float`, the return is simply another :class:`float`.
|
||||
* ``alpha`` is a :class:`~.ColVector`, the return is another :class:`~.ColVector`.
|
||||
|
||||
* If ``start`` and ``end`` are of type :class:`~.Point3D`, and:
|
||||
|
||||
* ``alpha`` is a :class:`float`, the return is another :class:`~.Point3D`.
|
||||
* ``alpha`` is a :class:`~.ColVector`, the return is a :class:`~.Point3D_Array`.
|
||||
"""
|
||||
return start + alpha * (end - start)
|
||||
|
||||
|
||||
def integer_interpolate(
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ else:
|
|||
|
||||
file_type = mimetypes.guess_type(config["output_file"])[0]
|
||||
embed = config["media_embed"]
|
||||
if embed is None:
|
||||
if not embed:
|
||||
# videos need to be embedded when running in google colab.
|
||||
# do this automatically in case config.media_embed has not been
|
||||
# set explicitly.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import numpy as np
|
|||
|
||||
T = TypeVar("T")
|
||||
U = TypeVar("U")
|
||||
F = TypeVar("F", np.float_, np.int_)
|
||||
F = TypeVar("F", np.float64, np.int_)
|
||||
H = TypeVar("H", bound=Hashable)
|
||||
|
||||
|
||||
|
|
@ -311,8 +311,8 @@ def resize_array(nparray: npt.NDArray[F], length: int) -> npt.NDArray[F]:
|
|||
|
||||
|
||||
def resize_preserving_order(
|
||||
nparray: npt.NDArray[np.float_], length: int
|
||||
) -> npt.NDArray[np.float_]:
|
||||
nparray: npt.NDArray[np.float64], length: int
|
||||
) -> npt.NDArray[np.float64]:
|
||||
"""Extends/truncates nparray so that ``len(result) == length``.
|
||||
The elements of nparray are duplicated to achieve the desired length
|
||||
(favours earlier elements).
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import unicodedata
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
|
|
@ -114,10 +114,11 @@ def generate_tex_file(
|
|||
return result
|
||||
|
||||
|
||||
def tex_compilation_command(
|
||||
def make_tex_compilation_command(
|
||||
tex_compiler: str, output_format: str, tex_file: Path, tex_dir: Path
|
||||
) -> str:
|
||||
"""Prepares the tex compilation command with all necessary cli flags
|
||||
) -> list[str]:
|
||||
"""Prepares the TeX compilation command, i.e. the TeX compiler name
|
||||
and all necessary CLI flags.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
|
@ -132,40 +133,36 @@ def tex_compilation_command(
|
|||
|
||||
Returns
|
||||
-------
|
||||
:class:`str`
|
||||
:class:`list[str]`
|
||||
Compilation command according to given parameters
|
||||
"""
|
||||
if tex_compiler in {"latex", "pdflatex", "luatex", "lualatex"}:
|
||||
commands = [
|
||||
command = [
|
||||
tex_compiler,
|
||||
"-interaction=batchmode",
|
||||
f'-output-format="{output_format[1:]}"',
|
||||
f"-output-format={output_format[1:]}",
|
||||
"-halt-on-error",
|
||||
f'-output-directory="{tex_dir.as_posix()}"',
|
||||
f'"{tex_file.as_posix()}"',
|
||||
">",
|
||||
os.devnull,
|
||||
f"-output-directory={tex_dir.as_posix()}",
|
||||
f"{tex_file.as_posix()}",
|
||||
]
|
||||
elif tex_compiler == "xelatex":
|
||||
if output_format == ".xdv":
|
||||
outflag = "-no-pdf"
|
||||
outflag = ["-no-pdf"]
|
||||
elif output_format == ".pdf":
|
||||
outflag = ""
|
||||
outflag = []
|
||||
else:
|
||||
raise ValueError("xelatex output is either pdf or xdv")
|
||||
commands = [
|
||||
command = [
|
||||
"xelatex",
|
||||
outflag,
|
||||
*outflag,
|
||||
"-interaction=batchmode",
|
||||
"-halt-on-error",
|
||||
f'-output-directory="{tex_dir.as_posix()}"',
|
||||
f'"{tex_file.as_posix()}"',
|
||||
">",
|
||||
os.devnull,
|
||||
f"-output-directory={tex_dir.as_posix()}",
|
||||
f"{tex_file.as_posix()}",
|
||||
]
|
||||
else:
|
||||
raise ValueError(f"Tex compiler {tex_compiler} unknown.")
|
||||
return " ".join(commands)
|
||||
return command
|
||||
|
||||
|
||||
def insight_inputenc_error(matching):
|
||||
|
|
@ -200,14 +197,14 @@ def compile_tex(tex_file: Path, tex_compiler: str, output_format: str) -> Path:
|
|||
result = tex_file.with_suffix(output_format)
|
||||
tex_dir = config.get_dir("tex_dir")
|
||||
if not result.exists():
|
||||
command = tex_compilation_command(
|
||||
command = make_tex_compilation_command(
|
||||
tex_compiler,
|
||||
output_format,
|
||||
tex_file,
|
||||
tex_dir,
|
||||
)
|
||||
exit_code = os.system(command)
|
||||
if exit_code != 0:
|
||||
cp = subprocess.run(command, stdout=subprocess.DEVNULL)
|
||||
if cp.returncode != 0:
|
||||
log_file = tex_file.with_suffix(".log")
|
||||
print_all_tex_errors(log_file, tex_compiler, tex_file)
|
||||
raise ValueError(
|
||||
|
|
@ -237,18 +234,16 @@ def convert_to_svg(dvi_file: Path, extension: str, page: int = 1):
|
|||
"""
|
||||
result = dvi_file.with_suffix(".svg")
|
||||
if not result.exists():
|
||||
commands = [
|
||||
command = [
|
||||
"dvisvgm",
|
||||
"--pdf" if extension == ".pdf" else "",
|
||||
"-p " + str(page),
|
||||
f'"{dvi_file.as_posix()}"',
|
||||
"-n",
|
||||
"-v 0",
|
||||
"-o " + f'"{result.as_posix()}"',
|
||||
">",
|
||||
os.devnull,
|
||||
*(["--pdf"] if extension == ".pdf" else []),
|
||||
f"--page={page}",
|
||||
"--no-fonts",
|
||||
"--verbosity=0",
|
||||
f"--output={result.as_posix()}",
|
||||
f"{dvi_file.as_posix()}",
|
||||
]
|
||||
os.system(" ".join(commands))
|
||||
subprocess.run(command, stdout=subprocess.DEVNULL)
|
||||
|
||||
# if the file does not exist now, this means conversion failed
|
||||
if not result.exists():
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
path_makefile = Path(__file__).parents[1] / "docs"
|
||||
os.system(f"cd {path_makefile} && make html")
|
||||
path_makefile = Path(__file__).resolve().parents[1] / "docs"
|
||||
subprocess.run(["make", "html"], cwd=path_makefile)
|
||||
|
||||
website = (path_makefile / "build" / "html" / "index.html").absolute().as_uri()
|
||||
try: # Allows you to pass a custom browser if you want.
|
||||
|
|
|
|||
|
|
@ -132,14 +132,14 @@ def test_vgroup_init():
|
|||
VGroup(3.0)
|
||||
assert str(init_with_float_info.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value 3.0 (at index 0) is of type float."
|
||||
"but the value 3.0 (at index 0 of parameter 0) is of type float."
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError) as init_with_mob_info:
|
||||
VGroup(Mobject())
|
||||
assert str(init_with_mob_info.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value Mobject (at index 0) is of type Mobject. You can try "
|
||||
"but the value Mobject (at index 0 of parameter 0) is of type Mobject. You can try "
|
||||
"adding this value into a Group instead."
|
||||
)
|
||||
|
||||
|
|
@ -147,11 +147,57 @@ def test_vgroup_init():
|
|||
VGroup(VMobject(), Mobject())
|
||||
assert str(init_with_vmob_and_mob_info.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value Mobject (at index 1) is of type Mobject. You can try "
|
||||
"but the value Mobject (at index 0 of parameter 1) is of type Mobject. You can try "
|
||||
"adding this value into a Group instead."
|
||||
)
|
||||
|
||||
|
||||
def test_vgroup_init_with_iterable():
|
||||
"""Test VGroup instantiation with an iterable type."""
|
||||
|
||||
def type_generator(type_to_generate, n):
|
||||
return (type_to_generate() for _ in range(n))
|
||||
|
||||
def mixed_type_generator(major_type, minor_type, minor_type_positions, n):
|
||||
return (
|
||||
minor_type() if i in minor_type_positions else major_type()
|
||||
for i in range(n)
|
||||
)
|
||||
|
||||
obj = VGroup(VMobject())
|
||||
assert len(obj.submobjects) == 1
|
||||
|
||||
obj = VGroup(type_generator(VMobject, 38))
|
||||
assert len(obj.submobjects) == 38
|
||||
|
||||
obj = VGroup(VMobject(), [VMobject(), VMobject()], type_generator(VMobject, 38))
|
||||
assert len(obj.submobjects) == 41
|
||||
|
||||
# A VGroup cannot be initialised with an iterable containing a Mobject
|
||||
with pytest.raises(TypeError) as init_with_mob_iterable:
|
||||
VGroup(type_generator(Mobject, 5))
|
||||
assert str(init_with_mob_iterable.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value Mobject (at index 0 of parameter 0) is of type Mobject."
|
||||
)
|
||||
|
||||
# A VGroup cannot be initialised with an iterable containing a Mobject in any position
|
||||
with pytest.raises(TypeError) as init_with_mobs_and_vmobs_iterable:
|
||||
VGroup(mixed_type_generator(VMobject, Mobject, [3, 5], 7))
|
||||
assert str(init_with_mobs_and_vmobs_iterable.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value Mobject (at index 3 of parameter 0) is of type Mobject."
|
||||
)
|
||||
|
||||
# A VGroup cannot be initialised with an iterable containing non VMobject's in any position
|
||||
with pytest.raises(TypeError) as init_with_float_and_vmobs_iterable:
|
||||
VGroup(mixed_type_generator(VMobject, float, [6, 7], 9))
|
||||
assert str(init_with_float_and_vmobs_iterable.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value 0.0 (at index 6 of parameter 0) is of type float."
|
||||
)
|
||||
|
||||
|
||||
def test_vgroup_add():
|
||||
"""Test the VGroup add method."""
|
||||
obj = VGroup()
|
||||
|
|
@ -165,7 +211,7 @@ def test_vgroup_add():
|
|||
obj.add(3)
|
||||
assert str(add_int_info.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value 3 (at index 0) is of type int."
|
||||
"but the value 3 (at index 0 of parameter 0) is of type int."
|
||||
)
|
||||
assert len(obj.submobjects) == 1
|
||||
|
||||
|
|
@ -175,7 +221,7 @@ def test_vgroup_add():
|
|||
obj.add(Mobject())
|
||||
assert str(add_mob_info.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value Mobject (at index 0) is of type Mobject. You can try "
|
||||
"but the value Mobject (at index 0 of parameter 0) is of type Mobject. You can try "
|
||||
"adding this value into a Group instead."
|
||||
)
|
||||
assert len(obj.submobjects) == 1
|
||||
|
|
@ -185,7 +231,7 @@ def test_vgroup_add():
|
|||
obj.add(VMobject(), Mobject())
|
||||
assert str(add_vmob_and_mob_info.value) == (
|
||||
"Only values of type VMobject can be added as submobjects of VGroup, "
|
||||
"but the value Mobject (at index 1) is of type Mobject. You can try "
|
||||
"but the value Mobject (at index 0 of parameter 1) is of type Mobject. You can try "
|
||||
"adding this value into a Group instead."
|
||||
)
|
||||
assert len(obj.submobjects) == 1
|
||||
|
|
|
|||
|
|
@ -10,14 +10,12 @@ from manim import Manager, Scene
|
|||
def test_add_sound(tmpdir):
|
||||
# create sound file
|
||||
sound_loc = Path(tmpdir, "noise.wav")
|
||||
f = wave.open(str(sound_loc), "w")
|
||||
f.setparams((2, 2, 44100, 0, "NONE", "not compressed"))
|
||||
for _ in range(22050): # half a second of sound
|
||||
packed_value = struct.pack("h", 14242)
|
||||
f.writeframes(packed_value)
|
||||
f.writeframes(packed_value)
|
||||
|
||||
f.close()
|
||||
with wave.open(str(sound_loc), "w") as f:
|
||||
f.setparams((2, 2, 44100, 0, "NONE", "not compressed"))
|
||||
for _ in range(22050): # half a second of sound
|
||||
packed_value = struct.pack("h", 14242)
|
||||
f.writeframes(packed_value)
|
||||
f.writeframes(packed_value)
|
||||
|
||||
manager = Manager(Scene)
|
||||
scene = manager.scene
|
||||
|
|
|
|||
|
|
@ -100,17 +100,16 @@ def test_background_color(config):
|
|||
|
||||
def test_digest_file(tmp_path, config):
|
||||
"""Test that a config file can be digested programmatically."""
|
||||
tmp_cfg = tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False)
|
||||
tmp_cfg.write(
|
||||
"""
|
||||
[CLI]
|
||||
media_dir = this_is_my_favorite_path
|
||||
video_dir = {media_dir}/videos
|
||||
sections_dir = {media_dir}/{scene_name}/prepare_for_unforeseen_consequences
|
||||
frame_height = 10
|
||||
""",
|
||||
)
|
||||
tmp_cfg.close()
|
||||
with tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False) as tmp_cfg:
|
||||
tmp_cfg.write(
|
||||
"""
|
||||
[CLI]
|
||||
media_dir = this_is_my_favorite_path
|
||||
video_dir = {media_dir}/videos
|
||||
sections_dir = {media_dir}/{scene_name}/prepare_for_unforeseen_consequences
|
||||
frame_height = 10
|
||||
""",
|
||||
)
|
||||
config.digest_file(tmp_cfg.name)
|
||||
|
||||
assert config.get_dir("media_dir") == Path("this_is_my_favorite_path")
|
||||
|
|
@ -156,15 +155,14 @@ def test_custom_dirs(tmp_path, config):
|
|||
|
||||
|
||||
def test_pixel_dimensions(tmp_path, config):
|
||||
tmp_cfg = tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False)
|
||||
tmp_cfg.write(
|
||||
"""
|
||||
[CLI]
|
||||
pixel_height = 10
|
||||
pixel_width = 10
|
||||
""",
|
||||
)
|
||||
tmp_cfg.close()
|
||||
with tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False) as tmp_cfg:
|
||||
tmp_cfg.write(
|
||||
"""
|
||||
[CLI]
|
||||
pixel_height = 10
|
||||
pixel_width = 10
|
||||
""",
|
||||
)
|
||||
config.digest_file(tmp_cfg.name)
|
||||
|
||||
# aspect ratio is set using pixel measurements
|
||||
|
|
@ -181,17 +179,16 @@ def test_frame_size(tmp_path, config):
|
|||
)
|
||||
np.testing.assert_allclose(config.frame_height, 8.0)
|
||||
|
||||
tmp_cfg = tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False)
|
||||
tmp_cfg.write(
|
||||
"""
|
||||
[CLI]
|
||||
pixel_height = 10
|
||||
pixel_width = 10
|
||||
frame_height = 10
|
||||
frame_width = 10
|
||||
""",
|
||||
)
|
||||
tmp_cfg.close()
|
||||
with tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False) as tmp_cfg:
|
||||
tmp_cfg.write(
|
||||
"""
|
||||
[CLI]
|
||||
pixel_height = 10
|
||||
pixel_width = 10
|
||||
frame_height = 10
|
||||
frame_width = 10
|
||||
""",
|
||||
)
|
||||
config.digest_file(tmp_cfg.name)
|
||||
|
||||
np.testing.assert_allclose(config.aspect_ratio, 1.0)
|
||||
|
|
@ -235,14 +232,13 @@ def test_tex_template_file(tmp_path):
|
|||
"""Test that a custom tex template file can be set from a config file."""
|
||||
tex_file = Path(tmp_path / "my_template.tex")
|
||||
tex_file.write_text("Hello World!")
|
||||
tmp_cfg = tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False)
|
||||
tmp_cfg.write(
|
||||
f"""
|
||||
[CLI]
|
||||
tex_template_file = {tex_file}
|
||||
""",
|
||||
)
|
||||
tmp_cfg.close()
|
||||
with tempfile.NamedTemporaryFile("w", dir=tmp_path, delete=False) as tmp_cfg:
|
||||
tmp_cfg.write(
|
||||
f"""
|
||||
[CLI]
|
||||
tex_template_file = {tex_file}
|
||||
""",
|
||||
)
|
||||
|
||||
custom_config = ManimConfig().digest_file(tmp_cfg.name)
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,4 +1,5 @@
|
|||
import sys
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
|
|
@ -6,6 +7,7 @@ import numpy as np
|
|||
import pytest
|
||||
|
||||
from manim import DR, Circle, Create, Manager, Scene, Star
|
||||
from manim.scene.scene_file_writer import to_av_frame_rate
|
||||
from manim.utils.commands import capture, get_video_metadata
|
||||
|
||||
|
||||
|
|
@ -167,3 +169,11 @@ def test_unicode_partial_movie(tmpdir, simple_scenes_path):
|
|||
|
||||
_, err, exit_code = capture(command)
|
||||
assert exit_code == 0, err
|
||||
|
||||
|
||||
def test_frame_rates():
|
||||
assert to_av_frame_rate(25) == Fraction(25, 1)
|
||||
assert to_av_frame_rate(24.0) == Fraction(24, 1)
|
||||
assert to_av_frame_rate(23.976) == Fraction(24 * 1000, 1001)
|
||||
assert to_av_frame_rate(23.98) == Fraction(24 * 1000, 1001)
|
||||
assert to_av_frame_rate(59.94) == Fraction(60 * 1000, 1001)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue