Merge branch 'main' of https://github.com/ManimCommunity/manim into experimental

This commit is contained in:
JasonGrace2282 2024-10-24 22:01:19 -04:00
commit d7fa8f051c
No known key found for this signature in database
GPG key ID: 8D61FE3F93FB15FA
33 changed files with 458 additions and 197 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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}"

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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(

View file

@ -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.

View file

@ -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).

View file

@ -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():

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)