Let SceneFileWriter access ffmpeg via av instead of via external process (#3501)

* added av as a dependency

* make partial movie files use av instead of piping to external ffmpeg

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* opengl rendering: use av for movie files

* no need to check for ffmpeg executable

* refactor: *_movie_pipe -> *_partial_movie_stream

* improve (oneline) documentation

* pass more options to partial movie file rendering

* move ffmpeg verbosity settings to config; renamed option dict

* replaced call to ffmpeg in combine_files by using av

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* there was one examples saved as a gif?

* chore(deps): re-order av

* chore(lib): simplify `write_frame` method

Reduces the overall code complexity

* chore(lib): add audio

* fix(lib): same issue for conversion

* fix(lib): webm export

* fix(lib): transparent export

Though the output video is weird

* try(lib): fix gif + TODOs

* chore(deps): lower dep crit

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* feat(lib): add support for GIF

* fix(ci): rewrite tests

* fix

* chore(ci): prevent calling concat on empty list

* add missing dot

* fix(ci): update frame comparison ?

* fix(log): add handler to libav logger

* chore: add TODO

* fix(lib): concat issue

* Revert "fix(ci): update frame comparison ?"

This reverts commit 904cfb46ae.

* fix(ci): make it pass tests

* chore(lib/docs/ci): remove FFMPEG entirely

This removes any reference to FFMPEG, except in translation files

* added av as a dependency

* make partial movie files use av instead of piping to external ffmpeg

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* opengl rendering: use av for movie files

* no need to check for ffmpeg executable

* refactor: *_movie_pipe -> *_partial_movie_stream

* improve (oneline) documentation

* pass more options to partial movie file rendering

* move ffmpeg verbosity settings to config; renamed option dict

* replaced call to ffmpeg in combine_files by using av

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* there was one examples saved as a gif?

* chore(deps): re-order av

* chore(lib): simplify `write_frame` method

Reduces the overall code complexity

* chore(lib): add audio

* fix(lib): same issue for conversion

* fix(lib): webm export

* fix(lib): transparent export

Though the output video is weird

* try(lib): fix gif + TODOs

* chore(deps): lower dep crit

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* feat(lib): add support for GIF

* fix(ci): rewrite tests

* fix

* chore(ci): prevent calling concat on empty list

* add missing dot

* fix(ci): update frame comparison ?

* fix(log): add handler to libav logger

* chore: add TODO

* fix(lib): concat issue

* Revert "fix(ci): update frame comparison ?"

This reverts commit 904cfb46ae.

* fix(ci): make it pass tests

* chore(lib/docs/ci): remove FFMPEG entirely

This removes any reference to FFMPEG, except in translation files

* chore(deps): update lockfile

* chore(lib): rewrite ffprobe

* fix typo

* slightly more aggressive removal of ffmpeg in docs; minor language changes

* fix gif output stream dimensions

* minor style change

* fix encoding of (transparent) mov files

* fixed metadata / comment

* set frame rate for --format=gif in output_stream

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* more video tests for different render settings, also test pix_fmt

* improve default bitrate setting via crf

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* parametrized format/transparency rendering test

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* context managers for (some) av.open

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update manim/utils/commands.py

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* fixed segfault

* update test data involving implicit functions (output improved!)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* explicity set pix_fmt for transparent webms

* special-special case extracting frame from vp9-encoded file with transparency

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fix transparent gifs, more special casing in parametrized video format test

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* run tests on macos-latest again

* removed old control data

* Revert "run tests on macos-latest again"

This reverts commit f50efa4b88.

* added sound to codec test; fixed issue with sound track in gif (disabled) and webm (now via opus)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* manual wav -> ogg transcoding

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* fixed f-string

* refactored codec test, split out gif

* check for non-zero audio samples

* more cleanup

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* remove ffmpeg from readthedocs apt_packages

* round up run_time if positive and shorter than current frame rate

* added more run_time tests

* black

* improve implementation of test

* removed some unused imports

* improve wording of logged warning

Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>

* move run_time checks from Animation.begin to Scene.get_run_time

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* remove unused import

* flake: PT012

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Jérome Eertmans <jeertmans@icloud.com>
This commit is contained in:
Benjamin Hackl 2024-05-15 15:23:09 +02:00 committed by GitHub
commit 1f249e45b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1868 additions and 1398 deletions

View file

@ -70,14 +70,5 @@ PASTE HERE
<!-- output of `tlmgr list --only-installed` for TeX Live or a screenshot of the Packages page for MikTeX -->
</details>
<details><summary>FFMPEG</summary>
Output of `ffmpeg -version`:
```
PASTE HERE
```
</details>
## Additional comments
<!-- Add further context that you think might be relevant for this issue here. -->

View file

@ -53,14 +53,5 @@ PASTE HERE
<!-- output of `tlmgr list --only-installed` for TeX Live or a screenshot of the Packages page for MikTeX -->
</details>
<details><summary>FFMPEG</summary>
Output of `ffmpeg -version`:
```
PASTE HERE
```
</details>
## Additional comments
<!-- Add further context that you think might be relevant for this issue here. -->

View file

@ -51,12 +51,6 @@ jobs:
run: |
echo "date=$(/bin/date -u "+%m%w%Y")" >> $GITHUB_OUTPUT
- name: Install and cache ffmpeg (all OS)
uses: FedericoCarboni/setup-ffmpeg@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
id: setup-ffmpeg
- name: Install system dependencies (Linux)
if: runner.os == 'Linux'
uses: awalsh128/cache-apt-pkgs-action@latest

View file

@ -19,7 +19,7 @@ jobs:
- name: Install system dependencies
run: |
sudo apt update && sudo apt install -y \
pkg-config libcairo-dev libpango1.0-dev ffmpeg wget fonts-roboto
pkg-config libcairo-dev libpango1.0-dev wget fonts-roboto
wget -qO- "https://yihui.org/tinytex/install-bin-unix.sh" | sh
echo ${HOME}/.TinyTeX/bin/x86_64-linux >> $GITHUB_PATH

View file

@ -7,7 +7,6 @@ build:
apt_packages:
- libpango1.0-dev
- ffmpeg
- graphviz
python:

View file

@ -2,7 +2,6 @@ FROM python:3.11-slim
RUN apt-get update -qq \
&& apt-get install --no-install-recommends -y \
ffmpeg \
build-essential \
gcc \
cmake \

View file

@ -51,8 +51,8 @@ For first-time contributors
#. Install Manim:
- Follow the steps in our :doc:`installation instructions
<../installation>` to install **Manim's dependencies**,
primarily ``ffmpeg`` and ``LaTeX``.
<../installation>` to install **Manim's system dependencies**.
We also recommend installing a LaTeX distribution.
- We recommend using `Poetry <https://python-poetry.org>`__ to manage your
developer installation of Manim. Poetry is a tool for dependency

View file

@ -266,7 +266,7 @@ The scene then asks its renderer to initialize the scene by calling
Inspecting both the default Cairo renderer and the OpenGL renderer shows that the ``init_scene``
method effectively makes the renderer instantiate a :class:`.SceneFileWriter` object, which
basically is Manim's interface to ``ffmpeg`` and actually writes the movie file. The Cairo
basically is Manim's interface to ``libav`` (FFMPEG) and actually writes the movie file. The Cairo
renderer (see the implementation `here <https://github.com/ManimCommunity/manim/blob/main/manim/renderer/cairo_renderer.py>`__) does not require any further initialization. The OpenGL renderer
does some additional setup to enable the realtime rendering preview window, which we do not go
into detail further here.
@ -310,8 +310,8 @@ the order they are called, these customizable methods are:
After these three methods are run, the animations have been fully rendered,
and Manim calls :meth:`.CairoRenderer.scene_finished` to gracefully
complete the rendering process. This checks whether any animations have been
played -- and if so, it tells the :class:`.SceneFileWriter` to close the pipe
to ``ffmpeg``. If not, Manim assumes that a static image should be output
played -- and if so, it tells the :class:`.SceneFileWriter` to close the output
file. If not, Manim assumes that a static image should be output
which it then renders using the same strategy by calling the render loop
(see below) once.
@ -762,10 +762,10 @@ to learn more, the :func:`.get_hash_from_play_call` function in the
mechanism.
In the event that the animation has to be rendered, the renderer asks
its :class:`.SceneFileWriter` to start a writing process. The process
is started by a call to ``ffmpeg`` and opens a pipe to which rendered
raw frames can be written. As long as the pipe is open, the process
can be accessed via the ``writing_process`` attribute of the file writer.
its :class:`.SceneFileWriter` to open an output container. The process
is started by a call to ``libav`` and opens a container to which rendered
raw frames can be written. As long as the output is open, the container
can be accessed via the ``output_container`` attribute of the file writer.
With the writing process in place, the renderer then asks the scene
to "begin" the animations.
@ -815,7 +815,7 @@ time is extracted (3 seconds long) and stored in
skip (it should not), then whether the animation is already
cached (it is not). The corresponding animation hash value is
determined and passed to the file writer, which then also calls
``ffmpeg`` to start the writing process which waits for rendered
``libav`` to start the writing process which waits for rendered
frames from the library.
The scene then ``begin``\ s the animation: for the
@ -1001,7 +1001,7 @@ and :meth:`.Animation.clean_up_from_scene` methods are called.
In the end, the time progression is closed (which completes the displayed progress bar)
in the terminal. With the closing of the time progression, the
:meth:`.Scene.play_internal` call is completed, and we return to the renderer,
which now orders the :class:`.SceneFileWriter` to close the movie pipe that has
which now orders the :class:`.SceneFileWriter` to close the output container that has
been opened for this animation: a partial movie file is written.
This pretty much concludes the walkthrough of a :class:`.Scene.play` call,

View file

@ -34,8 +34,7 @@ Installing Manim via Conda and related environment managers
Conda is a package manager for Python that allows creating environments
where all your dependencies are stored. Like this, you don't clutter up your PC with
unwanted libraries and you can just delete the environment when you don't need it anymore.
It is a good way to install manim since all dependencies like
``ffmpeg``, ``pycairo``, etc. come with it.
It is a good way to install manim since all dependencies like ``pycairo``, etc. come with it.
Also, the installation steps are the same, no matter if you are
on Windows, Linux, Intel Macs or on Apple Silicon.
@ -69,7 +68,7 @@ in order for Manim to work properly, some additional system
dependencies need to be installed first. The following pages have
operating system specific instructions for you to follow.
Manim requires Python version ``3.8`` or above to run.
Manim requires Python version ``3.9`` or above to run.
.. hint::

View file

@ -66,7 +66,7 @@ then execute it.
.. code-block::
!sudo apt update
!sudo apt install libcairo2-dev ffmpeg \
!sudo apt install libcairo2-dev \
texlive texlive-latex-extra texlive-fonts-extra \
texlive-latex-recommended texlive-science \
tipa libpango1.0-dev

View file

@ -5,10 +5,9 @@ The installation instructions depend on your particular operating
system and package manager. If you happen to know exactly what you are doing,
you can also simply ensure that your system has:
- a reasonably recent version of Python 3 (3.8 or above),
- a reasonably recent version of Python 3 (3.9 or above),
- with working Cairo bindings in the form of
`pycairo <https://cairographics.org/pycairo/>`__,
- FFmpeg accessible from the command line as ``ffmpeg``,
- and `Pango <https://pango.gnome.org>`__ headers.
Then, installing Manim is just a matter of running:
@ -33,13 +32,13 @@ Required Dependencies
apt Ubuntu / Mint / Debian
****************************
To first update your sources, and then install Cairo, Pango, and FFmpeg
To first update your sources, and then install Cairo and Pango
simply run:
.. code-block:: bash
sudo apt update
sudo apt install build-essential python3-dev libcairo2-dev libpango1.0-dev ffmpeg
sudo apt install build-essential python3-dev libcairo2-dev libpango1.0-dev
If you don't have python3-pip installed, install it via:
@ -72,14 +71,6 @@ need the Python development headers:
sudo dnf install python3-devel
FFmpeg is only available via the RPMfusion repository which you have to
configure first please consult https://rpmfusion.org/Configuration/ for
instructions. Then, install FFmpeg:
.. code-block:: bash
sudo dnf install ffmpeg
At this point you have all required dependencies and can install
Manim by running:
@ -100,12 +91,12 @@ pacman  Arch / Manjaro
If you don't want to use the packaged version from AUR, here is what
you need to do manually: Update your package sources, then install
Cairo, Pango, and FFmpeg:
Cairo and Pango:
.. code-block:: bash
sudo pacman -Syu
sudo pacman -S cairo pango ffmpeg
sudo pacman -S cairo pango
If you don't have ``python-pip`` installed, get it by running:

View file

@ -20,12 +20,12 @@ follow `Homebrew's installation instructions <https://docs.brew.sh/Installation>
Required Dependencies
---------------------
To install all required dependencies for installing Manim (namely: ffmpeg, Python,
To install all required dependencies for installing Manim (namely: Python,
and some required Python packages), run:
.. code-block:: bash
brew install py3cairo ffmpeg
brew install py3cairo
On *Apple Silicon* based machines (i.e., devices with the M1 chip or similar; if
you are unsure which processor you have check by opening the Apple menu, select

View file

@ -3,11 +3,8 @@ Windows
The easiest way of installing Manim and its dependencies is by using a
package manager like `Chocolatey <https://chocolatey.org/>`__
or `Scoop <https://scoop.sh>`__. If you are not afraid of editing
your System's ``PATH``, a manual installation is also possible.
In fact, if you already have an existing Python
installation (3.8 or above), it might be the easiest way to get
everything up and running.
or `Scoop <https://scoop.sh>`__, especially if you need optional dependencies
like LaTeX support.
If you choose to use one of the package managers, please follow
their installation instructions
@ -19,7 +16,7 @@ to make one of them available on your system.
Required Dependencies
---------------------
Manim requires a recent version of Python (3.8 or above) and ``ffmpeg``
Manim requires a recent version of Python (3.9 or above)
in order to work.
Chocolatey
@ -34,53 +31,11 @@ Manim can be installed via Chocolatey simply by running:
That's it, no further steps required. You can continue with installing
the :ref:`optional dependencies <win-optional-dependencies>` below.
Scoop
*****
While there is no recipe for installing Manim with Scoop directly,
you can install all requirements by running:
.. code-block:: powershell
scoop install python ffmpeg
and then Manim can be installed by running:
.. code-block:: powershell
python -m pip install manim
Manim should now be installed on your system. Continue reading
the :ref:`optional dependencies <win-optional-dependencies>` section
below.
Winget
******
While there is no recipe for installing Manim with Winget directly,
you can install all requirements by running:
.. code-block:: powershell
winget install python
winget install ffmpeg
and then Manim can be installed by running:
.. code-block:: powershell
python -m pip install manim
Manim should now be installed on your system. Continue reading
the :ref:`optional dependencies <win-optional-dependencies>` section
below.
Manual Installation
*******************
Pip
***
As mentioned above, Manim needs a reasonably recent version of
Python 3 (3.8 or above) and FFmpeg.
Python 3 (3.9 or above).
**Python:** Head over to https://www.python.org, download an installer
for a recent version of Python, and follow its instructions to get Python
@ -94,35 +49,16 @@ installed on your system.
install Python directly from the
`official website <https://www.python.org>`__.
**FFmpeg:** In order to install FFmpeg, you can get a
pre-compiled and ready-to-use version from one of the resources
linked at https://ffmpeg.org/download.html#build-windows, such as
`the version available here
<https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.7z>`__
(recommended), or if you know exactly what you are doing
you can alternatively get the source code
from https://ffmpeg.org/download.html and compile it yourself.
After downloading the pre-compiled archive,
`unzip it <https://www.7-zip.org>`__ and, if you like, move the
extracted directory to some more permanent place (e.g.,
``C:\Program Files\``). Next, edit the ``PATH`` environment variable:
first, visit ``Control Panel`` > ``System`` > ``System settings`` >
``Environment Variables``, then add the full path to the ``bin``
directory inside of the (moved) ffmpeg directory to the
``PATH`` variable. Finally, save your changes and exit.
If you now open a new command line prompt (or PowerShell) and
run ``ffmpeg``, the command should be recognized.
At this point, you have all the required dependencies and can now
install Manim via
Then, Manim can be installed via Pip simply by running:
.. code-block:: powershell
python -m pip install manim
Manim should now be installed on your system. Continue reading
the :ref:`optional dependencies <win-optional-dependencies>` section
below.
.. _win-optional-dependencies:

View file

@ -221,8 +221,6 @@ repr_number = green
# Uncomment the following line to manually set the loglevel for ffmpeg. See
# ffmpeg manpage for accepted values
loglevel = ERROR
# defaults to the one present in path
ffmpeg_executable = ffmpeg
[jupyter]
media_embed = False

View file

@ -100,6 +100,10 @@ def make_logger(
logger.addHandler(rich_handler)
logger.setLevel(verbosity)
if not (libav_logger := logging.getLogger()).hasHandlers():
libav_logger.addHandler(rich_handler)
libav_logger.setLevel(verbosity)
return logger, console, error_console

View file

@ -263,7 +263,6 @@ class ManimConfig(MutableMapping):
"dry_run",
"enable_wireframe",
"ffmpeg_loglevel",
"ffmpeg_executable",
"format",
"flush_cache",
"frame_height",
@ -679,10 +678,6 @@ class ManimConfig(MutableMapping):
if val:
self.ffmpeg_loglevel = val
# TODO: Fix the mess above and below
val = parser["ffmpeg"].get("ffmpeg_executable")
setattr(self, "ffmpeg_executable", val)
try:
val = parser["jupyter"].getboolean("media_embed")
except ValueError:
@ -1074,15 +1069,7 @@ class ManimConfig(MutableMapping):
val,
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
)
@property
def ffmpeg_executable(self) -> str:
"""Custom path to the ffmpeg executable."""
return self._d["ffmpeg_executable"]
@ffmpeg_executable.setter
def ffmpeg_executable(self, value: str) -> None:
self._set_str("ffmpeg_executable", value)
logging.getLogger("libav").setLevel(self.ffmpeg_loglevel)
@property
def media_embed(self) -> bool:

View file

@ -192,11 +192,6 @@ class Animation:
method.
"""
if self.run_time <= 0:
raise ValueError(
f"{self} has a run_time of <= 0 seconds, this cannot be rendered correctly. "
"Please set the run_time to be positive"
)
self.starting_mobject = self.create_starting_mobject()
if self.suspend_mobject_updating:
# All calls to self.mobject's internal updaters

View file

@ -81,16 +81,12 @@ class AnimationGroup(Animation):
return list(self.group)
def begin(self) -> None:
if self.run_time <= 0:
tmp = (
"Please set the run_time to be positive"
if len(self.animations) != 0
else "Please add at least one Animation with positive run_time"
)
if not self.animations:
raise ValueError(
f"{self} has a run_time of 0 seconds, this cannot be "
f"rendered correctly. {tmp}."
f"Trying to play {self} without animations, this is not supported. "
"Please add at least one subanimation."
)
self.anim_group_time = 0.0
if self.suspend_mobject_updating:
self.group.suspend_updating()
@ -233,7 +229,11 @@ class Succession(AnimationGroup):
super().__init__(*animations, lag_ratio=lag_ratio, **kwargs)
def begin(self) -> None:
assert len(self.animations) > 0
if not self.animations:
raise ValueError(
f"Trying to play {self} without animations, this is not supported. "
"Please add at least one subanimation."
)
self.update_active_animation(0)
def finish(self) -> None:

View file

@ -104,45 +104,6 @@ def is_manim_executable_associated_to_this_library():
return b"manim.__main__" in manim_exec or b'"%~dp0\\manim"' in manim_exec
@healthcheck(
description="Checking whether ffmpeg is available",
recommendation=(
"Manim does not work without ffmpeg. Please follow our "
"installation instructions "
"at https://docs.manim.community/en/stable/installation.html "
"to download ffmpeg. Then, either ...\n\n"
"(a) ... make the ffmpeg executable available to your system's PATH,\n"
"(b) or, alternatively, use <manim cfg write --open> to create a "
"custom configuration and set the ffmpeg_executable variable to the "
"full absolute path to the ffmpeg executable."
),
)
def is_ffmpeg_available():
path_to_ffmpeg = shutil.which(config.ffmpeg_executable)
return path_to_ffmpeg is not None and os.access(path_to_ffmpeg, os.X_OK)
@healthcheck(
description="Checking whether ffmpeg is working",
recommendation=(
"Your installed version of ffmpeg does not support x264 encoding, "
"which manim requires. Please follow our installation instructions "
"at https://docs.manim.community/en/stable/installation.html "
"to download and install a newer version of ffmpeg."
),
skip_on_failed=[is_ffmpeg_available],
)
def is_ffmpeg_working():
ffmpeg_version = subprocess.run(
[config.ffmpeg_executable, "-version"],
stdout=subprocess.PIPE,
).stdout.decode()
return (
ffmpeg_version.startswith("ffmpeg version")
and "--enable-libx264" in ffmpeg_version
)
@healthcheck(
description="Checking whether latex is available",
recommendation=(

View file

@ -472,7 +472,6 @@ class Star(Polygon):
Examples
--------
.. manim:: StarExample
:save_as_gif:
class StarExample(Scene):
def construct(self):

View file

@ -186,8 +186,7 @@ class CairoRenderer:
if self.skip_animations:
return
self.time += num_frames * dt
for _ in range(num_frames):
self.file_writer.write_frame(frame)
self.file_writer.write_frame(frame, num_frames=num_frames)
def freeze_current_frame(self, duration: float):
"""Adds a static frame to the movie for a given duration. The static frame is the current frame.

View file

@ -425,8 +425,9 @@ class OpenGLRenderer:
self.update_frame(scene)
if not self.skip_animations:
for _ in range(int(config.frame_rate * scene.duration)):
self.file_writer.write_frame(self)
self.file_writer.write_frame(
self, num_frames=int(config.frame_rate * scene.duration)
)
if self.window is not None:
self.window.swap_buffers()

View file

@ -1030,12 +1030,28 @@ class Scene:
float
The total ``run_time`` of all of the animations in the list.
"""
max_run_time = 0
frame_rate = (
1 / config.frame_rate
) # config.frame_rate holds the number of frames per second
for animation in animations:
if animation.run_time <= 0:
raise ValueError(
f"{animation} has a run_time of <= 0 seconds which Manim cannot render. "
"Please set the run_time to be positive."
)
elif animation.run_time < frame_rate:
logger.warning(
f"Original run time of {animation} is shorter than current frame "
f"rate (1 frame every {frame_rate:.2f} sec.) which cannot be rendered. "
"Rendering with the shortest possible duration instead."
)
animation.run_time = frame_rate
if len(animations) == 1 and isinstance(animations[0], Wait):
return animations[0].duration
if animation.run_time > max_run_time:
max_run_time = animation.run_time
else:
return np.max([animation.run_time for animation in animations])
return max_run_time
def play(
self,
@ -1205,16 +1221,16 @@ class Scene:
self.moving_mobjects = []
self.static_mobjects = []
self.duration = self.get_run_time(self.animations)
if len(self.animations) == 1 and isinstance(self.animations[0], Wait):
if self.should_update_mobjects():
self.update_mobjects(dt=0) # Any problems with this?
self.stop_condition = self.animations[0].stop_condition
else:
self.duration = self.animations[0].duration
# Static image logic when the wait is static is done by the renderer, not here.
self.animations[0].is_static_wait = True
return None
self.duration = self.get_run_time(self.animations)
return self
def begin_animations(self) -> None:

View file

@ -5,12 +5,11 @@ from __future__ import annotations
__all__ = ["SceneFileWriter"]
import json
import os
import shutil
import subprocess
from pathlib import Path
from typing import TYPE_CHECKING, Any
import av
import numpy as np
import srt
from PIL import Image
@ -24,11 +23,9 @@ from ..constants import RendererType
from ..utils.file_ops import (
add_extension_if_not_present,
add_version_before_extension,
ensure_executable,
guarantee_existence,
is_gif_format,
is_png_format,
is_webm_format,
modify_atime,
write_to_movie,
)
@ -83,14 +80,6 @@ class SceneFileWriter:
self.next_section(
name="autocreated", type=DefaultSectionType.NORMAL, skip_animations=False
)
# fail fast if ffmpeg is not found
if not ensure_executable(Path(config.ffmpeg_executable)):
raise RuntimeError(
"Manim could not find ffmpeg, which is required for generating video output.\n"
"For installing ffmpeg please consult https://docs.manim.community/en/stable/installation.html\n"
"Make sure to either add ffmpeg to the PATH environment variable\n"
"or set path to the ffmpeg executable under the ffmpeg header in Manim's configuration."
)
def init_output_directories(self, scene_name):
"""Initialise output directories.
@ -358,7 +347,7 @@ class SceneFileWriter:
Whether or not to write to a video file.
"""
if write_to_movie() and allow_write:
self.open_movie_pipe(file_path=file_path)
self.open_partial_movie_stream(file_path=file_path)
def end_animation(self, allow_write: bool = False):
"""
@ -371,9 +360,11 @@ class SceneFileWriter:
Whether or not to write to a video file.
"""
if write_to_movie() and allow_write:
self.close_movie_pipe()
self.close_partial_movie_stream()
def write_frame(self, frame_or_renderer: np.ndarray | OpenGLRenderer):
def write_frame(
self, frame_or_renderer: np.ndarray | OpenGLRenderer, num_frames: int = 1
):
"""
Used internally by Manim to write a frame to
the FFMPEG input buffer.
@ -382,41 +373,41 @@ class SceneFileWriter:
----------
frame_or_renderer
Pixel array of the frame.
num_frames
The number of times to write frame.
"""
if config.renderer == RendererType.OPENGL:
self.write_opengl_frame(frame_or_renderer)
elif config.renderer == RendererType.CAIRO:
frame = frame_or_renderer
if write_to_movie():
self.writing_process.stdin.write(frame.tobytes())
if is_png_format() and not config["dry_run"]:
self.output_image_from_array(frame)
def write_opengl_frame(self, renderer: OpenGLRenderer):
if write_to_movie():
self.writing_process.stdin.write(
renderer.get_raw_frame_buffer_object_data(),
frame: np.ndarray = (
frame_or_renderer.get_frame()
if config.renderer == RendererType.OPENGL
else frame_or_renderer
)
for _ in range(num_frames):
# Notes: precomputing reusing packets does not work!
# I.e., you cannot do `packets = encode(...)`
# and reuse it, as it seems that `mux(...)`
# consumes the packet.
# The same issue applies for `av_frame`,
# reusing it renders weird-looking frames.
av_frame = av.VideoFrame.from_ndarray(frame, format="rgba")
for packet in self.video_stream.encode(av_frame):
self.video_container.mux(packet)
if is_png_format() and not config["dry_run"]:
image: Image = (
frame_or_renderer.get_image()
if config.renderer == RendererType.OPENGL
else Image.fromarray(frame_or_renderer)
)
elif is_png_format() and not config["dry_run"]:
target_dir = self.image_file_path.parent / self.image_file_path.stem
extension = self.image_file_path.suffix
self.output_image(
renderer.get_image(),
image,
target_dir,
extension,
config["zero_pad"],
)
def output_image_from_array(self, frame_data):
target_dir = self.image_file_path.parent / self.image_file_path.stem
extension = self.image_file_path.suffix
self.output_image(
Image.fromarray(frame_data),
target_dir,
extension,
config["zero_pad"],
)
def output_image(self, image: Image.Image, target_dir, ext, zero_pad: bool):
if zero_pad:
image.save(f"{target_dir}{str(self.frame_count).zfill(zero_pad)}{ext}")
@ -467,11 +458,11 @@ class SceneFileWriter:
if self.subcaptions:
self.write_subcaption_file()
def open_movie_pipe(self, file_path=None):
"""
Used internally by Manim to initialise
FFMPEG and begin writing to FFMPEG's input
buffer.
def open_partial_movie_stream(self, file_path=None):
"""Open a container holding a video stream.
This is used internally by Manim initialize the container holding
the video stream of a partial movie file.
"""
if file_path is None:
file_path = self.partial_movie_files[self.renderer.num_plays]
@ -480,49 +471,48 @@ class SceneFileWriter:
fps = config["frame_rate"]
if fps == int(fps): # fps is integer
fps = int(fps)
if config.renderer == RendererType.OPENGL:
width, height = self.renderer.get_pixel_shape()
else:
height = config["pixel_height"]
width = config["pixel_width"]
command = [
config.ffmpeg_executable,
"-y", # overwrite output file if it exists
"-f",
"rawvideo",
"-s",
"%dx%d" % (width, height), # size of one frame
"-pix_fmt",
"rgba",
"-r",
str(fps), # frames per second
"-i",
"-", # The input comes from a pipe
"-an", # Tells FFMPEG not to expect any audio
"-loglevel",
config["ffmpeg_loglevel"].lower(),
"-metadata",
f"comment=Rendered with Manim Community v{__version__}",
]
if config.renderer == RendererType.OPENGL:
command += ["-vf", "vflip"]
if is_webm_format():
command += ["-vcodec", "libvpx-vp9", "-auto-alt-ref", "0"]
# .mov format
elif config["transparent"]:
command += ["-vcodec", "qtrle"]
else:
command += ["-vcodec", "libx264", "-pix_fmt", "yuv420p"]
command += [file_path]
self.writing_process = subprocess.Popen(command, stdin=subprocess.PIPE)
partial_movie_file_codec = "libx264"
partial_movie_file_pix_fmt = "yuv420p"
av_options = {
"an": "1", # ffmpeg: -an, no audio
"crf": "23", # ffmpeg: -crf, constant rate factor (improved bitrate)
}
def close_movie_pipe(self):
if config.format == "webm":
partial_movie_file_codec = "libvpx-vp9"
av_options["-auto-alt-ref"] = "1"
if config.transparent:
partial_movie_file_pix_fmt = "yuva420p"
elif config.transparent:
partial_movie_file_codec = "qtrle"
partial_movie_file_pix_fmt = "argb"
with av.open(file_path, mode="w") as video_container:
stream = video_container.add_stream(
partial_movie_file_codec,
rate=config.frame_rate,
options=av_options,
)
stream.pix_fmt = partial_movie_file_pix_fmt
stream.width = config.pixel_width
stream.height = config.pixel_height
self.video_container = video_container
self.video_stream = stream
def close_partial_movie_stream(self):
"""Close the currently opened video container.
Used internally by Manim to first flush the remaining packages
in the video stream holding a partial file, and then close
the corresponding container.
"""
Used internally by Manim to gracefully stop writing to FFMPEG's input buffer
"""
self.writing_process.stdin.close()
self.writing_process.wait()
for packet in self.video_stream.encode():
self.video_container.mux(packet)
self.video_container.close()
logger.info(
f"Animation {self.renderer.num_plays} : Partial movie file written in %(path)s",
@ -567,37 +557,92 @@ class SceneFileWriter:
for pf_path in input_files:
pf_path = Path(pf_path).as_posix()
fp.write(f"file 'file:{pf_path}'\n")
commands = [
config.ffmpeg_executable,
"-y", # overwrite output file if it exists
"-f",
"concat",
"-safe",
"0",
"-i",
str(file_list),
"-loglevel",
config.ffmpeg_loglevel.lower(),
"-metadata",
f"comment=Rendered with Manim Community v{__version__}",
"-nostdin",
]
if create_gif:
commands += [
"-vf",
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",
]
else:
commands += ["-c", "copy"]
av_options = {
"safe": "0", # needed to read files
}
if not includes_sound:
commands += ["-an"]
av_options["an"] = "1"
commands += [str(output_file)]
partial_movies_input = av.open(
str(file_list), options=av_options, format="concat"
)
partial_movies_stream = partial_movies_input.streams.video[0]
output_container = av.open(str(output_file), mode="w")
output_container.metadata["comment"] = (
f"Rendered with Manim Community v{__version__}"
)
output_stream = output_container.add_stream(
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":
output_stream.pix_fmt = "yuva420p"
if create_gif:
"""
The following solution was largely inspired from this comment
https://github.com/imageio/imageio/issues/995#issuecomment-1580533018,
and the following code
https://github.com/imageio/imageio/blob/65d79140018bb7c64c0692ea72cb4093e8d632a0/imageio/plugins/pyav.py#L927-L996.
"""
output_stream.pix_fmt = "rgb8"
if config.transparent:
output_stream.pix_fmt = "pal8"
output_stream.width = config.pixel_width
output_stream.height = config.pixel_height
output_stream.rate = config.frame_rate
graph = av.filter.Graph()
input_buffer = graph.add_buffer(template=partial_movies_stream)
split = graph.add("split")
palettegen = graph.add("palettegen", "stats_mode=diff")
paletteuse = graph.add(
"paletteuse", "dither=bayer:bayer_scale=5:diff_mode=rectangle"
)
output_sink = graph.add("buffersink")
combine_process = subprocess.Popen(commands)
combine_process.wait()
input_buffer.link_to(split)
split.link_to(palettegen, 0, 0) # 1st input of split -> input of palettegen
split.link_to(paletteuse, 1, 0) # 2nd output of split -> 1st input
palettegen.link_to(paletteuse, 0, 1) # output of palettegen -> 2nd input
paletteuse.link_to(output_sink)
graph.configure()
for frame in partial_movies_input.decode(video=0):
graph.push(frame)
graph.push(None) # EOF: https://github.com/PyAV-Org/PyAV/issues/886.
frames_written = 0
while True:
try:
frame = graph.pull()
frame.time_base = output_stream.codec_context.time_base
frame.pts = frames_written
frames_written += 1
output_container.mux(output_stream.encode(frame))
except av.error.EOFError:
break
for packet in output_stream.encode():
output_container.mux(packet)
else:
for packet in partial_movies_input.demux(partial_movies_stream):
# We need to skip the "flushing" packets that `demux` generates.
if packet.dts is None:
continue
packet.dts = None # This seems to be needed, as dts from consecutive
# files may not be monotically increasing, so we let libav compute it.
# We need to assign the packet to the new stream.
packet.stream = output_stream
output_container.mux(packet)
partial_movies_input.close()
output_container.close()
def combine_to_movie(self):
"""Used internally by Manim to combine the separate
@ -614,6 +659,10 @@ class SceneFileWriter:
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
logger.info("Combining to Movie file.")
self.combine_files(
partial_movie_files,
@ -623,44 +672,78 @@ class SceneFileWriter:
)
# handle sound
if self.includes_sound:
if self.includes_sound and config.format != "gif":
sound_file_path = movie_file_path.with_suffix(".wav")
# Makes sure sound file length will match video file
self.add_audio_segment(AudioSegment.silent(0))
self.audio_segment.export(
sound_file_path,
format="wav",
bitrate="312k",
)
# Audio added to a VP9 encoded (webm) video file needs
# to be encoded as vorbis or opus. Directly exporting
# self.audio_segment with such a codec works in principle,
# 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")
temp_file_path = movie_file_path.with_name(
f"{movie_file_path.stem}_temp{movie_file_path.suffix}"
)
commands = [
config.ffmpeg_executable,
"-i",
str(movie_file_path),
"-i",
str(sound_file_path),
"-y", # overwrite output file if it exists
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"320k",
# select video stream from first file
"-map",
"0:v:0",
# select audio stream from second file
"-map",
"1:a:0",
"-loglevel",
config.ffmpeg_loglevel.lower(),
"-metadata",
f"comment=Rendered with Manim Community v{__version__}",
# "-shortest",
str(temp_file_path),
]
subprocess.call(commands)
av_options = {
"shortest": "1",
"metadata": f"comment=Rendered with Manim Community v{__version__}",
}
with (
av.open(movie_file_path) as video_input,
av.open(sound_file_path) as audio_input,
):
video_stream = video_input.streams.video[0]
audio_stream = audio_input.streams.audio[0]
output_container = av.open(
str(temp_file_path), mode="w", options=av_options
)
output_video_stream = output_container.add_stream(template=video_stream)
output_audio_stream = output_container.add_stream(template=audio_stream)
for packet in video_input.demux(video_stream):
# We need to skip the "flushing" packets that `demux` generates.
if packet.dts is None:
continue
# We need to assign the packet to the new stream.
packet.stream = output_video_stream
output_container.mux(packet)
for packet in audio_input.demux(audio_stream):
# We need to skip the "flushing" packets that `demux` generates.
if packet.dts is None:
continue
# We need to assign the packet to the new stream.
packet.stream = output_audio_stream
output_container.mux(packet)
output_container.close()
shutil.move(str(temp_file_path), str(movie_file_path))
sound_file_path.unlink()

View file

@ -1,11 +1,12 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from subprocess import run
from typing import Generator
import av
__all__ = [
"capture",
"get_video_metadata",
@ -27,21 +28,26 @@ def capture(command, cwd=None, command_input=None):
def get_video_metadata(path_to_video: str | os.PathLike) -> dict[str]:
command = [
"ffprobe",
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,nb_frames,duration,avg_frame_rate,codec_name",
"-print_format",
"json",
str(path_to_video),
]
config, err, exitcode = capture(command)
assert exitcode == 0, f"FFprobe error: {err}"
return json.loads(config)["streams"][0]
with av.open(str(path_to_video)) as container:
stream = container.streams.video[0]
ctxt = stream.codec_context
rate = stream.average_rate
if stream.duration is not None:
duration = float(stream.duration * stream.time_base)
num_frames = stream.frames
else:
num_frames = sum(1 for _ in container.decode(video=0))
duration = float(num_frames / stream.base_rate)
return {
"width": ctxt.width,
"height": ctxt.height,
"nb_frames": str(num_frames),
"duration": f"{duration:.6f}",
"avg_frame_rate": f"{rate.numerator}/{rate.denominator}", # Can be a Fraction
"codec_name": stream.codec_context.name,
"pix_fmt": stream.codec_context.pix_fmt,
}
def get_dir_layout(dirpath: Path) -> Generator[str, None, None]:

View file

@ -198,10 +198,7 @@ def open_file(file_path, in_browser=False):
commands = ["cygstart"]
file_path = file_path if not in_browser else file_path.parent
elif current_os == "Darwin":
if is_gif_format():
commands = ["ffplay", "-loglevel", config["ffmpeg_loglevel"].lower()]
else:
commands = ["open"] if not in_browser else ["open", "-R"]
commands = ["open"] if not in_browser else ["open", "-R"]
else:
raise OSError("Unable to identify your operating system...")

View file

@ -64,14 +64,14 @@ class DummySceneFileWriter(SceneFileWriter):
def clean_cache(self):
pass
def write_frame(self, frame_or_renderer):
def write_frame(self, frame_or_renderer, num_frames=1):
self.i += 1
def _make_scene_file_writer_class(tester: _FramesTester) -> type[SceneFileWriter]:
class TestSceneFileWriter(DummySceneFileWriter):
def write_frame(self, frame_or_renderer):
def write_frame(self, frame_or_renderer, num_frames=1):
tester.check_frame(self.i, frame_or_renderer)
super().write_frame(frame_or_renderer)
super().write_frame(frame_or_renderer, num_frames=num_frames)
return TestSceneFileWriter

2318
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,7 @@ packages = [
[tool.poetry.dependencies]
python = ">=3.9,<3.13"
av = ">=9.0.0"
click = ">=8.0"
cloup = ">=2.0.0"
dearpygui = { version = ">=1.0.0", optional = true }

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [],
"section_index": []

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [],
"section_index": []

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [
"SquareToCircle.json",
@ -23,7 +24,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
}
]
}

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "4.000000",
"nb_frames": "60"
"nb_frames": "60",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [],
"section_index": []

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "7.000000",
"nb_frames": "105"
"nb_frames": "105",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [],
"section_index": []

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "5.000000",
"nb_frames": "75"
"nb_frames": "75",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [],
"section_index": []

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "7.000000",
"nb_frames": "105"
"nb_frames": "105",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [
"SceneWithSections.json",
@ -27,7 +28,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
},
{
"name": "unnamed",
@ -38,7 +40,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "2.000000",
"nb_frames": "30"
"nb_frames": "30",
"pix_fmt": "yuv420p"
},
{
"name": "test",
@ -49,7 +52,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
},
{
"name": "Prepare For Unforeseen Consequences.",
@ -60,7 +64,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "2.000000",
"nb_frames": "30"
"nb_frames": "30",
"pix_fmt": "yuv420p"
},
{
"name": "unnamed",
@ -71,7 +76,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
}
]
}

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "6.000000",
"nb_frames": "90"
"nb_frames": "90",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [
"ElaborateSceneWithSections.json",
@ -25,7 +26,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "2.000000",
"nb_frames": "30"
"nb_frames": "30",
"pix_fmt": "yuv420p"
},
{
"name": "transform to circle",
@ -36,7 +38,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "2.000000",
"nb_frames": "30"
"nb_frames": "30",
"pix_fmt": "yuv420p"
},
{
"name": "fade out",
@ -47,7 +50,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "2.000000",
"nb_frames": "30"
"nb_frames": "30",
"pix_fmt": "yuv420p"
}
]
}

View file

@ -6,7 +6,8 @@
"height": 1080,
"avg_frame_rate": "60/1",
"duration": "1.000000",
"nb_frames": "60"
"nb_frames": "60",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [],
"section_index": []

View file

@ -6,7 +6,8 @@
"height": 480,
"avg_frame_rate": "15/1",
"duration": "1.000000",
"nb_frames": "15"
"nb_frames": "15",
"pix_fmt": "yuv420p"
},
"section_dir_layout": [],
"section_index": []

View file

@ -4,6 +4,7 @@ import shutil
import sys
from pathlib import Path
from textwrap import dedent
from unittest.mock import patch
from click.testing import CliRunner
@ -83,11 +84,20 @@ def test_manim_checkhealth_subcommand():
def test_manim_checkhealth_failing_subcommand():
command = ["checkhealth"]
runner = CliRunner()
with tempconfig({"ffmpeg_executable": "/path/to/nowhere"}):
true_f = shutil.which
def mock_f(s):
if s == "latex":
return None
return true_f(s)
with patch.object(shutil, "which", new=mock_f):
result = runner.invoke(main, command)
output_lines = result.output.split("\n")
assert "- Checking whether ffmpeg is available ... FAILED" in output_lines
assert "- Checking whether ffmpeg is working ... SKIPPED" in output_lines
assert "- Checking whether latex is available ... FAILED" in output_lines
assert "- Checking whether dvisvgm is available ... SKIPPED" in output_lines
def test_manim_init_subcommand():

View file

@ -0,0 +1,34 @@
from __future__ import annotations
import pytest
from manim import FadeIn, Scene, config
@pytest.mark.parametrize(
"run_time",
[0, -1],
)
def test_animation_forbidden_run_time(run_time):
test_scene = Scene()
with pytest.raises(ValueError, match="Please set the run_time to be positive"):
test_scene.play(FadeIn(None, run_time=run_time))
def test_animation_run_time_shorter_than_frame_rate(caplog):
test_scene = Scene()
test_scene.play(FadeIn(None, run_time=1 / (config.frame_rate + 1)))
assert (
"Original run time of FadeIn(Mobject) is shorter than current frame rate"
in caplog.text
)
@pytest.mark.parametrize("frozen_frame", [False, True])
def test_wait_run_time_shorter_than_frame_rate(caplog, frozen_frame):
test_scene = Scene()
test_scene.wait(1e-9, frozen_frame=frozen_frame)
assert (
"Original run time of Wait(Mobject) is shorter than current frame rate"
in caplog.text
)

View file

@ -172,10 +172,10 @@ def test_animationgroup_is_passing_remover_to_nested_animationgroups():
def test_empty_animation_group_fails():
with pytest.raises(ValueError, match="Please add at least one Animation"):
with pytest.raises(ValueError, match="Please add at least one subanimation."):
AnimationGroup().begin()
def test_empty_animation_fails():
with pytest.raises(ValueError, match="Please set the run_time to be positive"):
FadeIn(None, run_time=0).begin()
def test_empty_succession_fails():
with pytest.raises(ValueError, match="Please add at least one subanimation."):
Succession().begin()

View file

@ -1,8 +1,148 @@
import sys
from pathlib import Path
import av
import numpy as np
import pytest
from manim.utils.commands import capture
from manim import DR, Circle, Create, Scene, Star, tempconfig
from manim.utils.commands import capture, get_video_metadata
class StarScene(Scene):
def construct(self):
circle = Circle(fill_opacity=1, color="#ff0000")
circle.to_corner(DR).shift(DR)
self.add(circle)
star = Star()
self.play(Create(star))
click_path = (
Path(__file__).parent.parent.parent
/ "docs"
/ "source"
/ "_static"
/ "click.wav"
)
self.add_sound(click_path)
self.wait()
@pytest.mark.slow
@pytest.mark.parametrize(
"transparent",
[False, True],
)
def test_gif_writing(tmp_path, transparent):
output_filename = f"gif_{'transparent' if transparent else 'opaque'}"
with tempconfig(
{
"media_dir": tmp_path,
"quality": "low_quality",
"format": "gif",
"transparent": transparent,
"output_file": output_filename,
}
):
StarScene().render()
video_path = tmp_path / "videos" / "480p15" / f"{output_filename}.gif"
assert video_path.exists()
metadata = get_video_metadata(video_path)
# reported duration + avg_frame_rate is slightly off for gifs
del metadata["duration"], metadata["avg_frame_rate"]
target_metadata = {
"width": 854,
"height": 480,
"nb_frames": "30",
"codec_name": "gif",
"pix_fmt": "bgra",
}
assert metadata == target_metadata
with av.open(video_path) as container:
first_frame = next(container.decode(video=0))
frame_format = "argb" if transparent else "rgb24"
first_frame = first_frame.to_ndarray(format=frame_format)
target_rgba_corner = (
np.array([0, 255, 255, 255], dtype=np.uint8)
if transparent
else np.array([0, 0, 0], dtype=np.uint8)
)
np.testing.assert_array_equal(first_frame[0, 0], target_rgba_corner)
target_rgba_center = (
np.array([255, 255, 0, 0]) # components (A, R, G, B)
if transparent
else np.array([255, 0, 0], dtype=np.uint8)
)
np.testing.assert_allclose(first_frame[-1, -1], target_rgba_center, atol=5)
@pytest.mark.slow
@pytest.mark.parametrize(
"format, transparent, codec, pixel_format",
[
("mp4", False, "h264", "yuv420p"),
("mov", False, "h264", "yuv420p"),
("mov", True, "qtrle", "argb"),
("webm", False, "vp9", "yuv420p"),
("webm", True, "vp9", "yuv420p"),
],
)
def test_codecs(tmp_path, format, transparent, codec, pixel_format):
output_filename = f"codec_{format}_{'transparent' if transparent else 'opaque'}"
with tempconfig(
{
"media_dir": tmp_path,
"quality": "low_quality",
"format": format,
"transparent": transparent,
"output_file": output_filename,
}
):
StarScene().render()
video_path = tmp_path / "videos" / "480p15" / f"{output_filename}.{format}"
assert video_path.exists()
metadata = get_video_metadata(video_path)
target_metadata = {
"width": 854,
"height": 480,
"nb_frames": "30",
"duration": "2.000000",
"avg_frame_rate": "15/1",
"codec_name": codec,
"pix_fmt": pixel_format,
}
assert metadata == target_metadata
with av.open(video_path) as container:
if transparent and format == "webm":
from av.codec.context import CodecContext
context = CodecContext.create("libvpx-vp9", "r")
packet = next(container.demux(video=0))
first_frame = context.decode(packet)[0].to_ndarray(format="argb")
else:
first_frame = next(container.decode(video=0)).to_ndarray()
has_samples = [
np.any(frame.to_ndarray()) for frame in container.decode(audio=0)
]
assert any(has_samples), "All audio samples are zero, this is not intended"
target_rgba_corner = (
np.array([0, 0, 0, 0]) if transparent else np.array(16, dtype=np.uint8)
)
np.testing.assert_array_equal(first_frame[0, 0], target_rgba_corner)
target_rgba_center = (
np.array([255, 255, 0, 0]) # components (A, R, G, B)
if transparent
else np.array(240, dtype=np.uint8)
)
np.testing.assert_allclose(first_frame[-1, -1], target_rgba_center, atol=5)
@pytest.mark.slow