initial commit and version 1.0

This commit is contained in:
2025-04-21 15:14:03 +02:00
commit ae6b2bbf44
82 changed files with 10782 additions and 0 deletions

View File

@@ -0,0 +1,167 @@
"""Implements ImageSequenceClip, a class to create a video clip from a set
of image files.
"""
import os
import numpy as np
from imageio.v2 import imread
from moviepy.video.VideoClip import VideoClip
class ImageSequenceClip(VideoClip):
"""A VideoClip made from a series of images.
Parameters
----------
sequence
Can be one of these:
- The name of a folder (containing only pictures). The pictures
will be considered in alphanumerical order.
- A list of names of image files. In this case you can choose to
load the pictures in memory pictures
- A list of Numpy arrays representing images. In this last case,
masks are not supported currently.
fps
Number of picture frames to read per second. Instead, you can provide
the duration of each image with durations (see below)
durations
List of the duration of each picture.
with_mask
Should the alpha layer of PNG images be considered as a mask ?
is_mask
Will this sequence of pictures be used as an animated mask.
load_images
Specify that all images should be loaded into the RAM. This is only
interesting if you have a small number of images that will be used
more than once.
"""
def __init__(
self,
sequence,
fps=None,
durations=None,
with_mask=True,
is_mask=False,
load_images=False,
):
# CODE WRITTEN AS IT CAME, MAY BE IMPROVED IN THE FUTURE
if (fps is None) and (durations is None):
raise ValueError("Please provide either 'fps' or 'durations'.")
VideoClip.__init__(self, is_mask=is_mask)
# Parse the data
fromfiles = True
if isinstance(sequence, list):
if isinstance(sequence[0], str):
if load_images:
sequence = [imread(file) for file in sequence]
fromfiles = False
else:
fromfiles = True
else:
# sequence is already a list of numpy arrays
fromfiles = False
else:
# sequence is a folder name, make it a list of files:
fromfiles = True
sequence = sorted(
[os.path.join(sequence, file) for file in os.listdir(sequence)]
)
# check that all the images are of the same size
if isinstance(sequence[0], str):
size = imread(sequence[0]).shape
else:
size = sequence[0].shape
for image in sequence:
image1 = image
if isinstance(image, str):
image1 = imread(image)
if size != image1.shape:
raise Exception(
"MoviePy: ImageSequenceClip requires all images to be the same size"
)
self.fps = fps
if fps is not None:
durations = [1.0 / fps for image in sequence]
self.images_starts = [
1.0 * i / fps - np.finfo(np.float32).eps for i in range(len(sequence))
]
else:
self.images_starts = [0] + list(np.cumsum(durations))
self.durations = durations
self.duration = sum(durations)
self.end = self.duration
self.sequence = sequence
if fps is None:
self.fps = self.duration / len(sequence)
def find_image_index(t):
return max(
[i for i in range(len(self.sequence)) if self.images_starts[i] <= t]
)
if fromfiles:
self.last_index = None
self.last_image = None
def frame_function(t):
index = find_image_index(t)
if index != self.last_index:
self.last_image = imread(self.sequence[index])[:, :, :3]
self.last_index = index
return self.last_image
if with_mask and (imread(self.sequence[0]).shape[2] == 4):
self.mask = VideoClip(is_mask=True)
self.mask.last_index = None
self.mask.last_image = None
def mask_frame_function(t):
index = find_image_index(t)
if index != self.mask.last_index:
frame = imread(self.sequence[index])[:, :, 3]
self.mask.last_image = frame.astype(float) / 255
self.mask.last_index = index
return self.mask.last_image
self.mask.frame_function = mask_frame_function
self.mask.size = mask_frame_function(0).shape[:2][::-1]
else:
def frame_function(t):
index = find_image_index(t)
return self.sequence[index][:, :, :3]
if with_mask and (self.sequence[0].shape[2] == 4):
self.mask = VideoClip(is_mask=True)
def mask_frame_function(t):
index = find_image_index(t)
return 1.0 * self.sequence[index][:, :, 3] / 255
self.mask.frame_function = mask_frame_function
self.mask.size = mask_frame_function(0).shape[:2][::-1]
self.frame_function = frame_function
self.size = frame_function(0).shape[:2][::-1]

View File

@@ -0,0 +1,175 @@
"""Implements VideoFileClip, a class for video clips creation using video files."""
from moviepy.audio.io.AudioFileClip import AudioFileClip
from moviepy.decorators import convert_path_to_string
from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader
from moviepy.video.VideoClip import VideoClip
class VideoFileClip(VideoClip):
"""A video clip originating from a movie file. For instance:
.. code:: python
clip = VideoFileClip("myHolidays.mp4")
clip.close()
with VideoFileClip("myMaskVideo.avi") as clip2:
pass # Implicit close called by context manager.
Parameters
----------
filename:
The name of the video file, as a string or a path-like object.
It can have any extension supported by ffmpeg:
.ogv, .mp4, .mpeg, .avi, .mov etc.
has_mask:
Set this to 'True' if there is a mask included in the videofile.
Video files rarely contain masks, but some video codecs enable
that. For instance if you have a MoviePy VideoClip with a mask you
can save it to a videofile with a mask. (see also
``VideoClip.write_videofile`` for more details).
audio:
Set to `False` if the clip doesn't have any audio or if you do not
wish to read the audio.
target_resolution:
Set to (desired_width, desired_height) to have ffmpeg resize the frames
before returning them. This is much faster than streaming in high-res
and then resizing. If either dimension is None, the frames are resized
by keeping the existing aspect ratio.
resize_algorithm:
The algorithm used for resizing. Default: "bicubic", other popular
options include "bilinear" and "fast_bilinear". For more information, see
https://ffmpeg.org/ffmpeg-scaler.html
fps_source:
The fps value to collect from the metadata. Set by default to 'fps', but
can be set to 'tbr', which may be helpful if you are finding that it is reading
the incorrect fps from the file.
pixel_format
Optional: Pixel format for the video to read. If is not specified
'rgb24' will be used as the default format unless ``has_mask`` is set
as ``True``, then 'rgba' will be used.
is_mask
`True` if the clip is going to be used as a mask.
Attributes
----------
filename:
Name of the original video file.
fps:
Frames per second in the original file.
Read docs for Clip() and VideoClip() for other, more generic, attributes.
Lifetime
--------
Note that this creates subprocesses and locks files. If you construct one
of these instances, you must call close() afterwards, or the subresources
will not be cleaned up until the process ends.
If copies are made, and close() is called on one, it may cause methods on
the other copies to fail.
"""
@convert_path_to_string("filename")
def __init__(
self,
filename,
decode_file=False,
has_mask=False,
audio=True,
audio_buffersize=200000,
target_resolution=None,
resize_algorithm="bicubic",
audio_fps=44100,
audio_nbytes=2,
fps_source="fps",
pixel_format=None,
is_mask=False,
):
VideoClip.__init__(self, is_mask=is_mask)
# Make a reader
if not pixel_format:
pixel_format = "rgba" if has_mask else "rgb24"
self.reader = FFMPEG_VideoReader(
filename,
decode_file=decode_file,
pixel_format=pixel_format,
target_resolution=target_resolution,
resize_algo=resize_algorithm,
fps_source=fps_source,
)
# Make some of the reader's attributes accessible from the clip
self.duration = self.reader.duration
self.end = self.reader.duration
self.fps = self.reader.fps
self.size = self.reader.size
self.rotation = self.reader.rotation
self.filename = filename
if has_mask:
self.frame_function = lambda t: self.reader.get_frame(t)[:, :, :3]
def mask_frame_function(t):
return self.reader.get_frame(t)[:, :, 3] / 255.0
self.mask = VideoClip(
is_mask=True, frame_function=mask_frame_function
).with_duration(self.duration)
self.mask.fps = self.fps
else:
self.frame_function = lambda t: self.reader.get_frame(t)
# Make a reader for the audio, if any.
if audio and self.reader.infos["audio_found"]:
self.audio = AudioFileClip(
filename,
buffersize=audio_buffersize,
fps=audio_fps,
nbytes=audio_nbytes,
)
def __deepcopy__(self, memo):
"""Implements ``copy.deepcopy(clip)`` behaviour as ``copy.copy(clip)``.
VideoFileClip class instances can't be deeply copied because the locked Thread
of ``proc`` isn't pickleable. Without this override, calls to
``copy.deepcopy(clip)`` would raise a ``TypeError``:
```
TypeError: cannot pickle '_thread.lock' object
```
"""
return self.__copy__()
def close(self):
"""Close the internal reader."""
if self.reader:
self.reader.close()
self.reader = None
try:
if self.audio:
self.audio.close()
self.audio = None
except AttributeError: # pragma: no cover
pass

View File

@@ -0,0 +1 @@
"""Classes and methods for reading, writing and previewing video files."""

View File

@@ -0,0 +1,284 @@
"""Implements ``display_in_notebook``, a function to embed images/videos/audio in the
Jupyter Notebook.
"""
# Notes:
# All media are physically embedded in the Jupyter Notebook
# (instead of simple links to the original files)
# That is because most browsers use a cache system and they won't
# properly refresh the media when the original files are changed.
import inspect
import os
from base64 import b64encode
from moviepy.audio.AudioClip import AudioClip
from moviepy.tools import extensions_dict
from moviepy.video.io.ffmpeg_reader import ffmpeg_parse_infos
from moviepy.video.VideoClip import ImageClip, VideoClip
try: # pragma: no cover
from IPython.display import HTML
ipython_available = True
class HTML2(HTML): # noqa D101
def __add__(self, other):
return HTML2(self.data + other.data)
except ImportError:
def HTML2(content): # noqa D103
return content
ipython_available = False
sorry = "Sorry, seems like your browser doesn't support HTML5 audio/video"
templates = {
"audio": (
"<audio controls>"
"<source %(options)s src='data:audio/%(ext)s;base64,%(data)s'>"
+ sorry
+ "</audio>"
),
"image": "<img %(options)s src='data:image/%(ext)s;base64,%(data)s'>",
"video": (
"<video %(options)s"
"src='data:video/%(ext)s;base64,%(data)s' controls>" + sorry + "</video>"
),
}
def html_embed(
clip, filetype=None, maxduration=60, rd_kwargs=None, center=True, **html_kwargs
):
"""Returns HTML5 code embedding the clip.
Parameters
----------
clip : moviepy.Clip.Clip
Either a file name, or a clip to preview.
Either an image, a sound or a video. Clips will actually be
written to a file and embedded as if a filename was provided.
filetype : str, optional
One of 'video','image','audio'. If None is given, it is determined
based on the extension of ``filename``, but this can bug.
maxduration : float, optional
An error will be raised if the clip's duration is more than the indicated
value (in seconds), to avoid spoiling the browser's cache and the RAM.
rd_kwargs : dict, optional
Keyword arguments for the rendering, like ``dict(fps=15, bitrate="50k")``.
Allow you to give some options to the render process. You can, for
example, disable the logger bar passing ``dict(logger=None)``.
center : bool, optional
If true (default), the content will be wrapped in a
``<div align=middle>`` HTML container, so the content will be displayed
at the center.
html_kwargs
Allow you to give some options, like ``width=260``, ``autoplay=True``,
``loop=1`` etc.
Examples
--------
.. code:: python
from moviepy import *
# later ...
html_embed(clip, width=360)
html_embed(clip.audio)
clip.write_gif("test.gif")
html_embed('test.gif')
clip.save_frame("first_frame.jpeg")
html_embed("first_frame.jpeg")
"""
if rd_kwargs is None: # pragma: no cover
rd_kwargs = {}
if "Clip" in str(clip.__class__):
TEMP_PREFIX = "__temp__"
if isinstance(clip, ImageClip):
filename = TEMP_PREFIX + ".png"
kwargs = {"filename": filename, "with_mask": True}
argnames = inspect.getfullargspec(clip.save_frame).args
kwargs.update(
{key: value for key, value in rd_kwargs.items() if key in argnames}
)
clip.save_frame(**kwargs)
elif isinstance(clip, VideoClip):
filename = TEMP_PREFIX + ".mp4"
kwargs = {"filename": filename, "preset": "ultrafast"}
kwargs.update(rd_kwargs)
clip.write_videofile(**kwargs)
elif isinstance(clip, AudioClip):
filename = TEMP_PREFIX + ".mp3"
kwargs = {"filename": filename}
kwargs.update(rd_kwargs)
clip.write_audiofile(**kwargs)
else:
raise ValueError("Unknown class for the clip. Cannot embed and preview.")
return html_embed(
filename,
maxduration=maxduration,
rd_kwargs=rd_kwargs,
center=center,
**html_kwargs,
)
filename = clip
options = " ".join(["%s='%s'" % (str(k), str(v)) for k, v in html_kwargs.items()])
name, ext = os.path.splitext(filename)
ext = ext[1:]
if filetype is None:
ext = filename.split(".")[-1].lower()
if ext == "gif":
filetype = "image"
elif ext in extensions_dict:
filetype = extensions_dict[ext]["type"]
else:
raise ValueError(
"No file type is known for the provided file. Please provide "
"argument `filetype` (one of 'image', 'video', 'sound') to the "
"display_in_notebook function."
)
if filetype == "video":
# The next lines set the HTML5-cvompatible extension and check that the
# extension is HTML5-valid
exts_htmltype = {"mp4": "mp4", "webm": "webm", "ogv": "ogg"}
allowed_exts = " ".join(exts_htmltype.keys())
try:
ext = exts_htmltype[ext]
except Exception:
raise ValueError(
"This video extension cannot be displayed in the "
"Jupyter Notebook. Allowed extensions: " + allowed_exts
)
if filetype in ["audio", "video"]:
duration = ffmpeg_parse_infos(filename, decode_file=True)["duration"]
if duration > maxduration:
raise ValueError(
(
"The duration of video %s (%.1f) exceeds the 'maxduration'"
" attribute. You can increase 'maxduration', by passing"
" 'maxduration' parameter to display_in_notebook function."
" But note that embedding large videos may take all the memory"
" away!"
)
% (filename, duration)
)
with open(filename, "rb") as file:
data = b64encode(file.read()).decode("utf-8")
template = templates[filetype]
result = template % {"data": data, "options": options, "ext": ext}
if center:
result = r"<div align=middle>%s</div>" % result
return result
def display_in_notebook(
clip,
filetype=None,
maxduration=60,
t=None,
fps=None,
rd_kwargs=None,
center=True,
**html_kwargs,
):
"""Displays clip content in an Jupyter Notebook.
Remarks: If your browser doesn't support HTML5, this should warn you.
If nothing is displayed, maybe your file or filename is wrong.
Important: The media will be physically embedded in the notebook.
Parameters
----------
clip : moviepy.Clip.Clip
Either the name of a file, or a clip to preview. The clip will actually
be written to a file and embedded as if a filename was provided.
filetype : str, optional
One of ``"video"``, ``"image"`` or ``"audio"``. If None is given, it is
determined based on the extension of ``filename``, but this can bug.
maxduration : float, optional
An error will be raised if the clip's duration is more than the indicated
value (in seconds), to avoid spoiling the browser's cache and the RAM.
t : float, optional
If not None, only the frame at time t will be displayed in the notebook,
instead of a video of the clip.
fps : int, optional
Enables to specify an fps, as required for clips whose fps is unknown.
rd_kwargs : dict, optional
Keyword arguments for the rendering, like ``dict(fps=15, bitrate="50k")``.
Allow you to give some options to the render process. You can, for
example, disable the logger bar passing ``dict(logger=None)``.
center : bool, optional
If true (default), the content will be wrapped in a
``<div align=middle>`` HTML container, so the content will be displayed
at the center.
kwargs
Allow you to give some options, like ``width=260``, etc. When editing
looping gifs, a good choice is ``loop=1, autoplay=1``.
Examples
--------
.. code:: python
from moviepy import *
# later ...
clip.display_in_notebook(width=360)
clip.audio.display_in_notebook()
clip.write_gif("test.gif")
display_in_notebook('test.gif')
clip.save_frame("first_frame.jpeg")
display_in_notebook("first_frame.jpeg")
"""
if not ipython_available:
raise ImportError("Only works inside an Jupyter Notebook")
if rd_kwargs is None:
rd_kwargs = {}
if fps is not None:
rd_kwargs["fps"] = fps
if t is not None:
clip = clip.to_ImageClip(t)
return HTML2(
html_embed(
clip,
filetype=filetype,
maxduration=maxduration,
center=center,
rd_kwargs=rd_kwargs,
**html_kwargs,
)
)

View File

@@ -0,0 +1,882 @@
"""Implements all the functions to read a video or a picture using ffmpeg."""
import os
import re
import subprocess as sp
import warnings
from log import log_step
import numpy as np
from moviepy.config import FFMPEG_BINARY # ffmpeg, ffmpeg.exe, etc...
from moviepy.tools import (
convert_to_seconds,
cross_platform_popen_params,
ffmpeg_escape_filename,
)
class FFMPEG_VideoReader:
"""Class for video byte-level reading with ffmpeg."""
def __init__(
self,
filename,
decode_file=True,
print_infos=False,
bufsize=None,
pixel_format="rgb24",
check_duration=True,
target_resolution=None,
resize_algo="bicubic",
fps_source="fps",
):
self.filename = filename
self.proc = None
infos = ffmpeg_parse_infos(
filename,
check_duration=check_duration,
fps_source=fps_source,
decode_file=decode_file,
print_infos=print_infos,
)
# If framerate is unavailable, assume 1.0 FPS to avoid divide-by-zero errors.
self.fps = infos.get("video_fps", 1.0)
# If frame size is unavailable, set 1x1 divide-by-zero errors.
self.size = infos.get("video_size", (1, 1))
# ffmpeg automatically rotates videos if rotation information is
# available, so exchange width and height
self.rotation = abs(infos.get("video_rotation", 0))
if self.rotation in [90, 270]:
self.size = [self.size[1], self.size[0]]
if target_resolution:
if None in target_resolution:
ratio = 1
for idx, target in enumerate(target_resolution):
if target:
ratio = target / self.size[idx]
self.size = (int(self.size[0] * ratio), int(self.size[1] * ratio))
else:
self.size = target_resolution
self.resize_algo = resize_algo
self.duration = infos.get("video_duration", 0.0)
self.ffmpeg_duration = infos.get("duration", 0.0)
self.n_frames = infos.get("video_n_frames", 0)
self.bitrate = infos.get("video_bitrate", 0)
self.infos = infos
self.pixel_format = pixel_format
self.depth = 4 if pixel_format[-1] == "a" else 3
# 'a' represents 'alpha' which means that each pixel has 4 values instead of 3.
# See https://github.com/Zulko/moviepy/issues/1070#issuecomment-644457274
if bufsize is None:
w, h = self.size
bufsize = self.depth * w * h + 100
self.bufsize = bufsize
self.initialize()
def initialize(self, start_time=0):
"""
Opens the file, creates the pipe.
Sets self.pos to the appropriate value (1 if start_time == 0 because
it pre-reads the first frame).
"""
self.close(delete_lastread=False) # if any
if start_time != 0:
offset = min(1, start_time)
i_arg = [
"-ss",
"%.06f" % (start_time - offset),
"-i",
ffmpeg_escape_filename(self.filename),
"-ss",
"%.06f" % offset,
]
else:
i_arg = ["-i", ffmpeg_escape_filename(self.filename)]
# For webm video (vp8 and vp9) with transparent layer, force libvpx/libvpx-vp9
# as ffmpeg native webm decoder dont decode alpha layer
# (see
# https://www.reddit.com/r/ffmpeg/comments/fgpyfb/help_with_webm_with_alpha_channel/
# )
if self.depth == 4:
codec_name = self.infos.get("video_codec_name")
if codec_name == "vp9":
i_arg = ["-c:v", "libvpx-vp9"] + i_arg
elif codec_name == "vp8":
i_arg = ["-c:v", "libvpx"] + i_arg
# print(self.infos)
log_step("init", 100, self.infos)
cmd = (
[FFMPEG_BINARY]
+ i_arg
+ [
"-loglevel",
"error",
"-f",
"image2pipe",
"-vf",
"scale=%d:%d" % tuple(self.size),
"-sws_flags",
self.resize_algo,
"-pix_fmt",
self.pixel_format,
"-vcodec",
"rawvideo",
"-",
]
)
# print(" ".join(cmd))
popen_params = cross_platform_popen_params(
{
"bufsize": self.bufsize,
"stdout": sp.PIPE,
"stderr": sp.PIPE,
"stdin": sp.DEVNULL,
}
)
self.proc = sp.Popen(cmd, **popen_params)
# self.pos represents the (0-indexed) index of the frame that is next in line
# to be read by self.read_frame().
# Eg when self.pos is 1, the 2nd frame will be read next.
self.pos = self.get_frame_number(start_time)
self.last_read = self.read_frame()
def skip_frames(self, n=1):
"""Reads and throws away n frames"""
w, h = self.size
for i in range(n):
self.proc.stdout.read(self.depth * w * h)
# self.proc.stdout.flush()
self.pos += n
def read_frame(self):
"""
Reads the next frame from the file.
Note that upon (re)initialization, the first frame will already have been read
and stored in ``self.last_read``.
"""
w, h = self.size
nbytes = self.depth * w * h
s = self.proc.stdout.read(nbytes)
if len(s) != nbytes:
warnings.warn(
(
"In file %s, %d bytes wanted but %d bytes read at frame index"
" %d (out of a total %d frames), at time %.02f/%.02f sec."
" Using the last valid frame instead."
)
% (
self.filename,
nbytes,
len(s),
self.pos,
self.n_frames,
1.0 * self.pos / self.fps,
self.duration,
),
UserWarning,
)
if not hasattr(self, "last_read"):
raise IOError(
(
"MoviePy error: failed to read the first frame of "
f"video file {self.filename}. That might mean that the file is "
"corrupted. That may also mean that you are using "
"a deprecated version of FFMPEG. On Ubuntu/Debian "
"for instance the version in the repos is deprecated. "
"Please update to a recent version from the website."
)
)
result = self.last_read
else:
if hasattr(np, "frombuffer"):
result = np.frombuffer(s, dtype="uint8")
else:
result = np.fromstring(s, dtype="uint8")
result.shape = (h, w, len(s) // (w * h)) # reshape((h, w, len(s)//(w*h)))
self.last_read = result
# We have to do this down here because `self.pos` is used in the warning above
self.pos += 1
return result
def get_frame(self, t):
"""Read a file video frame at time t.
Note for coders: getting an arbitrary frame in the video with
ffmpeg can be painfully slow if some decoding has to be done.
This function tries to avoid fetching arbitrary frames
whenever possible, by moving between adjacent frames.
"""
# + 1 so that it represents the frame position that it will be
# after the frame is read. This makes the later comparisons easier.
pos = self.get_frame_number(t) + 1
# Initialize proc if it is not open
if not self.proc:
# raise Exception("Proc not detected")
self.initialize(t)
return self.last_read
if pos == self.pos:
return self.last_read
elif (pos < self.pos) or (pos > self.pos + 100):
# We can't just skip forward to `pos` or it would take too long
self.initialize(t)
return self.last_read
else:
# If pos == self.pos + 1, this line has no effect
self.skip_frames(pos - self.pos - 1)
result = self.read_frame()
return result
@property
def lastread(self):
"""Alias of `self.last_read` for backwards compatibility with MoviePy 1.x."""
return self.last_read
def get_frame_number(self, t):
"""Helper method to return the frame number at time ``t``"""
# I used this horrible '+0.00001' hack because sometimes due to numerical
# imprecisions a 3.0 can become a 2.99999999... which makes the int()
# go to the previous integer. This makes the fetching more robust when you
# are getting the nth frame by writing get_frame(n/fps).
return int(self.fps * t + 0.00001)
def close(self, delete_lastread=True):
"""Closes the reader terminating the process, if is still open."""
if self.proc:
if self.proc.poll() is None:
self.proc.terminate()
self.proc.stdout.close()
self.proc.stderr.close()
self.proc.wait()
self.proc = None
if delete_lastread and hasattr(self, "last_read"):
del self.last_read
def __del__(self):
self.close()
def ffmpeg_read_image(filename, with_mask=True, pixel_format=None):
"""Read an image file (PNG, BMP, JPEG...).
Wraps FFMPEG_Videoreader to read just one image.
Returns an ImageClip.
This function is not meant to be used directly in MoviePy.
Use ImageClip instead to make clips out of image files.
Parameters
----------
filename
Name of the image file. Can be of any format supported by ffmpeg.
with_mask
If the image has a transparency layer, ``with_mask=true`` will save
this layer as the mask of the returned ImageClip
pixel_format
Optional: Pixel format for the image to read. If is not specified
'rgb24' will be used as the default format unless ``with_mask`` is set
as ``True``, then 'rgba' will be used.
"""
if not pixel_format:
pixel_format = "rgba" if with_mask else "rgb24"
reader = FFMPEG_VideoReader(
filename, pixel_format=pixel_format, check_duration=False
)
im = reader.last_read
del reader
return im
class FFmpegInfosParser:
"""Finite state ffmpeg `-i` command option file information parser.
Is designed to parse the output fast, in one loop. Iterates line by
line of the `ffmpeg -i <filename> [-f null -]` command output changing
the internal state of the parser.
Parameters
----------
filename
Name of the file parsed, only used to raise accurate error messages.
infos
Information returned by FFmpeg.
fps_source
Indicates what source data will be preferably used to retrieve fps data.
check_duration
Enable or disable the parsing of the duration of the file. Useful to
skip the duration check, for example, for images.
decode_file
Indicates if the whole file has been decoded. The duration parsing strategy
will differ depending on this argument.
"""
def __init__(
self,
infos,
filename,
fps_source="fps",
check_duration=True,
decode_file=False,
):
self.infos = infos
self.filename = filename
self.check_duration = check_duration
self.fps_source = fps_source
self.duration_tag_separator = "time=" if decode_file else "Duration: "
self._reset_state()
def _reset_state(self):
"""Reinitializes the state of the parser. Used internally at
initialization and at the end of the parsing process.
"""
# could be 2 possible types of metadata:
# - file_metadata: Metadata of the container. Here are the tags set
# by the user using `-metadata` ffmpeg option
# - stream_metadata: Metadata for each stream of the container.
self._inside_file_metadata = False
# this state is needed if `duration_tag_separator == "time="` because
# execution of ffmpeg decoding the whole file using `-f null -` appends
# to the output the blocks "Stream mapping:" and "Output:", which
# should be ignored
self._inside_output = False
# flag which indicates that a default stream has not been found yet
self._default_stream_found = False
# current input file, stream and chapter, which will be built at runtime
self._current_input_file = {"streams": []}
self._current_stream = None
self._current_chapter = None
# resulting data of the parsing process
self.result = {
"video_found": False,
"audio_found": False,
"metadata": {},
"inputs": [],
}
# keep the value of latest metadata value parsed so we can build
# at next lines a multiline metadata value
self._last_metadata_field_added = None
def parse(self):
"""Parses the information returned by FFmpeg in stderr executing their binary
for a file with ``-i`` option and returns a dictionary with all data needed
by MoviePy.
"""
# chapters by input file
input_chapters = []
for line in self.infos.splitlines()[1:]:
if (
self.duration_tag_separator == "time="
and self.check_duration
and "time=" in line
):
# parse duration using file decodification
self.result["duration"] = self.parse_duration(line)
elif self._inside_output or line[0] != " ":
if self.duration_tag_separator == "time=" and not self._inside_output:
self._inside_output = True
# skip lines like "At least one output file must be specified"
elif not self._inside_file_metadata and line.startswith(" Metadata:"):
# enter " Metadata:" group
self._inside_file_metadata = True
elif line.startswith(" Duration:"):
# exit " Metadata:" group
self._inside_file_metadata = False
if self.check_duration and self.duration_tag_separator == "Duration: ":
self.result["duration"] = self.parse_duration(line)
# parse global bitrate (in kb/s)
bitrate_match = re.search(r"bitrate: (\d+) kb/s", line)
self.result["bitrate"] = (
int(bitrate_match.group(1)) if bitrate_match else None
)
# parse start time (in seconds)
start_match = re.search(r"start: (\d+\.?\d+)", line)
self.result["start"] = (
float(start_match.group(1)) if start_match else None
)
elif self._inside_file_metadata:
# file metadata line
field, value = self.parse_metadata_field_value(line)
# multiline metadata value parsing
if field == "":
field = self._last_metadata_field_added
value = self.result["metadata"][field] + "\n" + value
else:
self._last_metadata_field_added = field
self.result["metadata"][field] = value
elif line.lstrip().startswith("Stream "):
# exit stream " Metadata:"
if self._current_stream:
self._current_input_file["streams"].append(self._current_stream)
# get input number, stream number, language and type
main_info_match = re.search(
r"^Stream\s#(\d+):(\d+)(?:\[\w+\])?\(?(\w+)?\)?:\s(\w+):",
line.lstrip(),
)
(
input_number,
stream_number,
language,
stream_type,
) = main_info_match.groups()
input_number = int(input_number)
stream_number = int(stream_number)
stream_type_lower = stream_type.lower()
if language == "und":
language = None
# start builiding the current stream
self._current_stream = {
"input_number": input_number,
"stream_number": stream_number,
"stream_type": stream_type_lower,
"language": language,
"default": not self._default_stream_found
or line.endswith("(default)"),
}
self._default_stream_found = True
# for default streams, set their numbers globally, so it's
# easy to get without iterating all
if self._current_stream["default"]:
self.result[
f"default_{stream_type_lower}_input_number"
] = input_number
self.result[
f"default_{stream_type_lower}_stream_number"
] = stream_number
# exit chapter
if self._current_chapter:
input_chapters[input_number].append(self._current_chapter)
self._current_chapter = None
if "input_number" not in self._current_input_file:
# first input file
self._current_input_file["input_number"] = input_number
elif self._current_input_file["input_number"] != input_number:
# new input file
# include their chapters if there are for this input file
if len(input_chapters) >= input_number + 1:
self._current_input_file["chapters"] = input_chapters[
input_number
]
# add new input file to self.result
self.result["inputs"].append(self._current_input_file)
self._current_input_file = {"input_number": input_number}
# parse relevant data by stream type
try:
global_data, stream_data = self.parse_data_by_stream_type(
stream_type, line
)
except NotImplementedError as exc:
warnings.warn(
f"{str(exc)}\nffmpeg output:\n\n{self.infos}", UserWarning
)
else:
self.result.update(global_data)
self._current_stream.update(stream_data)
elif line.startswith(" Metadata:"):
# enter group " Metadata:"
continue
elif self._current_stream:
# stream metadata line
if "metadata" not in self._current_stream:
self._current_stream["metadata"] = {}
field, value = self.parse_metadata_field_value(line)
if self._current_stream["stream_type"] == "video":
field, value = self.video_metadata_type_casting(field, value)
if field == "rotate":
self.result["video_rotation"] = value
# multiline metadata value parsing
if field == "":
field = self._last_metadata_field_added
value = self._current_stream["metadata"][field] + "\n" + value
else:
self._last_metadata_field_added = field
self._current_stream["metadata"][field] = value
elif line.startswith(" Chapter"):
# Chapter data line
if self._current_chapter:
# there is a previews chapter?
if len(input_chapters) < self._current_chapter["input_number"] + 1:
input_chapters.append([])
# include in the chapters by input matrix
input_chapters[self._current_chapter["input_number"]].append(
self._current_chapter
)
# extract chapter data
chapter_data_match = re.search(
r"^ Chapter #(\d+):(\d+): start (\d+\.?\d+?), end (\d+\.?\d+?)",
line,
)
input_number, chapter_number, start, end = chapter_data_match.groups()
# start building the chapter
self._current_chapter = {
"input_number": int(input_number),
"chapter_number": int(chapter_number),
"start": float(start),
"end": float(end),
}
elif self._current_chapter:
# inside chapter metadata
if "metadata" not in self._current_chapter:
self._current_chapter["metadata"] = {}
field, value = self.parse_metadata_field_value(line)
# multiline metadata value parsing
if field == "":
field = self._last_metadata_field_added
value = self._current_chapter["metadata"][field] + "\n" + value
else:
self._last_metadata_field_added = field
self._current_chapter["metadata"][field] = value
# last input file, must be included in self.result
if self._current_input_file:
self._current_input_file["streams"].append(self._current_stream)
# include their chapters, if there are any
if (
"input_number" in self._current_input_file
and len(input_chapters) == self._current_input_file["input_number"] + 1
):
self._current_input_file["chapters"] = input_chapters[
self._current_input_file["input_number"]
]
self.result["inputs"].append(self._current_input_file)
# some video duration utilities
if self.result["video_found"] and self.check_duration:
self.result["video_duration"] = self.result["duration"]
self.result["video_n_frames"] = int(
self.result["duration"] * self.result.get("video_fps", 0)
)
else:
self.result["video_n_frames"] = 0
self.result["video_duration"] = 0.0
# We could have also recomputed duration from the number of frames, as follows:
# >>> result['video_duration'] = result['video_n_frames'] / result['video_fps']
# not default audio found, assume first audio stream is the default
if self.result["audio_found"] and not self.result.get("audio_bitrate"):
self.result["audio_bitrate"] = None
for streams_input in self.result["inputs"]:
for stream in streams_input["streams"]:
if stream["stream_type"] == "audio" and stream.get("bitrate"):
self.result["audio_bitrate"] = stream["bitrate"]
break
if self.result["audio_bitrate"] is not None:
break
result = self.result
# reset state of the parser
self._reset_state()
return result
def parse_data_by_stream_type(self, stream_type, line):
"""Parses data from "Stream ... {stream_type}" line."""
try:
return {
"Audio": self.parse_audio_stream_data,
"Video": self.parse_video_stream_data,
"Data": lambda _line: ({}, {}),
}[stream_type](line)
except KeyError:
raise NotImplementedError(
f"{stream_type} stream parsing is not supported by moviepy and"
" will be ignored"
)
def parse_audio_stream_data(self, line):
"""Parses data from "Stream ... Audio" line."""
global_data, stream_data = ({"audio_found": True}, {})
try:
stream_data["fps"] = int(re.search(r" (\d+) Hz", line).group(1))
except (AttributeError, ValueError):
# AttributeError: 'NoneType' object has no attribute 'group'
# ValueError: invalid literal for int() with base 10: '<string>'
stream_data["fps"] = "unknown"
match_audio_bitrate = re.search(r"(\d+) kb/s", line)
stream_data["bitrate"] = (
int(match_audio_bitrate.group(1)) if match_audio_bitrate else None
)
if self._current_stream["default"]:
global_data["audio_fps"] = stream_data["fps"]
global_data["audio_bitrate"] = stream_data["bitrate"]
return (global_data, stream_data)
def parse_video_stream_data(self, line):
"""Parses data from "Stream ... Video" line."""
global_data, stream_data = ({"video_found": True}, {})
try:
match_video_size = re.search(r" (\d+)x(\d+)[,\s]", line)
if match_video_size:
# size, of the form 460x320 (w x h)
stream_data["size"] = [int(num) for num in match_video_size.groups()]
except Exception:
raise IOError(
(
"MoviePy error: failed to read video dimensions in"
" file '%s'.\nHere are the file infos returned by"
"ffmpeg:\n\n%s"
)
% (self.filename, self.infos)
)
match_bitrate = re.search(r"(\d+) kb/s", line)
stream_data["bitrate"] = int(match_bitrate.group(1)) if match_bitrate else None
# Get the frame rate. Sometimes it's 'tbr', sometimes 'fps', sometimes
# tbc, and sometimes tbc/2...
# Current policy: Trust fps first, then tbr unless fps_source is
# specified as 'tbr' in which case try tbr then fps
# If result is near from x*1000/1001 where x is 23,24,25,50,
# replace by x*1000/1001 (very common case for the fps).
if self.fps_source == "fps":
try:
fps = self.parse_fps(line)
except (AttributeError, ValueError):
fps = self.parse_tbr(line)
elif self.fps_source == "tbr":
try:
fps = self.parse_tbr(line)
except (AttributeError, ValueError):
fps = self.parse_fps(line)
else:
raise ValueError(
("fps source '%s' not supported parsing the video '%s'")
% (self.fps_source, self.filename)
)
# It is known that a fps of 24 is often written as 24000/1001
# but then ffmpeg nicely rounds it to 23.98, which we hate.
coef = 1000.0 / 1001.0
for x in [23, 24, 25, 30, 50]:
if (fps != x) and abs(fps - x * coef) < 0.01:
fps = x * coef
stream_data["fps"] = fps
# Try to extract video codec and profile
main_info_match = re.search(
r"Video:\s(\w+)?\s?(\([^)]+\))?",
line.lstrip(),
)
if main_info_match is not None:
(codec_name, profile) = main_info_match.groups()
stream_data["codec_name"] = codec_name
stream_data["profile"] = profile
if self._current_stream["default"] or "video_codec_name" not in self.result:
global_data["video_codec_name"] = stream_data.get("codec_name", None)
if self._current_stream["default"] or "video_profile" not in self.result:
global_data["video_profile"] = stream_data.get("profile", None)
if self._current_stream["default"] or "video_size" not in self.result:
global_data["video_size"] = stream_data.get("size", None)
if self._current_stream["default"] or "video_bitrate" not in self.result:
global_data["video_bitrate"] = stream_data.get("bitrate", None)
if self._current_stream["default"] or "video_fps" not in self.result:
global_data["video_fps"] = stream_data["fps"]
return (global_data, stream_data)
def parse_fps(self, line):
"""Parses number of FPS from a line of the ``ffmpeg -i`` command output."""
return float(re.search(r" (\d+.?\d*) fps", line).group(1))
def parse_tbr(self, line):
"""Parses number of TBS from a line of the ``ffmpeg -i`` command output."""
s_tbr = re.search(r" (\d+.?\d*k?) tbr", line).group(1)
# Sometimes comes as e.g. 12k. We need to replace that with 12000.
if s_tbr[-1] == "k":
tbr = float(s_tbr[:-1]) * 1000
else:
tbr = float(s_tbr)
return tbr
def parse_duration(self, line):
"""Parse the duration from the line that outputs the duration of
the container.
"""
try:
time_raw_string = line.split(self.duration_tag_separator)[-1]
match_duration = re.search(
r"([0-9][0-9]:[0-9][0-9]:[0-9][0-9].[0-9][0-9])",
time_raw_string,
)
return convert_to_seconds(match_duration.group(1))
except Exception:
raise IOError(
(
"MoviePy error: failed to read the duration of file '%s'.\n"
"Here are the file infos returned by ffmpeg:\n\n%s"
)
% (self.filename, self.infos)
)
def parse_metadata_field_value(
self,
line,
):
"""Returns a tuple with a metadata field-value pair given a ffmpeg `-i`
command output line.
"""
raw_field, raw_value = line.split(":", 1)
return (raw_field.strip(" "), raw_value.strip(" "))
def video_metadata_type_casting(self, field, value):
"""Cast needed video metadata fields to other types than the default str."""
if field == "rotate":
return (field, float(value))
return (field, value)
def ffmpeg_parse_infos(
filename,
check_duration=True,
fps_source="fps",
decode_file=False,
print_infos=False,
):
"""Get the information of a file using ffmpeg.
Returns a dictionary with next fields:
- ``"duration"``
- ``"metadata"``
- ``"inputs"``
- ``"video_found"``
- ``"video_fps"``
- ``"video_n_frames"``
- ``"video_duration"``
- ``"video_bitrate"``
- ``"video_metadata"``
- ``"audio_found"``
- ``"audio_fps"``
- ``"audio_bitrate"``
- ``"audio_metadata"``
- ``"video_codec_name"``
- ``"video_profile"``
Note that "video_duration" is slightly smaller than "duration" to avoid
fetching the incomplete frames at the end, which raises an error.
Parameters
----------
filename
Name of the file parsed, only used to raise accurate error messages.
infos
Information returned by FFmpeg.
fps_source
Indicates what source data will be preferably used to retrieve fps data.
check_duration
Enable or disable the parsing of the duration of the file. Useful to
skip the duration check, for example, for images.
decode_file
Indicates if the whole file must be read to retrieve their duration.
This is needed for some files in order to get the correct duration (see
https://github.com/Zulko/moviepy/pull/1222).
"""
# Open the file in a pipe, read output
cmd = [FFMPEG_BINARY, "-hide_banner", "-i", ffmpeg_escape_filename(filename)]
if decode_file:
cmd.extend(["-f", "null", "-"])
popen_params = cross_platform_popen_params(
{
"bufsize": 10**5,
"stdout": sp.PIPE,
"stderr": sp.PIPE,
"stdin": sp.DEVNULL,
}
)
proc = sp.Popen(cmd, **popen_params)
(output, error) = proc.communicate()
infos = error.decode("utf8", errors="ignore")
proc.terminate()
del proc
if print_infos:
# print the whole info text returned by FFMPEG
print(infos)
try:
return FFmpegInfosParser(
infos,
filename,
fps_source=fps_source,
check_duration=check_duration,
decode_file=decode_file,
).parse()
except Exception as exc:
if os.path.isdir(filename):
raise IsADirectoryError(f"'{filename}' is a directory")
elif not os.path.exists(filename):
raise FileNotFoundError(f"'{filename}' not found")
raise IOError(f"Error passing `ffmpeg -i` command output:\n\n{infos}") from exc

View File

@@ -0,0 +1,209 @@
"""Miscellaneous bindings to ffmpeg."""
import os
from moviepy.config import FFMPEG_BINARY
from moviepy.decorators import convert_parameter_to_seconds, convert_path_to_string
from moviepy.tools import ffmpeg_escape_filename, subprocess_call
@convert_path_to_string(("inputfile", "outputfile"))
@convert_parameter_to_seconds(("start_time", "end_time"))
def ffmpeg_extract_subclip(
inputfile, start_time, end_time, outputfile=None, logger="bar"
):
"""Makes a new video file playing video file between two times.
Parameters
----------
inputfile : str
Path to the file from which the subclip will be extracted.
start_time : float
Moment of the input clip that marks the start of the produced subclip.
end_time : float
Moment of the input clip that marks the end of the produced subclip.
outputfile : str, optional
Path to the output file. Defaults to
``<inputfile_name>SUB<start_time>_<end_time><ext>``.
"""
if not outputfile:
name, ext = os.path.splitext(inputfile)
t1, t2 = [int(1000 * t) for t in [start_time, end_time]]
outputfile = "%sSUB%d_%d%s" % (name, t1, t2, ext)
cmd = [
FFMPEG_BINARY,
"-y",
"-ss",
"%0.2f" % start_time,
"-i",
ffmpeg_escape_filename(inputfile),
"-t",
"%0.2f" % (end_time - start_time),
"-map",
"0",
"-vcodec",
"copy",
"-acodec",
"copy",
"-copyts",
ffmpeg_escape_filename(outputfile),
]
subprocess_call(cmd, logger=logger)
@convert_path_to_string(("videofile", "audiofile", "outputfile"))
def ffmpeg_merge_video_audio(
videofile,
audiofile,
outputfile,
video_codec="copy",
audio_codec="copy",
logger="bar",
):
"""Merges video file and audio file into one movie file.
Parameters
----------
videofile : str
Path to the video file used in the merge.
audiofile : str
Path to the audio file used in the merge.
outputfile : str
Path to the output file.
video_codec : str, optional
Video codec used by FFmpeg in the merge.
audio_codec : str, optional
Audio codec used by FFmpeg in the merge.
"""
cmd = [
FFMPEG_BINARY,
"-y",
"-i",
ffmpeg_escape_filename(audiofile),
"-i",
ffmpeg_escape_filename(videofile),
"-vcodec",
video_codec,
"-acodec",
audio_codec,
ffmpeg_escape_filename(outputfile),
]
subprocess_call(cmd, logger=logger)
@convert_path_to_string(("inputfile", "outputfile"))
def ffmpeg_extract_audio(inputfile, outputfile, bitrate=3000, fps=44100, logger="bar"):
"""Extract the sound from a video file and save it in ``outputfile``.
Parameters
----------
inputfile : str
The path to the file from which the audio will be extracted.
outputfile : str
The path to the file to which the audio will be stored.
bitrate : int, optional
Bitrate for the new audio file.
fps : int, optional
Frame rate for the new audio file.
"""
cmd = [
FFMPEG_BINARY,
"-y",
"-i",
ffmpeg_escape_filename(inputfile),
"-ab",
"%dk" % bitrate,
"-ar",
"%d" % fps,
ffmpeg_escape_filename(outputfile),
]
subprocess_call(cmd, logger=logger)
@convert_path_to_string(("inputfile", "outputfile"))
def ffmpeg_resize(inputfile, outputfile, size, logger="bar"):
"""Resizes a file to new size and write the result in another.
Parameters
----------
inputfile : str
Path to the file to be resized.
outputfile : str
Path to the output file.
size : list or tuple
New size in format ``[width, height]`` for the output file.
"""
cmd = [
FFMPEG_BINARY,
"-i",
ffmpeg_escape_filename(inputfile),
"-vf",
"scale=%d:%d" % (size[0], size[1]),
ffmpeg_escape_filename(outputfile),
]
subprocess_call(cmd, logger=logger)
@convert_path_to_string(("inputfile", "outputfile", "output_dir"))
def ffmpeg_stabilize_video(
inputfile, outputfile=None, output_dir="", overwrite_file=True, logger="bar"
):
"""
Stabilizes ``filename`` and write the result to ``output``.
Parameters
----------
inputfile : str
The name of the shaky video.
outputfile : str, optional
The name of new stabilized video. Defaults to appending '_stabilized' to
the input file name.
output_dir : str, optional
The directory to place the output video in. Defaults to the current
working directory.
overwrite_file : bool, optional
If ``outputfile`` already exists in ``output_dir``, then overwrite
``outputfile`` Defaults to True.
"""
if not outputfile:
without_dir = os.path.basename(inputfile)
name, ext = os.path.splitext(without_dir)
outputfile = f"{name}_stabilized{ext}"
outputfile = os.path.join(output_dir, outputfile)
cmd = [
FFMPEG_BINARY,
"-i",
ffmpeg_escape_filename(inputfile),
"-vf",
"deshake",
ffmpeg_escape_filename(outputfile),
]
if overwrite_file:
cmd.append("-y")
subprocess_call(cmd, logger=logger)

View File

@@ -0,0 +1,344 @@
"""
On the long term this will implement several methods to make videos
out of VideoClips
"""
import subprocess as sp
import numpy as np
from proglog import proglog
from moviepy.config import FFMPEG_BINARY
from moviepy.tools import cross_platform_popen_params, ffmpeg_escape_filename
class FFMPEG_VideoWriter:
"""A class for FFMPEG-based video writing.
Parameters
----------
filename : str
Any filename like ``"video.mp4"`` etc. but if you want to avoid
complications it is recommended to use the generic extension ``".avi"``
for all your videos.
size : tuple or list
Size of the output video in pixels (width, height).
fps : int
Frames per second in the output video file.
codec : str, optional
FFMPEG codec. It seems that in terms of quality the hierarchy is
'rawvideo' = 'png' > 'mpeg4' > 'libx264'
'png' manages the same lossless quality as 'rawvideo' but yields
smaller files. Type ``ffmpeg -codecs`` in a terminal to get a list
of accepted codecs.
Note for default 'libx264': by default the pixel format yuv420p
is used. If the video dimensions are not both even (e.g. 720x405)
another pixel format is used, and this can cause problem in some
video readers.
audiofile : str, optional
The name of an audio file that will be incorporated to the video.
preset : str, optional
Sets the time that FFMPEG will take to compress the video. The slower,
the better the compression rate. Possibilities are: ``"ultrafast"``,
``"superfast"``, ``"veryfast"``, ``"faster"``, ``"fast"``, ``"medium"``
(default), ``"slow"``, ``"slower"``, ``"veryslow"``, ``"placebo"``.
bitrate : str, optional
Only relevant for codecs which accept a bitrate. "5000k" offers
nice results in general.
with_mask : bool, optional
Set to ``True`` if there is a mask in the video to be encoded.
pixel_format : str, optional
Optional: Pixel format for the output video file. If is not specified
``"rgb24"`` will be used as the default format unless ``with_mask`` is
set as ``True``, then ``"rgba"`` will be used.
logfile : int, optional
File descriptor for logging output. If not defined, ``subprocess.PIPE``
will be used. Defined using another value, the log level of the ffmpeg
command will be "info", otherwise "error".
threads : int, optional
Number of threads used to write the output with ffmpeg.
ffmpeg_params : list, optional
Additional parameters passed to ffmpeg command.
"""
def __init__(
self,
filename,
size,
fps,
codec="libx264",
audiofile=None,
preset="medium",
bitrate=None,
with_mask=False,
logfile=None,
threads=None,
ffmpeg_params=None,
pixel_format=None,
):
if logfile is None:
logfile = sp.PIPE
self.logfile = logfile
self.filename = filename
self.codec = codec
self.ext = self.filename.split(".")[-1]
pixel_format = "rgba" if with_mask else "rgb24"
# order is important
cmd = [
FFMPEG_BINARY,
"-y",
"-loglevel",
"error" if logfile == sp.PIPE else "info",
"-f",
"rawvideo",
"-vcodec",
"rawvideo",
"-s",
"%dx%d" % (size[0], size[1]),
"-pix_fmt",
pixel_format,
"-r",
"%.02f" % fps,
"-an",
"-i",
"-",
]
if audiofile is not None:
cmd.extend(["-i", audiofile, "-acodec", "copy"])
cmd.extend(["-vcodec", codec, "-preset", preset])
if ffmpeg_params is not None:
cmd.extend(ffmpeg_params)
if bitrate is not None:
cmd.extend(["-b", bitrate])
if threads is not None:
cmd.extend(["-threads", str(threads)])
# Disable auto alt ref for transparent webm and set pix format yo yuva420p
if codec == "libvpx" and with_mask:
cmd.extend(["-pix_fmt", "yuva420p"])
cmd.extend(["-auto-alt-ref", "0"])
elif (codec == "libx264") and (size[0] % 2 == 0) and (size[1] % 2 == 0):
cmd.extend(["-pix_fmt", "yuva420p"])
cmd.extend([ffmpeg_escape_filename(filename)])
popen_params = cross_platform_popen_params(
{"stdout": sp.DEVNULL, "stderr": logfile, "stdin": sp.PIPE}
)
self.proc = sp.Popen(cmd, **popen_params)
def write_frame(self, img_array):
"""Writes one frame in the file."""
try:
self.proc.stdin.write(img_array.tobytes())
except IOError as err:
_, ffmpeg_error = self.proc.communicate()
if ffmpeg_error is not None:
ffmpeg_error = ffmpeg_error.decode()
else:
# The error was redirected to a logfile with `write_logfile=True`,
# so read the error from that file instead
self.logfile.seek(0)
ffmpeg_error = self.logfile.read()
error = (
f"{err}\n\nMoviePy error: FFMPEG encountered the following error while "
f"writing file {self.filename}:\n\n {ffmpeg_error}"
)
if "Unknown encoder" in ffmpeg_error:
error += (
"\n\nThe video export failed because FFMPEG didn't find the "
f"specified codec for video encoding {self.codec}. "
"Please install this codec or change the codec when calling "
"write_videofile.\nFor instance:\n"
" >>> clip.write_videofile('myvid.webm', codec='libvpx')"
)
elif "incorrect codec parameters ?" in ffmpeg_error:
error += (
"\n\nThe video export failed, possibly because the codec "
f"specified for the video {self.codec} is not compatible with "
f"the given extension {self.ext}.\n"
"Please specify a valid 'codec' argument in write_videofile.\n"
"This would be 'libx264' or 'mpeg4' for mp4, "
"'libtheora' for ogv, 'libvpx for webm.\n"
"Another possible reason is that the audio codec was not "
"compatible with the video codec. For instance, the video "
"extensions 'ogv' and 'webm' only allow 'libvorbis' (default) as a"
"video codec."
)
elif "bitrate not specified" in ffmpeg_error:
error += (
"\n\nThe video export failed, possibly because the bitrate "
"specified was too high or too low for the video codec."
)
elif "Invalid encoder type" in ffmpeg_error:
error += (
"\n\nThe video export failed because the codec "
"or file extension you provided is not suitable for video"
)
raise IOError(error)
def close(self):
"""Closes the writer, terminating the subprocess if is still alive."""
if self.proc:
self.proc.stdin.close()
if self.proc.stderr is not None:
self.proc.stderr.close()
self.proc.wait()
self.proc = None
# Support the Context Manager protocol, to ensure that resources are cleaned up.
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def ffmpeg_write_video(
clip,
filename,
fps,
codec="libx264",
bitrate=None,
preset="medium",
write_logfile=False,
audiofile=None,
threads=None,
ffmpeg_params=None,
logger="bar",
pixel_format=None,
):
"""Write the clip to a videofile. See VideoClip.write_videofile for details
on the parameters.
"""
logger = proglog.default_bar_logger(logger)
if write_logfile:
logfile = open(filename + ".log", "w+")
else:
logfile = None
logger(message="MoviePy - Writing video %s\n" % filename)
has_mask = clip.mask is not None
with FFMPEG_VideoWriter(
filename,
clip.size,
fps,
codec=codec,
preset=preset,
bitrate=bitrate,
with_mask=has_mask,
logfile=logfile,
audiofile=audiofile,
threads=threads,
ffmpeg_params=ffmpeg_params,
pixel_format=pixel_format,
) as writer:
for t, frame in clip.iter_frames(
logger=logger, with_times=True, fps=fps, dtype="uint8"
):
if clip.mask is not None:
mask = 255 * clip.mask.get_frame(t)
if mask.dtype != "uint8":
mask = mask.astype("uint8")
frame = np.dstack([frame, mask])
writer.write_frame(frame)
if write_logfile:
logfile.close()
logger(message="MoviePy - Done !")
def ffmpeg_write_image(filename, image, logfile=False, pixel_format=None):
"""Writes an image (HxWx3 or HxWx4 numpy array) to a file, using ffmpeg.
Parameters
----------
filename : str
Path to the output file.
image : np.ndarray
Numpy array with the image data.
logfile : bool, optional
Writes the ffmpeg output inside a logging file (``True``) or not
(``False``).
pixel_format : str, optional
Pixel format for ffmpeg. If not defined, it will be discovered checking
if the image data contains an alpha channel (``"rgba"``) or not
(``"rgb24"``).
"""
if image.dtype != "uint8":
image = image.astype("uint8")
if not pixel_format:
pixel_format = "rgba" if (image.shape[2] == 4) else "rgb24"
cmd = [
FFMPEG_BINARY,
"-y",
"-s",
"%dx%d" % (image.shape[:2][::-1]),
"-f",
"rawvideo",
"-pix_fmt",
pixel_format,
"-i",
"-",
ffmpeg_escape_filename(filename),
]
if logfile:
log_file = open(filename + ".log", "w+")
else:
log_file = sp.PIPE
popen_params = cross_platform_popen_params(
{"stdout": sp.DEVNULL, "stderr": log_file, "stdin": sp.PIPE}
)
proc = sp.Popen(cmd, **popen_params)
out, err = proc.communicate(image.tobytes())
if proc.returncode:
error = (
f"{err}\n\nMoviePy error: FFMPEG encountered the following error while "
f"writing file {filename} with command {cmd}:\n\n {err.decode()}"
)
raise IOError(error)
del proc

View File

@@ -0,0 +1,137 @@
"""
On the long term this will implement several methods to make videos
out of VideoClips
"""
import subprocess as sp
from moviepy.config import FFPLAY_BINARY
from moviepy.tools import cross_platform_popen_params
class FFPLAY_VideoPreviewer:
"""A class for FFPLAY-based video preview.
Parameters
----------
size : tuple or list
Size of the output video in pixels (width, height).
fps : int
Frames per second in the output video file.
pixel_format : str
Pixel format for the output video file, ``rgb24`` for normal video, ``rgba``
if video with mask.
"""
def __init__(
self,
size,
fps,
pixel_format,
):
# order is important
cmd = [
FFPLAY_BINARY,
"-autoexit", # If you don't precise, ffplay won't stop at end
"-f",
"rawvideo",
"-pixel_format",
pixel_format,
"-video_size",
"%dx%d" % (size[0], size[1]),
"-framerate",
"%.02f" % fps,
"-",
]
popen_params = cross_platform_popen_params(
{"stdout": sp.DEVNULL, "stderr": sp.STDOUT, "stdin": sp.PIPE}
)
self.proc = sp.Popen(cmd, **popen_params)
def show_frame(self, img_array):
"""Writes one frame in the file."""
try:
self.proc.stdin.write(img_array.tobytes())
except IOError as err:
_, ffplay_error = self.proc.communicate()
if ffplay_error is not None:
ffplay_error = ffplay_error.decode()
error = (
f"{err}\n\nMoviePy error: FFPLAY encountered the following error while "
f"previewing clip :\n\n {ffplay_error}"
)
raise IOError(error)
def close(self):
"""Closes the writer, terminating the subprocess if is still alive."""
if self.proc:
self.proc.stdin.close()
if self.proc.stderr is not None:
self.proc.stderr.close()
self.proc.wait()
self.proc = None
# Support the Context Manager protocol, to ensure that resources are cleaned up.
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def ffplay_preview_video(
clip, fps, pixel_format="rgb24", audio_flag=None, video_flag=None
):
"""Preview the clip using ffplay. See VideoClip.preview for details
on the parameters.
Parameters
----------
clip : VideoClip
The clip to preview
fps : int
Number of frames per seconds in the displayed video.
pixel_format : str, optional
Warning: This is not used anywhere in the code and should probably
be removed.
It is believed pixel format rgb24 does not work properly for now because
it requires applying a mask on CompositeVideoClip and that is believed to
not be working.
Pixel format for the output video file, ``rgb24`` for normal video, ``rgba``
if video with mask
audio_flag : Thread.Event, optional
A thread event that video will wait for. If not provided we ignore audio
video_flag : Thread.Event, optional
A thread event that video will set after first frame has been shown. If not
provided, we simply ignore
"""
with FFPLAY_VideoPreviewer(clip.size, fps, pixel_format) as previewer:
first_frame = True
for t, frame in clip.iter_frames(with_times=True, fps=fps, dtype="uint8"):
previewer.show_frame(frame)
# After first frame is shown, if we have audio/video flag, set video ready
# and wait for audio
if first_frame:
first_frame = False
if video_flag:
video_flag.set() # say to the audio: video is ready
if audio_flag:
audio_flag.wait() # wait for the audio to be ready

View File

@@ -0,0 +1,20 @@
"""MoviePy video GIFs writing."""
import imageio.v3 as iio
import proglog
from moviepy.decorators import requires_duration, use_clip_fps_by_default
@requires_duration
@use_clip_fps_by_default
def write_gif_with_imageio(clip, filename, fps=None, loop=0, logger="bar"):
"""Writes the gif with the Python library ImageIO (calls FreeImage)."""
logger = proglog.default_bar_logger(logger)
with iio.imopen(filename, "w", plugin="pillow") as writer:
logger(message="MoviePy - Building file %s with imageio." % filename)
for frame in clip.iter_frames(fps=fps, logger=logger, dtype="uint8"):
writer.write(
frame, duration=1000 / fps, loop=loop
) # Duration is in ms not s