Spaces:
Restarting
on
Zero
Restarting
on
Zero
File size: 9,399 Bytes
d1ed09d |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 |
from __future__ import annotations
import errno
import itertools
import shlex
import subprocess
from typing import IO, Any, Mapping, Sequence
class FFmpeg:
"""Wrapper for various `FFmpeg <https://www.ffmpeg.org/>`_ related applications (ffmpeg,
ffprobe).
"""
def __init__(
self,
executable: str = "ffmpeg",
global_options: Sequence[str] | str | None = None,
inputs: Mapping[str, Sequence[str] | str | None] | None = None,
outputs: Mapping[str, Sequence[str] | str | None] | None = None,
) -> None:
"""Initialize FFmpeg command line wrapper.
Compiles FFmpeg command line from passed arguments (executable path, options, inputs and
outputs). ``inputs`` and ``outputs`` are dictionares containing inputs/outputs as keys and
their respective options as values. One dictionary value (set of options) must be either a
single space separated string, or a list or strings without spaces (i.e. each part of the
option is a separate item of the list, the result of calling ``split()`` on the options
string). If the value is a list, it cannot be mixed, i.e. cannot contain items with spaces.
An exception are complex FFmpeg command lines that contain quotes: the quoted part must be
one string, even if it contains spaces (see *Examples* for more info).
For more info about FFmpeg command line format see `here
<https://ffmpeg.org/ffmpeg.html#Synopsis>`_.
:param str executable: path to ffmpeg executable; by default the ``ffmpeg`` command will be
searched for in the ``PATH``, but can be overridden with an absolute path to ``ffmpeg``
executable
:param iterable global_options: global options passed to ``ffmpeg`` executable (e.g.
``-y``, ``-v`` etc.); can be specified either as a list/tuple/set of strings, or one
space-separated string; by default no global options are passed
:param dict inputs: a dictionary specifying one or more input arguments as keys with their
corresponding options (either as a list of strings or a single space separated string) as
values
:param dict outputs: a dictionary specifying one or more output arguments as keys with their
corresponding options (either as a list of strings or a single space separated string) as
values
"""
self.executable = executable
self._cmd = [executable]
self._cmd += _normalize_options(global_options, split_mixed=True)
if inputs is not None:
self._cmd += _merge_args_opts(inputs, add_minus_i_option=True)
if outputs is not None:
self._cmd += _merge_args_opts(outputs)
self.cmd = subprocess.list2cmdline(self._cmd)
self.process: subprocess.Popen | None = None
def __repr__(self) -> str:
return f"<{self.__class__.__name__!r} {self.cmd!r}>"
def run(
self,
input_data: bytes | None = None,
stdout: IO | int | None = None,
stderr: IO | int | None = None,
env: Mapping[str, str] | None = None,
**kwargs: Any,
) -> tuple[bytes | None, bytes | None]:
"""Execute FFmpeg command line.
``input_data`` can contain input for FFmpeg in case ``pipe`` protocol is used for input.
``stdout`` and ``stderr`` specify where to redirect the ``stdout`` and ``stderr`` of the
process. By default no redirection is done, which means all output goes to running shell
(this mode should normally only be used for debugging purposes). If FFmpeg ``pipe`` protocol
is used for output, ``stdout`` must be redirected to a pipe by passing `subprocess.PIPE` as
``stdout`` argument. You can pass custom environment to ffmpeg process with ``env``.
Returns a 2-tuple containing ``stdout`` and ``stderr`` of the process. If there was no
redirection or if the output was redirected to e.g. `os.devnull`, the value returned will
be a tuple of two `None` values, otherwise it will contain the actual ``stdout`` and
``stderr`` data returned by ffmpeg process.
More info about ``pipe`` protocol `here <https://ffmpeg.org/ffmpeg-protocols.html#pipe>`_.
:param str input_data: input data for FFmpeg to deal with (audio, video etc.) as bytes (e.g.
the result of reading a file in binary mode)
:param stdout: redirect FFmpeg ``stdout`` there (default is `None` which means no
redirection)
:param stderr: redirect FFmpeg ``stderr`` there (default is `None` which means no
redirection)
:param env: custom environment for ffmpeg process
:param kwargs: any other keyword arguments to be forwarded to `subprocess.Popen
<https://docs.python.org/3/library/subprocess.html#subprocess.Popen>`_
:return: a 2-tuple containing ``stdout`` and ``stderr`` of the process
:rtype: tuple
:raise: `FFRuntimeError` in case FFmpeg command exits with a non-zero code;
`FFExecutableNotFoundError` in case the executable path passed was not valid
"""
try:
self.process = subprocess.Popen(
self._cmd, stdin=subprocess.PIPE, stdout=stdout, stderr=stderr, env=env, **kwargs
)
except OSError as e:
if e.errno == errno.ENOENT:
raise FFExecutableNotFoundError(f"Executable '{self.executable}' not found")
else:
raise
o_stdout, o_stderr = self.process.communicate(input=input_data)
if self.process.returncode != 0:
raise FFRuntimeError(self.cmd, self.process.returncode, o_stdout, o_stderr)
return o_stdout, o_stderr
class FFprobe(FFmpeg):
"""Wrapper for `ffprobe <https://www.ffmpeg.org/ffprobe.html>`_."""
def __init__(
self,
executable: str = "ffprobe",
global_options: Sequence[str] | str | None = None,
inputs: Mapping[str, Sequence[str] | str | None] | None = None,
) -> None:
"""Create an instance of FFprobe.
Compiles FFprobe command line from passed arguments (executable path, options, inputs).
FFprobe executable by default is taken from ``PATH`` but can be overridden with an
absolute path. For more info about FFprobe command line format see
`here <https://ffmpeg.org/ffprobe.html#Synopsis>`_.
:param str executable: absolute path to ffprobe executable
:param iterable global_options: global options passed to ffmpeg executable; can be specified
either as a list/tuple of strings or a space-separated string
:param dict inputs: a dictionary specifying one or more inputs as keys with their
corresponding options as values
"""
super().__init__(executable=executable, global_options=global_options, inputs=inputs)
class FFExecutableNotFoundError(Exception):
"""Raise when FFmpeg/FFprobe executable was not found."""
class FFRuntimeError(Exception):
"""Raise when FFmpeg/FFprobe command line execution returns a non-zero exit code.
The resulting exception object will contain the attributes relates to command line execution:
``cmd``, ``exit_code``, ``stdout``, ``stderr``.
"""
def __init__(self, cmd: str, exit_code: int, stdout: bytes, stderr: bytes) -> None:
self.cmd = cmd
self.exit_code = exit_code
self.stdout = stdout
self.stderr = stderr
message = "`{}` exited with status {}\n\nSTDOUT:\n{}\n\nSTDERR:\n{}".format(
self.cmd, exit_code, (stdout or b"").decode(), (stderr or b"").decode()
)
super().__init__(message)
def _merge_args_opts(
args_opts_dict: Mapping[str, Sequence[str] | str | None],
add_minus_i_option: bool = False,
) -> list[str]:
"""Merge options with their corresponding arguments.
Iterates over the dictionary holding arguments (keys) and options (values). Merges each
options string with its corresponding argument.
:param dict args_opts_dict: a dictionary of arguments and options
:param dict kwargs: *input_option* - if specified prepends ``-i`` to input argument
:return: merged list of strings with arguments and their corresponding options
:rtype: list
"""
merged: list[str] = []
for arg, opt in args_opts_dict.items():
merged += _normalize_options(opt)
if not arg:
continue
if add_minus_i_option:
merged.append("-i")
merged.append(arg)
return merged
def _normalize_options(options: Sequence[str] | str | None, split_mixed: bool = False) -> list[str]:
"""Normalize options string or list of strings.
Splits `options` into a list of strings. If `split_mixed` is `True`, splits (flattens) mixed
options (i.e. list of strings with spaces) into separate items.
:param options: options string or list of strings
:param bool split_mixed: whether to split mixed options into separate items
"""
if options is None:
return []
elif isinstance(options, str):
return shlex.split(options)
else:
if split_mixed:
return list(itertools.chain(*[shlex.split(o) for o in options]))
else:
return list(options)
|