diff --git a/README.md b/README.md index 83d1912..c9776ab 100644 --- a/README.md +++ b/README.md @@ -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 + + # ... + ``` \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index 2d80a39..d873ad0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 --------- diff --git a/examples/custom_ffmpeg_sine_wave.py b/examples/custom_ffmpeg_sine_wave.py new file mode 100644 index 0000000..b55cd32 --- /dev/null +++ b/examples/custom_ffmpeg_sine_wave.py @@ -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) + ) + diff --git a/examples/parallel_sine_wave.py b/examples/parallel_sine_wave.py index f8eb574..5c855db 100644 --- a/examples/parallel_sine_wave.py +++ b/examples/parallel_sine_wave.py @@ -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): diff --git a/matplotloom/__init__.py b/matplotloom/__init__.py index b13fcfb..766e319 100644 --- a/matplotloom/__init__.py +++ b/matplotloom/__init__.py @@ -1,10 +1,11 @@ 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"] @@ -12,12 +13,12 @@ def _check_ffmpeg_availability(): """Check if ffmpeg is available on the system.""" 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 @@ -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 ) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index 4105361..5a547e9 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -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 @@ -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. @@ -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 ------ @@ -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 @@ -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( @@ -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 @@ -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. @@ -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: @@ -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: """ @@ -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"), @@ -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", @@ -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) diff --git a/pyproject.toml b/pyproject.toml index c321267..3465bd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" } @@ -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",