From 9c9fe306c8ff6519e702ee1931d6535213b7a96b Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Fri, 28 Nov 2025 11:33:51 +1000 Subject: [PATCH 01/17] Add the ability to specify ffmpeg path & use MatPlotLib's ffmpeg path --- matplotloom/__init__.py | 13 ++++++++----- matplotloom/loom.py | 19 ++++++++++++++++--- pyproject.toml | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/matplotloom/__init__.py b/matplotloom/__init__.py index b13fcfb..f65c770 100644 --- a/matplotloom/__init__.py +++ b/matplotloom/__init__.py @@ -2,9 +2,9 @@ import shutil import warnings -from .loom import Loom +from .loom import Loom, DEFAULT_FFMPEG_PATH -__version__ = "0.9.1" +__version__ = "0.9.2" __all__ = ["Loom"] @@ -12,12 +12,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 @@ -32,7 +32,10 @@ def _check_ffmpeg_availability(): "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." + "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..2bc5ac9 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -10,6 +10,8 @@ from IPython.display import Video, Image +DEFAULT_FFMPEG_PATH = plt.rcParams['animation.ffmpeg_path'] + class Loom: """ A class for creating animations from matplotlib figures. @@ -59,6 +61,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 +82,7 @@ def __init__( savefig_kwargs: Optional[Dict[str, Any]] = None, verbose: bool = False, show_ffmpeg_output: bool = False, + ffmpeg_path: Optional[Union[Path, str]] = None, ) -> None: self.output_filepath: Path = Path(output_filepath) self.fps: int = fps @@ -86,7 +92,7 @@ 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: raise ValueError( @@ -107,6 +113,13 @@ def __init__( self.frames_directory = Path(self._temp_dir.name) else: self.frames_directory = Path(frames_directory) + + # Use matplotlib ffmpeg + self.ffmpeg_path: str = DEFAULT_FFMPEG_PATH + if ffmpeg_path is not None: + _ffmpeg_path = Path(ffmpeg_path) + if _ffmpeg_path.exists(): + self.ffmpeg_path = str(_ffmpeg_path.absolute()) # We don't use the frame counter in parallel mode. self.frame_counter: Optional[int] = 0 if not self.parallel else None @@ -247,7 +260,7 @@ def save_video(self) -> None: if self.file_format == "mp4": command = [ - "ffmpeg", + self.ffmpeg_path, "-y", "-framerate", str(self.fps), "-i", str(self.frames_directory / "frame_%06d.png"), @@ -263,7 +276,7 @@ def save_video(self) -> None: ]) elif self.file_format == "gif": command = [ - "ffmpeg", + self.ffmpeg_path, "-y", "-framerate", str(self.fps), "-f", "image2", diff --git a/pyproject.toml b/pyproject.toml index c321267..507fb35 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" } From 2cc30dfa28f9f73247ad3a5cbcb33926619c49a8 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:01:02 +1000 Subject: [PATCH 02/17] Add mutli-thread check to the init warning. --- matplotloom/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/matplotloom/__init__.py b/matplotloom/__init__.py index f65c770..5156008 100644 --- a/matplotloom/__init__.py +++ b/matplotloom/__init__.py @@ -1,6 +1,7 @@ import subprocess import shutil import warnings +from multiprocessing import current_process from .loom import Loom, DEFAULT_FFMPEG_PATH @@ -27,7 +28,7 @@ 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. " From d6ef5095ffef9ab0aab17692979e354e13063b9c Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:14:04 +1000 Subject: [PATCH 03/17] Whitelist file extensions and do check --- matplotloom/loom.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index 2bc5ac9..ed088e5 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -12,6 +12,8 @@ DEFAULT_FFMPEG_PATH = plt.rcParams['animation.ffmpeg_path'] +ACCEPTABLE_EXTENSIONS = ("mp4", "gif") + class Loom: """ A class for creating animations from matplotlib figures. @@ -135,6 +137,11 @@ def __init__( print(f"output_filepath: {self.output_filepath}") print(f"frames_directory: {self.frames_directory}") + if self.file_format not in ACCEPTABLE_EXTENSIONS: + raise ValueError("File Extension not Valid! " + f"Must be one of: {ACCEPTABLE_EXTENSIONS}" + ) + def __enter__(self) -> 'Loom': """ Enter the runtime context related to this object. @@ -290,6 +297,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) From de8df6b17dc296bd91502f46f7f266e4677efb40 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:18:01 +1000 Subject: [PATCH 04/17] Quick check to make sure frame counter is never None --- matplotloom/loom.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index ed088e5..be0a309 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -216,6 +216,8 @@ def save_frame( if self.parallel and frame_number is None: raise ValueError("frame_number must be provided when parallel=True") + assert self.frame_counter is not None + if not self.parallel: frame_filepath = self.frames_directory / f"frame_{self.frame_counter:06d}.png" self.frame_counter += 1 From d28281d3e2520339c9c8b8381456a1340f4bd521 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:18:35 +1000 Subject: [PATCH 05/17] Add checks to make sure Scale Filters are valid --- matplotloom/loom.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index be0a309..5bbd168 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -102,6 +102,7 @@ def __init__( 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( @@ -255,6 +256,8 @@ 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("Scale Settings Incorrect!") def save_video(self) -> None: """ From 53d7e193343559e02965fd4e0b072beab3ccab1f Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:22:17 +1000 Subject: [PATCH 06/17] Fix badly placed assert --- matplotloom/loom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index 5bbd168..ff7349f 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -217,9 +217,9 @@ def save_frame( if self.parallel and frame_number is None: raise ValueError("frame_number must be provided when parallel=True") - assert self.frame_counter is not None - 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: From b1723a76e3f2fc504a07390987b5c937ab0ff639 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:10:14 +1000 Subject: [PATCH 07/17] Apply formatting suggestion from @ali-ramadhan Co-authored-by: Ali Ramadhan --- matplotloom/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matplotloom/__init__.py b/matplotloom/__init__.py index 5156008..e3f1a5d 100644 --- a/matplotloom/__init__.py +++ b/matplotloom/__init__.py @@ -33,7 +33,7 @@ def _check_ffmpeg_availability(): "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. " "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}`", From aa71fbe1ee3144145d037d53f6b54d2fe0e95e0e Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:11:00 +1000 Subject: [PATCH 08/17] Make default path more clear --- matplotloom/loom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index ff7349f..98c0d53 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -10,7 +10,7 @@ from IPython.display import Video, Image -DEFAULT_FFMPEG_PATH = plt.rcParams['animation.ffmpeg_path'] +DEFAULT_FFMPEG_PATH: str = plt.rcParams['animation.ffmpeg_path'] ACCEPTABLE_EXTENSIONS = ("mp4", "gif") From cbfafd84f47951627c01971d67b46bfc48c378c7 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:11:44 +1000 Subject: [PATCH 09/17] Add optional path fallback & warning to ffmpeg path provider --- matplotloom/loom.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index 98c0d53..98538cd 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -1,4 +1,5 @@ import subprocess +import warnings from pathlib import Path from typing import Union, Optional, Dict, Type, List, Any @@ -85,6 +86,7 @@ def __init__( 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 @@ -94,6 +96,7 @@ def __init__( self.parallel: bool = parallel self.show_ffmpeg_output: bool = show_ffmpeg_output self.savefig_kwargs: Dict[str, Any] = savefig_kwargs or {} + self.enable_ffmpeg_path_fallback = enable_ffmpeg_path_fallback valid_odd_options = {"round_up", "round_down", "crop", "pad", "none"} if odd_dimension_handling not in valid_odd_options: @@ -117,13 +120,38 @@ def __init__( else: self.frames_directory = Path(frames_directory) - # Use matplotlib ffmpeg - self.ffmpeg_path: str = DEFAULT_FFMPEG_PATH + # Allow providing of ffmpeg path to class instance + self.ffmpeg_path: str | None = None 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 = str(_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}`" + ) + + self.ffmpeg_path = 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, str), "ffmpeg path is not a string?" + # We don't use the frame counter in parallel mode. self.frame_counter: Optional[int] = 0 if not self.parallel else None From 44c2b239d025891e6cfd8fd96255909f21e82aa7 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:19:48 +1000 Subject: [PATCH 10/17] Make the default ffmpeg path one that is sync'd across multi-processes --- matplotloom/__init__.py | 8 +++++--- matplotloom/loom.py | 12 ++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/matplotloom/__init__.py b/matplotloom/__init__.py index e3f1a5d..5865003 100644 --- a/matplotloom/__init__.py +++ b/matplotloom/__init__.py @@ -3,7 +3,7 @@ import warnings from multiprocessing import current_process -from .loom import Loom, DEFAULT_FFMPEG_PATH +from .loom import Loom, DEFAULT_FFMPEG_PATH, _LOOM_DEFAULT_ENVIRON_VAR __version__ = "0.9.2" __all__ = ["Loom"] @@ -34,8 +34,10 @@ def _check_ffmpeg_availability(): "matplotloom requires ffmpeg to create animations. " "Please install ffmpeg to use this library. " "Visit https://ffmpeg.org/download.html for installation instructions. " - "Or configure `matplotlib.pyplot.rcParams['animation.ffmpeg_path']` to " - "point to an ffmpeg executable. The current command used to run ffmpeg " + 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 98538cd..8daaec5 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -1,4 +1,4 @@ -import subprocess +import subprocess, os import warnings from pathlib import Path @@ -11,7 +11,15 @@ from IPython.display import Video, Image -DEFAULT_FFMPEG_PATH: str = plt.rcParams['animation.ffmpeg_path'] + +# 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 ACCEPTABLE_EXTENSIONS = ("mp4", "gif") From a65301bac10a2ee44391e9f467814284ee30dd88 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:49:32 +1000 Subject: [PATCH 11/17] Fix bug where default path isn't used if an override path isn't provided --- matplotloom/loom.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index 8daaec5..d99139e 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -129,7 +129,9 @@ def __init__( self.frames_directory = Path(frames_directory) # Allow providing of ffmpeg path to class instance - self.ffmpeg_path: str | None = None + self.ffmpeg_path: str = DEFAULT_FFMPEG_PATH + + # Only throws an error if a path was provided if ffmpeg_path is not None: _ffmpeg_path = Path(ffmpeg_path) @@ -148,15 +150,13 @@ def __init__( f"path of `{DEFAULT_FFMPEG_PATH}`" ) - self.ffmpeg_path = 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, str), "ffmpeg path is not a string?" From d6e7a3236cc697dc4741ee630f1f8f00d67f8b85 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:50:42 +1000 Subject: [PATCH 12/17] Add example of overloading matplotlibs ffmpeg rcPrams setting with a path provided by imageio_ffmpeg --- examples/parallel_sine_wave.py | 16 ++++++++++++++++ matplotloom/__init__.py | 2 +- pyproject.toml | 1 + 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/examples/parallel_sine_wave.py b/examples/parallel_sine_wave.py index f8eb574..9cfc2b6 100644 --- a/examples/parallel_sine_wave.py +++ b/examples/parallel_sine_wave.py @@ -2,6 +2,22 @@ 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): diff --git a/matplotloom/__init__.py b/matplotloom/__init__.py index 5865003..766e319 100644 --- a/matplotloom/__init__.py +++ b/matplotloom/__init__.py @@ -38,7 +38,7 @@ def _check_ffmpeg_availability(): " 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}`", + f"is: `{DEFAULT_FFMPEG_PATH}`", UserWarning, stacklevel=2 ) diff --git a/pyproject.toml b/pyproject.toml index 507fb35..3465bd2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", From 1ec77f70c4ed05acb36508fff5acfd0474e298e7 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:18:45 +1000 Subject: [PATCH 13/17] Remove Limits on Filetype --- matplotloom/loom.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index d99139e..430ef87 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -21,7 +21,6 @@ DEFAULT_FFMPEG_PATH: str = plt.rcParams['animation.ffmpeg_path'] os.environ[_LOOM_DEFAULT_ENVIRON_VAR] = DEFAULT_FFMPEG_PATH -ACCEPTABLE_EXTENSIONS = ("mp4", "gif") class Loom: """ @@ -174,10 +173,6 @@ def __init__( print(f"output_filepath: {self.output_filepath}") print(f"frames_directory: {self.frames_directory}") - if self.file_format not in ACCEPTABLE_EXTENSIONS: - raise ValueError("File Extension not Valid! " - f"Must be one of: {ACCEPTABLE_EXTENSIONS}" - ) def __enter__(self) -> 'Loom': """ From b18707df8d3da64a45a8d9f920f7f98ae299e15d Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:21:53 +1000 Subject: [PATCH 14/17] Move custom ffmpeg example to 'custom_ffmpeg_sine_wave.py' --- examples/custom_ffmpeg_sine_wave.py | 41 +++++++++++++++++++++++++++++ examples/parallel_sine_wave.py | 15 ----------- 2 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 examples/custom_ffmpeg_sine_wave.py 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 9cfc2b6..5c855db 100644 --- a/examples/parallel_sine_wave.py +++ b/examples/parallel_sine_wave.py @@ -3,21 +3,6 @@ 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): From f6f9fff3c5ca586b22df8758a0553554248fb8b8 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:24:57 +1000 Subject: [PATCH 15/17] Make \self.ffmpeg_path\ be a path object --- matplotloom/loom.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index 430ef87..c06f429 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -128,7 +128,7 @@ def __init__( self.frames_directory = Path(frames_directory) # Allow providing of ffmpeg path to class instance - self.ffmpeg_path: str = DEFAULT_FFMPEG_PATH + self.ffmpeg_path: Path = Path(DEFAULT_FFMPEG_PATH) # Only throws an error if a path was provided if ffmpeg_path is not None: @@ -138,7 +138,7 @@ def __init__( if _ffmpeg_path.exists(): # Store the absolute path as things can get a bit funky with # path enrolment & multiprocessing. - self.ffmpeg_path = str(_ffmpeg_path.absolute()) + self.ffmpeg_path = _ffmpeg_path.absolute() else: # Otherwise check if path fallback is enabled & warn the user @@ -157,7 +157,7 @@ def __init__( ) # In theory this should never fail. - assert isinstance(self.ffmpeg_path, str), "ffmpeg path is not a string?" + assert isinstance(self.ffmpeg_path, Path), "ffmpeg path is not a string?" # We don't use the frame counter in parallel mode. self.frame_counter: Optional[int] = 0 if not self.parallel else None From df66f64ef9c50099fbf03f169838ce9c5c19ce5d Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 12 Feb 2026 13:36:56 +1000 Subject: [PATCH 16/17] Make \loom._get_scale_filter\ return the accepted scale options on error. --- matplotloom/loom.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/matplotloom/loom.py b/matplotloom/loom.py index c06f429..5a547e9 100644 --- a/matplotloom/loom.py +++ b/matplotloom/loom.py @@ -2,7 +2,7 @@ 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 @@ -11,7 +11,6 @@ from IPython.display import Video, Image - # This should allow _LOOM_DEFAULT_ENVIRON_VAR = "LOOM_FFMPEG_PATH" @@ -21,6 +20,7 @@ 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: """ @@ -105,10 +105,9 @@ def __init__( self.savefig_kwargs: Dict[str, Any] = savefig_kwargs or {} self.enable_ffmpeg_path_fallback = enable_ffmpeg_path_fallback - valid_odd_options = {"round_up", "round_down", "crop", "pad", "none"} - if odd_dimension_handling not in valid_odd_options: + 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 @@ -157,7 +156,7 @@ def __init__( ) # In theory this should never fail. - assert isinstance(self.ffmpeg_path, Path), "ffmpeg path is not a string?" + 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 @@ -288,7 +287,8 @@ def _get_scale_filter(self) -> str: 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("Scale Settings Incorrect!") + raise ValueError(f"Scale Settings not one of `{VALID_SCALE_ODD_OPTIONS}`, " + + f"got `{self.odd_dimension_handling}`") def save_video(self) -> None: """ @@ -303,7 +303,7 @@ def save_video(self) -> None: if self.file_format == "mp4": command = [ - self.ffmpeg_path, + str(self.ffmpeg_path), "-y", "-framerate", str(self.fps), "-i", str(self.frames_directory / "frame_%06d.png"), @@ -319,7 +319,7 @@ def save_video(self) -> None: ]) elif self.file_format == "gif": command = [ - self.ffmpeg_path, + str(self.ffmpeg_path), "-y", "-framerate", str(self.fps), "-f", "image2", From b5576890f3ed0371607c8680c72c8acda61dc998 Mon Sep 17 00:00:00 2001 From: Cal Wing <20716204+calw20@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:25:04 +1000 Subject: [PATCH 17/17] Add some simple docs about providing a custom FFmpeg path --- README.md | 34 ++++++++++++++++++++++++++++++++++ docs/index.rst | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) 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 ---------