-
Notifications
You must be signed in to change notification settings - Fork 4
Add the ability to specify ffmpeg path & use Matplotlib's ffmpeg path #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9c9fe30
2cc30df
d6ef509
de8df6b
d28281d
53d7e19
b1723a7
aa71fbe
cbfafd8
44c2b23
a65301b
d6e7a32
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should the user-supplied
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 This works but only in the context of the main thread, as soon as you parallelise |
||
| 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 | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| import subprocess | ||
| import subprocess, os | ||
| import warnings | ||
|
|
||
| from pathlib import Path | ||
| from typing import Union, Optional, Dict, Type, List, Any | ||
|
|
@@ -10,6 +11,18 @@ | |
|
|
||
| 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 | ||
|
|
||
| ACCEPTABLE_EXTENSIONS = ("mp4", "gif") | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmmm, I could be wrong but I thought
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added the check as a bit of a bodge; it was something I was hopeing to clean up a bit later a work & wanted to seek comments on - I added it as I got an erorr from
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that's a format I haven't seen in a while haha. It's true that we don't really test for any output format besides GIF and MP4. I would say if AVI output does not work, let's open an issue about it and hopefully it'll be an easy fix. |
||
|
|
||
| class Loom: | ||
| """ | ||
| A class for creating animations from matplotlib figures. | ||
|
|
@@ -59,6 +72,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 +93,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 +104,16 @@ 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: | ||
| raise ValueError( | ||
| f"odd_dimension_handling must be one of {valid_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 +127,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: str = 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 = str(_ffmpeg_path.absolute()) | ||
|
Comment on lines
132
to
142
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is great but if the user provides an |
||
|
|
||
| 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, str), "ffmpeg path is not a string?" | ||
|
Comment on lines
+160
to
+161
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmmm, I'm thinking we will probably want |
||
|
|
||
| # We don't use the frame counter in parallel mode. | ||
| self.frame_counter: Optional[int] = 0 if not self.parallel else None | ||
|
|
@@ -122,6 +174,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. | ||
|
|
@@ -197,6 +254,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 +292,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!") | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might be good to print the value of |
||
|
|
||
| def save_video(self) -> None: | ||
| """ | ||
|
|
@@ -247,7 +308,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 +324,7 @@ def save_video(self) -> None: | |
| ]) | ||
| elif self.file_format == "gif": | ||
| command = [ | ||
| "ffmpeg", | ||
| self.ffmpeg_path, | ||
| "-y", | ||
| "-framerate", str(self.fps), | ||
| "-f", "image2", | ||
|
|
@@ -277,6 +338,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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice, didn't know about this package!