Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,3 +458,37 @@ with Loom("parallel_sine_wave.gif", fps=30, parallel=True) as loom:
for i, phase in enumerate(phases)
)
```

Providing a Custom FFmpeg Path
------------------------------

matplotloom supports providing custom FFmpeg path via three methods

1. Set the matplotlib ``animation.ffmpeg_args`` [rcPrams](https://matplotlib.org/stable/api/matplotlib_configuration_api.html#matplotlib.rcParams) to the custom FFmpeg path
* This is the default path matplotloom attempts to use
2. Set the Environment Variable ``LOOM_FFMPEG_PATH`` to the custom FFmpeg path
3. Pass the custom FFmpeg path as the ``ffmpeg_path`` argument when creating a ``Loom``

```python
# Either the environ var OR matplotlib rcPram *needs* to be set BEFORE
# importing matplotloom
import imageio_ffmpeg # A python library that provides an ffmpeg binary
FFMPEG_PATH: str = imageio_ffmpeg.get_ffmpeg_exe()

# Configure matplotlib rcPrams, if your global / project rcPrams file
# already sets this then you don't need to overload it.
plt.rcParams['animation.ffmpeg_path'] = FFMPEG_PATH


# Alternatively could set the environment variable however this *does not*
# inform matplotlib there is a valid ffmpeg binary so should be avoided.
# The intention is to allow any more complex toolchains the option if needed.
import os
os.environ["LOOM_FFMPEG_PATH"] = FFMPEG_PATH


# Import Matplotloom once the path has been set
from matplotloom import Loom

# ...
```
36 changes: 36 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,42 @@ By passing ``parallel=True`` when creating a ``Loom``, you can save frames using
for i, phase in enumerate(phases)
)

Providing a Custom FFmpeg Path
------------------------------

matplotloom supports providing custom FFmpeg path via three methods

1. Set the matplotlib ``animation.ffmpeg_args`` `rcPrams`_ to the custom FFmpeg path
* This is the default path matplotloom attempts to use
2. Set the Environment Variable ``LOOM_FFMPEG_PATH`` to the custom FFmpeg path
3. Pass the custom FFmpeg path as the ``ffmpeg_path`` argument when creating a ``Loom``

.. _rcPrams: https://matplotlib.org/stable/api/matplotlib_configuration_api.html#matplotlib.rcParams

.. code-block:: python

# Either the environ var OR matplotlib rcPram *needs* to be set BEFORE
# importing matplotloom
import imageio_ffmpeg # A python library that provides an ffmpeg binary
FFMPEG_PATH: str = imageio_ffmpeg.get_ffmpeg_exe()

# Configure matplotlib rcPrams, if your global / project rcPrams file
# already sets this then you don't need to overload it.
plt.rcParams['animation.ffmpeg_path'] = FFMPEG_PATH


# Alternatively could set the environment variable however this *does not*
# inform matplotlib there is a valid ffmpeg binary so should be avoided.
# The intention is to allow any more complex toolchains the option if needed.
import os
os.environ["LOOM_FFMPEG_PATH"] = FFMPEG_PATH


# Import Matplotloom once the path has been set
from matplotloom import Loom

# ...

Reference
---------

Expand Down
41 changes: 41 additions & 0 deletions examples/custom_ffmpeg_sine_wave.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import numpy as np
import matplotlib.pyplot as plt

from joblib import Parallel, delayed

# Either the environ var OR matplotlib rcPram *needs* to be set BEFORE
# importing matplotloom
import imageio_ffmpeg # python library that provides ffmpeg binary
FFMPEG_PATH: str = imageio_ffmpeg.get_ffmpeg_exe()

# Configure matplotlib rcPrams, if your global / project rcPrams file
# already sets this then you don't need to overload it.
plt.rcParams['animation.ffmpeg_path'] = FFMPEG_PATH

# Alternatively could set the environment variable however this *does not*
# inform matplotlib there is a valid ffmpeg binary so should be avoided.
# The intention is to allow any more complex toolchains the option if needed.
# import os
#os.environ["LOOM_FFMPEG_PATH"] = FFMPEG_PATH

from matplotloom import Loom

def plot_frame(phase, frame_number, loom):
fig, ax = plt.subplots()

x = np.linspace(0, 2*np.pi, 200)
y = np.sin(x + phase)

ax.plot(x, y)
ax.set_xlim(0, 2*np.pi)

loom.save_frame(fig, frame_number)

with Loom("custom_parallel_sine_wave.gif", fps=30, parallel=True) as loom:
phases = np.linspace(0, 2*np.pi, 100)

Parallel(n_jobs=-1)(
delayed(plot_frame)(phase, i, loom)
for i, phase in enumerate(phases)
)

1 change: 1 addition & 0 deletions examples/parallel_sine_wave.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import matplotlib.pyplot as plt

from joblib import Parallel, delayed

from matplotloom import Loom

def plot_frame(phase, frame_number, loom):
Expand Down
18 changes: 12 additions & 6 deletions matplotloom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import subprocess
import shutil
import warnings
from multiprocessing import current_process

from .loom import Loom
from .loom import Loom, DEFAULT_FFMPEG_PATH, _LOOM_DEFAULT_ENVIRON_VAR

__version__ = "0.9.1"
__version__ = "0.9.2"
__all__ = ["Loom"]


def _check_ffmpeg_availability():
"""Check if ffmpeg is available on the system."""
Comment on lines 12 to 13
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the user-supplied ffmpeg_path be an input here? I guess it can't be since this is __init__.py. I guess in the instance that ffmpeg is not available on the system but the user-supplied path works this will still produce a warning, but maybe this is fine?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that was my thought as well but then I saw it was passed to __init__.py I couldn't figure out a way to make it stick. The hope was that by using matplotlib's rcPrams if a user updated the rcPrams before importing Loom it would pull the updated value.

This works but only in the context of the main thread, as soon as you parallelise Loom it re-imports matplotlib & its rcPrams seems to be reset to the file-based defaults. I'm yet to test it but in theory if you add an rcPrams file where matplotlib expects it those settings should be maintained in the mutli-threaded/process context.

try:
# more reliable cross-platform
if shutil.which("ffmpeg") is not None:
if shutil.which(DEFAULT_FFMPEG_PATH) is not None:
return True

# Fallback: try running ffmpeg with subprocess
subprocess.run(
["ffmpeg", "-version"],
[DEFAULT_FFMPEG_PATH, "-version"],
capture_output=True,
check=True,
timeout=5
Expand All @@ -27,12 +28,17 @@ def _check_ffmpeg_availability():
return False


if not _check_ffmpeg_availability():
if not _check_ffmpeg_availability() and current_process().name == 'MainProcess':
warnings.warn(
"ffmpeg is not available on your system. "
"matplotloom requires ffmpeg to create animations. "
"Please install ffmpeg to use this library. "
"Visit https://ffmpeg.org/download.html for installation instructions.",
"Visit https://ffmpeg.org/download.html for installation instructions. "
f"Optionally ensure the environment variable `{_LOOM_DEFAULT_ENVIRON_VAR}`"
" is set to a valid ffmpeg executable or configure "
"`matplotlib.pyplot.rcParams['animation.ffmpeg_path']` to point to an "
"ffmpeg executable. The current command used to run ffmpeg "
f"is: `{DEFAULT_FFMPEG_PATH}`",
UserWarning,
stacklevel=2
)
74 changes: 66 additions & 8 deletions matplotloom/loom.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import subprocess
import subprocess, os
import warnings

from pathlib import Path
from typing import Union, Optional, Dict, Type, List, Any
from typing import Literal, Union, Optional, Dict, Type, List, Any
from types import TracebackType
from tempfile import TemporaryDirectory

Expand All @@ -10,6 +11,17 @@

from IPython.display import Video, Image

# This should allow
_LOOM_DEFAULT_ENVIRON_VAR = "LOOM_FFMPEG_PATH"

if _LOOM_DEFAULT_ENVIRON_VAR in os.environ:
DEFAULT_FFMPEG_PATH = os.environ[_LOOM_DEFAULT_ENVIRON_VAR]
else:
DEFAULT_FFMPEG_PATH: str = plt.rcParams['animation.ffmpeg_path']
os.environ[_LOOM_DEFAULT_ENVIRON_VAR] = DEFAULT_FFMPEG_PATH

VALID_SCALE_ODD_OPTIONS = {"round_up", "round_down", "crop", "pad", "none"}

class Loom:
"""
A class for creating animations from matplotlib figures.
Expand Down Expand Up @@ -59,6 +71,9 @@ class Loom:
Whether to show ffmpeg output when saving the video. Default is False.
When True, the ffmpeg command and its stdout/stderr output will be printed
during video creation, regardless of the verbose setting.
ffmpeg_path : Union[Path, str, None], optional
Path to ffmpeg, if not provided will use the default path.
Default path is configured to use matplotlib's rcParams ffmpeg path

Raises
------
Expand All @@ -77,6 +92,8 @@ def __init__(
savefig_kwargs: Optional[Dict[str, Any]] = None,
verbose: bool = False,
show_ffmpeg_output: bool = False,
ffmpeg_path: Optional[Union[Path, str]] = None,
enable_ffmpeg_path_fallback: bool = True,
) -> None:
self.output_filepath: Path = Path(output_filepath)
self.fps: int = fps
Expand All @@ -86,14 +103,15 @@ def __init__(
self.parallel: bool = parallel
self.show_ffmpeg_output: bool = show_ffmpeg_output
self.savefig_kwargs: Dict[str, Any] = savefig_kwargs or {}

valid_odd_options = {"round_up", "round_down", "crop", "pad", "none"}
if odd_dimension_handling not in valid_odd_options:
self.enable_ffmpeg_path_fallback = enable_ffmpeg_path_fallback

if odd_dimension_handling not in VALID_SCALE_ODD_OPTIONS:
raise ValueError(
f"odd_dimension_handling must be one of {valid_odd_options}, "
f"odd_dimension_handling must be one of {VALID_SCALE_ODD_OPTIONS}, "
f"got {odd_dimension_handling}"
)
self.odd_dimension_handling: str = odd_dimension_handling
self._get_scale_filter() # Should throw value error if wrong

if self.output_filepath.exists() and not self.overwrite:
raise FileExistsError(
Expand All @@ -107,6 +125,38 @@ def __init__(
self.frames_directory = Path(self._temp_dir.name)
else:
self.frames_directory = Path(frames_directory)

# Allow providing of ffmpeg path to class instance
self.ffmpeg_path: Path = Path(DEFAULT_FFMPEG_PATH)

# Only throws an error if a path was provided
if ffmpeg_path is not None:
_ffmpeg_path = Path(ffmpeg_path)

# If the path exists use it
if _ffmpeg_path.exists():
# Store the absolute path as things can get a bit funky with
# path enrolment & multiprocessing.
self.ffmpeg_path = _ffmpeg_path.absolute()

else:
# Otherwise check if path fallback is enabled & warn the user
if self.enable_ffmpeg_path_fallback:
warnings.warn(
f"Provided ffmpeg path of `{ffmpeg_path}` (resolving " +
f"to `{_ffmpeg_path}`) was not found! Using default " +
f"path of `{DEFAULT_FFMPEG_PATH}`"
)

# If path fallback is not enabled, raise an error
else:
raise FileNotFoundError(
f"Provided ffmpeg path of `{ffmpeg_path}` (resolving " +
f"to `{_ffmpeg_path}`) was not found!"
)

# In theory this should never fail.
assert isinstance(self.ffmpeg_path, Path), "ffmpeg path is not a valid Path object?"

# We don't use the frame counter in parallel mode.
self.frame_counter: Optional[int] = 0 if not self.parallel else None
Expand All @@ -122,6 +172,7 @@ def __init__(
print(f"output_filepath: {self.output_filepath}")
print(f"frames_directory: {self.frames_directory}")


def __enter__(self) -> 'Loom':
"""
Enter the runtime context related to this object.
Expand Down Expand Up @@ -197,6 +248,8 @@ def save_frame(
raise ValueError("frame_number must be provided when parallel=True")

if not self.parallel:
assert self.frame_counter is not None

frame_filepath = self.frames_directory / f"frame_{self.frame_counter:06d}.png"
self.frame_counter += 1
else:
Expand Down Expand Up @@ -233,6 +286,9 @@ def _get_scale_filter(self) -> str:
return "crop='if(mod(iw,2),iw-1,iw)':'if(mod(ih,2),ih-1,ih)':0:0"
elif self.odd_dimension_handling == "pad":
return "pad='if(mod(iw,2),iw+1,iw)':'if(mod(ih,2),ih+1,ih)':0:0:color=white"
else:
raise ValueError(f"Scale Settings not one of `{VALID_SCALE_ODD_OPTIONS}`, " +
f"got `{self.odd_dimension_handling}`")

def save_video(self) -> None:
"""
Expand All @@ -247,7 +303,7 @@ def save_video(self) -> None:

if self.file_format == "mp4":
command = [
"ffmpeg",
str(self.ffmpeg_path),
"-y",
"-framerate", str(self.fps),
"-i", str(self.frames_directory / "frame_%06d.png"),
Expand All @@ -263,7 +319,7 @@ def save_video(self) -> None:
])
elif self.file_format == "gif":
command = [
"ffmpeg",
str(self.ffmpeg_path),
"-y",
"-framerate", str(self.fps),
"-f", "image2",
Expand All @@ -277,6 +333,8 @@ def save_video(self) -> None:
gif_filter = "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"

command.extend(["-vf", gif_filter, str(self.output_filepath)])
else:
raise ValueError("Export File Format Not Valid!")

PIPE = subprocess.PIPE
process = subprocess.Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE)
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "matplotloom"
version = "0.9.1"
version = "0.9.2"
description = "Weave your frames into matplotlib animations."
authors = [
{ name = "ali-ramadhan", email = "ali.hh.ramadhan@gmail.com" }
Expand All @@ -23,6 +23,7 @@ dev = [
"sphinx>=8.1.3",
]
examples = [
"imageio-ffmpeg>=0.6.0",
"cartopy>=0.24.1",
"cmocean>=4.0.3",
"joblib>=1.5.1",
Expand Down