diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb0ff9f..e339a01b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +### Changed + +- allow writers' `extra_inputs` arguments to be `str` or `tuple[str, dict|None]` +- `probe` functions accepts PathLike object as the media url + +### Added + +- `media` module - block +- `PipedStreams` module - media stream classes with multiple inputs and outputs. + +### Removed + +- `build_basic_vf()` options from video readers and filters. Users can generate + equivalent filter chains via `filtergraph.presets.filter_video_basic()`. + ## [0.11.1] - 2025-05-17 ### Fixed diff --git a/Makefile b/Makefile index dde022fb..6fbc22f5 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= +SPHINXOPTS ?= -j auto -n -v -W -T SPHINXBUILD ?= sphinx-build SOURCEDIR = docsrc BUILDDIR = build diff --git a/docsrc/analysis.rst b/docsrc/analysis.rst index 122b345b..a4c791c7 100644 --- a/docsrc/analysis.rst +++ b/docsrc/analysis.rst @@ -8,7 +8,7 @@ There are a number of `FFmpeg filters `_ which analyze video and audio streams and inject per-frame results into frame metadata to be used in a later stage of -a filtergraph. :py:mod:`ffmpegio.analyze.run` retrieves the injected metadata by appending ``metadata`` +a filtergraph. :py:mod:`run` retrieves the injected metadata by appending ``metadata`` and ``ametadata`` filters and logs the frame metadata outputs. You can use either the supplied Python classes or a custom class, which conforms to :py:class:`MetadataLogger` interface to specify the FFmpeg filter and to log its output. @@ -88,10 +88,10 @@ Analyze API Reference :nosignatures: :recursive: - ffmpegio.analyze.run + run ffmpegio.video.detect ffmpegio.audio.detect - ffmpegio.analyze.MetadataLogger + MetadataLogger .. autofunction:: ffmpegio.analyze.run .. autofunction:: ffmpegio.video.detect diff --git a/docsrc/conf.py b/docsrc/conf.py index 294b1ac6..a5809871 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -20,7 +20,7 @@ project = "python-ffmpegio" copyright = ( - "2021-2022, Takeshi (Kesh) Ikuma, Louisiana State University Health Sciences Center" + "2021-2026, Takeshi (Kesh) Ikuma, Louisiana State University Health Sciences Center" ) author = "Takeshi (Kesh) Ikuma" release = ffmpegio.__version__ @@ -36,13 +36,38 @@ "sphinx.ext.intersphinx", "sphinx.ext.autosummary", "sphinx.ext.todo", - "sphinxcontrib.blockdiag", - "sphinxcontrib.repl", + # "sphinx.ext.graphviz", + # "sphinxcontrib.repl", "matplotlib.sphinxext.plot_directive", ] # Looks for objects in external projects -autodoc_typehints = 'description' + +# Autodoc configuration +autodoc_member_order = "groupwise" +autodoc_type_aliases = { + "ArrayLike": "~numpy.typing.ArrayLike", + "NDArray": "~numpy.typing.NDArray", + "ff": "ffmpegio", +} +autodoc_mock_imports = ["builtins"] +autodoc_typehints_format = "short" +# autodoc_class_signature = "separated" +autodoc_default_options = {"exclude-members": "__new__", "class-doc-from": "init"} +autodoc_typehints = "description" + +overloads_location = "signature" + +# Intersphinx configuration +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "scipy": ("https://docs.scipy.org/doc/scipy/", None), + "matplotlib": ("https://matplotlib.org/stable/", None), + "python": ("https://docs.python.org/3/", None), +} + +autodoc_typehints = "description" # autodoc_type_aliases = {'AgentAssignment': 'AgentAssignment'} # Add any paths that contain templates here, relative to this directory. @@ -53,29 +78,38 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +copybutton_selector = "div:not(.output_area) > div.highlight > pre" + +graphviz_output_format = "svg" # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +html_theme = "sphinx_book_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] -# html_sidebars = { -# "**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"] -# } -# Fontpath for blockdiag (truetype font) -blockdiag_fontpath = "_static/ipagp.ttf" -blockdiag_html_image_format = "SVG" - -intersphinx_mapping = { - "numpy": ("https://numpy.org/doc/stable", None), +# html_logo = "images/logo.png" +html_theme_options = { + # "logo": { + # "image_light": "images/wave-reflection-model-light.png", + # "image_dark": "images/wave-reflection-model-dark.png", + # }, + "path_to_docs": "docs/", + "repository_url": "https://github.com/tikuma-lsuhsc/pyLeTalker", + # "repository_branch": branch_or_commit, + "use_repository_button": True, + "use_source_button": True, + "show_toc_level": 2, } plot_html_show_source_link = False diff --git a/docsrc/filtergraph.rst b/docsrc/filtergraph.rst index 4982211c..2634fcd9 100644 --- a/docsrc/filtergraph.rst +++ b/docsrc/filtergraph.rst @@ -30,9 +30,9 @@ These functions are served by three classes: :nosignatures: :recursive: - ffmpegio.filtergraph.Filter - ffmpegio.filtergraph.Chain - ffmpegio.filtergraph.Graph + Filter + Chain + Graph See :ref:`api` section below for the full documentation of these classes and other helper functions. @@ -528,7 +528,7 @@ temporary script file. The previous example can also run as follows: with fg.as_script_file() as script_path: ffmpegio.ffmpegprocess.run( { - 'inputs': [('input.mp4', None)] + 'inputs': [('input.mp4', None)], 'outputs': [('output.mp4', {'filter_script:v': script_path})] }) diff --git a/docsrc/index.rst b/docsrc/index.rst index 967e3ea1..cd6612e1 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -14,6 +14,9 @@ .. |github-status| image:: https://img.shields.io/github/actions/workflow/status/python-ffmpegio/python-ffmpegio/test_n_pub.yml?branch=main :alt: GitHub Workflow Status +* GitHub Repository +* GitHub Discussion Board + Python `ffmpegio` package aims to bring the full capability of `FFmpeg `__ to read, write, probe, and manipulate multimedia data to Python. FFmpeg is an open-source cross-platform multimedia framework, which can handle most of the multimedia formats available today. @@ -39,10 +42,13 @@ Main Features * I/O device enumeration to eliminate the need to look up device names. (currently supports only: Windows DirectShow) * More features to follow -Installation ------------- -Install the full `ffmpegio` package via ``pip``: +Where to start +-------------- + +* Read :ref:`Quick-start guide ` + +* Install via ``pip``: .. code-block:: bash @@ -330,3 +336,45 @@ Run FFmpeg and FFprobe Directly }, capture_log=True) >>> print(out.stderr) # print the captured FFmpeg logs (banner text omitted) >>> b = out.stdout # width*height bytes of the first frame + +Introductory Info +----------------- + +.. toctree:: + :maxdepth: 1 + + quick + install + + +High-level API Reference +------------------------ + +.. toctree:: + :maxdepth: 1 + + basicio + probe + options + filtergraph + caps + analysis + devices + concat + +Advanced Topics +--------------- + +.. toctree:: + :maxdepth: 1 + + adv-ffmpeg + adv-args + +External Links +-------------- + +.. toctree:: + :maxdepth: 1 + + links diff --git a/docsrc/install.rst b/docsrc/install.rst index 308865ea..581ac0c2 100644 --- a/docsrc/install.rst +++ b/docsrc/install.rst @@ -18,41 +18,54 @@ Install the :py:mod:`ffmpegio` package via :code:`pip`. Install FFmpeg program ^^^^^^^^^^^^^^^^^^^^^^ -There are two platform independent approaches to install FFmpeg for the use in Python: +There are two Python libraries to install FFmpeg for the use in Python: -::code::`ffmpeg-downloader` -""""""""""""""""""""""""""" +The installation of FFmpeg is platform dependent. One platform agnostic approach +is to use our sister package: +`ffmpeg-downloader `__. + +Install with `ffmpeg-downloader` +"""""""""""""""""""""""""""""""" + +First, install the `ffmpegio-downloader` package, then run its cli command `ffdl`: .. code-block:: + pip install ffmpeg-downloader - ffdl install -U # grabs the latest version + ffdl install - # optionally - ffdl install -U --add-path to have it on the system path in Windows or MacOS +If you wish to use the FFmpeg outside of `ffmpegio`, you can also install and add +the installed directory to the user's system path (only for Windows and MacOS). -::code::`static-ffmpeg` -""""""""""""""""""""""" +.. code-block:: + + # optionally + ffdl install --add-path +At a later date, you could re-run `ffdl` to look for an update (similar to `pip`): +I .. code-block:: - pip install static-ffmpeg - static_ffmpeg_paths -The installation of FFmpeg is platform dependent. For Ubuntu/Debian Linux, + ffdl install -U + +Install on Ubuntu/Debian Linux +"""""""""""""""""""""""""""""" .. code-block:: sudo apt install ffmpeg -and for MacOS, +Install on MacOS +"""""""""""""""" .. code-block:: brew install ffmpeg -no other actions are needed as these commands will place the FFmpeg executables -on the system path. +Install on Windows +"""""""""""""""""" -For Windows, it is a bit more complicated. +It is a bit more complicated in Windows. 1. Download pre-built packages from the links available on the `FFmpeg's Download page `__. diff --git a/docsrc/options.rst b/docsrc/options.rst index ed85e732..940cff98 100644 --- a/docsrc/options.rst +++ b/docsrc/options.rst @@ -92,7 +92,7 @@ ncomp dtype pix_fmt Description 4 `__), -:py:mod:`ffmpegio`'s video and image routines adds several convenience -video options to perform simple video maninpulations without the need of setting -up a filtergraph. - - -.. list-table:: Options to manipulate video frames - :widths: auto - :header-rows: 1 - :class: tight-table - - * - name - - value - - FFmpeg filter - - Description - * - :code:`crop` - - seq(int[, int[, int[, int]]]) - - `crop `__ - - video frame cropping/padding, values representing the number of pixels to crop from [left top right bottom]. - If positive, the video frame is cropped from the respective edge. If negative, the video frame is padded on - the respective edge. If right or bottom is missing, uses the same value as left or top, respectively. If top - is missing, it defaults to 0. - * - :code:`flip` - - {:code:`'horizontal'`, :code:`'vertical'`, :code:`'both'`} - - `hflip `__ or `vflip `__ - - flip the video frames horizontally, vertically, or both. - * - :code:`transpose` - - int - - `transpose `__ - - tarnspose the video frames. Its value specifies the mode of operation. Use 0 for the conventional transpose operation. - For the others, see the FFmpeg documentation. - * - :code:`square_pixels` - - {:code:`'upscale'`, :code:`'downscale'`, :code:`'upscale_even'`, - :code:`'downscale_even'`} - - `scale `__ and `setsar `__ - - Resize video frames so that their pixels are square (i.e., SAR=1:1). - :code:`'upscale'` stretches the short side - of the pixels while :code:`'downscale'` compresses the long side. - :code:`'even'` makes sure that the resulting frame size is even (required by some codecs). - * - :code:`remove_alpha` - - bool - - `overlay `__ and `color `__ - - Fill transparent background with :code:`fill_color` color. This filter is automatically - inserted if input :code:`'pix_fmt'` has alpha but not the output. - * - :code:`fill_color` - - str - - n/a - - This option is used for the auto-conversion of an image with transparency to - opaque by setting the output option :code:`pix_fmt`. The option value - specifies a color according to - `FFmpeg Color Specifications `__. - Default color is :code:`'white'`. - -Note that the these operations are pre-wired to perform in a specific order: - -.. blockdiag:: - :caption: Video Manipulation Order - - blockdiag { - square_pixels -> crop -> flip -> transpose; - crop -> flip [folded] - } - -Be aware of this ordering as these filters are non-commutative (i.e., a change in the -order of operation alters the outcome). If your desired order of filters differs or -need to use additional filters, use the :code:`vf` option to specify your own filtergraph. - -.. list-table:: Examples of manipulated images - :class: tight-table - - * - .. plot:: - - IM = ffmpegio.image.read('ffmpeg-logo.png') - plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) - plt.imshow(IM) - plt.gca().set_position((0, 0, 1, 1)) - plt.axis('off') - - .. code-block:: python - - ffmpegio.image.read('ffmpeg-logo.png') - - * - .. plot:: - - IM = ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), transpose=0) - plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) - plt.imshow(IM) - plt.gca().set_position((0, 0, 1, 1)) - plt.axis('off') - - .. code-block:: python - - ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), transpose=0) - - * - .. plot:: - - IM = ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), flip='both', s=(200,50)) - plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) - plt.imshow(IM) - plt.gca().set_position((0, 0, 1, 1)) - plt.axis('off') - - .. code-block:: python - - ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), flip='both', size=(200,-1)) +.. deprecated:: 0.12 + + This feature has been deprecated. It is now implemented in + :py:mod:`filtergraph.presets` to generate a filtergraph, which can then be + used with :code:`vf` or :code:`filter_complex` options. + + - :py:func:`filtergraph.presets.filter_video_basic` - a filterchain + with scale, crop, flip, and transpose filters + - :py:func:`filtergraph.presets.remove_alpha` - a filterchain to remove alpha + channel + - :py:func:`filtergraph.presets.square_pixels` - a filterchain to square + pixels diff --git a/docsrc/quick.rst b/docsrc/quick.rst index 7beff41e..9942103a 100644 --- a/docsrc/quick.rst +++ b/docsrc/quick.rst @@ -384,7 +384,7 @@ for the list of predefined color names. * - :code:`'gray'` with light gray background - .. plot:: - IM = ffmpegio.image.read('ffmpeg-logo.png', pix_fmt='gray', fill_color='#F0F0F0') + IM = ffmpegio.image.read('ffmpeg-logo.png', pix_fmt='gray', vf=ffmpegio.filtergraph.presets.remove_alpha('#F0F0F0')) plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) plt.imshow(IM, cmap='gray') plt.gca().set_position((0, 0, 1, 1)) diff --git a/docsrc/requirements.txt b/docsrc/requirements.txt index c7bf27eb..6648811b 100644 --- a/docsrc/requirements.txt +++ b/docsrc/requirements.txt @@ -1,15 +1,10 @@ -Pillow==10.3.0 sphinx sphinx-rtd-theme sphinx-autopackagesummary -sphinxcontrib-blockdiag -blockdiag @ git+https://github.com/yuzutech/blockdiag.git sphinxcontrib-repl sphinx-exec-directive sphinx-autobuild -PyQt5-sip -PyQt5 +sphinx-book-theme +sphinx-copybutton matplotlib -ffmpegio numpy -kiwisolver \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 37d8d9be..eeda756d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,18 +18,19 @@ classifiers = [ "Topic :: Multimedia :: Video", "Topic :: Multimedia :: Video :: Capture", "Topic :: Multimedia :: Video :: Conversion", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = ["version"] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "pluggy", "packaging", - "typing_extensions", + "typing_extensions >= 4.12", + "namedpipe >= 0.2.5", ] [project.urls] @@ -46,3 +47,6 @@ version = { attr = "ffmpegio.__version__" } testpaths = ["tests"] # minversion = "6.0" # addopts = "-ra -q" + +[tool.ruff] +typing-modules = ["ffmpegio._typing"] \ No newline at end of file diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 94c280a9..f75e91bf 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -29,7 +29,6 @@ """ import logging -from typing import Optional, Tuple logger = logging.getLogger("ffmpegio") logger.addHandler(logging.NullHandler()) @@ -47,30 +46,42 @@ use = plugins.use + def __getattr__(name): if name == "ffmpeg_ver": return path.FFMPEG_VER raise AttributeError(f"module '{__name__}' has no attribute '{name}'") -from . import ffmpegprocess +from . import ( + audio, + caps, + devices, + ffmpegprocess, + image, + media, + probe, + stream_spec, + streams, + video, +) -from .errors import FFmpegError -from .utils.concat import FFConcat +# check if ffmpegio-core is installed, if it is warn its deprecation +from ._utils import deprecate_core +from .errors import FFmpegError, FFmpegioError from .filtergraph import Graph as FilterGraph -from . import devices, ffmpegprocess, caps, probe, audio, image, video, media +from .streams.open import open from .transcode import transcode -from . import streams as _streams +from .utils.concat import FFConcat from .utils.parser import FLAG -# check if ffmpegio-core is installed, if it is warn its deprecation -from ._utils import deprecate_core deprecate_core() # fmt:off __all__ = ["ffmpeg_info", "get_path", "set_path", "is_ready", "ffmpeg", "ffprobe", "transcode", "caps", "probe", "audio", "image", "video", "media", "devices", - "open", "ffmpegprocess", "FFmpegError", "FilterGraph", "FFConcat", "use"] + "open", "streams", "ffmpegprocess", "FFmpegError", "FFmpegioError", "FilterGraph", + "FFConcat", "use", "FLAG", "stream_spec"] # fmt:on __version__ = "0.11.1" @@ -81,272 +92,3 @@ def __getattr__(name): is_ready = path.found ffmpeg = path.ffmpeg ffprobe = path.ffprobe - - -def open( - url_fg: str, - mode: str, - rate_in: Optional[float] = None, - shape_in: Optional[Tuple[int, ...]] = None, - dtype_in: Optional[str] = None, - rate: Optional[float] = None, - shape: Optional[Tuple[int, ...]] = None, - **kwds, -): - """Open a multimedia file/stream for read/write - - :param url_fg: URL of the media source/destination for file read/write or filtergraph definition - for filter operation. - :type url_fg: str or seq(str) - :param mode: specifies the mode in which the FFmpeg is used, see below - :type mode: str - :param rate_in: (write and filter only, required) input frame rate (video) or sampling rate - (audio), defaults to None - :type rate_in: Fraction, float, int, optional - :param shape_in: (write and filter only) input video frame size (height x width [x ncomponents]), - or audio sample size (channels,), defaults to None - :type shape_in: seq of int, optional - :param dtype_in: (write and filter only) input data type, defaults to None - :type dtype_in: str, optional - :param rate: (filter only, required) output frame rate (video write) or sample rate (audio - write), defaults to None - :type rate: Fraction, float, int, optional - :param dtype: (read and filter specific) output data type, defaults to None - :type dtype: str, optional - :param shape: (read and filter specific) output video frame size (height x width [x ncomponents]), - or audio sample size (channels,), defaults to None - :type shape: seq of int, optional - :param show_log: True to echo the ffmpeg log to stdout, default to False - :type show_log: bool, optional - :param progress: progress callback function (see :ref:`quick-callback`) - :type progress: Callable, optional - :param blocksize: (read and filter only) Number of frames to read by `read()` method, default to None (auto) - :type blocksize: int, optional - :param extra_inputs: (write only) List of additional (non-pipe) inputs to pass onto FFmpeg. Each - input is defined by a tuple of its url or a dict of input options, default to None - :type extra_inputs: List[Tuple[str,dict]], optional - :param default_timeout: (filter only) default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional - :param sp_kwargs: Keyword arguments for FFmpeg process (see :py:class:`ffmpegio.ffmpegprocess.Popen`), default to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :returns: ffmpegio stream object - - Start FFmpeg and open I/O link to it to perform read/write/filter operation and return - a corresponding stream object. If the file cannot be opened, an error is raised. - See :ref:`quick-streamio` for more examples of how to use this function. - - Just like built-in `open()`, it is good practice to use the with keyword when dealing with - ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are - properly closed after ffmpeg terminates, even if an exception is raised at some point. - Using with is also much shorter than writing equivalent try-finally blocks. - - :Examples: - - Open an MP4 file and process all the frames:: - - with ffmpegio.open('video_source.mp4', 'rv') as f: - frame = f.read() - while frame: - # process the captured frame data - frame = f.read() - - Read an audio stream of MP4 file and write it to a FLAC file as samples - are decoded:: - - with ffmpegio.open('video_source.mp4','ra') as rd: - fs = rd.sample_rate - with ffmpegio.open('video_dst.flac','wa',rate_in=fs) as wr: - frame = rd.read() - while frame: - wr.write(frame) - frame = rd.read() - - :Additional Notes: - - `url_fg` can be a string specifying either the pathname (absolute or relative to the current - working directory) of the media target (file or streaming media) to be opened or a string describing - the filtergraph to be implemented. Its interpretation depends on the `mode` argument. - - `mode` is an optional string that specifies the mode in which the FFmpeg is opened. - - ==== =================================================== - Mode Description - ==== =================================================== - 'r' read from url - 'w' write to url - 'f' filter data defined by fg - 'v' operate on video stream, 'vv' if multi-video reader - 'a' operate on audio stream, 'aa' if multi-audio reader - ==== =================================================== - - `rate` and `rate_in`: Video frame rates shall be given in frames/second and - may be given as a number, string, or `fractions.Fraction`. Audio sample rate in - samples/second (per channel) and shall be given as an integer or string. - - Optional `shape` or `shape_in` for video defines the video frame size and - number of components with a 2 or 3 element sequence: `(width, height[, ncomp])`. - The number of components and other optional `dtype` (or `dtype_in`) implicitly - define the pixel format (FFmpeg pix_fmt option): - - ===== ===== ========= =================================== - ncomp dtype pix_fmt Description - ===== ===== ========= =================================== - 1 \|u8 gray grayscale - 1 1: - raise ValueError( - f"Too many streams specified: {mode}. A {'write' if write else 'filter'} stream can only process one stream at a time." - ) - - if write: - if is_fg: - ValueError("Cannot write to a filtergraph.") - if rate_in is None: - raise ValueError( - "Missing required argument: rate_in. A write stream must specify the rate of the input media stream." - ) - if rate is not None: - raise ValueError( - "Invalid argument for a write stream: rate. To change rate, use FFmpeg 'r' argument for video stream or 'ar' argument for audio stream." - ) - if shape is not None: - raise ValueError( - "Invalid argument for a read stream: shape. To change shape, use FFmpeg 's' argument for video frame or 'ac' for the number of audio channels." - ) - else: # if filter - vars = [] - if rate_in is None: - vars.append("rate_in") - if rate is None: - vars.append("rate") - if len(vars): - vars = ", ".join(vars) - raise ValueError( - f"Missing required arguments: {vars}. A filter stream must specify the rates of both the input and output media streams." - ) - - try: - StreamClass = ( - { - 0: { - 0: _streams.SimpleAudioReader, - 1: _streams.SimpleAudioWriter, - 2: _streams.SimpleAudioFilter, - }, - 1: { - 0: _streams.SimpleVideoReader, - 1: _streams.SimpleVideoWriter, - 2: _streams.SimpleVideoFilter, - }, - }[video][write + 2 * filter] - if audio + video == 1 - else _streams.AviMediaReader - ) - except: - raise ValueError(f"Invalid/unsupported FFmpeg streaming mode: {mode}.") - - if len(url_fg) > 1 and not StreamClass.multi_read: - raise ValueError(f'Multi-input streaming is not supported in "{mode}" mode') - - # add other info to the arguments - args = (*url_fg,) if read else (*url_fg, rate_in) - if not read: - for k, v in ( - ("dtype_in", dtype_in), - ("shape_in", shape_in), - ("rate", rate), - ("shape", shape), - ): - if v is not None: - kwds[k] = v - - # instantiate the streaming object - return StreamClass(*args, **kwds) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py new file mode 100644 index 00000000..c9cafdf4 --- /dev/null +++ b/src/ffmpegio/_typing.py @@ -0,0 +1,465 @@ +"""ffmpegio object independent common type hints""" + +from __future__ import annotations + +from fractions import Fraction + +from typing_extensions import * + +if TYPE_CHECKING: + from namedpipe import NPopen + + from .threading import CopyFileObjThread + +# from typing_extensions import * + + +FFmpegOptionDict = dict[str, Any] +"""FFmpeg options with their values keyed by the option names without preceding dash. +For option flags (e.g., -y) without any value, use `None` or its alias `ffmpegio.FLAG`""" + +RawDataBlob = Any +"""any object to represent raw binary data supported by a data I/O plugin.""" + +DTypeString = LiteralString +"""Numpy array interface protocol typestr string + +The string format consists of 3 parts: a character describing the byteorder of the data +(`'<'`: little-endian, `'>'`: big-endian, `'|'`: not-relevant), a character code giving +the basic type of the array, and an integer providing the number of bytes the type uses. + +Three basic type character codes are relevant to `ffmpegio` package: + +===== ================ +code description +===== ================ +`'i'` Integer +`'u'` Unsigned integer +`'f'` Floating point +===== ================ + +See https://numpy.org/doc/stable/reference/arrays.interface.html for Numpy's +official documentation. +""" + +ShapeTuple = tuple[int, ...] +"""Tuple whose elements are the array size in each dimension. Each entry is an integer (a Python int).""" + + +RawStreamDef = tuple[int | Fraction, RawDataBlob] | tuple[RawDataBlob, FFmpegOptionDict] +"""2-element tuple to define a raw stream data + + It comes in two forms: rate-data or data-option. The rate-data form specifies + a pair of the frame rate (video) or sampling rate (audio) and the data blob. + The data-option form specifies the data blob and its FFmpeg options. Note + that a data-option tuple is only valid if its option dict contains the rate + field: `r` for video or `ar` for audio. + +""" + +RawStreamInfoTuple = tuple[DTypeString, ShapeTuple, int | Fraction] +"""3-element tuple (dtype, shape, rate) to characterize raw data stream""" + +ProgressCallable = Callable[[dict[str, Any], bool], bool] +"""FFmpeg progress callback function + + callback(status, done) + + status - dict of encoding status + done - True if the last callback + + The callback may return True to cancel the FFmpeg execution. +""" + +MediaType = Literal["audio", "video"] +"""supported media stream types + +=============== ================================================================ +value description +=============== ================================================================ +`'video'` video stream +`'audio'` audio stream +=============== ================================================================ +""" + +FFmpegMediaType = Literal["video", "audio", "subtitle", "data", "attachments"] +"""FFmpeg media stream types + +=============== ================================================================ +value description +=============== ================================================================ +`'video'` video stream +`'audio'` audio stream +`'subtitle'` subtitle stream +`'data'` data stream +`'attachments'` attachments stream +=============== ================================================================ +""" + +FFmpegUrlType = str +"""input and output file/stream urls (str or a stringifiable object) +""" + +FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] +"""mechanisms to feed encoded input data to FFmpeg input pipe + +=============== ================================================================ +value description +=============== ================================================================ +`'url'` path to the input file or streaming url +`'filtergraph'` input filtergraph +`'buffer'` binary input data given as a bytes-like object or to be piped in +`'fileobj'` open readable file object +=============== ================================================================ +""" + +FFmpegOutputType = Literal["url", "fileobj", "buffer"] +"""mechanisms to extract encoded output data from FFmpeg output pipe + +=============== ============================================================================ +value description +=============== ============================================================================ +`'url'` path to the output file or streaming url +`'buffer'` buffer output data as `RawDataBlob` (raw stream) or `bytes` (encoded stream) +`'fileobj'` open readable file object +=============== ============================================================================ +""" + +################## +# Plugin protocols +################## + + +class GetInfoCallable(Protocol): + """Plugin function prototype to get information of a raw data blob object + + A plugin may implement this prototype with `audio_info()` for audio stream or + `video_info()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return shape: tuple of `int`s of the raw data shape + :return dtype: numpy dtype string of a video/image pixel or an audio sample + """ + + def __call__(self, *, obj: object) -> tuple[ShapeTuple, DTypeString]: ... + + +class ToBytesCallable(Protocol): + """Plugin function prototype to convert raw data blob object to a byte buffer + + A plugin may implement this prototype with `audio_bytes()` for audio stream or + `video_bytes()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return: a FFmpeg raw media stream compatible bytes + """ + + def __call__(self, *, obj: object) -> memoryview: ... + + +class CountDataCallable(Protocol): + """Plugin function prototype to count a number of video frames/audio samples + + A plugin may implement this prototype with `audio_samples()` for audio stream or + `video_frames()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return: number of video frames or of audio samples + """ + + def __call__(self, *, obj: object) -> int: ... + + +class FromBytesCallable(Protocol): + """Plugin function prototype to convert FFmpeg output bytes to raw data blob + + A plugin may implement this prototype with `bytes_to_audio()` for audio stream or + `bytes_to_video()` for video stream. + + :param b: FFmpeg output of raw audio/video/image frames + :param dtype: numpy dtype string of pixel/sample data format + :param shape: tuple of the dimension of one video frame or one audio sample. + Audio: (channels,), Video: (height, width, components) + :param squeeze: True to remove all dimensions with length 1 + :return: Plugin-specific raw data blob object + """ + + def __call__( + self, b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool + ) -> object: ... + + +class IsEmptyCallable(Protocol): + """Plugin function prototype to check if data blob contains no data + + A plugin may implement this prototype with `audio_samples()` for audio stream or + `video_frames()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return: True if the blob contains no data + """ + + def __call__(self, *, obj: object) -> bool: ... + + +###### + + +class RawInputInfoDict(TypedDict): + """raw input media stream information + + =============== ================================================================ + key description + =============== ================================================================ + `'src_type'` always `'buffer'` + `'media_type'` media stream identifier: `'audio'` or '`video'` + `'raw_info'` tuple of (rate, shape, dtype) + `'item_size` size of each frame/sample in bytes + `'data2bytes'` conversion function + `'data_is_empty'` function to check empty data frame + `'data_count'` function to count number of frames/samples in a blob + `'buffer'` (optional) known media data blobs to be input (typically for + a batch operation) + `'pipe'` (optional) named pipe assigned to this data stream + `'writer'` (optional) writer thread assigned to this data stream + =============== ================================================================ + """ + + src_type: Literal["buffer"] + """True if file path/url""" + media_type: MediaType + """media type if input pipe""" + raw_info: RawStreamInfoTuple + """tuple of (rate, shape, dtype)""" + item_size: int + """size of each frame/sample in bytes""" + data2bytes: ToBytesCallable + """converts a Python data blob to raw media bytes""" + data_is_empty: IsEmptyCallable + """returns True if the data blob is empty""" + data_count: CountDataCallable + """returns number of frames in the data blob""" + buffer: NotRequired[object] + """stores data blob (typically for batch operation)""" + + +class UrlEncodedInputInfoDict(TypedDict): + """url/filtergraph encoded input source info""" + + src_type: Literal["url", "filtergraph"] + """input data is from a url/file or from an input filtergraph""" + + +class PipedEncodedInputInfoDict(TypedDict): + """piped encoded input source info""" + + src_type: Literal["buffer"] + buffer: NotRequired[bytes] # index of the source index + + +class FileObjEncodedInputInfoDict(TypedDict): + """fileobj encoded input info""" + + src_type: Literal["fileobj"] + fileobj: IO # file object + + +EncodedInputInfoDict = ( + UrlEncodedInputInfoDict | PipedEncodedInputInfoDict | FileObjEncodedInputInfoDict +) +"""encoded input container stream information + +=============== ================================================================ +key description +=============== ================================================================ +`'src_type'` `'url'`, `'filtergraph'`, `'buffer'`, or `'fileobj'` +`'buffer'` (optional for `src_type = 'buffer') known media data bytes to be + input (typically for a batch operation) +=============== ================================================================ +""" + +InputInfoDict = RawInputInfoDict | EncodedInputInfoDict + + +class PipeWriter(Protocol): + def write(self, data: bytes | None): ... + def join(self): ... + def closed(self) -> bool: ... + + +class PipeReader(Protocol): + def read(self, n: int = -1) -> bytes: ... + def join(self): ... + def cool_down(self): ... + + +class InputPipeInfoDict(TypedDict): + """ + ========== ========================================== + `'pipe'` named pipe assigned to this data stream + `'writer'` writer thread assigned to this data stream + ========== ========================================== + """ + + pipe: NPopen | Literal["stdin"] + """named pipe assigned to this data stream""" + writer: PipeWriter + """writer thread assigned to this data stream""" + + +################################################## + + +class RawDirectOutputInfoDict(TypedDict): + """raw output media stream info + + =================== ================================================================ + key description + =================== ================================================================ + `'dst_type'` `'buffer'` + `'media_type'` media stream identifier: `'audio'` or '`video'` + `'raw_info'` tuple of (dtype, shape, rate) + `'item_size` size of each frame/sample in bytes + `'bytes2data'` function to convert bytes to raw data blob + `'data_is_empty'` function to check empty data frame + `'data_count'` function to count number of frames/samples in a blob + `'user_map'` user specified FFmpeg map option of this stream + `'squeeze'` True to squeeze output shape (remove all length-1 dims) + `'input_file_id'` input file id + `'input_stream_id'` input stream id + =================== ================================================================ + """ + + dst_type: Literal["buffer"] # True if file path/url + media_type: MediaType # + raw_info: RawStreamInfoTuple + bytes2data: FromBytesCallable + item_size: int + data_is_empty: IsEmptyCallable + data_count: CountDataCallable + user_map: str # user specified map option + squeeze: bool + input_file_id: NotRequired[int] + input_stream_id: NotRequired[int] + + +class RawFilteredOutputInfoDict(TypedDict): + """raw output media stream info + + =================== ================================================================ + key description + =================== ================================================================ + `'dst_type'` `'buffer'` + `'media_type'` media stream identifier: `'audio'` or '`video'` + `'raw_info'` tuple of (dtype, shape, rate) + `'item_size` size of each frame/sample in bytes + `'bytes2data'` function to convert bytes to raw data blob + `'data_is_empty'` function to check empty data frame + `'data_count'` function to count number of frames/samples in a blob + `'user_map'` user specified FFmpeg map option of this stream + `'squeeze'` True to squeeze output shape (remove all length-1 dims) + `'linklabel'` mapped filtergraph output label + =============== ================================================================ + """ + + dst_type: Literal["buffer"] # True if file path/url + media_type: MediaType # + raw_info: RawStreamInfoTuple + item_size: int + bytes2data: FromBytesCallable + data_is_empty: IsEmptyCallable + data_count: CountDataCallable + user_map: str # user specified map option + squeeze: bool + linklabel: str + + +RawOutputInfoDict = RawDirectOutputInfoDict | RawFilteredOutputInfoDict +"""raw output media stream info + +=================== ================================================================ +key description +=================== ================================================================ +`'dst_type'` `'buffer'` +`'media_type'` media stream identifier: `'audio'` or '`video'` +`'raw_info'` tuple of (dtype, shape, rate) +`'bytes2data'` function to convert bytes to raw data blob +`'data_is_empty'` function to check empty raw data blob +`'data_count'` function to count number of frames/samples in a blob +`'squeeze'` True to squeeze output shape (remove all length-1 dims) +`'input_file_id'` (optional) input file id if there is no complex filtergraph +`'input_stream_id'` (optional) input stream id if there is no complex filtergraph +`'linklabel'` (optional) mapped filtergraph output label if there is complex + filtergraph +=============== ================================================================ +""" + + +class UrlOrPipedEncodedOutputInfoDict(TypedDict): + """url/filtergraph encoded input source info""" + + dst_type: Literal["url", "buffer"] + """output data goes to either a url/filepath or a pipe""" + + +class FileObjEncodedOutputInfoDict(TypedDict): + """fileobj encoded input info""" + + dst_type: Literal["fileobj"] + fileobj: IO # file object + + +EncodedOutputInfoDict = UrlOrPipedEncodedOutputInfoDict | FileObjEncodedOutputInfoDict +"""encoded output container stream information + +=============== ================================================================ +key description +=============== ================================================================ +`'src_type'` `'url'`, `'filtergraph'`, `'buffer'`, or `'fileobj'` +`'buffer'` (optional for `src_type = 'buffer') known media data bytes to be + input (typically for a batch operation) +`'pipe'` (optional for `src_type` is `'buffer'` or `'fileobj'`) + named pipe assigned to this data stream +`'writer'` (optional for `src_type` is `'buffer'` or `'fileobj'`) + writer thread assigned to this data stream +=============== ================================================================ +""" + + +OutputInfoDict = RawOutputInfoDict | EncodedOutputInfoDict +"""combined output info""" + + +class OutputPipeInfoDict(TypedDict): + """ + =============== ================================================================ + `'pipe'` named pipe assigned to this data stream + `'reader'` reader thread assigned to this data stream + `'itemsize'` (optional) one frame/sample size in bytes + `'nmin'` (optional) minimum read block size + =============== ================================================================ + """ + + pipe: NPopen | Literal["stdout"] + reader: PipeReader | CopyFileObjThread + itemsize: NotRequired[int] + nmin: NotRequired[int] + + +################################################## + + +class AudioFilterGraphInfoDict(TypedDict): + media_type: Literal["audio"] + sample_fmt: str + ac: int + ar: int + + +class VideoFilterGraphInfoDict(TypedDict): + media_type: Literal["video"] + r: int | Fraction + pix_fmt: str + + +FilterGraphInfoDict = AudioFilterGraphInfoDict | VideoFilterGraphInfoDict diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 7f81e99b..d138064e 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -1,3 +1,18 @@ +"""common-across-subpackages utility functions that are not dependent on ffmpegio types and other functions""" + +from __future__ import annotations + +import re +import urllib.parse +from io import IOBase +from pathlib import Path +from typing import Any, Sequence + +import numpy as np +from namedpipe import NPopen + +from ._typing import DTypeString, ShapeTuple + try: from math import prod except: @@ -6,18 +21,97 @@ prod = lambda seq: reduce(mul, seq, 1) +from builtins import zip as builtin_zip + + +def zip(*args, strict=False): + + # backwards compatibility for pre-py3.10 + + try: + return builtin_zip(*args, strict=strict) + except TypeError: + if strict is False: + return builtin_zip(*args) + + def strict_zip(): + # strict=True case, excerpted from PEP618: https://peps.python.org/pep-0618/ + iterators = tuple(iter(iterable) for iterable in args) + try: + while True: + items = [] + for iterator in iterators: + items.append(next(iterator)) + yield tuple(items) + except StopIteration: + pass + + if items: + i = len(items) + plural = " " if i == 1 else "s 1-" + msg = f"zip() argument {i+1} is shorter than argument{plural}{i}" + raise ValueError(msg) + sentinel = object() + for i, iterator in enumerate(iterators[1:], 1): + if next(iterator, sentinel) is not sentinel: + plural = " " if i == 1 else "s 1-" + msg = f"zip() argument {i+1} is longer than argument{plural}{i}" + raise ValueError(msg) + + return strict_zip() + + +def is_non_str_sequence( + value: Any, class_excluded: type | tuple[type, ...] = str +) -> bool: + """Returns true if value is a sequence but not a str object""" + return isinstance(value, Sequence) and not isinstance(value, class_excluded) + -def dtype_itemsize(dtype): +def as_multi_option( + value: Any, exclude_classes: tuple[type] = str, SeqCls: type = list +) -> Sequence[Any] | None: + """Put value in a list if it is not already a sequence + + :param value: value to be put in a list + :param exclude_classes: sequence classes to be treated as an option value, defaults to None + :param SeqCls: output sequence type + :return: option value in a sequence, unless value is `None` + """ + + return ( + value + if value is None or isinstance(value, SeqCls) + else ( + SeqCls(value) + if isinstance(value, Sequence) and not isinstance(value, exclude_classes) + else SeqCls([value]) + ) + ) + + +def dtype_itemsize(dtype: DTypeString) -> int: + """get the byte size of each dtype sample + + :param dtype: numpy-style data type string + :return: number of bytes per audio sample/video pixel + """ return int(dtype[-1]) -def get_samplesize(shape, dtype): +def get_samplesize(shape: ShapeTuple, dtype: DTypeString) -> int: + """get the byte size of each video frame or audio sample + + :param shape: sample shape + :param dtype: numpy-style data type string + :return: number of bytes per audio sample (all channels) or video frame + """ return prod(shape) * dtype_itemsize(dtype) def deprecate_core(): - from importlib import metadata import warnings + from importlib import metadata try: metadata.version("ffmpegio-core") @@ -27,3 +121,149 @@ def deprecate_core(): warnings.warn( message="!!PACKAGE CONFLICT!! ffmpegio-core distribution package has been deprecated. Please read the following link for the instructions: https://github.com/python-ffmpegio/python-ffmpegio/wiki/Instructions-to-upgrade-to-v0.11.0." ) + + +def is_url(value: Any, *, pipe_ok: bool = False) -> bool: + """True if input/output url string path parsed URL + :param pipe_ok: True to allow FFmpeg pipe protocol string""" + return ( + pipe_ok or not is_pipe(value) + if isinstance(value, str) + else isinstance(value, (Path, urllib.parse.ParseResult)) + ) + + +def is_pipe(value: Any) -> bool: + """True if FFmpeg pipe protocol string""" + try: + return value == "-" or bool(re.match(r"pipe(\:\d*)?", value)) + except: + return False + + +def is_namedpipe( + value: Any, *, readable: bool | None = None, writable: bool | None = None +) -> bool: + """True if named pipe object + + :param readable: True to test for readable pipe, False to test for non-readable pipe, defaults to None (either) + :param writable: True to test for writable pipe, False to test for non-writable pipe, defaults to None (either) + """ + return ( + isinstance(value, NPopen) + and (readable is None or value.readable() is readable) + and (writable is None or value.writable() is writable) + ) + + +def is_fileobj( + value: Any, + *, + seekable: bool | None = None, + readable: bool | None = None, + writable: bool | None = None, +) -> bool: + """True if file object + + :param readable: True to test for readable pipe, False to test for non-readable pipe, defaults to None (either) + :param writable: True to test for writable pipe, False to test for non-writable pipe, defaults to None (either) + """ + + if not isinstance(value, IOBase): + return False + + if seekable is True and not value.seekable(): + raise ValueError("Requested seekable file object but it's not seekable.") + elif seekable is False and value.seekable(): + raise ValueError("Requested non-seekable file object but it is seekable.") + + if readable is True and not value.readable(): + raise ValueError("Requested readable file object but it's not readable.") + elif readable is False and value.readable(): + raise ValueError("Requested non-readable file object but it is readable.") + + if writable is True and not value.writable(): + raise ValueError("Requested writable file object but it's not writable.") + elif writable is False and value.writable(): + raise ValueError("Requested non-writable file object but it is writable.") + + return True + + +def escape(txt: str) -> str: + """apply FFmpeg single quote escaping + + :param txt: Unescaped string + :return: Escaped string + + See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping + """ + + txt = str(txt) + + if re.search(r"\s", txt, re.MULTILINE): + # quote if txt has any white space + txt = txt.replace("'", r"'\''") + return f"'{txt}'" + else: + # if not quoted, escape quotes and backslashes + return re.sub(r"(['\\])", r"\\\1", txt) + + +def unescape(txt: str) -> str: + """undo FFmpeg single quote escaping + + :param txt: Escaped string + :return: Original string + + See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping + """ + + n = len(txt) + if not n: + return txt + + re_start = re.compile(r"[^\\](?:\\\\)*'") + re_sub = re.compile(r"\\([\\'])") + + blks = [] + + # look for a first quoted text block + m = re.search(r"(?:^|[^\\])(?:\\\\)*'", txt) + if m: + i0 = m.end() + if i0 > 1: + # unescape the initial unquoted block + blks.append(re_sub.sub(r"\1", txt[0 : i0 - 1])) + else: + # no quoted text block, unescape the whole string + return re_sub.sub(r"\1", txt) + + # always starts with quoted block + in_quote = True + + while i0 < n: + + if in_quote: + # find the end quote + i1 = txt.find("'", i0) + if i1 < 0: + raise ValueError("incorrectly escaped text: missing a closing quote.") + blks.append(txt[i0:i1]) + else: + # find the next starting quote + m = re_start.search(txt, i0 - 1) + i1 = m.end() - 1 if m else n + blks.append(re_sub.sub(r"\1", txt[i0:i1])) + i0 = i1 + 1 + in_quote = not in_quote + + return "".join(blks) + + +def get_bytesize(shape: ShapeTuple | None, dtype: DTypeString | None) -> int | None: + return ( + None + if shape is None or dtype is None + else prod(shape) * np.dtype(dtype).itemsize + ) diff --git a/src/ffmpegio/analyze.py b/src/ffmpegio/analyze.py index 8a946e76..5c0b5f4e 100644 --- a/src/ffmpegio/analyze.py +++ b/src/ffmpegio/analyze.py @@ -3,22 +3,23 @@ """ from __future__ import annotations -from collections import namedtuple -from abc import ABC + import logging +from abc import ABC +from collections import namedtuple logger = logging.getLogger("ffmpegio") -from . import configure -from .filtergraph import Graph, Filter, Chain, as_filtergraph -from .utils.filter import compose_filter -from .errors import FFmpegError -from .path import devnull -from . import ffmpegprocess as fp import re from json import loads +from typing import Any, List, NamedTuple, Optional, Tuple -from typing import Any, Tuple, NamedTuple, List, Optional +from . import configure +from . import ffmpegprocess as fp +from .errors import FFmpegError +from .filtergraph import Chain, Filter, Graph, as_filtergraph +from .filtergraph.utils import compose_filter +from .path import devnull try: from typing import Literal diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 8c3041c7..64017d79 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -1,118 +1,67 @@ -"""Audio Read/Write Module -""" +"""Audio Read/Write Module""" +from __future__ import annotations + +import logging import warnings -from . import ffmpegprocess, utils, configure, FFmpegError, plugins, analyze -from .probe import _audio_info as _probe_audio_info -from .utils import log as log_utils + +from . import analyze, configure, utils +from . import filtergraph as fgb +from ._typing import Any, ProgressCallable, RawDataBlob +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, +) +from .errors import FFmpegioError +from .filtergraph.abc import FilterGraphObject +from .std_runners import run_and_return_encoded, run_and_return_raw + +logger = logging.getLogger("ffmpegio") __all__ = ["create", "read", "write", "filter", "detect"] -def _run_read( +def create( + expr: str | fgb.abc.FilterGraphObject, *args, - sample_fmt_in=None, - ac_in=None, - ar_in=None, - show_log=None, - sp_kwargs=None, - **kwargs, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, ): - """run FFmpeg and retrieve audio stream data - :param *args ffmpegprocess.run arguments - :type *args: tuple - :param sample_fmt_in: input sample format if known but not specified in the ffmpeg arg dict, defaults to None - :type sample_fmt_in: str, optional - :param ac_in: number of input channels if known but not specified in the ffmpeg arg dict, defaults to None - :type ac_in: int, optional - :param ar_in: input sampling rate if known but not specified in the ffmpeg arg dict, defaults to None - :type ar_in: int, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param **kwargs ffmpegprocess.run keyword arguments - :type **kwargs: tuple - :return: [description] - :rtype: [type] - """ - """ - - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :rtype: (int, str) - """ - - dtype, ac, rate = configure.finalize_audio_read_opts( - args[0], sample_fmt_in, ac_in, ar_in - ) - - if sp_kwargs is not None: - kwargs = {**sp_kwargs, **kwargs} - - if dtype is None or ac is None or rate is None: - configure.clear_loglevel(args[0]) - - out = ffmpegprocess.run(*args, capture_log=True, **kwargs) - if show_log: - print(out.stderr) - if out.returncode: - raise FFmpegError(out.stderr) - - info = log_utils.extract_output_stream(out.stderr) - - ac = info.get("ac", None) - rate = info.get("ar", None) - else: - out = ffmpegprocess.run( - *args, - capture_log=None if show_log else True, - **kwargs, - ) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - - return rate, plugins.get_hook().bytes_to_audio( - b=out.stdout, dtype=dtype, shape=(ac,), squeeze=False - ) - - -def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options): """Create audio data using an audio source filter :param expr: name of the source filter or full filter expression - :type expr: str - :param \\*args: sequential filter option arguments. Only valid for - a single-filter expr, and they will overwrite the - options set by expr. - :type \\*args: seq, optional + :param args: sequential filter option arguments. Only valid for + a single-filter expr, and they will overwrite the + options set by expr. + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, + eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**options: Named filter options or FFmpeg options. Items are - only considered as the filter options if expr is a - single-filter graph, and take the precedents over - general FFmpeg options. Append '_in' for input - option names (see :doc:`options`), and '_out' for - output option names if they conflict with the filter - options. - :type \\**options: dict, optional - :return: sampling rate and audio data (a plugin may change this behavior - with the `bytes_to_audio` hook.) - :rtype: tuple[int, object] + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + :param options: Named filter options or FFmpeg options. Items are + only considered as the filter options if expr is a + single-filter graph, and take the precedents over + general FFmpeg options. Append '_in' for input + option names (see :doc:`options`), and '_out' for + output option names if they conflict with the filter + options. + :return rate: sample rate in samples/second + :return data: audio data object specified by the selected ``bytes_to_audio`` + plugin hook (set by :py:func:`ffmpegio.use`). (pre v0.12.0) the output shape is always 2D with the time + axis in the first dimension. (since v0.12.0) The shape is default to 1D + if data is monaural. .. seealso:: https://ffmpeg.org/ffmpeg-filters.html#Audio-Sources for available @@ -129,54 +78,67 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) """ - input_options = utils.pop_extra_options(options, "_in") - output_options = utils.pop_extra_options(options, "_out") url, t_, options = configure.config_input_fg(expr, args, options) - options = {**options, **output_options} - if ( - t_ is None - and not any(a in input_options for a in ("t", "to")) - and not any(a in options for a in ("t", "to", "frames:a", "aframes")) + if t_ is None and not any( + a in options for a in ("t_in", "to_in", "t", "to", "frames:a", "aframes") ): warnings.warn( "neither input nor output duration specified. this function call may hang." ) - ffmpeg_args = configure.empty() - inopts = configure.add_url( - ffmpeg_args, "input", url, {**input_options, "f": "lavfi"} - )[1][1] - configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - - return _run_read( - ffmpeg_args, - sample_fmt_in=inopts.get("sample_fmt", "dbl"), + return read( + url, + squeeze=squeeze, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs, + **options, ) -def read(url, progress=None, show_log=None, sp_kwargs=None, **options): +def read( + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + *, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[int, RawDataBlob]: """Read audio samples. - :param url: URL of the audio file to read. - :type url: str + :param url: URL of the audio file to read or a list of URLs to be used by + complex filtergraph. Each url may be accompanied by its own input + options (a tuple pair of url and its option dict). These options + supersede the input options given with keyword arguments with `'_in'` + suffix. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be url string or a pair of a url string and + an option dict. + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, eliminating + the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional + :param show_log: True to show FFmpeg log messages on the console, defaults + to None (no show/capture). Ignored if stream format must be retrieved + automatically. :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: sample rate in samples/second and audio data object specified by `bytes_to_audio` plugin hook - :rtype: tuple(float, object) + `subprocess.Popen()` call used to run the FFmpeg, defaults to None + :param options: FFmpeg options, append '_in' for input option names + (see :doc:`options`) + :return rate: sample rate in samples/second + :return data: audio data object specified by selected `bytes_to_audio` + plugin hook. (pre v0.12.0) the output shape is always 2D with the time + axis in the first dimension. (since v0.12.0) The shape is default to 1D + data is monaural. .. note:: Even if :code:`start_time` option is set, all the prior samples will be read. The retrieved data will be truncated before returning it to the caller. @@ -187,163 +149,167 @@ def read(url, progress=None, show_log=None, sp_kwargs=None, **options): """ - sample_fmt = options.get("sample_fmt", None) - ac_in = ar_in = None - if sample_fmt is None: - try: - # use the same format as the input - ar_in, sample_fmt, ac_in = _probe_audio_info(url, "a:0", sp_kwargs) - except: - sample_fmt = "s16" - - input_options = utils.pop_extra_options(options, "_in") - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) - ) - - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options)[1][1] - configure.add_url(ffmpeg_args, "output", "-", options)[1][1] + # use user-specified map or default '0:a:0' map + output_map = options.pop("map", "0:a:0") - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = stdin - sp_kwargs["input"] = input + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_read( + [url] if utils.is_valid_input_url(url) else url, + [output_map], + options, + extra_outputs, + squeeze, + ) - return _run_read( - ffmpeg_args, - sample_fmt_in=sample_fmt, - ac_in=ac_in, - ar_in=ar_in, - progress=progress, - show_log=show_log, - sp_kwargs=sp_kwargs, + if output_info is None: + raise FFmpegioError( + "Unknown configuration error occurred. Necessary output information could not be collected." + ) + if output_info[0]["media_type"] != "audio": + raise ValueError("Mapped stream is not an audio stream.") + + return run_and_return_raw( + args, + input_info, + output_info, + progress, + show_log, + sp_kwargs, ) def write( - url, - rate_in, - data, - progress=None, - overwrite=None, - show_log=None, - extra_inputs=None, - sp_kwargs=None, + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + rate_in: int, + data: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, **options, ): - """Write a NumPy array to an audio file. + """Write a raw audio data blob to an audio file. :param url: URL of the audio file to write. - :type url: str :param rate_in: The sample rate in samples/second. - :type rate_in: int :param data: input audio data object, converted to bytes by `audio_bytes` plugin hook . - :type data: object :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :type extra_inputs: seq(str|(str,dict)) :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - url, stdout, _ = configure.check_url(url, True) - input_options = utils.pop_extra_options(options, "_in") + # if filter_complex is not defined use '0:a:0' as default mapping + if ( + not any( + (o in options) + for o in ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + ) + or "map" not in options + ): + options["map"] = "0:a:0" - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_audio_input(rate_in, data=data, **input_options), + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_write( + url, [{"ar": rate_in}], extra_inputs, options, [data] ) - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - configure.add_url(ffmpeg_args, "output", url, options) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - { - "input": plugins.get_hook().audio_bytes(obj=data), - "stdout": stdout, - "progress": progress, - "overwrite": overwrite, - } + return run_and_return_encoded( + progress, overwrite, show_log, sp_kwargs, args, input_info, output_info ) - kwargs["capture_log"] = None if show_log else False - - out = ffmpegprocess.run(ffmpeg_args, **kwargs) - if out.returncode: - raise FFmpegError(out.stderr, show_log) def filter( - expr, - input_rate, - input, - sample_fmt=None, - progress=None, - show_log=None, - sp_kwargs=None, + expr: str | FilterGraphObject | None, + input_rate: int, + input: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, **options, -): +) -> tuple[int, RawDataBlob]: """Filter audio samples. - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None + :param expr: filter graph or None if implicit filtering via output options. :param input_rate: Input sample rate in samples/second - :type input_rate: int :param input: input audio data, accessed by `audio_info()` and `audio_bytes()` plugin hooks. - :type input: object + :param extra_inputs: list of additional input sources, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be url string or a pair of + a url string and an option dict. + :param squeeze: False to always returning 2D data with the 2nd dimension as + the audio channels, defaults to True to reduce monaural data + to 1D, eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: output sampling rate and audio data object, created by `bytes_to_audio` plugin hook - :rtype: tuple(int, object) + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :return rate: sample rate in samples/second + :return data: audio data object specified by selected `bytes_to_audio` plugin hook. + (pre v0.12.0) the output shape is always 2D with the time axis in the + first dimension. (since v0.12.0) The shape is default to 1D if + data is monaural. To match the shape """ - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_audio_input(input_rate, data=input, **input_options), + if expr is not None: + if extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:a"] = expr + options["map"] = "0:a:0" + else: + options["filter_complex"] = expr + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + [{"ar": input_rate}], + extra_inputs, + None, + extra_outputs, + options, + squeeze, + [input], ) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - outopts["sample_fmt"] = sample_fmt - if expr: - outopts["filter:a"] = expr - - return _run_read( - ffmpeg_args, - input=plugins.get_hook().audio_bytes(obj=input), - progress=progress, - show_log=show_log, - sp_kwargs=sp_kwargs + + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") + + return run_and_return_raw( + args, input_info, output_info, progress, show_log, sp_kwargs ) diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index c7425030..a96d515f 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -4,13 +4,15 @@ logger = logging.getLogger("ffmpegio") -import re, fractions, subprocess as sp +import fractions +import re +import subprocess as sp from collections import namedtuple from fractions import Fraction from functools import partial -from .path import ffmpeg as _ffmpeg from .errors import FFmpegError +from .path import ffmpeg as _ffmpeg # fmt:off __all__ = ["options", "filters", "codecs", "coders", "formats", "devices", @@ -30,7 +32,7 @@ ) # g _formatRegexp = re.compile(r"([D ]) *([E ]) +(\S+) +(.*)") # g _filterRegexp = re.compile( - r"([T.])([S.])([C.])\s+(\S+)\s+(A+|V+|N|\|)->(A+|V+|N|\|)\s+(.*)" + r"([T.])([S.])([C.])?\s+(\S+)\s+(A+|V+|N|\|)->(A+|V+|N|\|)\s+(.*)" ) # g _cache = dict() @@ -185,17 +187,21 @@ def filters(type=None): data[match[4]] = FilterSummary( description=match[7], input=intype, - num_inputs=0 - if intype == "none" - else len(match[5]) - if intype != "dynamic" - else None, + num_inputs=( + 0 + if intype == "none" + else len(match[5]) + if intype != "dynamic" + else None + ), output=outtype, - num_outputs=0 - if outtype == "none" - else len(match[6]) - if outtype != "dynamic" - else None, + num_outputs=( + 0 + if outtype == "none" + else len(match[6]) + if outtype != "dynamic" + else None + ), timeline_support=match[1] == "T", slice_threading=match[2] == "S", command_support=match[3] == "C", @@ -421,7 +427,7 @@ def devices(type=None): try: key = {"source": "can_demux", "sink": "can_mux"}[type] except: - raise ValueError(f'type must be either "source" or "sink"') + raise ValueError('type must be either "source" or "sink"') return {k: v for k, v in devs.items() if v[key]} return devs @@ -472,7 +478,7 @@ def _getFormats(type, doCan): data = {} for match in _formatRegexp.finditer(stdout): for format in match[3].split(","): - if not (format in data): + if format not in data: data[format] = {"description": match[4]} if doCan: data[format]["can_demux"] = match[1] == "D" @@ -677,6 +683,9 @@ def demuxer_info(name): stdout, ) + if m is None: + raise FFmpegError(stdout) + data = dict( names=m[1].split(","), long_name=m[2], @@ -684,7 +693,7 @@ def demuxer_info(name): options=m[4], ) - if not "demuxer" in _cache: + if "demuxer" not in _cache: _cache["demuxer"] = {} _cache["demuxer"][name] = data return data @@ -723,6 +732,9 @@ def muxer_info(name): stdout, ) + if m is None: + raise FFmpegError(stdout) + data = { "names": m[1].split(","), "long_name": m[2], @@ -733,7 +745,7 @@ def muxer_info(name): "subtitle_codecs": m[7].split(",") if m[7] else [], "options": m[8], } - if not "muxer" in _cache: + if "muxer" not in _cache: _cache["muxer"] = {} _cache["muxer"][name] = data return data @@ -815,6 +827,9 @@ def _getCodecInfo(name, encoder): stdout, ) + if m is None: + raise FFmpegError(stdout) + def resolveFs(s): m = re.match(r"(\d+)\/(\d+)", s) return fractions.Fraction(int(m[1]), int(m[2])) @@ -838,7 +853,7 @@ def resolveFs(s): "options": m[11], } - if not "muxer" in _cache: + if "muxer" not in _cache: _cache["muxer"] = {} _cache["muxer"][name] = data return data @@ -878,24 +893,32 @@ def _conv_func(type, s): return s -def _get_filter_option_constant(str): +def _get_filter_option_constant( + str: str, is_flag: bool = False +) -> tuple[str, str] | tuple[tuple[str, int], str]: + # from libavutil/opts.c opt_list() with flags AV_OPT_FLAG_FILTERING_PARAM and AV_OPT_TYPE_CONST + m = re.match( - r" ([^ \n]+) {1,16}(?:([^ ]+) {1,12}| {13})" - r"[.E][.D][.F][.V][.A][.S][.X][.R][.B][.T][.P]" - r"(?: (.+))?\n?", + r" (.+?)[.E][.D][.F][.V][.A][.S][.X][.R][.B][.T][.P](?: (.+))?", str, ) - return m[1], (m[2] and int(m[2]), m[3] or "") + desc = m[2] or "" + + if is_flag: + return m[1].strip(), desc + else: + name, intval = m[1].rsplit(maxsplit=1) + return (name, int(intval)), desc def _get_filter_option(str, name): - # libavutil/opt.c/opt_list + # from libavutil/opts.c opt_list() with flags AV_OPT_FLAG_FILTERING_PARAM + lines = str.splitlines() # first line is the main option definition m0 = re.match( - r" (?: |-)?([^ \n]+) {1,17}(?:\<([^ >]+)\> {1,12}| {13})" - r"[.E][.D][.F]([.V])([.A])[.S][.X][.R][.B]([.T])[.P]", + r" (?: |-)(.+?)\s+\[?\<(.+?)\>\s*\]?[.E][.D][.F]([.V])([.A])[.S][.X][.R][.B]([.T])[.P]", lines[0], ) if not m0: @@ -904,7 +927,7 @@ def _get_filter_option(str, name): f"_get_filter_option(): invalid option line found for {name} filter. Likely deprecated:\n{lines[0]}" ) return None - name, type, *flags = m0.groups() + name, otype, *flags = m0.groups() m1 = re.search(r"( \(from \S+? to \S+?\))*(?: \(default (.+)\))?$", lines[0]) ranges_str, default = m1.groups() @@ -912,20 +935,22 @@ def _get_filter_option(str, name): help = lines[0][m0.end() + 1 : m1.start()] if default: - if type == "string": + if otype == "string": # remove quotes default = default[1:-1] - elif type == "boolean": + elif otype == "boolean": default = {"true": True, "false": False}.get(default, default) conv = ( partial(_conv_func, int) - if type in ("int", "int64", "uint64") - else partial(_conv_func, float) - if type in ("float", "double") - else partial(_conv_func, Fraction) - if type == "rational" - else (lambda s: s) + if otype in ("int", "int64", "uint64") + else ( + partial(_conv_func, float) + if otype in ("float", "double") + else partial(_conv_func, Fraction) + if otype == "rational" + else (lambda s: s) + ) ) ranges = ( @@ -937,30 +962,43 @@ def _get_filter_option(str, name): ] ) - constants = [_get_filter_option_constant(l) for l in lines[1:] if l] - - if len(constants): - # combines aliases - def chk_is_alias(i, o): - other = constants[i] - return other[1] == o[1] - - has_alias = [chk_is_alias(i, o) for i, o in enumerate(constants[1:])] - has_alias.append(False) - for i, has in enumerate(has_alias): - k, v = constants[i] - constants[i] = (k, (constants[i + 1][0] if has else None, *v)) - - has_alias.insert(0, False) - constants = [o for o, isa in zip(constants, has_alias[:-1]) if not isa] + constants = [ + _get_filter_option_constant(l, otype == "flags") for l in lines[1:] if l + ] + + if not len(constants): + cdict = None + elif otype == "int": + # add int values as constant entries + cdict = {} + for (k, kint), v in constants: + cdict[k] = v + cdict[kint] = v + else: + cdict = dict(constants) + + # if len(constants): + # # combines aliases + # def chk_is_alias(i, o): + # other = constants[i] + # return other[1] == o[1] + + # has_alias = [chk_is_alias(i, o) for i, o in enumerate(constants[1:])] + # has_alias.append(False) + # for i, has in enumerate(has_alias): + # k, v = constants[i] + # constants[i] = (k, (constants[i + 1][0] if has else None, *v)) + + # has_alias.insert(0, False) + # constants = [o for o, isa in zip(constants, has_alias[:-1]) if not isa] return FilterOption( name, [], - type, + otype, help, ranges, - dict(constants), + cdict, conv(default), *(fl != "." for fl in flags), ) @@ -1048,6 +1086,8 @@ def filter_info(name): return data blocks = re.split(r"\n(?! |\n|$)", stdout) + if blocks[-1].startswith("Exiting with exit code"): + blocks = blocks[:-1] m = re.match( r"Filter (\S+)\s*?\n" @@ -1059,6 +1099,10 @@ def filter_info(name): r"([\s\S]*)", blocks[0], ) + + if m is None: + raise FFmpegError(blocks[0]) + name = m[1] desc = m[2] threading = ["slice"] if m[3] else [] @@ -1093,9 +1137,7 @@ def filter_info(name): options = extra_options.pop(opt_name) elif len(extra_options) == 1: o_name, options = extra_options.popitem() - logger.info( - f"filter_info({name}): assigned mismatched AVOptions {o_name}." - ) + logger.info(f"filter_info({name}): assigned mismatched AVOptions {o_name}.") else: logger.warning( f"filter_info({name}): none of the AVOption sets appears to be the main option set:\n {[k for k in extra_options]}" @@ -1112,7 +1154,7 @@ def filter_info(name): timeline, ) - if not "filter" in _cache: + if "filter" not in _cache: _cache["filter"] = {} _cache["filter"][name] = data return data @@ -1149,14 +1191,14 @@ def bsfilter_info(name): ) if stdout.startswith("Unknown"): - raise Exception(stdout) + raise FFmpegError(stdout) data = { "name": m[1], "supported_codecs": m[2].split(" ") if m[2] else [], "options": m[3], } - if not "filter" in _cache: + if "filter" not in _cache: _cache["filter"] = {} _cache["filter"][name] = data return data diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 14c291c6..c4ffa742 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,564 +1,945 @@ +"""`configure` module + +This module is used by all batch and streaming functions of `ffmpegio` to +process their input arguments and to generate FFmpeg arguments (`FFmpegArgs`) +and lists of input and output information (`InputInfoDict` and `OutputInfoDict`). + +There are four primary functions for the four operation types supported by +`ffmpegio`: + +======================== ================================ +`init_media_read()` encoded data to raw media data +`init_media_write()` raw media data to encoded data +`init_media_filter()` raw media data to raw media data +`init_media_transcode()` encoded data to encoded data +======================== ================================ + +These functions call ffprobe to get raw media information best it could. + +The above functions do not initialize the pipes and IO threads. + +- `assign_input_pipes()` +- `assign_output_pipes()` +- `init_named_pipes()` + +""" + from __future__ import annotations -from typing import Literal +import logging +import re +import subprocess as fp +from collections import Counter from collections.abc import Sequence - -import re, logging +from contextlib import ExitStack +from functools import cache +from itertools import count + +from namedpipe import NPopen + +from . import filtergraph as fgb +from . import plugins, utils +from ._typing import ( + Any, + Buffer, + Callable, + CountDataCallable, + DTypeString, + EncodedInputInfoDict, + EncodedOutputInfoDict, + FFmpegOptionDict, + FFmpegUrlType, + FilterGraphInfoDict, + FromBytesCallable, + InputInfoDict, + InputPipeInfoDict, + IsEmptyCallable, + Literal, + MediaType, + NotRequired, + OutputInfoDict, + OutputPipeInfoDict, + RawDataBlob, + RawInputInfoDict, + RawOutputInfoDict, + RawStreamInfoTuple, + ShapeTuple, + ToBytesCallable, + TypedDict, + cast, +) +from .errors import ( + FFmpegError, + FFmpegioError, + FFmpegioInsufficientInputData, + FFmpegioNoPipeAllowed, +) +from .filtergraph.abc import FilterGraphObject +from .stream_spec import parse_map_option, stream_type_to_media_type +from .threading import CopyFileObjThread, ReaderThread, WriterThread +from .utils import ( + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegOutputUrlComposite, + FFmpegOutputUrlNoPipe, +) +from .utils.concat import FFConcat # for typing logger = logging.getLogger("ffmpegio") -from . import utils, plugins -from .filtergraph import Graph, Filter, Chain -from .filtergraph.abc import FilterGraphObject -from .errors import FFmpegioError +################################# +## module types UrlType = Literal["input", "output"] +FFmpegInputOptionTuple = tuple[FFmpegInputUrlComposite, FFmpegOptionDict] +"""tuple pair of FFmpeg input url compatible objects and its option dict + +Supported input url objects: + +- `str` +- `os.Path` +- `urllib.UrlParseResult` +- `FFConcat` +- `FilterGraphObject` +- `IO` +- `Buffer` +""" + +FFmpegOutputOptionTuple = tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] +"""tuple pair of FFmpeg output url compatible objects and its option dict + +Supported output url objects: + +- `str` +- `IO` +- `Buffer` +""" + +FFmpegNoPipeInputOptionTuple = tuple[FFmpegInputUrlNoPipe, FFmpegOptionDict] +"""tuple pair of FFmpeg input non-pipe url compatible objects and its option dict + +Supported input url objects: + +- `str` +- `FFConcat` +- `FilterGraphObject` +""" + +FFmpegNoPipeOutputOptionTuple = tuple[FFmpegOutputUrlNoPipe, FFmpegOptionDict] +"""tuple pair of FFmpeg output non-pipe url compatible objects and its option dict +""" + -def array_to_video_input(rate, data, stream_id=None, **opts): - """create an stdin input with video stream +raw_formats = ("rawvideo", *(formats for _, formats in utils.audio_codecs.values())) - :param rate: input frame rate in frames/second - :type rate: int, float, or `fractions.Fraction` - :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) - :type data: object - :param stream_id: video stream id ('v:#'), defaults None to set the options to be file-wide ('v') - :type stream_id: int, optional - :param **opts: input options - :type **opts: dict - :return: tuple of input url and option dict - :rtype: tuple(str, dict) + +class FFmpegArgs(TypedDict): + """FFmpeg arguments""" + + inputs: list[FFmpegInputOptionTuple] + # list of input definitions (pairs of url and options) + outputs: list[FFmpegOutputOptionTuple] + # list of output definitions (pairs of url and options) + global_options: dict # FFmpeg global options + + +InitMediaOutputsCallable = Callable[ + [ + FFmpegArgs, + list[RawInputInfoDict | EncodedInputInfoDict], + Any, + list[list[RawDataBlob] | bytes], + ], + list[RawOutputInfoDict], +] +"""function to finalize the media output initialization + + init_media_xxx_outputs(ffmpeg_args, input_info, output_options) + + Inputs: + + args - partial FFmpeg arguments (to be modified) + input_info - list of input information + output_args - output arguments + deferred_inputs - list of input data + + Outputs: + + output_info - list of output information + + The callback may return True to cancel the FFmpeg execution. +""" + + +################################# +## module functions + +############################################################################### +### compatible typed dicts for media initializer function keyword arguments ### +############################################################################### + + +class MediaReadKwsDict(TypedDict): + input_urls: Sequence[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ] + output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + options: FFmpegOptionDict + squeeze: bool + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + + +class MediaWriteKwsDict(TypedDict): + output_urls: Sequence[FFmpegOutputOptionTuple] + input_options: Sequence[FFmpegOptionDict] + options: FFmpegOptionDict + extra_inputs: NotRequired[ + Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ] + input_data: NotRequired[Sequence[RawDataBlob | None] | None] + input_dtypes: NotRequired[Sequence[DTypeString | None] | None] + input_shapes: NotRequired[Sequence[ShapeTuple | None] | None] + + +class MediaFilterKwsDict(TypedDict): + input_options: Sequence[Literal["a", "v"]] + output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + options: FFmpegOptionDict + extra_inputs: NotRequired[ + Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ] + extra_outputs: NotRequired[ + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ] + input_data: NotRequired[ + Sequence[tuple[RawDataBlob | None, FFmpegOptionDict]] | None + ] + input_dtypes: NotRequired[Sequence[DTypeString] | None] + input_shapes: NotRequired[Sequence[ShapeTuple] | None] + squeeze: bool + + +class MediaTranscoderKwsDict(TypedDict): + input_urls: Sequence[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ] + output_urls: list[FFmpegOutputOptionTuple] + options: FFmpegOptionDict + + +FFmpegMediaKwsDict = ( + MediaReadKwsDict | MediaWriteKwsDict | MediaFilterKwsDict | MediaTranscoderKwsDict +) + +####################R### +### I/O initializers ### +######################## + + +def init_media_read( + input_urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + output_streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, + options: FFmpegOptionDict | None, + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ), + squeeze: bool, +) -> tuple[FFmpegArgs, list[EncodedInputInfoDict], list[RawOutputInfoDict]]: + """Initialize FFmpeg arguments for media read + + :param urls: URLs of the media files to read. + :param output_streams: output stream mappings and optional per-stream options: + + - ``None`` to map all filtergraph outputs + - (str) output map option string + - (dict) output ffmpeg options with the required ``'map'`` option + - (Sequence) a sequence of output map option string or ffmpeg option + dict with a ``'map'`` key. + + :param options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + :param extra_outputs: list of additional output destinations, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param squeeze: True to remove length-1 dimensions from the output shape + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return output_info: output stream information, None if outputs not initialized + + Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run + `ffmpegio.video.read()` for each url. + + Specify the streams to return by `map` output option: + + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + + Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output + 'pix_fmt' option is not explicitly set, 'rgb24' is used. """ - spec = "" if stream_id is None else ":" + utils.stream_spec(stream_id, "v") + options = {} if options is None else {**options} - s, pix_fmt = utils.guess_video_format(*plugins.get_hook().video_info(obj=data)) + ninputs = len(input_urls) + if not ninputs: + raise ValueError("At least one URL must be given.") - return ( - "-", - { - "f": "rawvideo", - f"c{spec or ':v'}": "rawvideo", - f"s{spec}": s, - f"r{spec}": rate, - f"pix_fmt{spec}": pix_fmt, - **opts, - }, - ) + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None + + # assign inputs + input_info = process_url_inputs(args, input_urls, inopts_default) + + # assign outputs + try: + output_info = process_raw_outputs( + args, input_info, output_streams, options, squeeze + ) + except FFmpegError as e: + raise FFmpegioInsufficientInputData( + "Failed to retrieve input stream information." + ) from e + + # standardize output stream options + + if extra_outputs is not None: + output_info.extend( + process_url_outputs( + args, + input_info, + extra_outputs, + {}, + skip_automapping=True, + no_pipe=True, + ) + ) + + return args, input_info, output_info + + +def init_media_write( + output_urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + input_options: Sequence[FFmpegOptionDict], + extra_inputs: ( + Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ] + | None + ), + options: FFmpegOptionDict | None, + input_data: Sequence[RawDataBlob | None] | None = None, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, +) -> tuple[ + FFmpegArgs, + list[RawInputInfoDict | EncodedInputInfoDict], + list[EncodedOutputInfoDict], +]: + """write multiple streams to a url/file + + :param output_url: output url + :param input_options: list of input option dicts. Each must include either + ``'ar'`` (audio) or ``'r'`` (video) to specify the + media type and rate. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + :param input_data: list of input data to be written in a batch-mode (or ``None`` + if streaming), defaults to no data. + :param input_dtypes: list of numpy-style data type strings of input samples or frames + of input media streams, defaults to `None` (auto-detect). + :param input_shapes: list of shapes of input samples or frames of input media streams, + defaults to `None` (auto-detect). + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return input_ready: Element is True if corresponding input is ready (known dtype and shape) + :return output_info: output stream information, None if outputs not initialized + :return output_options: output options, None if outputs already initialized + + TIPS + ---- + + * All the input streams will be added to the output file by default, unless `map` option is specified + * If the input streams are of different durations, use `shortest=ffmpegio.FLAG` option to trim all streams to the shortest. + * Using merge_audio_streams: + - adds a `filter_complex` global option + - merged input streams are removed from the `map` option and replaced by the merged stream -def array_to_audio_input( - rate, - data=None, - stream_id=None, - **opts, -): - """create an stdin input with audio stream - - :param rate: input sample rate in samples/second - :type rate: int - :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) - :type data: object - :param stream_id: audio stream id ('a:#'), defaults to None to set the options to be file-wide ('a') - :type stream_id: int, optional - :return: tuple of input url and option dict - :rtype: tuple(str, dict) """ - shape = dtype = None - shape, dtype = plugins.get_hook().audio_info(obj=data) - sample_fmt, ac = utils.guess_audio_format(dtype, shape) - codec, f = utils.get_audio_codec(sample_fmt) - - spec = "" if stream_id is None else ":" + utils.stream_spec(stream_id, "a") - - return ( - "-", - { - "f": f, - f"c{spec or ':a'}": codec, - f"ac{spec}": ac, - f"ar{spec}": rate, - f"sample_fmt{spec}": sample_fmt, - **opts, - }, + options = {} if options is None else {**options} + + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + + # analyze and assign inputs + input_info = process_raw_inputs( + args, input_options, inopts_default, input_data, input_dtypes, input_shapes ) + # append extra (not-piped) inputs + if extra_inputs is not None: + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed as e: + raise FFmpegioError("extra_inputs cannot be piped in.") from e -def empty(global_options=None): - """create empty ffmpeg arg dict + # analyze and assign outputs + output_info = process_url_outputs(args, input_info, output_urls, options) + + # if output is piped, it must have the -f option specified + for url, opts in args["outputs"]: + if url is None and "f" not in opts: + raise FFmpegioError( + 'all piped encoded output stream must have its format (`"f"`) defined in its option dict' + ) + + return args, input_info, output_info + + +def init_media_filter( + input_options: Sequence[FFmpegOptionDict], + extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, + output_streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ), + options: FFmpegOptionDict | None, + squeeze: bool, + input_data: list[RawDataBlob | None] | None = None, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, +) -> tuple[FFmpegArgs, list[RawInputInfoDict], list[RawOutputInfoDict]]: + """Prepare FFmpeg arguments for media read + + :param input_options: list of input option dicts. Each must include either + ``'ar'`` (audio) or ``'r'`` (video) to specify the + media type and rate. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param output_streams: output stream mappings and optional per-stream options: + + - ``None`` to map all filtergraph outputs + - (str) output map option string + - (dict) output ffmpeg options with the required ``'map'`` option + - (Sequence) a sequence of output map option string or ffmpeg option + dict with a ``'map'`` key. + + :param extra_outputs: list of additional output destinations, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + :param squeeze: True to squeeze output data blob shape + :param input_data: list of input data to be written in a batch-mode (or ``None`` + if streaming), defaults to no data. + :param input_dtypes: list of numpy-style data type strings of input samples or frames + of input media streams, use `None` to auto-detect. + :param input_shapes: list of shapes of input samples or frames of input media streams, + use `None` to auto-detect. + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return output_info: output stream information, None if outputs not initialized - :param global_options: global options, defaults to None - :type global_options: dict, optional - :return: empty ffmpeg arg dict with 'inputs','outputs',and 'global_options' entries. - :rtype: dict """ - return {"inputs": [], "outputs": [], "global_options": global_options} + options = {} if options is None else {**options} -def check_url(url, nodata=True, nofileobj=False, format=None): - """Analyze url argument for non-url input + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") - :param url: url argument string or data or file or a custom class - :type url: str, bytes-like object, audio or video data object, file-like object, or pipe input custom object - :param nodata: True to raise exception if url is a bytes-like object, default to True - :type nodata: bool, optional - :param nofileobj: True to raise exception if url is a file-like object, default to False - :type nofileobj: bool, optional - :return: url string, file object, and data object - :rtype: tuple + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") - Custom Pipe Class - ----------------- + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None - `url` may be a class instance of which `str(url)` call yields a stdin pipe expression - (i.e., '-' or 'pipe:' or 'pipe:0') with `url.input` returns the input data. For such `url`, - `check_url()` returns url and data objects, accordingly. + # analyze and assign inputs + input_info = process_raw_inputs( + args, input_options, inopts_default, input_data, input_dtypes, input_shapes + ) + if extra_inputs is not None: + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed: + raise FFmpegioError("extra_inputs cannot be piped in.") + + # analyze and assign outputs + + try: + output_info = process_raw_outputs( + args, input_info, output_streams, options, squeeze + ) + except FFmpegError as e: + raise FFmpegioInsufficientInputData( + "Failed to retrieve input stream information." + ) from e + + # if additional (encoded) outputs are specified, append them to ffmpeg args + # and output info + if extra_outputs is not None: + try: + output_info.extend( + process_url_outputs( + args, + input_info, + extra_outputs, + {}, + skip_automapping=True, + no_pipe=True, + ) + ) + except FFmpegioNoPipeAllowed: + raise FFmpegioError("extra_outputs cannot be piped out.") + + return args, input_info, output_info + + +def init_media_transcode( + input_urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + output_urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + options: FFmpegOptionDict | None, +) -> tuple[FFmpegArgs, list[EncodedInputInfoDict], list[EncodedOutputInfoDict]]: + """initialize media transcoder + + :param inputs: FFmpeg input options of piped inputs + :param outputs: FFmpeg output options of piped outputs + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + :return ffmpeg_args: FFmpeg argument dict + :return input_info: list of input stream information + :return output_info: list of output stream information """ - def hasmethod(o, name): - return hasattr(o, name) and callable(getattr(o, name)) + options = {} if options is None else {**options} - fileobj = None - data = None + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") - if format != "lavfi": - try: - memoryview(url) - data = url - url = "-" - except: - if hasmethod(url, "fileno"): - if nofileobj: - raise ValueError("File-like object cannot be specified as url.") - fileobj = url - url = "-" - elif str(url) in ("-", "pipe:", "pipe:0"): - try: - data = url.input - except: - pass - - if nodata and data is not None: - raise ValueError("Bytes-like object cannot be specified as url.") - - return url, fileobj, data - - -def add_url(args, type, url, opts=None, update=False): + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + + input_info = process_url_inputs(args, input_urls, inopts_default) + + if not len(input_info): + raise ValueError("At least one input must be given.") + + output_info = process_url_outputs( + args, input_info, output_urls, options, skip_automapping=True + ) + + if not len(output_info): + raise ValueError("At least one output must be given.") + + return args, input_info, output_info + + +############################################################### + + +def empty(global_options: FFmpegOptionDict | None = None) -> FFmpegArgs: + """create empty ffmpeg arg dict + + :param global_options: global options, defaults to None + :return: ffmpeg arg dict with empty 'inputs','outputs',and 'global_options' entries. + """ + return {"inputs": [], "outputs": [], "global_options": global_options or {}} + + +def add_url( + args: FFmpegArgs, + type: Literal["input", "output"], + url: FFmpegUrlType | None, + opts: FFmpegOptionDict | None = None, + update: bool = False, +) -> tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]: """add new or modify existing url to input or output list :param args: ffmpeg arg dict (modified in place) - :type args: dict - :param type: input or output - :type type: 'input' or 'output' + :param type: input or output (may use None to update later) :param url: url of the new entry - :type url: str :param opts: FFmpeg options associated with the url, defaults to None - :type opts: dict, optional :param update: True to update existing input of the same url, default to False - :type update: bool, optional :return: file index and its entry - :rtype: tuple(int, tuple(str, dict or None)) """ - type = f"{type}s" - filelist = args.get(type, None) - if filelist is None: - filelist = args[type] = [] + # get current list of in/outputs + filelist = args[f"{type}s"] n = len(filelist) - id = next((i for i in range(n) if filelist[i][0] == url), None) if update else None - if id is None: - id = n - filelist.append((url, opts and {**opts})) + + # if updating, get the existing id + file_id = ( + next((i for i in range(n) if filelist[i][0] == url), None) if update else None + ) + if file_id is None: + # new entry + file_id = n + filelist.append((url, {} if opts is None else {**opts})) elif opts is not None: - filelist[id] = ( + # update option dict + filelist[file_id] = ( url, ( - opts and {**opts} - if filelist[id][1] is None - else {**filelist[id][1], **opts} + opts + if filelist[file_id][1] is None + else ( + filelist[file_id][1] + if opts is None + else {**filelist[file_id][1], **opts} + ) ), ) - return id, filelist[id] - - -def has_filtergraph(args, type): + return file_id, filelist[file_id] + + +def find_filtergraph_option( + args: FFmpegArgs, stream: int = -1, media_type: MediaType | None = None +) -> ( + Literal[ + "filter_complex", + "/filter_complex", + "lavfi", + "/lavfi", + "filter_complex_script", + "filter", + "/filter", + "af", + "/af", + "filter:a", + "/filter:a", + "vf", + "/vf", + "filter:v", + "/filter:v", + ] + | None +): """True if FFmpeg arguments specify a filter graph :param args: FFmpeg argument dict - :type args: dict - :param type: filter type - :type type: 'video' or 'audio' - :param file_id: specify output file id (ignored if type=='complex'), defaults to None (or 0) - :type file_id: int, optional - :param stream_id: stream, defaults to None - :type stream_id: int, optional - :return: True if filter graph is specified - :rtype: bool + :param stream: output stream index, by default -1 to check global complex filtergraphs + :param media_type: for output stream filter, specify to check a particular + media type, defaults to checking both types of filters + :return: FFmpeg option name if filter graph is specified else None """ - try: - if ( - "filter_complex" in args["global_options"] - or "lavfi" in args["global_options"] - ): - return True - except: - pass # no global_options defined - - # input filter - if any( - ( - opts is not None and opts.get("f", None) == "lavfi" - for _, opts in args["inputs"] - ) - ): - return True - - # output filter - short_opt = {"video": "vf", "audio": "af"}[type] - other_st = {"video": "a", "audio": "v"}[type] - re_opt = re.compile(rf"{short_opt}$|filter(?::(?=[^{other_st}]).*?)?$") - if any( - (any((re_opt.match(key) for key in opts.keys())) for _, opts in args["outputs"]) - ): - return True - return False # no output options defined + if stream < 0: # global filtergraph + return utils.find_filter_complex_option(args["global_options"]) + else: + return utils.find_filter_simple_option(args["outputs"][stream], media_type) + + +def gather_video_read_opts( + options: FFmpegOptionDict, + skip_rate: bool = False, + args: FFmpegArgs | None = None, + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + get_fg_info: Callable[[], dict[str, FilterGraphInfoDict] | None] | None = None, +) -> tuple[RawStreamInfoTuple, FFmpegOptionDict | None]: + """Gathering raw video read output options + + :param options: option dict for this output. To run input/fg analysis, it + must contain a `'map'` item. + :param skip_rate: True to skip requiring the frame rate information, defaults + to False + :param args: FFmpeg argument dict populated `inputs` and `global_options` + items or None to skip input & filtergraph analysis, defaults to + None to skip the analysis + :param input_info: list of input information, only required if `args` is given + :param get_fg_info: function to retrieve filtergraph output info if available. + :return raw_info: tuple of (dtype, shape, r) where shape is a video shape + tuple (height, width, nb_components) + :return additional_options: additional output options or None if `raw_info` + is not complete + + The output `pix_fmt` must be a raw-data compatible format (i.e., grayscales + and RGBs, and byte-aligned alternate formats). + + If `args is None`, `options` must contain items with `s`, `pix_fmt`, and `r` + (the latter only if `skip_rate=False`) to be successful. + + If `args` is provided, `options['map']` must be present. Also, if `options['map']` + is a link label, `fg_info` must be provided to be successful. + + The input/fg analysis code path may raise an exception if necessary information + is not provided. + """ -def finalize_video_read_opts( - args, pix_fmt_in=None, s_in=None, r_in=None, ofile=0, ifile=0 -): - inopts = args["inputs"][ifile][1] or {} - outopts = args["outputs"][ofile][1] - - if outopts is None: - outopts = {} - args["outputs"][ofile] = (args["outputs"][ofile][0], outopts) - - # pixel format must be specified - pix_fmt = outopts.get("pix_fmt", None) - remove_alpha = False - if pix_fmt is None: - # deduce output pixel format from the input pixel format - pix_fmt_in = inopts.get("pix_fmt", pix_fmt_in) - try: - outopts["pix_fmt"], ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) - except: - ncomp = dtype = None - else: - # make sure assigned pix_fmt is valid - if pix_fmt_in is None: - try: - dtype, ncomp = utils.get_pixel_format(pix_fmt) - except: - ncomp = dtype = None - remove_alpha = False - else: - _, ncomp, dtype, remove_alpha = utils.get_pixel_config(pix_fmt_in, pix_fmt) + # required options + req_opts = ("pix_fmt", "s", "r") - # set up basic video filter if specified - build_basic_vf(args, remove_alpha, ofile) + # use the output option by default + opt_vals = [options.get(o, None) for o in req_opts] - outopts["f"] = "rawvideo" + if opt_vals[0] is None: + dtype = None + ncomp = 0 + else: + dtype, ncomp = utils.get_pixel_format(opt_vals[0]) - # if no filter and video shape and rate are known, all known - r = s = None - if not has_filtergraph(args, "video") and ncomp is not None: - r = outopts.get("r", inopts.get("r", r_in)) - - s = outopts.get("s", inopts.get("s", s_in)) - if s is not None: - if isinstance(s, str): - m = re.match(r"(\d+)x(\d+)", s) - s = [int(m[1]), int(m[2])] - - return dtype, None if s is None else (*s[::-1], ncomp), r - - -def check_alpha_change(args, dir=None, ifile=0, ofile=0): - # check removal of alpha channel - inopts = args["inputs"][ifile][1] - outopts = args["outputs"][ofile][1] - if inopts is None or outopts is None: - return None if dir is None else False # indeterminable - return utils.alpha_change(inopts.get("pix_fmt", None), outopts.get("pix_fmt", None)) - - -def _build_video_basic_filter( - fill_color: str | None = None, - remove_alpha: bool = False, - scale: str | Sequence | None = None, - crop: str | Sequence | None = None, - flip: Literal["horizontal", "vertical", "both"] | None = None, - transpose: str | Sequence | None = None, - square_pixels: ( - Literal["upscale", "downscale", "upscale_even", "downscale_even"] | None - ) = None, -) -> FilterGraphObject: - bg_color = fill_color or "white" - - vfilters = ( - Graph(f"color=c={bg_color}[l1];[l1][in]scale2ref[l2],[l2]overlay=shortest=1") - if remove_alpha - else Chain() - ) + pix_fmt, s, r = opt_vals + outopts = {} - if square_pixels == "upscale": - vfilters += "scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1" - elif square_pixels == "downscale": - vfilters += "scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1" - elif square_pixels == "upscale_even": - vfilters += "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1" - elif square_pixels == "downscale_even": - vfilters += "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1" - elif square_pixels is not None: - raise ValueError(f"unknown `square_pixels` option value given: {square_pixels}") - - if crop: - try: - assert not isinstance(crop, str) - vfilters += Filter("crop", *crop) - except: - vfilters += Filter("crop", crop) + scaled_s = bool(s) and all( + si > 0 for si in s + ) # true if output size requires input size - if flip: - try: - ftype = ("", "horizontal", "vertical", "both").index(flip) - except: - raise Exception("Invalid flip filter specified.") - if ftype % 2: - vfilters += "hflip" - if ftype >= 2: - vfilters += "vflip" - - if transpose is not None: + if ( + scaled_s or not all(opt_vals[:-1] if skip_rate else opt_vals) + ) and args is not None: + # run input analysis try: - assert not isinstance(transpose, str) - vfilters += Filter("transpose", *transpose) - except: - vfilters += Filter("transpose", transpose) + map_spec = options["map"] + except KeyError as e: + raise FFmpegioError('`options["map"]` is missing') from e + map_fields = parse_map_option(map_spec, input_file_id=0) - if scale: - try: - scale = [int(s) for s in scale.split("x")] - except: - pass - try: - assert not isinstance(scale, str) - vfilters += Filter("scale", *scale) - except: - vfilters += Filter("scale", scale) + # get the options of the input/filtergraph output + if linklabel := map_fields.get("linklabel", None): + try: + info = get_fg_info()[linklabel] + except (AttributeError, KeyError) as e: + raise KeyError(f"`fg_info[{linklabel}]` is missing.") from e + try: + pix_fmt_in = info["pix_fmt"] + s_in = info["s"] + r_in = info["r"] + except KeyError as e: + raise KeyError( + f'`fg_info[{linklabel}]` is missing at least one of the required video attributes ("s", "pix_fmt", "r")' + ) from e + else: + # insert basic video filter if specified + # build_basic_vf(args, False, ofile) - return vfilters + ifile = map_fields["input_file_id"] + # get input option values + r_in, pix_fmt_in, s_in = utils.analyze_video_stream( + map_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) -def build_basic_vf(args, remove_alpha=None, ofile=0): - """convert basic VF options to vf option + if (vf := (options.get("vf") or options.get("filter:v"))) or scaled_s: + # analyze output simple filter + r_in, pix_fmt_in, s_in = utils.analyze_output_video_filter( + vf, r_in, pix_fmt_in, s_in, s if scaled_s else None + ) - :param args: FFmpeg dict - :type args: dict - :param remove_alpha: True to add overlay filter to add a background color, defaults to None - : This argument would be ignored if `'remove_alpha'` key is defined in `'args'`. - :type remove_alpha: bool, optional - :param ofile: output file id, defaults to 0 - :type ofile: int, optional - """ + # pixel format must be specified + if pix_fmt is None: + # use the analyzed value, falling back to 'rgb24' + if pix_fmt_in == "unknown": + raise FFmpegioError( + "input pixel format unknown. Please specify output pix_fmt" + ) - # get output opts, nothing to do if no option set - outopts = args["outputs"][ofile][1] - if outopts is None: - return - - # extract the options - fopts = { - name: outopts.pop(name) - for name in ( - "fill_color", - "crop", - "flip", - "transpose", - "square_pixels", - "remove_alpha", - ) - if name in outopts - } + # deduce output pixel format from the input pixel format + pix_fmt, ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) + outopts["pix_fmt"] = pix_fmt - # check if output needs to be scaled - scale = outopts.get("s", None) - do_scale = scale is not None - if do_scale: - try: - m = re.match(r"(\d+)x(\d+)", scale) - scale = (int(m[1]), int(m[2])) - except: - pass - try: - do_scale = len(scale) > 2 or (scale[0] <= 0 or scale[1] <= 0) - except: - do_scale = False - - nfo = len(fopts) - if (nfo and (nfo > 1 or "fill_color" not in fopts)) or remove_alpha or do_scale: - if do_scale: - fopts["scale"] = scale - del outopts["s"] - - if remove_alpha and "remove_alpha" not in fopts: - fopts["remove_alpha"] = True - - bvf = _build_video_basic_filter(**fopts) # Graph is remove alpha else Chain - vf = outopts.get("vf", None) - if vf: + elif pix_fmt_in is None: + # make sure assigned pix_fmt is valid (shouldn't get here) try: - outopts["vf"] = vf + bvf + dtype, ncomp = utils.get_pixel_format(pix_fmt) except Exception as e: raise FFmpegioError( - f"Cannot append the basic video filter to the user specified video filter (vf):\n {e}" - ) - else: - outopts["vf"] = bvf + "could not resolve output pixel format. Please specify output `'pix_fmt'` option" + ) from e + if s is None: + s = s_in -def finalize_audio_read_opts( - args, sample_fmt_in=None, ac_in=None, ar_in=None, ofile=0, ifile=0 -): - inopts = args["inputs"][ifile][1] or {} - outopts = args["outputs"][ofile][1] - has_filter = has_filtergraph(args, "audio") + if r is None: + r = r_in - if outopts is None: - outopts = {} - args["outputs"][ofile] = (args["outputs"][ofile][0], outopts) + # get shape tuple if resolved + shape = (*s[::-1], ncomp) if s is not None and ncomp != 0 else None + raw_info = (dtype, shape, r) - # pixel format must be specified - sample_fmt = outopts.get("sample_fmt", None) - if sample_fmt is None: - # get pixel format from input - sample_fmt = inopts.get("sample_fmt", sample_fmt_in) - if sample_fmt: - if sample_fmt[-1] == "p": - # planar format is not supported - sample_fmt = sample_fmt[:-1] - outopts["sample_fmt"] = sample_fmt # set the format + # if any raw info is missing, return + if any(v is None for v in raw_info): + return raw_info, None - # set output format and codec - outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) + # populate the rest of new option dict + outopts["f"] = "rawvideo" - ac = ar = None - if not has_filter: - ac = outopts.get("ac", inopts.get("ac", ac_in)) - ar = outopts.get("ar", inopts.get("ar", ar_in)) + return raw_info, outopts + + +def gather_audio_read_opts( + options: FFmpegOptionDict, + skip_rate: bool = False, + args: FFmpegArgs | None = None, + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + get_fg_info: Callable[[], dict[str, FilterGraphInfoDict] | None] | None = None, + default_sample_fmt: str = "dbl", +) -> tuple[RawStreamInfoTuple, FFmpegOptionDict | None]: + """Gathering raw video read output options + + :param options: option dict for this output. To run input/fg analysis, it + must contain a `'map'` item. + :param skip_rate: True to skip requiring the frame rate information, defaults + to False + :param args: FFmpeg argument dict populated `inputs` and `global_options` + items or None to skip input & filtergraph analysis, defaults to + None to skip the analysis + :param input_info: list of input information, only required if `args` is given + :param get_fg_info: function to retrieve filtergraph output info if available. + :param default_sample_fmt: if the input sample format is incompatible, + force this format, defaults to 'dbl' + :return raw_info: audio shape tuple (nb_channels,) + :return additional_options: additional output options or None if `raw_info` + is not complete + + The output `sample_fmt` must be a raw-data compatible format (i.e., grayscales + and RGBs, and byte-aligned alternate formats). + + If `args is None`, `options` must contain items with `ac`, `sample_fmt`, and `ar` + (the latter only if `skip_rate=False`) to be successful. + + If `args` is provided, `options['map']` must be present. Also, if `options['map']` + is a link label, `fg_info` must be provided to be successful. + + The input/fg analysis code path may raise an exception if necessary information + is not provided. - # sample_fmt must be given - dtype, shape = utils.get_audio_format(sample_fmt, ac) + """ - return dtype, ac, ar + # required options + req_opts = ("sample_fmt", "ac", "ar") + # TODO - support channel_layout/ch_layout options as stronger alternative to ac -################################################################################ + # use the output option by default + sample_fmt, ac, ar = [options.get(o, None) for o in req_opts] + outopts = {} -def get_option(ffmpeg_args, type, name, file_id=0, stream_type=None, stream_id=None): - """get ffmpeg option value from ffmpeg args dict - - :param ffmpeg_args: ffmpeg args dict - :type ffmpeg_args: dict - :param type: option type: 'video', 'audio', or 'global' - :type type: str - :param name: option name w/out stream specifier - :type name: str - :param file_id: index of target file, defaults to 0 - :type file_id: int, optional - :param stream_type: target stream type: 'v' or 'a', defaults to None - :type stream_type: str, optional - :param stream_id: target stream index (within specified stream type), defaults to None - :type stream_id: int, optional - :return: option value - :rtype: various - - If stream is specified, several option names are looked up till one is defined. For example, - 3 entries are checked for `name`='c', `stream_type`='v', and `stream_id`=0 in this order: - "c:v:0", "c:v", then "c". Function returns the first hit. + if ( + sample_fmt is None + or ac is None + or (not skip_rate and ar is None) + and args is not None + ): + # run input analysis + try: + map_spec = options["map"] + except KeyError as e: + raise FFmpegioError('`options["map"]` is missing') from e + map_fields = parse_map_option(map_spec, input_file_id=0) - """ - if ffmpeg_args is None: - return None - names = [name] - if type.startswith("global"): - opts = ffmpeg_args.get("global_options", None) - else: - filelists = ffmpeg_args.get(f"{type}s", None) - if filelists is None: - return None - entry = filelists[file_id] - if entry is None: - return None - opts = entry[1] - if stream_type is not None: - name += f":{stream_type}" - names.append(name) - if stream_id is not None: - name += f":{stream_id}" - names.append(name) - if opts is None: - return None - - v = None - while v is None and len(names): - name = names.pop() - v = opts.get(name, None) - - return v - - -def merge_user_options(ffmpeg_args, type, user_options, file_index=None): - if type == "global": - type = "global_options" - opts = ffmpeg_args.get(type, None) - if opts is None: - opts = ffmpeg_args[type] = {**user_options} + # get the options of the input/filtergraph output + if linklabel := map_fields.get("linklabel", None): + try: + info = get_fg_info()[linklabel] + except (AttributeError, KeyError) as e: + raise KeyError(f"`fg_info[{linklabel}]` is missing.") from e + try: + sample_fmt_in = info["sample_fmt"] + ac_in = info["ac"] + ar_in = info["ar"] + except KeyError as e: + raise KeyError( + f'`fg_info[{linklabel}]` is missing at least one of the required audio attributes ("ac", "sample_fmt", "ar")' + ) from e else: - ffmpeg_args[type] = {**opts, **user_options} + # insert basic video filter if specified + # build_basic_vf(args, False, ofile) + + ifile = map_fields["input_file_id"] + + # get input option values + ar_in, sample_fmt_in, ac_in = utils.analyze_audio_stream( + map_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) + + if af := (options.get("af") or options.get("filter:a")): + # analyze output simple filter + sample_fmt_in, ar_in, ac_in = utils.analyze_output_audio_filter( + af, ar_in, sample_fmt_in, ac_in + ) + + # sample format must be specified + if sample_fmt is None: + sample_fmt = sample_fmt_in or default_sample_fmt + + if ac is None: + ac = ac_in + + if ar is None: + ar = ar_in + + # planar format is not supported, convert to interleaved format + if sample_fmt[-1] == "p": + sample_fmt = sample_fmt[:-1] + outopts["sample_fmt"] = sample_fmt # set the format to non-planar + + # sample_fmt must be given + if sample_fmt is None: + dtype = None + shape = ac and (ac,) else: - type += "s" - filelist = ffmpeg_args.get(type, None) - if file_index is None: - file_index = 0 - if filelist is None or len(filelist) <= file_index: - raise Exception(f"{type} list does not have file #{file_index}") - url, opts = ffmpeg_args[type][file_index] - ffmpeg_args[type][file_index] = ( - url, - {**user_options} if opts is None else {**opts, **user_options}, - ) + dtype, shape = utils.get_audio_format(sample_fmt, ac) - return ffmpeg_args + # get shape tuple if resolved + raw_info = (dtype, shape, ar) + # if any raw info is missing, return + if any(v is None for v in raw_info): + return raw_info, None + + # set output format and codec + outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) + + return raw_info, outopts -def get_video_array_format(ffmpeg_args, type, file_id=0): - try: - opts = ffmpeg_args[f"{type}s"][file_id][1] - except: - raise ValueError(f"{type} file #{file_id} is not specified") - try: - dtype, ncomp = utils.get_pixel_format(opts["pix_fmt"]) - shape = [*opts["s"][::-1], ncomp] - except: - raise ValueError(f"{type} options must specify both `s` and `pix_fmt` options") - return shape, dtype +################################################################################ -def move_global_options(args): +def move_global_options(args: FFmpegArgs) -> FFmpegArgs: """move global options from the output options dicts :param args: FFmpeg arguments - :type args: dict :returns: FFmpeg arguments (the same object as the input) - :rtype: dict """ from .caps import options @@ -582,100 +963,34 @@ def move_global_options(args): return args -def clear_loglevel(args): - """clear global loglevel option - - :param args: FFmpeg argument dict - :type args: dict - - - """ - try: - del args["global_options"]["loglevel"] - logger.warn("loglevel option is cleared by ffmpegio") - except: - pass - - -def finalize_media_read_opts(args): - """finalize multiple-input media reader setup - - :param args: FFmpeg dict - :type args: dict - :return: use_ya flag - True to expect grayscale+alpha pixel format rather than grayscale - :rtype: bool - - - assumes options dict of the first output is already present - - insert `pix_fmt='rgb24'` and `sample_fmt='sa16le'` options if these options are not assigned - - check for the use of both 'gray16le' and 'ya8', and returns True if need to use 'ya8' - - set f=avi and vcodec=rawvideo - - set acodecs according to sample_fmts - - """ - - # get output options, create new - options = args["outputs"][0][1] - - # check to make sure all pixel and sample formats are supported - gray16le = ya8 = 0 - for k in utils.find_stream_options(options, "pix_fmt"): - v = options[k] - if v in ("gray16le", "grayf32le"): - gray16le += 1 - elif v in ("ya8", "ya16le"): - ya8 += 1 - if gray16le and ya8: - raise ValueError( - "pix_fmts: grayscale with and without transparency cannot be mixed." - ) - - # if pix_fmt and sample_fmt not specified, set defaults - # user can conditionally override these by stream-specific option - if "pix_fmt" not in options: - options["pix_fmt"] = "rgb24" - if "sample_fmt" not in options: - options["sample_fmt"] = "s16" - - # add output formats and codecs - options["f"] = "avi" - options["c:v"] = "rawvideo" - - # add audio codec - for k in utils.find_stream_options(options, "sample_fmt"): - options[f"c:a" + k[10:]] = utils.get_audio_codec(options[k])[0] - - return ya8 > 0 - - -def config_input_fg(expr, args, kwargs): +def config_input_fg( + expr: str | FilterGraphObject, args: tuple, kwargs: dict +) -> tuple[str | fgb.Filter, float | None, dict]: """configure input filtergraph :param expr: filtergraph expression - :type expr: str :param args: input argument sequence, all arguments are intended to be used with the filter. Errors if expr yields a multi-filter filtergraph. - :type args: seq :param kwargs: input keyword argument dict. Only keys matching the filter's options are consumed. The rest are returned. - :type kwargs: dict - :return: original expression or a Filter object, duration in seconds if - known and finite, and unprocessed kwarg items. - :rtype: (str|Filter,float|None,dict) + :return expr: original expression or a Filter object + :return duration: duration in seconds if known and finite + :return kwargs: kwargs minus the filter options. """ - fg = Graph(expr) + fg = fgb.as_filtergraph_object(expr) dopt = None # duration option - if len(fg) != 1 or len(fg[0]) != 1: + if not isinstance(fg, fgb.Filter): # multi-filter input filtergraph, cannot take arguments if len(args): raise FFmpegioError( - f"filtergraph input expresion cannot take ordered options." + "filtergraph input expresion cannot take ordered options." ) - return expr, dopt, kwargs + return fg, dopt, kwargs # single-filter graph, can apply its options given in the arguments - f = fg[0][0] + f = fg info = f.info if info.inputs is None or len(info.inputs) > 0: raise FFmpegioError(f"{f.name} filter is not a source filter") @@ -688,7 +1003,7 @@ def config_input_fg(expr, args, kwargs): opts.add(o.name) opts.update(o.aliases) - # split filter named option andn other keyword arguments + # split filter named option and other keyword arguments fargs = {i: v for i, v in enumerate(args)} oargs = {} for k, v in kwargs.items(): @@ -712,44 +1027,1069 @@ def config_input_fg(expr, args, kwargs): return f.apply(fargs), dopt, oargs -def add_urls( - ffmpeg_args: dict, - url_type: UrlType, - urls: str | tuple[str, dict | None] | Sequence[str | tuple[str, dict | None]], - *, - update: bool = False, -) -> list[tuple[int, tuple[str, dict | None]]]: - """add one or more urls to the input or output list at once +class RawInputCallablesDict(TypedDict): + data2bytes: ToBytesCallable + data_count: CountDataCallable + data_is_empty: IsEmptyCallable + + +class RawOutputCallablesDict(TypedDict): + bytes2data: FromBytesCallable + data_count: CountDataCallable + data_is_empty: IsEmptyCallable + + +def get_raw_output_plugin_callables( + media_type: MediaType, +) -> RawOutputCallablesDict: + """get three raw output plugin callbacks""" + hook = plugins.pm.hook + is_empty = cast(IsEmptyCallable, hook.is_empty) + if media_type == "audio": + return { + "bytes2data": cast(FromBytesCallable, hook.bytes_to_audio), + "data_count": cast(CountDataCallable, hook.audio_samples), + "data_is_empty": is_empty, + } + + else: + return { + "bytes2data": cast(FromBytesCallable, hook.bytes_to_video), + "data_count": cast(CountDataCallable, hook.video_frames), + "data_is_empty": is_empty, + } + + +def resolve_raw_output_streams( + stream_opts: list[FFmpegOptionDict], + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], +) -> tuple[list[FFmpegOptionDict], list[dict]]: + """resolve the raw output streams from given sequence of map options + + :param stream_opts: output raw stream options + :param args: FFmpeg argument dict + :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` + :return: list of individual output streams. Each item is a tuple of + (stream_index, output_opts, partial_RawOutputInfoDict) + + -stream_index - index of streams + -map_spec - final output option + -partial_RawOutputInfoDict - to-be-completed output_info entry + + Since a map option value may yield multiple media streams (e.g., '0' or '0:v'), + the length of returned outputs may be longer than the number of streams given. + The user specified map value is returned in the 'user_label' field of the returned + dicts while the - :param args: ffmpeg arg dict (modified in place) - :type args: dict - :param url_type: input or output - :type url_type: 'input' or 'output' - :param urls: a sequence of urls (and optional dict of their options) - :type urls: str | tuple[str, dict] | Sequence[str | tuple[str, dict]] - :param opts: FFmpeg options associated with the url, defaults to None - :type opts: dict, optional - :param update: True to update existing input of the same url, default to False - :type update: bool, optional - :return: list of file indices and their entries - :rtype: list[tuple[int, tuple[str, dict | None]]] """ - def process_one(url): - return ( - add_url(ffmpeg_args, url_type, url, update=update) - if isinstance(url, str) - else ( - add_url(ffmpeg_args, url_type, *url, update=update) - if ( - isinstance(url, tuple) - and len(url) == 2 - and isinstance(url[0], str) - and isinstance(url[1], (dict, type(None))) + # parse all mapping option values + input_file_id = 0 if len(input_info) == 1 else None + + inputs = args["inputs"] + + output_opts = [] + output_info = [] + for i, opts in enumerate(stream_opts): + spec = opts["map"] + + try: + opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + except ValueError: + # incorrect spec if there is no complex filter in place + if not utils.find_filter_complex_option(args["global_options"]): + raise + + # test spec with possibly omitted brackets + spec = f"[{spec}]" + opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + opts["map"] = spec + + # get output stream information + if "linklabel" in opt: + # case 1: complex filtergraph requires only its outputs to be used + # link labels are unique, so each entry is guaranteed to be + # only associated with one label. + + output_opts.append(opts) + output_info.append( + { + "user_map": spec[1:-1], + "linklabel": opt["linklabel"], + } + ) + else: + if "negative" in opt: + raise ValueError("negative map is not supported.") + + file_index = opt["input_file_id"] + stream_spec = opt["stream_specifier"] + + # retrieve input stream data + if "index" in stream_spec and "stream_type" in stream_spec: + # case 2: specific input stream with known media type + output_opts.append(opts) + output_info.append( + { + "user_map": spec, + "media_type": stream_type_to_media_type( + stream_spec["stream_type"] + ), + "input_file_id": file_index, + "input_stream_id": -1, # unknown and don't care + } + ) + else: + # case 3: generic stream spec, possibly resultsing in multiple output streams + url, opts = inputs[file_index] + for stream_index, stream_spec in utils.input_file_stream_specs( + url, stream_spec, opts or {}, input_info[file_index] + ).items(): + # append all streams + spec = f"{file_index}:{stream_index}" + output_opts.append({**opts, "map": spec}) + output_info.append( + { + "user_map": spec, + "media_type": "audio" if stream_spec[0] == "a" else "video", + "input_file_id": file_index, + "input_stream_id": stream_index, + }, + ) + + # resolve duplicate user_map values + name_counts = Counter((v["user_map"] for v in output_info)) + + if any(v <= 1 for v in name_counts.values()): + return output_opts, output_info + + # create alt names in case {name}:{i} naming convention yields existing name + # e.g., 'v' vs. 'v:0' with 'v' resulting in multiples streams + + # first make sure alt name won't interfere with existing streams + aliases = [] + alias_bases = {} + for k, cnt in name_counts.items(): + if cnt <= 1: + continue + need_alias = False + use_alias = None + for i in count(): + alias = f"{k}:{i}" + if ( + alias in name_counts + ): # already used, cannot be used as stream name nor alias name + need_alias = True + if use_alias is None: + continue + else: + break + elif alias not in aliases and use_alias is None: + use_alias = alias + + if i >= cnt and (not need_alias or use_alias): + # must count past # of stream with this user_name + # continue counting until usable alias is found + break + + if need_alias: + aliases.append(use_alias) + alias_bases[k] = use_alias + + # keep renaming counter to avoid duplicate names + name_counter = {k: 0 for k in name_counts} + + # rename duplicate user_map's + for info in output_info: + user_map = info["user_map"] + if name_counter[user_map] > 1: + alt_base = alias_bases.get(user_map, user_map) + info["user_map"] = f"{alt_base}:{name_counter[user_map]}" + name_counter[user_map] += 1 + + return output_opts, output_info + + +def auto_map( + args: FFmpegArgs, + options: FFmpegOptionDict, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + fg_info: dict[str, FilterGraphInfoDict] | None, +) -> tuple[list[FFmpegOptionDict], list[dict[str, Any]]]: + """list all available streams from all FFmpeg input sources + + This function complements `resolve_raw_output_streams()` + + :param args: FFmpeg argument dict. `filter_complex` argument may be modified. + :param options: FFmpeg output options to be applied to every output + :param input_info: a list of input data source information + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels or None if args does not contain any + complex filtergraph + :return stream_opts: a list of FFmpeg output options + :return stream_info: partial raw output info + + Mapping Input Streams vs. Complex Filtergraph Outputs + ----------------------------------------------------- + + If `filter_complex` global option is defined in `args`, `auto_map()` returns the mapping + of all the output pads of the complex filtergraphs'. Otherwise, all the audio and video + streams of the input urls are mapped. + + """ + + stream_opts = [] + stream_info = [] + + if fg_info is None: + # if no filtergraph, get all video & audio streams from all the input urls + for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)): + for j, st_spec in utils.input_file_stream_specs( + url, None, opts or {}, info + ).items(): + spec = f"{i}:{st_spec}" + stream_opts.append({**options, "map": spec}) + stream_info.append( + { + "user_map": spec, + "media_type": "audio" if st_spec[0] == "a" else "video", + "input_file_id": i, + "input_stream_id": j, + } + ) + else: + # return all filtergraph outputs + for linklabel, info in fg_info.items(): + stream_opts.append({**options, "map": linklabel}) + stream_info.append( + { + "user_map": linklabel[1:-1], + "media_type": info["media_type"], + "linklabel": linklabel, + } + ) + + return stream_opts, stream_info + + +def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: + """list all available output labels of the complex filtergraphs + + :param args: FFmpeg argument dict. `filter_complex` argument may be modified if present. + :return: a map of filtergraph output labels to their media types + + Possible Complex Filtergraph Modification + ----------------------------------------- + + To enable auto-mapping, all the output pads must be labeled. Thus, if the complex filtergraphs + in the `filter_complex` global option have any unlabeled output, they are automatically + labeled as `outN` where N is a number starting from `0`. If a label has alraedy been assigned + to another output pad, that label will be skipped. + """ + + gopts = args.get("global_options", None) or {} + + if "filter_complex" not in gopts: + # no filtergraph + return {} + + # make sure it's a list of filtergraphs + filters_complex = utils.as_multi_option( + gopts["filter_complex"], (str, FilterGraphObject) + ) + + # make sure all are FilterGraphObjects + filters_complex = [fgb.as_filtergraph_object(fg) for fg in filters_complex] + + # check for unlabeled outputs and log existing output labels + out_indices = set() + out_labels = {} + out_unlabeled = False + for fg in filters_complex: + for idx, filter, _ in fg.iter_output_pads(full_pad_index=True): + label = fg.get_label(outpad=idx) + if label is None: + out_unlabeled = True + elif m := re.match(r"out(\d+)$", label): + out_indices.add(int(m[1])) + out_labels[label] = (filter, idx) + + # remove all the output pads connected to an input pad of another filtergraph + if len(filters_complex) > 1: + for fg in filters_complex: + for label, _ in fg.iter_input_labels(): + if label in out_labels: + out_labels.pop(label) + + # if there are unlabeled outputs, label them all + if out_unlabeled: + out_n = next(i for i in range(len(out_labels) + 1) if i not in out_labels) + for i, fg in enumerate(filters_complex): + new_labels = [] + for idx, filter, _ in fg.iter_output_pads( + unlabeled_only=True, full_pad_index=True + ): + label = f"out{out_n}" + out_labels[label] = (filter, idx) + new_labels.append({"label": label, "outpad": idx}) + + # next index + while True: + out_n += 1 + if out_n not in out_labels: + break + + for kwargs in new_labels: + fg = fg.add_label(**kwargs) + filters_complex[i] = fg + + # create the output map + map = { + f"[{label}]": filter.get_pad_media_type("output", pad_id) + for label, (filter, pad_id) in out_labels.items() + } + + # update the filtergraphs + args["global_options"]["filter_complex"] = filters_complex + + return map + + +################################################################################ + + +def process_url_inputs( + args: FFmpegArgs, + urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + inopts_default: FFmpegOptionDict, + no_pipe: bool = False, +) -> list[EncodedInputInfoDict]: + """analyze and process heterogeneous (encoded) input url argument + + :param args: FFmpeg argument dict, `args['inputs']` receives all the new inputs. + If input is a buffer, a fileobj, or an FFconcat, the first element + of the FFmpeg inputs entry is set to 'None', to be replaced by + a pipe expression. + :param urls: input urls/data or a pair of input url and its options or a list thereof + :param inopts_default: default input options + :param no_pipe: True to raise exception if an input is piped without data buffer, defaults to False + :return: list of input information + """ + + urls = [urls] if utils.is_valid_input_url(urls) or isinstance(urls, tuple) else urls + + if len(urls) == 0: + raise FFmpegioError("At least one URL must be given.") + + input_info_list = [None] * len(urls) + for i, url in enumerate(urls): # add inputs + # get the option dict + if utils.is_non_str_sequence(url, (str, FilterGraphObject, Buffer)): + if len(url) != 2: + raise ValueError( + "url-options pair input must be a tuple of the length 2." ) - else None + url, opts = url + opts = inopts_default if opts is None else {**inopts_default, **opts} + else: + # only URL given + opts = inopts_default + + # check url (must be url and not fileobj) + is_fg = isinstance(url, FilterGraphObject) + if is_fg or ("lavfi" == opts.get("f", None) and isinstance(url, str)): + if is_fg: + if "f" not in opts: + opts["f"] = "lavfi" + elif opts["f"] != "lavfi": + raise ValueError( + "input filtergraph must use the `'lavfi'` input format." + ) + + input_info = {"src_type": "filtergraph"} + + elif utils.is_fileobj(url, readable=True): + # if not url.seekable(): + # raise FFmpegioNoPipeAllowed("Fileobj input must be seekable.") + input_info = {"src_type": "fileobj", "fileobj": url} + url = None + elif utils.is_pipe(url): + if no_pipe: + raise FFmpegioNoPipeAllowed("No input pipe allowed.") + input_info = {"src_type": "buffer"} + url = None + elif utils.is_url(url): + input_info = {"src_type": "url"} + elif isinstance(url, FFConcat): + # TODO - generalize this to handle an arbitrary Muxer class + opts["f"] = "concat" + url0 = url.url + if url0 in ("-", "unset"): + input_info = { + "src_type": "buffer", + "buffer": url.compose().getvalue().encode(), + } + url = None + else: + input_info = {"src_type": "url"} + url = url0 + else: + try: + buffer = memoryview(url) + except TypeError as e: + raise TypeError("Given input URL argument is not supported.") from e + else: + input_info = {"src_type": "buffer", "buffer": buffer} + url = None + + url_opts, input_info_list[i] = (url, opts), input_info + + # leave the URL None if data needs to be piped in + add_url(args, "input", *url_opts) + + return input_info_list + + +def process_raw_outputs( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, + options: FFmpegOptionDict, + squeeze: bool, +) -> list[OutputInfoDict]: + """analyze and process piped raw outputs + + :param args: FFmpeg argument dict, A new item in`args['outputs']` is + appended for each piped output. Output URLs are left `None`. + :param input_info: list of input information (same length as `args['inputs']) + :param streams: output stream mappings: + + - `None` to include all input streams OR all filtergraph outputs + - a sequence of either a map option or an output ffmpeg option + dict with `'map'` item + + :param options: default output options + :param squeeze: True to remove shape dimensions with length 1 + :return output_info: list of output information + + """ + + if isinstance(streams, (str, dict)): + streams = [streams] + + gopts = args["global_options"] + + # on-demand complex filtergraph analysis + @cache + def get_fg_info() -> dict[str, FilterGraphInfoDict] | None: + """:returns fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + """ + + optname = utils.find_filter_complex_option(gopts) + + if optname is None: + return None + + if optname in ("/filter_complex", "/lavfi", "filter_complex_script"): + raise NotImplementedError( + "filtergraph on a file is not yet supported. All output video streams must have `r`, `s`, and `pix_fmt` options defined." + "Likewise, all output audio streams mjust have `ar`, `ac`, and `sample_fmt` options defined." + ) + + gopts[optname], fg_info = utils.analyze_complex_filtergraphs( + gopts[optname], args["inputs"], input_info + ) + return fg_info + + # resolve requested output streams + stream_opts: list[FFmpegOptionDict] + stream_info: list[dict[str, Any]] # partial RawOutputInfoDict + if (streams is None or len(streams) == 0) and "map" not in options: + # gather all available streams keyed by their map specifier + stream_opts, stream_info = auto_map(args, options, input_info, get_fg_info()) + else: + if streams is None: + stream_opts = [options] + else: + stream_opts = [ + {**options, **({"map": v} if isinstance(v, str) else v)} + for v in streams + ] + + # expand all streams (targetting ) + stream_opts, stream_info = resolve_raw_output_streams( + stream_opts, args, input_info + ) + + # finalize the output configuration + + @cache + def get_callables(media_type): + return get_raw_output_plugin_callables(media_type) + + for opts, info in zip(stream_opts, stream_info): + media_type = info.get("media_type", None) + + # if media_type is unknown (must be a linklabel not yet analyzed) + if media_type is None: + fg_info = get_fg_info() + pad_info = fg_info[info["linklabel"]] + info["media_type"] = media_type = pad_info["media_type"] + + # add outputs to FFmpeg arguments + + # append raw_info key to the output info dict + gather_media_read_opts = ( + gather_audio_read_opts if media_type == "audio" else gather_video_read_opts + ) + + raw_info, more_opts = gather_media_read_opts( + opts, False, args, input_info, get_fg_info + ) + + if more_opts is None: + raise FFmpegioError( + f'failed to retrieve raw data information for the stream "{info["user_map"]}"' ) + + info["dst_type"] = "buffer" + info["raw_info"] = raw_info + info["item_size"] = utils.get_samplesize(*raw_info[1::-1]) + + info["squeeze"] = squeeze + info.update(get_callables(info["media_type"])) + + # finalize each output streams and identify the output formats + add_url(args, "output", None, {**opts, **more_opts}) + + return stream_info + + +def process_raw_inputs( + args: FFmpegArgs, + stream_options: Sequence[FFmpegOptionDict], + default_options: FFmpegOptionDict, + data: Sequence[RawDataBlob | None] | None = None, + dtypes: list[DTypeString | None] | None = None, + shapes: list[ShapeTuple | None] | None = None, +) -> list[RawInputInfoDict]: + """configure input raw media streams + + :param args: FFmpeg argument dict (to be modified) + :param stream_options: per-stream dict of FFmpeg input options + :param default_options: dict of FFmpeg input options applied to all streams + :param data: per-stream data blob to be written when ffmpeg starts, defaults + to data + :param dtypes: per-stream data types (numpy dtype string), defaults to + auto-detect + :param shapes: per-stream data shapes, defaults to auto-detect + :return: a list of dict containing the provided info + """ + + @cache + def get_callables(media_type: MediaType) -> RawInputCallablesDict: + hook = plugins.pm.hook + return ( + { + "data2bytes": cast(ToBytesCallable, hook.audio_bytes), + "data_is_empty": cast(IsEmptyCallable, hook.is_empty), + "data_count": cast(CountDataCallable, hook.audio_samples), + } + if media_type == "audio" + else { + "data2bytes": cast(ToBytesCallable, hook.video_bytes), + "data_is_empty": cast(IsEmptyCallable, hook.is_empty), + "data_count": cast(CountDataCallable, hook.video_frames), + } ) - ret = process_one(urls) - return [process_one(url) for url in urls] if ret is None else [ret] + nstreams = len(stream_options) + none_list = [None] * nstreams + input_info: list[RawInputInfoDict] = [] + + for opts, blob, dtype, shape in zip( + stream_options, + none_list if data is None else data, + none_list if dtypes is None else dtypes, + none_list if shapes is None else shapes, + ): + # combine the default & per-stream options + opts = {**default_options, **opts} + mtype = "v" if "r" in opts else "a" + more_opts = None + shape_dtype = None + if mtype == "a": # audio + if "r" in opts or "ar" not in opts: + raise ValueError( + "audio stream option dict must contain 'ar' option and must not contain 'r' option." + ) + media_type = "audio" + opts["ar"] = rate = round(opts["ar"]) # force int sampling rate + if blob is not None: + more_opts, shape_dtype = utils.array_to_audio_options(blob) + + elif dtypes and shapes and shape is not None and dtype is not None: + shape_dtype = (shape, dtype) + sample_fmt, ac = utils.guess_audio_format(shape, dtype) + acodec, f = utils.get_audio_codec(sample_fmt) + more_opts = {"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f} + + else: # video + if "ar" in opts: + raise ValueError( + "video stream option dict must not contain 'ar' option." + ) + media_type = "video" + rate = opts["r"] + if blob is not None: + more_opts, shape_dtype = utils.array_to_video_options(blob) + elif dtype and shape: + shape_dtype = (shape, dtype) + pix_fmt, s = utils.guess_video_format(*raw_info) + more_opts = { + "f": "rawvideo", + "c:v": "rawvideo", + "pix_fmt": pix_fmt, + "s": s, + } + + if shape_dtype is None: + raise FFmpegioInsufficientInputData( + "Both input_dtypes and input_shapes must be defined for all raw input streams." + ) + + raw_info = (*shape_dtype, rate) + + if more_opts is not None: + opts.update(more_opts) + + info = { + "src_type": "buffer", + "media_type": media_type, + "raw_info": (*raw_info, rate), + "item_size": utils.get_samplesize(*raw_info[:-1]), + **get_callables(media_type), + } + + if data is not None: + info["buffer"] = info["data2bytes"](obj=blob) + + add_url(args, "input", None, opts) + input_info.append(info) + + return input_info + + +def process_url_outputs( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + options: FFmpegOptionDict, + skip_automapping: bool = False, + no_pipe: bool = False, +) -> list[EncodedOutputInfoDict]: + """analyze and process url outputs + + :param args: FFmpeg argument dict, A new item in`args['outputs']` is + appended for each piped output. Output URLs are left `None`. + :param input_info: list of input information (same length as `args['inputs']) + :param urls: output file names and optionally with file-specific options + :param options: default output options. If `"map"` option is given, it is appended + to the per-file `"map"` option in `streams` argument + :param skip_automapping: True to skip automapping, uses the default mapping, + defaults to False + :param no_pipe: True to raise exception if output is piped without data buffer, + defaults to False + :return output_info: list of output information + """ + + urls = ( + [urls] if utils.is_valid_output_url(urls) or isinstance(urls, tuple) else urls + ) + + if len(urls) == 0: + raise FFmpegioError("At least one URL must be given.") + + missing_map = False + output_info_list = [None] * len(urls) + for i, url in enumerate(urls): # add inputs + # get the option dict + if utils.is_non_str_sequence(url, (str, FilterGraphObject, Buffer)): + if len(url) != 2: + raise ValueError( + "url-options pair input must be a tuple of the length 2." + ) + url, opts = url + opts = {**options} if opts is None else {**options, **opts} + else: + # only URL given + opts = {**options} + + # check url (must be url and not fileobj) + if utils.is_fileobj(url, writable=True): + output_info = {"dst_type": "fileobj", "fileobj": url} + url = None + elif utils.is_pipe(url): + if no_pipe: + raise FFmpegioNoPipeAllowed("No output pipe allowed.") + # convert to buffer + output_info = {"dst_type": "buffer"} + url = None + elif utils.is_url(url): + output_info = {"dst_type": "url"} + else: + raise TypeError("Unknown output {url}.") + + url_opts, output_info_list[i] = (url, opts), output_info + + # leave the URL None if data needs to be piped in + add_url(args, "output", *url_opts) + + if "map" not in opts: + missing_map = True + + if missing_map and not skip_automapping: + # some output file is missing `map` option + # add all input streams or all complex filter outputs + + fgname = find_filtergraph_option(args) + if fgname is None: + out_opts, _ = auto_map(args, options, input_info, None) + map_opts = [o["map"] for o in out_opts] + else: + # get filtergraph + fg = fgb.as_filtergraph(args["global_options"][fgname]) + map_opts = [label for label in fg.iter_output_labels()] + # add outputs to FFmpeg arguments + for _, opts in args["outputs"]: + if "map" not in opts: + opts["map"] = map_opts + + return output_info_list + + +def assign_input_url(args: FFmpegArgs, ifile: int, url: str): + """assign a new url to an FFmpeg input + + :param args: FFmpeg arguments (args['inputs'][ifile] to be modified) + :param ifile: file index + :param url: new url + """ + args["inputs"][ifile] = (url, args["inputs"][ifile][1]) + + +def assign_output_url(args: FFmpegArgs, ofile: int, url: str): + """assign a new url to an FFmpeg output + + :param args: FFmpeg arguments (args['outputs'][ofile] to be modified) + :param ofile: file index + :param url: new url + """ + args["outputs"][ofile] = (url, args["outputs"][ofile][1]) + + +######################################## + + +def assign_output_pipes( + args: FFmpegArgs, + output_info: list[OutputInfoDict], + use_std_pipes: bool = False, +) -> tuple[dict[int, OutputPipeInfoDict], dict]: + """initialize pipes for write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) + :param use_std_pipes: True to assign the first piped output to stdout + :param sp_kwargs: the subprocess.Popen keyword arguments for stdout pipe + :returns pipe_info: output named pipes and their writer threads keyed by output_info index + :returns sp_kwargs: subprocess keywords with `stdout` if `use_std_pipes=True` + and there is at least one piped output + """ + + pipe_info: dict[int, OutputPipeInfoDict] = {} + sp_kwargs = {} + + if output_info is None: + return sp_kwargs, sp_kwargs + + # configure output pipes + use_stdout = False + has_pipeout = False + + for i, (info, arg) in enumerate(zip(output_info, args["outputs"])): + if arg[0]: + # url already configured + continue + + has_pipeout = True + if use_std_pipes and not use_stdout: + use_stdout = True + pipe_path = "pipe:1" + + dst_type = info["dst_type"] + if dst_type == "fileobj": + assert "fileobj" in info + sp_kwargs["stdout"] = info["fileobj"] + elif dst_type == "buffer": + sp_kwargs["stdout"] = fp.PIPE + pipe_info[i] = {"pipe": "stdout"} + else: + # if fileobj or buffer output, use pipe + pipe = NPopen("r", bufsize=0) + pipe_path = pipe.path + pipe_info[i] = {"pipe": pipe} + assign_output_url(args, i, pipe_path) + + if has_pipeout: + # if any output is piped, must run in the overwrite mode + args["global_options"].pop("n", None) + args["global_options"]["y"] = None + + return pipe_info, sp_kwargs + + +def assign_input_pipes( + args: FFmpegArgs, + input_info: list[InputInfoDict], + use_std_pipes: bool = False, + set_sp_kwargs_input: bool = False, +) -> tuple[dict[int, InputPipeInfoDict], dict]: + """initialize named pipes for write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param input_info: FFmpeg input information, its length matches that of `args['inputs']` (modified) + :param use_std_pipes: True to assign the first piped output to stdout + :param set_sp_kwargs_input: True to assign 'input' instead of 'stdin' for sp_kwargs + :returns pipe_info: input pipe information keyed by the indices of the + `input_info` entries with named pipe + :returns sp_kwargs: Specify the subprocess.Popen keyword arguments for stdin related arguments + + """ + + pipe_info = {} + sp_kwargs = {} + + if input_info is None: + return pipe_info, sp_kwargs + + # configure input pipes + use_stdin = False + + # configure input pipes (if needed) + for i, (info, arg) in enumerate(zip(input_info, args["inputs"])): + if arg[0]: + # url already configured + continue + + if use_std_pipes and not use_stdin: + use_stdin = True + pipe_path = "pipe:0" + + src_type = info["src_type"] + if src_type == "fileobj": + assert "fileobj" in info + sp_kwargs["stdin"] = info["fileobj"] + elif src_type == "buffer": + if set_sp_kwargs_input and "buffer" in info: + # given data to send to subprocess + sp_kwargs["input"] = info["buffer"] + else: + sp_kwargs["stdin"] = fp.PIPE + pipe_info[i] = {"pipe": "stdin"} + else: + pipe = NPopen("w", bufsize=0) + pipe_path = pipe.path + pipe_info[i] = {"pipe": pipe} + assign_input_url(args, i, pipe_path) + + return pipe_info, sp_kwargs + + +def init_named_pipes( + inpipe_info: dict[int, InputPipeInfoDict], + outpipe_info: dict[int, OutputPipeInfoDict], + input_info: list[InputInfoDict], + output_info: list[OutputInfoDict], + ref_stream: int | None = None, + ref_blocksize: int | None = None, + enc_blocksize: int | None = None, + queue_size: int | None = None, + timeout: float | None = None, + stack: ExitStack | None = None, +) -> ExitStack: + """initialize named pipes for read & write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param input_info: FFmpeg input information, its length matches that of `args['inputs']` + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) + :param ref_stream: index of reference raw media output stream, defaults to 0 + if raw media stream is present or -1 if only encoded + :param ref_blocksize: block size of the reference stream, defaults to 1 if video + and 1024 for audio + :param encoded_blocksize: encoded data output block size in bytes, defaults to None (2**20 bytes) + :param queuesize: the depth of named pipe queues, defaults to 16. For + unlimited queue size, specify zero (0). + :param timeout: Default queue read timeout in seconds, defaults to `None` to + wait indefinitely. Note this timeout does not apply to + stdout pipe operation. + :param stack: ExitStack context manager object to handle __exit__() of NOpen and Thread objects + :returns: a list of indices of the FFmpeg outputs that are raw data streams + + In addition to the retured list, this function modifies the dicts in its arguements. + + - The named pipe paths are assigned to the URLs of FFmpeg outputs (`args['outputs'][][0]`) + - The reader threads for FFmpeg outputs that are written to buffers (i.e., + `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` + so the reader object can be used to retrieve the data. + + + if any output is a piped, overwrite flag (-y) is automatically inserted + """ + + if stack is None: + stack = ExitStack() + + wr_kws = {"queuesize": queue_size, "timeout": timeout} + + # configure output pipes + if ref_stream is None and len(output_info): + ref_stream = 0 if "raw_info" in output_info[0] else -1 + + ref_rate = 1 + if ref_stream is not None and ref_stream >= 0: + ref_rate = output_info[ref_stream]["raw_info"][-1] + + for i, pinfo in outpipe_info.items(): + info = output_info[i] + + pipe = pinfo["pipe"] + + if pipe == "stdout": + continue + + stack.enter_context(pipe) + + dst_type = info["dst_type"] + if dst_type == "fileobj": + assert "fileobj" in info + reader = CopyFileObjThread(pipe, info["fileobj"]) + else: + assert dst_type == "buffer" + kws = {**wr_kws} + if "raw_info" in info: + kws["itemsize"] = info["item_size"] + if ref_rate is None: + ref_stream = i + ref_rate = info["raw_info"][-1] + kws["nmin"] = ref_blocksize + elif i == ref_stream: + kws["nmin"] = ref_blocksize + else: + rate = info["raw_info"][-1] + kws["nmin"] = round(rate / ref_rate) or 1 + else: + # encoded output in bytes + kws["itemsize"] = 1 + kws["nmin"] = enc_blocksize or 2**16 + reader = ReaderThread(pipe, **kws) + + pinfo["reader"] = reader + stack.enter_context(reader) # starts thread & wait for pipe connection + + # configure input pipes + for i, pinfo in inpipe_info.items(): + info = input_info[i] + + pipe = pinfo["pipe"] + if pipe == "stdin": + continue + + stack.enter_context(pipe) + + src_type = info["src_type"] + if src_type == "fileobj": + assert "fileobj" in info + writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) + # starts thread & wait for pipe connection + else: + assert src_type == "buffer" + writer = WriterThread(pipe, **wr_kws) + # starts thread & wait for pipe connection + if "buffer" in info: + # data buffer given, feed the data and terminate + writer.write(info["buffer"]) + writer.write(None) # close the writer immediately + else: + # if no data given, provide the access to the writer + pinfo["writer"] = writer + stack.enter_context(writer) + + return stack + + +class StdWriter: + def __init__(self, proc: fp.Popen) -> None: + self._proc = proc + + def write(self, data: bytes | None): + if data is None: + self.join() + else: + self._proc.stdin.write(data) + + def join(self): + # no thread, just close the stdin + self._proc.stdin.flush() + self._proc.stdin.close() + + def closed(self) -> bool: + return self._proc.stdin.closed + + +class StdReader: + def __init__(self, proc: fp.Popen, itemsize: int) -> None: + self._proc = proc + self._itemsize = itemsize + + def read(self, n: int = -1) -> bytes: + return self._proc.stdout.read(n if n <= 0 else n * self._itemsize) + + def cool_down(self): + pass + + def join(self): + pass + + +def init_std_pipes( + input_pipes: dict[int, InputPipeInfoDict], + output_pipes: dict[int, OutputPipeInfoDict], + output_info: list[OutputInfoDict], + proc: fp.Popen, +): + """initialize std pipe reader or writer + + :param input_pipes: _description_ + :param output_pipes: _description_ + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` + :param proc: _description_ + """ + stdin = next((st for st, p in input_pipes.items() if p["pipe"] == "stdin"), None) + if stdin is not None: + input_pipes[stdin]["writer"] = StdWriter(proc) + + stdout = next((st for st, p in output_pipes.items() if p["pipe"] == "stdout"), None) + if stdout is not None: + output_pipes[stdout]["reader"] = StdReader( + proc, output_info[stdout]["item_size"] + ) diff --git a/src/ffmpegio/devices.py b/src/ffmpegio/devices.py index dbac80ae..75c804d9 100644 --- a/src/ffmpegio/devices.py +++ b/src/ffmpegio/devices.py @@ -19,10 +19,12 @@ logger = logging.getLogger("ffmpegio") +import re +from subprocess import DEVNULL, PIPE + from ffmpegio.path import ffmpeg -from subprocess import PIPE, DEVNULL + from . import plugins -import re SOURCES = {} SINKS = {} diff --git a/src/ffmpegio/errors.py b/src/ffmpegio/errors.py index a20696b1..ac4f0e0d 100644 --- a/src/ffmpegio/errors.py +++ b/src/ffmpegio/errors.py @@ -5,6 +5,11 @@ class FFmpegioError(Exception): pass +class FFmpegioInsufficientInputData(FFmpegioError): + pass + +class FFmpegioNoPipeAllowed(FFmpegioError): + pass ERROR_MESSAGES = ( # cmdutils.c::parse_optgroup() diff --git a/src/ffmpegio/ffmpegprocess.py b/src/ffmpegio/ffmpegprocess.py index e7bde8f5..0d88d1d3 100644 --- a/src/ffmpegio/ffmpegprocess.py +++ b/src/ffmpegio/ffmpegprocess.py @@ -19,21 +19,22 @@ """ -from collections import abc -from os import path, name as os_name -from threading import Thread +import logging +import signal import subprocess as sp +from collections import abc from copy import deepcopy +from os import name as os_name +from os import path from tempfile import TemporaryDirectory -import logging -import signal +from threading import Thread logger = logging.getLogger("ffmpegio") -from .utils.parser import parse, compose, FLAG -from .threading import ProgressMonitorThread from .configure import move_global_options -from .path import ffmpeg, DEVNULL, PIPE, devnull +from .path import DEVNULL, PIPE, devnull, ffmpeg +from .threading import ProgressMonitorThread +from .utils.parser import FLAG, compose, parse __all__ = ["versions", "run", "Popen", "FLAG", "PIPE", "DEVNULL", "devnull"] @@ -205,7 +206,12 @@ def monitor_process(proc, on_exit=None): if on_exit is not None: returncode = proc.returncode for fcn in on_exit: - fcn(returncode) + try: + fcn(returncode) + except Exception as e: + pass + #TODO - need to re-raise these exceptions? + logger.debug("[monitor] executed all on_exit callbacks") @@ -350,10 +356,12 @@ def wait(self, timeout=None): def terminate(self): """Terminate the FFmpeg process""" super().terminate() - try: - self._monitor.join() - except: - pass + + if self.poll() is not None: + try: + self._monitor.join() + except: + pass def kill(self): """Kill the FFmpeg process""" diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index e7f847e3..48f55697 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -2,15 +2,12 @@ from collections import UserList from collections.abc import Callable, Generator, Sequence - from itertools import chain -from ..utils import filter as filter_utils from .. import filtergraph as fgb - -from .typing import PAD_INDEX, Literal +from . import utils as filter_utils from .exceptions import * - +from .typing import PAD_INDEX, Literal __all__ = ["Chain"] @@ -35,6 +32,12 @@ class Error(FFmpegioError): def __init__(self, filter_specs=None): # convert str to a list of filter_specs + if isinstance(filter_specs, fgb.Graph): + nchains = len(filter_specs) + if nchains != 1: + raise TypeError("Cannot convert a `Graph` object to a `Chain` object") + filter_specs = filter_specs[0] if nchains == 1 else "" + if isinstance(filter_specs, fgb.Filter): filter_specs = [filter_specs] elif filter_specs is not None: @@ -70,7 +73,7 @@ def compose( """ return ( - fgb.Graph(self.data).compose( + fgb.Graph([self.data]).compose( show_unconnected_inputs, show_unconnected_outputs ) if show_unconnected_inputs or show_unconnected_outputs @@ -86,7 +89,9 @@ def __repr__(self): Output pads: ({self.get_num_outputs()}): {', '.join((str(id) for id,*_ in self.iter_output_pads()))} """ - def __getitem__(self, key: int | slice | tuple[int | slice, int | slice]): + def __getitem__( + self, key: int | slice | tuple[int | slice, int | slice] + ) -> fgb.Filter: if not isinstance(key, (int, slice)): i, key = key if i != 0: @@ -99,12 +104,13 @@ def __setitem__(self, key, value): def get_num_chains(self) -> int: """get the number of chains""" - return len(self) + return 1 - def get_num_filters(self, chain: int) -> int: + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ if chain: @@ -121,6 +127,30 @@ def is_last_filter(self, filter_id: int) -> bool: """Returns True if the given id is the last filter of the chain""" return filter_id == len(self) - 1 + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + + if isinstance(index, int): + index = (0, 0, index) + elif len(index) == 1: + index = (0, 0, *index) + else: + if len(index) == 2: + index = (0, *index) + if index[-2] < 0: + index = (index[-3], len(self) + index[-2], index[-1]) + + return self[index[1]].normalize_pad_index(input, index) + def add_label( self, label: str, @@ -292,6 +322,8 @@ def iter_input_pads( filter: int | None = None, chain: Literal[0] | None = None, *, + exclude_stream_specs: bool = False, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -304,6 +336,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 7adfd351..99ea12d9 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -1,17 +1,18 @@ from __future__ import annotations -from collections.abc import Generator, Sequence import re +from collections.abc import Generator, Sequence from functools import partial from itertools import chain -from ..caps import filters as list_filters, filter_info, layouts, FilterInfo -from ..utils import filter as filter_utils - from .. import filtergraph as fgb - -from .typing import PAD_INDEX +from ..caps import FilterInfo, filter_info +from ..caps import filters as list_filters +from ..caps import layouts +from ..stream_spec import parse_stream_spec +from . import utils as filter_utils from .exceptions import * +from .typing import PAD_INDEX, Literal __all__ = ["Filter"] @@ -64,7 +65,23 @@ def _get_info(name: str) -> FilterInfo: def __new__(self, filter_spec, *args, filter_id=None, **kwargs): """_summary_""" + + if isinstance(filter_spec, fgb.Graph): + if len(filter_spec) != 1: + raise TypeError( + "Cannot convert a `Graph` object with more than one filter to a `Filter` object" + ) + filter_spec = filter_spec[0] + + if isinstance(filter_spec, fgb.Chain): + if len(filter_spec) != 1: + raise TypeError( + "Cannot convert a `Chain` or `Graph` object to a `Filter` object if it does not have exactly one filter." + ) + filter_spec = filter_spec[0] + proto = [] + if isinstance(filter_spec, Filter): if filter_spec.id and filter_id is not None: # new id proto.append((filter_spec.name, filter_id)) @@ -151,9 +168,7 @@ def compose( """ return ( - fgb.Graph(self.data).compose( - show_unconnected_inputs, show_unconnected_outputs - ) + fgb.Graph(self).compose(show_unconnected_inputs, show_unconnected_outputs) if show_unconnected_inputs or show_unconnected_outputs else filter_utils.compose_filter(*self) ) @@ -198,7 +213,9 @@ def info(self): except: raise Filter.InvalidName(self.name) - def get_pad_media_type(self, port, pad_id): + def get_pad_media_type( + self, port: Literal["input", "output"], pad_id: int + ) -> Literal["audio", "video"]: try: port = ( "inputs" @@ -253,9 +270,23 @@ def get_pad_media_type(self, port, pad_id): # multiple pads possible if streams option set if self.name in ("movie", "amovie"): - if self.get_option_value("streams") is None: + val = self.get_option_value("streams") + if val is None: return "video" if self.name == "movie" else "audio" + spec = val.split("+")[pad_id] + return ( + "video" + if spec == "dv" + else ( + "audio" + if spec == "da" + else {"v": "video", "a": "audio", None: None}[ + parse_stream_spec(spec).get("media_type", None) + ] + ) + ) + # 2nd pad for audio visualization stream vis_mode = ["afir", "aiir", "anequalizer", "ebur128", "aphasemeter"] if port == "outputs" and self.name in vis_mode: @@ -354,6 +385,20 @@ def _concat(): self.get_option_value("v") + self.get_option_value("a") ) + def _scale(): + # ref input supported in v7.1 or later + w_expr = self.get_option_value("w") + h_expr = self.get_option_value("h") + return ( + 2 + if any( + expr.find(key) >= 0 + for expr in (w_expr, h_expr) + for key in ("ref_", "rw", "rh") + ) + else 1 + ) + option_name, inc = { "afir": ("nbirs", 1), "concat": (None, _concat), @@ -366,6 +411,7 @@ def _concat(): "premultiply": (None, _inplace), "unpremultiply": (None, _inplace), "signature": ("nb_inputs", 0), + "scale": (None, _scale), # "astreamselect": ("inputs", 0), # "bm3d": ("inputs", 0), # "hstack": ("inputs", 0), @@ -402,9 +448,13 @@ def _concat(): def _list_var(opt, sep, inc): v = self.get_option_value(opt) return ( - len(v) - if sep == r"\|" and not isinstance(v, str) - else len(re.split(rf"\s*{sep}\s*", v)) + 1 + if v is None + else ( + len(v) + if sep == r"\|" and not isinstance(v, str) + else len(re.split(rf"\s*{sep}\s*", v)) + ) ) + inc def _channelsplit(): @@ -447,10 +497,36 @@ def _channelsplit(): else inc() ) - def get_num_filters(self, chain: int) -> int: + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + + if isinstance(index, int): + index = (0, 0, index) + elif len(index) == 1: + index = (0, 0, *index) + elif len(index) == 2: + index = (0, *index) + + if index[-1] < 0: + numpads = self.get_num_inputs() if input else self.get_num_outputs() + index = (*index[-3:-1], numpads + index[-1]) + + return index + + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ if chain: @@ -554,6 +630,8 @@ def iter_input_pads( filter: Literal[0] | None = None, chain: Literal[0] | None = None, *, + exclude_stream_specs: bool = False, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -566,6 +644,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 2cd03545..e8e2bec8 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -1,22 +1,20 @@ from __future__ import annotations +import os from collections import UserList -from collections.abc import Generator, Callable, Sequence - +from collections.abc import Callable, Generator, Sequence from contextlib import contextmanager -from itertools import chain from copy import deepcopy +from itertools import chain from math import floor, log10 -import os from tempfile import NamedTemporaryFile -from ..utils import filter as filter_utils, is_stream_spec from .. import filtergraph as fgb - -from .typing import PAD_INDEX +from ..stream_spec import is_map_option +from . import utils as filter_utils from .exceptions import * from .GraphLinks import GraphLinks - +from .typing import PAD_INDEX, Literal __all__ = ["Graph"] @@ -89,12 +87,15 @@ def __init__( # convert str to a list of filter_specs if isinstance(filter_specs, fgb.Graph): links = filter_specs._links - sws_flags = filter_specs.sws_flags and filter_specs.sws_flags[1:] + sws_flags = filter_specs.sws_flags and [*filter_specs.sws_flags[1:]] elif isinstance(filter_specs, fgb.Chain): filter_specs = [filter_specs] if len(filter_specs) else () elif filter_specs is not None: if isinstance(filter_specs, fgb.Filter): filter_specs = [[filter_specs]] + elif not len(filter_specs): + filter_specs = [] + links = sws_flags = None elif isinstance(filter_specs, str): filter_specs, links, sws_flags = filter_utils.parse_graph(filter_specs) @@ -103,9 +104,10 @@ def __init__( "An empty filterchain found. All chains must be populated." ) - filter_specs = (fgb.Chain(fspec) for fspec in filter_specs) - - UserList.__init__(self, () if filter_specs is None else filter_specs) + UserList.__init__( + self, + () if filter_specs is None else iter(fgb.Chain(c) for c in filter_specs), + ) self._links = GraphLinks(links) """utils.fglinks.GraphLinks: filtergraph link specifications @@ -117,16 +119,25 @@ def __init__( """Filter|None: swscale flags for automatically inserted scalers """ + @property + def links(self) -> GraphLinks | None: + """full filtergraph link definition""" + return self._links + def get_num_chains(self) -> int: """get the number of hains""" return len(self) - def get_num_filters(self, chain: int) -> int: + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ + if chain is None: + return sum(len(fc) for fc in self) + if chain < 0 or chain >= len(self): raise ValueError(f"{chain=} is invalid.") return len(self[chain]) @@ -210,8 +221,33 @@ def resolve_pad_index( chainable_first=chainable_first, ) + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + + if isinstance(index, int): + index = (0, 0, index) + elif len(index) == 1: + index = (0, 0, *index) + elif len(index) == 2: + index = (0, *index) + elif index[-3] < 0: + index = (len(self) + index[-3], *index[-2:]) + + return self[index[0]].normalize_pad_index(input, index) + def _get_label(self, input: bool, index: PAD_INDEX): + index = self.normalize_pad_index(input, index) + return getattr( self._links, "find_inpad_label" if input else "find_outpad_label" )(index) @@ -242,7 +278,7 @@ def compose( for j, (index, _, _) in enumerate( self.iter_output_pads(unlabeled_only=True) ): - unc_pads[f"{label}{i+j+1}"] = (None, index) + unc_pads[f"{label}{i + j + 1}"] = (None, index) links = {**fg._links, **unc_pads} if i >= 0 or j >= 0 else fg._links @@ -266,18 +302,21 @@ def __repr__(self): pos.append(len(expr)) prefix = " chain" - nzeros = floor(log10(nchains)) + 1 + nzeros = floor(log10(nchains)) + 1 if nchains else 0 fmt = f"0{nzeros}" chain_list = [ f"{prefix}[{j:{fmt}}]: {expr[i0:i1]}" for j, (i0, i1) in enumerate(zip(pos[:-1], pos[1:])) ] if self.sws_flags: - chain_list = [f"{[' ']*(len(prefix)+3+nzeros)}{expr[:pos[0]]}", *chain_list] + chain_list = [ + f"{[' '] * (len(prefix) + 3 + nzeros)}{expr[: pos[0]]}", + *chain_list, + ] if len(chain_list) > 12: chain_list = [ chain_list[:-4], - f"{[' ']*(len(prefix)+3+nzeros)}{expr[:pos[0]]}", + f"{[' '] * (len(prefix) + 3 + nzeros)}{expr[: pos[0]]}", chain_list[-3:], ] chain_list = "\n".join(chain_list) @@ -286,8 +325,8 @@ def __repr__(self): FFmpeg expression: \"{str(self)}\" Number of chains: {len(self)} {chain_list} - Available input pads ({self.get_num_inputs()}): {', '.join((str(id[0]) for id in self.iter_input_pads()))} - Available output pads: ({self.get_num_outputs()}): {', '.join((str(id[0]) for id in self.iter_output_pads()))} + Available input pads ({self.get_num_inputs()}): {", ".join((str(id[0]) for id in self.iter_input_pads()))} + Available output pads: ({self.get_num_outputs()}): {", ".join((str(id[0]) for id in self.iter_output_pads()))} """ def __setitem__(self, key, value): @@ -306,7 +345,7 @@ def __getitem__(self, key): return UserList.__getitem__(self, key) except (IndexError, StopIteration) as e: raise e - except Exception as e: + except Exception: try: assert len(key) == 2 and all((isinstance(k, int) for k in key)) return UserList.__getitem__(self, key[0])[key[1]] @@ -429,7 +468,6 @@ def _iter_pads( ioff = chain for i, c in enumerate(chains): - j = (len(c) + filter) if filter is not None and filter < 0 else filter for pidx, f, other_pidx in iter_filter_pad( @@ -466,6 +504,8 @@ def iter_input_pads( filter: int | None = None, chain: int | None = None, *, + exclude_stream_specs: bool = True, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -478,6 +518,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False @@ -500,10 +542,9 @@ def iter_input_pads( chainable_only, ): # exclude a pad connected to an input stream - if ( - not include_connected - and isinstance(other_pidx, str) - and is_stream_spec(other_pidx) + is_stream_spec = is_map_option(other_pidx, allow_missing_file_id=True) + if (is_stream_spec and exclude_stream_specs) or ( + not is_stream_spec and only_stream_specs ): continue @@ -551,20 +592,29 @@ def iter_output_pads( yield v def get_num_inputs(self, chainable_only=False): - return len(list(self.iter_input_pads(chainable_only=chainable_only))) + return len( + list( + self.iter_input_pads( + exclude_stream_specs=True, chainable_only=chainable_only + ) + ) + ) def get_num_outputs(self, chainable_only=False): return len(list(self.iter_output_pads(chainable_only=chainable_only))) def iter_input_labels( - self, exclude_stream_specs: bool = False + self, exclude_stream_specs: bool = False, only_stream_specs: bool = False ) -> Generator[tuple[str, PAD_INDEX]]: """iterate over the dangling labeled input pads of the filtergraph object :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :yield: a tuple of 3-tuple pad index and the pad index of the connected output pad if connected """ - for label_index in self._links.iter_inputs(exclude_stream_specs): + for label_index in self._links.iter_inputs( + exclude_stream_specs, only_stream_specs + ): yield label_index def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: @@ -672,6 +722,34 @@ def link( return self._links.link(inpad, outpad, label, preserve_label, force) + def has_label( + self, label: str, only_if: Literal["input", "output", "internal"] | None = None + ) -> bool: + """True if a linklabel is defined + + :param label: name of the link label + :param only_if: also check for the type of the label + :return: True if exists + """ + try: + link = self._links[label] + except KeyError: + return False + + return ( + True + if only_if is None + else ( + (only_if == "input" and link[1] is None) + or (only_if == "output" and link[0] is None) + or ( + only_if == "internal" + and link[0] is not None + and link[1] is not None + ) + ) + ) + def add_label( self, label: str, @@ -730,13 +808,15 @@ def add_label( return self - def remove_label(self, label: str): + def remove_label(self, label: str, inpad: PAD_INDEX | None = None): """remove an input/output label :param label: linkn label + :param inpad: specify input pad if multiple pads receives the same input + stream, defaults to `None` to delete all input pads. """ - self._links.remove_label(label) + self._links.remove_label(label, inpad) def rename_label(self, old_label: str, new_label: str) -> str | None: """rename an existing link label @@ -772,7 +852,6 @@ def is_chain_siso( :param chain_id: chain id :param check_input: False to check only for single-output, defaults to True :param check_output: False to check only for single-input, defaults to True - :param check_link: True to return True if and only if the chain has no active connection, defaults to True """ try: @@ -780,14 +859,63 @@ def is_chain_siso( except IndexError: raise ValueError(f"{chain_id=} is an invalid chain id.") - if check_input and chain.get_num_inputs() != 1: + if len(chain) == 0: + return False # empty chain + + if check_input and chain[0].get_num_inputs() != 1: return False - if check_output and chain.get_num_outputs() != 1: + if check_output and chain[-1].get_num_outputs() != 1: return False return not (check_link and self._links.chain_has_link(chain_id)) + def is_chain_prependable(self, chain_id: int) -> bool: + """True if another chain can be prepended to the specified filter chain""" + + try: + chain = self[chain_id] + except IndexError: + raise ValueError(f"{chain_id=} is an invalid chain id.") + + if len(chain) == 0: + return True # empty chain + + # must have at least one input pad + nin = chain[0].get_num_inputs() + if nin == 0: + return False + + inpad = (chain_id, 0, nin - 1) + conn_from = self._links.input_dict().get(inpad) + + return conn_from is None or isinstance(conn_from, str) + + def is_chain_appendable(self, chain_id: int) -> bool: + """True if another chain can be appended to the specified filter chain + + :param chain_id: chain id + """ + + try: + chain = self[chain_id] + except IndexError: + raise ValueError(f"{chain_id=} is an invalid chain id.") + + if len(chain) == 0: + return True # empty chain + + nout = chain[-1].get_num_outputs() + if nout == 0: # a sink filter, no connectivity + return False + + # the last output pad must not be already connected + filter_id = len(chain) - 1 + outpad = (chain_id, filter_id, nout - 1) + + conn_to = self._links.output_dict().get(outpad) + return conn_to is None or isinstance(conn_to, str) + def _stack( self, other: fgb.abc.FilterGraphObject, @@ -821,19 +949,18 @@ def _stack( return Graph(other) if isinstance(other, Graph): - fg = Graph(self) if other.sws_flags is not None: if fg.sws_flags is None or replace_sws_flags is True: fg.sws_flags = deepcopy(other.sws_flags) elif replace_sws_flags is None: raise Graph.Error( - f"sws_flags are defined on both FilterGraphs. Specify replace_sws_flags option to True or False to avoid this error." + "sws_flags are defined on both FilterGraphs. Specify replace_sws_flags option to True or False to avoid this error." ) try: fg._links.update( - other._links.map_chains(len(self), False), auto_link=auto_link + other._links.map_chains(len(self)), auto_link=auto_link ) except Exception as e: if auto_link: @@ -861,8 +988,8 @@ def _connect( """combine another filtergraph object and make downstream connections (worker) :param right: other filtergraph - :param fwd_links: a list of tuples, pairing self's output pad and right's ipnut pad - :param bwd_links: a list of tuples, pairing right's output pad and self's ipnut pad + :param fwd_links: a list of tuples, pairing self's output pad and right's input pad + :param bwd_links: a list of tuples, pairing right's output pad and self's input pad :param chain_siso: True to chain the single-input single-output connection, default: True :param replace_sws_flags: True to use `right` sws_flags if present, False to drop `right` sws_flags, @@ -873,76 +1000,124 @@ def _connect( """ - fg = Graph(self) - - must_link_fwd = [True] * len(fwd_links) - right_chained = [] - - if chain_siso and not len(bwd_links): - # if linking chains are both siso and free of any other linkages and both pads are not labeled - # the chain of the right fg is joined to the chain of the left - - right = fgb.as_filtergraph(right, copy=True) - - # chain links if there is no ambiguity - for i, (outpad, inpad) in enumerate(fwd_links): - ochain, ichain = outpad[0], inpad[0] - - # label check - if ( - fg.is_chain_siso( - ochain, check_input=False, check_output=True, check_link=False - ) - and not fg._links.are_linked(None, outpad) - and right.is_chain_siso( - ichain, check_input=True, check_output=False, check_link=True - ) - ): - # add the right chain to the matching left chain - fg[ochain].extend(right[ichain]) + # procedure outline + # 0. analyze fwd_links whether they can be chained or not + # 1. chain or stack each chain of the right filtergraph object + # - chain if there is a responsible fwd_link else stack + # - drop chained fwd_link from the list + # 2. if right is a Graph, add its links to the output fg with adjustments + # 3. add remaining fwd_links + # 4. add bwd_links - label = fg._links.find_outpad_label(outpad) - if label: - fg._links.remove_label(label) - - # mark already connected - must_link_fwd[i] = False - right_chained.append(ichain) - - # stack the remaining chains - if len(bwd_links) or any(must_link_fwd): + fg = Graph(self) - # sift through the connections for chainable and unchainables - n0 = fg.get_num_chains() # chain index offset + right_links = ( + GraphLinks(right._links) if isinstance(right, Graph) else GraphLinks(None) + ) - # stack 2 filtergraphs and build right chain id conversion lookup table - lut = {} - for i, c in enumerate(right): - if i not in right_chained: - lut[i] = n0 - n0 += 1 - fg = fg._stack(c) + lut_shift = {} + lut_map = {} - right_links = right._links.drop_labels(tuple(fg._links.keys())).map_chains( - lut, False + # scan fwd_links: split fwd_links to be chained and stacked + fwd_chain_links = {} # keyed by input chain idx + fwd_stack_links = [] + for outpad, inpad in fwd_links: + link = ( + self.normalize_pad_index(False, outpad), + right.normalize_pad_index(True, inpad), ) - # transfer the right links to fg (remap chains) - fg._links.update(right_links) - - # create iterators to organize the links in (input, output) of the combined graph - it_fwd = ( - ((lut[r[0]], *r[1:]), l) - for (l, r), do_link in zip(fwd_links, must_link_fwd) - if do_link - ) - it_bwd = ((l, (lut[r[0]], *r[1:])) for (r, l) in bwd_links) - fg._links.update( - {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, - validate=False, + if ( + chain_siso + and self._output_pad_is_chainable(link[0]) + and right._input_pad_is_chainable(link[1]) + ): + # there should be only 1 link which is a chaining link for inpad (and also for outpad) + fwd_chain_links[link[1][0]] = link + else: + fwd_stack_links.append(link) + + # drop labels currently exists on these pads + label = fg._links.find_outpad_label(outpad) + if label is not None: + assert isinstance(label, str) + fg._links.remove_label(label) + label = right_links.find_inpad_label(inpad) + if label is not None: + assert isinstance(label, str) + right_links.remove_label(label) + + # scan bwd_links + bwd_links_ = [] + for outpad, inpad in bwd_links: + link = ( + self.normalize_pad_index(False, outpad), + right.normalize_pad_index(True, inpad), ) + bwd_links_.append(link) + + # drop labels currently exists on these pads + label = right_links.find_outpad_label(outpad) + if label is not None: + assert isinstance(label, str) + right_links.remove_label(label) + label = fg._links.find_inpad_label(inpad) + if label is not None: + assert isinstance(label, str) + fg._links.remove_label(label) + + # stack/chain the chains of the right filtergraph to the left fg + n0 = len(fg) # chain index offset + for i, c in right.iter_chains(): + if i in fwd_chain_links: + op, ip = fwd_chain_links[i] + + # all the links on this chain gets mapped to outpad's chain + # and shifted by the length of the chain before chaining + lut_map[ip[0]] = op[0] + lut_shift[ip[0]] = len(fg[op[0]]) + + # chain + fg[op[0]].extend(c) + + else: # stack + # map the right links to the new chain + lut_map[i] = n0 + # increment the chain counter + n0 += 1 + # stack the new chain + fg = fg._stack(c) + + # map the remainig right links to the new fg + right_links = right_links.map_chains(lut_map, lut_shift) + + # make sure labels don't collide + right_links = { + fg._links.resolve_label(label, auto_index=True): link + for label, link in right_links.items() + } + + # transfer the right links to fg (remap chains) + fg._links.update(right_links) + + # add the new links in (input, output) of the combined graph + def adjust_right_pad(pad): + c = pad[0] + if c in lut_shift: + pad = (pad[0], pad[1] + lut_shift[c], pad[2]) + if c in lut_map: + pad = (lut_map[c], *pad[1:]) + return pad + + it_fwd = tuple((adjust_right_pad(r), l) for l, r in fwd_stack_links) + it_bwd = tuple((l, adjust_right_pad(r)) for r, l in bwd_links) + fg._links.update( + {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, + validate=False, + ) - if replace_sws_flags and right.sws_flags: + # if commanded, use the right sws flags as the output sws flags + if replace_sws_flags and isinstance(right, Graph) and right.sws_flags: fg.sws_flags = right.sws_flags return fg @@ -970,60 +1145,9 @@ def _rconnect( """ - # return fgb.as_filtergraph(left)._connect( - # self, fwd_links, bwd_links, chain_siso, replace_sws_flags - # ) - - fg = Graph(self) - - must_link_fwd = [True] * len(fwd_links) - left_chained = [] - - if chain_siso and not len(bwd_links): - # if linking chains are both siso and free of any other linkages and both pads are not labeled - # the chain of the right fg is joined to the chain of the left - - left = fgb.as_filtergraph(left, copy=True) - - # chain links if there is no ambiguity - for i, (outpad, inpad) in enumerate(fwd_links): - ochain, ichain = outpad[0], inpad[0] - - # label check - if ( - fg.is_chain_siso( - ichain, check_input=True, check_output=False, check_link=False - ) - and not fg._links.are_linked(inpad, None) - and left.is_chain_siso( - ochain, check_input=False, check_output=True, check_link=True - ) - ): - # add the right chain to the matching left chain - left_chain = left[ochain] - fg[ichain].data = [*left_chain, *fg[ichain]] - - label = fg._links.find_inpad_label(inpad) - if label: - fg._links.remove_label(label) - - fg._links.adjust_filters(ichain, 0, len(left_chain)) - - # mark already connected - must_link_fwd[i] = False - left_chained.append(ochain) - - # stack the remaining chains - if len(bwd_links) or any(must_link_fwd): - fg = left._connect( - fg, - [link for link, do_link in zip(fwd_links, must_link_fwd) if do_link], - bwd_links, - chain_siso=False, - replace_sws_flags=replace_sws_flags, - ) - - return fg + return fgb.as_filtergraph(left)._connect( + self, fwd_links, bwd_links, chain_siso, replace_sws_flags + ) def _iter_io_pads(self, is_input, how, ignore_labels=False): """Iterates input/output pads of the filtergraph @@ -1138,10 +1262,7 @@ def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified input pad index is available""" # check linked indices - if any( - link[1] == index - for link in self._links.iter_links(include_input_stream=True) - ): + if self._links.are_linked(inpad=index, outpad=None, check_input_stream=True): # already connected return False @@ -1152,7 +1273,7 @@ def _output_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified output pad index is available""" # check linked indices - if any(link[2] == index for link in self._links.iter_links()): + if self._links.are_linked(outpad=index, inpad=None): # already connected return False diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index bfdb7f2f..d1c7f534 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -2,11 +2,10 @@ import re from collections import UserDict -from collections.abc import Generator, Mapping, Sequence, Callable +from collections.abc import Callable, Generator, Mapping, Sequence - -from ..utils import is_stream_spec from ..errors import FFmpegioError +from ..stream_spec import is_map_option from .typing import PAD_INDEX, PAD_PAIR, Literal """ @@ -27,11 +26,7 @@ """ -class GraphLinks: ... - - class GraphLinks(UserDict): - class Error(FFmpegioError): pass @@ -69,13 +64,14 @@ def validate_label( "A pad label without a link must be a string label." ) else: - try: - if no_stream_spec or not is_stream_spec(label): - assert re.match(r"[a-zA-Z0-9_]+$", label) - except Exception as e: + if not (isinstance(label, str) and len(label)): + raise GraphLinks.Error( + "Pad label must be a string and has at least one character." + ) + if no_stream_spec and is_map_option(label, allow_missing_file_id=True): raise GraphLinks.Error( - f'{label=} is not a valid link label. A link label must be a string with only alphanumeric and "_" characters' - ) from e + f"Pad label cannot be an input stream specifier ({label})." + ) @staticmethod def validate_pad_idx(id: PAD_INDEX | None, none_ok: bool = True): @@ -83,7 +79,7 @@ def validate_pad_idx(id: PAD_INDEX | None, none_ok: bool = True): if id is None: if none_ok: return - raise GraphLinks.Error(f"pad index cannot be None") + raise GraphLinks.Error("pad index cannot be None") if not ( isinstance(id, (tuple)) @@ -101,7 +97,7 @@ def validate_pad_idx_pair(ids: PAD_PAIR): assert len(ids) == 2 except: raise GraphLinks.Error( - f"Link value must be a 2-element tuple with inpad and outpad pad ids" + "Link value must be a 2-element tuple with inpad and outpad pad ids" ) (inpad, outpad) = ids @@ -109,12 +105,12 @@ def validate_pad_idx_pair(ids: PAD_PAIR): inpad_is_none = inpad is None if inpad_is_none and outpad is None: - raise GraphLinks.Error(f"Both input and output pads cannot be None.") + raise GraphLinks.Error("Both input and output pads cannot be None.") i = -1 for i, d in enumerate(GraphLinks.iter_inpad_ids(inpad, True)): if d is None and not inpad_is_none: - raise GraphLinks.Error(f"multi-id input label item cannot be None.") + raise GraphLinks.Error("multi-id input label item cannot be None.") GraphLinks.validate_pad_idx(d) @staticmethod @@ -136,14 +132,13 @@ def validate(data: dict[str | int, PAD_PAIR]): # validate each link for label, pads in data.items(): - if ( - not is_stream_spec(label) + not is_map_option(label, allow_missing_file_id=True) and pads[0] is not None and isinstance(pads[0][0], tuple) ): raise GraphLinks.Error( - "Only stream specifier labels can have multiple input pads." + "Only map specifier labels can have multiple input pads." ) GraphLinks.validate_item(label, pads) @@ -260,7 +255,7 @@ def link( if not (in_label or out_label): # new label, resolve - label = self._resolve_label(label, force) + label = self.resolve_label(label, force) # create the new link (overwrite if forced) self.data[label] = (inpad, outpad) @@ -309,11 +304,13 @@ def _refresh_autolabels(self): for id in range(new_id + 1, old_id + 1): del self.data[id] - def _resolve_label( + def resolve_label( self, label: str | int | None, force: bool = False, check_stream_spec: bool = True, + auto_index: bool = False, + auto_index_sep: str = "", ) -> str | int: """check the label name for duplicate, adjust as needed @@ -321,6 +318,10 @@ def _resolve_label( is ignored and replaced with the autonumbering label :param force: True to allow overwrite an existing label, defaults to False :param check_stream_spec: False to skip stream spec check, defaults to True + :param auto_index: True to append a number to a string label until a unique + label is found, defaults to False to error out. + :param auto_index_sep: a string to separate the label and the auto-index number, + defaults to '' :return: validated label name/id """ @@ -330,16 +331,110 @@ def _resolve_label( except ValueError: return 0 - if check_stream_spec and is_stream_spec(label): + if check_stream_spec and is_map_option(label, allow_missing_file_id=True): return label if not force and label in self: - raise GraphLinks.Error(f"{label=} is already in use.") + if not auto_index: + raise GraphLinks.Error(f"{label=} is already in use.") + i = 0 + label_ = f"{label}{auto_index_sep}" + while label in self: + i += 1 + label = f"{label_}{i}" self.validate_label(label) return label + @staticmethod + def duplicates( + *link_objs: tuple[GraphLinks | None, ...], + ) -> dict[str | int, list[tuple[int, str]]]: + """re-label the duplicate label names of multiple ``GraphLink`` objects + + :param link_objs: ``GraphLink``s objects to be re-labeled. ``None`` elements + are ignored + :return: copies of ``link_objs`` with relabeled ``GraphLink``s + """ + + # accumulate all the labels (remove trailing numbers if exist to match) + labels: dict[str | int, list[tuple[int, str]]] = {} + regexp = re.compile(r"\d+$") + for i, obj in enumerate(link_objs): + if obj is None: + continue + for label in obj: + key = label + if isinstance(key, str): + m = regexp.search(key) + if m: + key = key[: m.start()] + + if key in labels: + labels[key].append((i, label)) + else: + labels[key] = [(i, label)] + + return {k: v for k, v in labels.items() if len(v) > 1} + + @staticmethod + def relabel_duplicates( + *link_objs: tuple[GraphLinks | None, ...], + ) -> tuple[GraphLinks | None, ...]: + """re-label the duplicate label names of multiple ``GraphLink`` objects + + :param link_objs: ``GraphLink``s objects to be re-labeled. ``None`` elements + are ignored + :return: copies of ``link_objs`` with relabeled ``GraphLink``s + """ + + # accumulate all the labels (remove trailing numbers if exist to match) + labels: dict[str | int, list[tuple[int, str]]] = {} + regexp = re.compile(r"\d+$") + for i, obj in enumerate(link_objs): + if obj is None: + continue + for label in obj: + key = label + if isinstance(key, str): + m = regexp.search(key) + if m: + key = key[: m.start()] + + if key in labels: + labels[key].append((i, label)) + else: + labels[key] = [(i, label)] + + # copy the link objects + new_links = [obj or GraphLinks(obj) for obj in link_objs] + + # generate new labels for duplicated labels + int_counter = 0 + for key, matches in labels.items(): + if isinstance(key, int): + # integer label (auto-labels) + for i, old_label in matches: + new_label = int_counter + int_counter += 1 + if new_label != old_label: + obj = new_links[i] + obj[new_label] = obj.pop(old_label) + else: + # user label's + if len(matches) == 1: + # unique, keep + continue + + for j, (i, old_label) in enumerate(matches): + new_label = f"{key}{j}" + if new_label != old_label: + obj = new_links[i] + obj[new_label] = obj.pop(old_label) + + return new_links + def __getitem__(self, key: str | int) -> PAD_PAIR: """get link item by label or by inpad pad id tuple @@ -439,7 +534,10 @@ def iter_links( """ def iter(label, inpad, outpad): - if outpad is not None or (include_input_stream and is_stream_spec(label)): + if outpad is not None or ( + include_input_stream + and is_map_option(label, allow_missing_file_id=True) + ): for d in self.iter_inpad_ids(inpad): yield (label, d, outpad) @@ -452,16 +550,21 @@ def iter(label, inpad, outpad): yield v def iter_inputs( - self, exclude_stream_specs: bool = True + self, exclude_stream_specs: bool = True, only_stream_specs: bool = False ) -> Generator[tuple[str, PAD_INDEX]]: """Iterate over only input labels, possibly repeating the same label if shared among multiple input pad ids :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :yield: label and pad index """ for label, (inpad, outpad) in self.data.items(): - if outpad is None and not (exclude_stream_specs and is_stream_spec(label)): + is_stream = is_map_option(label, allow_missing_file_id=True) + if outpad is None and ( + (is_stream and not exclude_stream_specs) + or not (is_stream or only_stream_specs) + ): for d in self.iter_inpad_ids(inpad): yield (label, d) @@ -472,7 +575,7 @@ def iter_input_streams(self) -> Generator[tuple[str, PAD_INDEX]]: :yield: label and pad index """ for label, (inpad, outpad) in self.data.items(): - if outpad is None and is_stream_spec(label): + if outpad is None and is_map_option(label, allow_missing_file_id=True): for d in self.iter_inpad_ids(inpad): yield (label, d) @@ -584,7 +687,7 @@ def are_linked( ) else: if inpad is None and outpad is None: - raise ValueError(f"At least one of inpad or outpad must be specified.") + raise ValueError("At least one of inpad or outpad must be specified.") # check internal links first it_links = self.iter_links() @@ -611,7 +714,12 @@ def are_linked( def chain_has_link( self, chain_id: int, check_input: bool = True, check_output: bool = True ) -> bool: - """True if there is any link/label defined on the chain specified by its id""" + """True if there is any link/label defined on the chain specified by its id + + :param chain_id: index of the chain under test + :param check_input: True to check all the input pads, defaults to True + :param check_output: _description_, defaults to True + """ for inpads, outpad in self.values(): if check_output and outpad and outpad[0] == chain_id: return True @@ -651,9 +759,9 @@ def create_label( if (outpad is None) == (inpad is None): raise ValueError("outpad or inpad (but not both) must be given.") - is_stspec = is_stream_spec(label) + is_stspec = is_map_option(label, allow_missing_file_id=True) if not is_stspec: - label = self._resolve_label(label, force=force, check_stream_spec=False) + label = self.resolve_label(label, force=force, check_stream_spec=False) label_in_use = label in self @@ -757,7 +865,7 @@ def rename(self, old_label: str, new_label: str, force: bool = False) -> str: :return: renamed label name """ v = self.data[old_label] - label = self._resolve_label(new_label, force) + label = self.resolve_label(new_label, force) del self.data[old_label] self.data[label] = v return label @@ -785,8 +893,8 @@ def update( if not isinstance(other, GraphLinks) and validate: try: assert isinstance(other, Mapping) - except Exception as e: - raise GraphLinks.Error(f"Other must be a dict-like mapping object") + except Exception: + raise GraphLinks.Error("Other must be a dict-like mapping object") self.validate(other) # set aside labels @@ -862,8 +970,8 @@ def adjust_filters(self, chain_id: int, pos: int, len: int): :param len: number of chains to be inserted (if positive) or removed (if negative) """ - select = ( - lambda pid: pid[0] == chain_id and pid[1] >= pos + select = lambda pid: ( + pid[0] == chain_id and pid[1] >= pos ) # select all chains at or above pos adjust = lambda pid: (pid[0], pid[1] + len, pid[2]) self._modify_pad_ids(select, adjust) @@ -889,21 +997,55 @@ def adj(pid): self._modify_pad_ids(select, adj) def map_chains( - self, mapper: int | Mapping[int:int], validate_new: bool = True + self, + mapper: int | Mapping[int, int] | None, + shifter: Mapping[int, int] | None = None, ) -> GraphLinks: """Generate a new GraphLink object with a chain id mapper - :param mapper: the current chain id as a key and the new chain id as its value + :param mapper: the current chain id as a key and the new chain id as its + value. If an int value, all the chains are offset by the value. + :param shifter: keyed chain links are shifted by the given value if specified + + Note: if a chain is indexed in both `mapper` and `shifter`, its links + are first shifted then mapped. + """ + if shifter is not None and len(shifter): + + def shift_padidx(pad): + if pad[0] in shifter: + pad = (pad[0], pad[1] + shifter[pad[0]], pad[2]) + return pad + + def shift_pair(inpads, outpad): + if outpad is not None: + outpad = shift_padidx(outpad) + if inpads is not None: + if isinstance(inpads[0], int): # single-input + inpads = shift_padidx(inpads) + else: # multiple-inputs (an input stream) + inpads = tuple(shift_padidx(d) for d in inpads) + return (inpads, outpad) + + data = {label: shift_pair(*value) for label, value in self.items()} + else: + data = self.data + # check for duplicate value if isinstance(mapper, int): class OffsetMapper: + nmap = len(self) + def __init__(self, offset): self._off = offset + def __len__(self): + return self.nmap + def __contains__(self, _): # applies to all return True @@ -915,41 +1057,28 @@ def get(self, k, defaults=None): return k + self._off mapper = OffsetMapper(mapper) - elif validate_new: - new_ids = sorted(set(mapper.values())) - if len(new_ids) != len(mapper): - raise ValueError("Values of mapper must have no duplicate.") - if new_ids != list(range(len(new_ids))): - raise ValueError( - "Values of mapper must be values between 0 and len(mapper)." - ) - def adjust_pair(inpads, outpad): - if outpad is not None: - if outpad[0] not in mapper: - return None - outpad = (mapper[outpad[0]], *outpad[1:]) - if inpads is not None: - if isinstance(inpads[0], int): # single-input - if inpads[0] not in mapper: - return None - inpads = (mapper[inpads[0]], *inpads[1:]) - else: # multiple-inputs (an input stream) - inpads = tuple( - (cid, *d[1:]) - for d in inpads - if ((cid := mapper.get(d[0], None)) is not None) - ) - if not len(inpads): - return None - return (inpads, outpad) + if mapper is not None and len(mapper): + + def map_padidx(pad): + if pad[0] in mapper: + pad = (mapper[pad[0]], *pad[1:]) + return pad + + def adjust_pair(inpads, outpad): + if outpad is not None: + outpad = map_padidx(outpad) + if inpads is not None: + if isinstance(inpads[0], int): # single-input + inpads = map_padidx(inpads) + else: # multiple-inputs (an input stream) + inpads = tuple(map_padidx(d) for d in inpads) + return (inpads, outpad) + + data = {label: adjust_pair(*value) for label, value in data.items()} fglinks = GraphLinks() - fglinks.data = { - label: pair - for label, value in self.data.items() - if (pair := adjust_pair(*value)) is not None - } + fglinks.data = data return fglinks def drop_labels(self, labels: Sequence[str], keep_links: bool = True) -> GraphLinks: @@ -963,7 +1092,7 @@ def drop_labels(self, labels: Sequence[str], keep_links: bool = True) -> GraphLi def keep(k): if isinstance(k, str) and k in labels: if keep_links and self.is_linked(k): - return self._resolve_label(None) + return self.resolve_label(None) return None else: return k diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index 264a77f6..c4763c4b 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -9,7 +9,7 @@ :widths: 15 10 30 :header-rows: 1 - --------------------------------- ------------------------------------------------------------ + ------------------------------ ------------------------------------------------------------ Operation Description Related Methods ------------------------------ ------------------------------------------------------------ `+` operator Chaining/join operator, supports scalar expansion @@ -63,16 +63,17 @@ Filter Pad Labeling =================== -`str >> Filter/Chain/Graph` and `Filter/Chain/Graph >> str` operations can be used to set input -and output labels, respectively. The labels must be specified in square brackets as in the same -manner as FFmpeg filtergraph specification. +`str >> Filter/Chain/Graph` and `Filter/Chain/Graph >> str` operations can be +used to set input and output labels, respectively. The labels must be specified +in square brackets as in the same manner as FFmpeg filtergraph specification. .. code-block::python fg = '[in]' >> Filter('scale',0.5,-1) >> '[out]' -The brackets are required to distinguish labels from str expressions of filter, chain, and graph. -For example, the following expression chains `scale` and `setsar` filters: +The brackets are required to distinguish labels from str expressions of filter, +chain, and graph. For example, the following expression chains `scale` and +`setsar` filters: .. code-block::python @@ -102,10 +103,8 @@ from .. import path from ..caps import filters as list_filters from . import abc -from .Filter import Filter +from .build import attach, concatenate, connect, join, stack from .Chain import Chain -from .Graph import Graph -from .build import connect, join, attach, stack, concatenate from .convert import ( as_filter, as_filterchain, @@ -115,11 +114,14 @@ atleast_filterchain, ) from .exceptions import FiltergraphInvalidIndex, FiltergraphPadNotFoundError +from .Filter import Filter +from .Graph import Graph # chain | filter | pad __all__ = [ "abc", + "presets", "as_filter", "as_filterchain", "as_filtergraph", @@ -168,3 +170,25 @@ def func(*args, filter_id=None, **kwargs): _filters[name] = func return func + + +# TODO +# def validate_input_filtergraph(fg): + +# for idx, f, _ in fg.iter_output_pads(): +# label = fg.get_label(outpad=idx) +# if label is None: # '[Out0]' +# if 0 in outlabels: +# raise ValueError( +# "Invalid input filtergraph. Only one unlabeled output allowed." +# ) +# st = 0 +# elif m := re.match(r"out(\d)+$", label): +# st = int(m[1]) +# else: +# raise ValueError( +# 'Input filtergraph must be labelled as "outN" where N is a nonnegative integer, starting at 0.' +# ) +# outlabels[st] = f.get_pad_media_type(port="out", pad_id=idx[-1]) +# if (n := len(outlabels)) != max(outlabels) + 1: +# raise ValueError("Invalid input filtergraph. Missing output label(s).") diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index f11d4ed0..65c707ae 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -3,18 +3,25 @@ from abc import ABC, abstractmethod from collections.abc import Generator, Sequence -from .typing import PAD_INDEX, JOIN_HOW, Literal -from .exceptions import * - from .. import filtergraph as fgb - -from ..utils import zip # pre-py310 compatibility - +from .._utils import zip # pre-py310 compatibility +from .exceptions import * +from .GraphLinks import GraphLinks +from .typing import JOIN_HOW, PAD_INDEX, Literal __all__ = ["FilterGraphObject"] class FilterGraphObject(ABC): + @staticmethod + def relabel_duplicates(*fgs: tuple[FilterGraphObject]): ... + + def relabel(self, labels: dict[str | int, str | int]): ... + + @property + def links(self) -> GraphLinks | None: + """filtergraph link definition only if filtergraph""" + return None def get_num_pads(self, input: bool) -> int: """get the number of available pads at input or output @@ -40,10 +47,11 @@ def get_num_chains(self) -> int: """get the number of chains""" @abstractmethod - def get_num_filters(self, chain: int) -> int: + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ def next_input_pad( @@ -160,6 +168,8 @@ def iter_input_pads( filter: int | None = None, chain: int | None = None, *, + exclude_stream_specs: bool = False, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -172,6 +182,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False @@ -212,15 +224,16 @@ def iter_output_pads( # Label management methods (default operation for non-Graph objects) def iter_input_labels( - self, exclude_stream_specs: bool = False + self, exclude_stream_specs: bool = False, only_stream_specs: bool = False ) -> Generator[tuple[str, PAD_INDEX]]: """iterate over the dangling labeled input pads of the filtergraph object :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :yield: a tuple of 3-tuple pad index and the pad index of the connected output pad if connected """ - raise StopIteration() + yield from () def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: """iterate over the dangling labeled output pads of the filtergraph object @@ -228,7 +241,7 @@ def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: :yield: a tuple of 3-tuple pad index and the pad index of the connected input pad if connected """ - raise StopIteration() + yield from () def get_label( self, @@ -253,7 +266,7 @@ def get_label( return self._get_label(input, index) if inpad is not None: return self._get_label(True, inpad) - if (outpad is not None) != 1: + if outpad is not None: return self._get_label(False, outpad) raise ValueError( "One and only one of index, inpad, or outpad must be specified." @@ -262,6 +275,19 @@ def get_label( def _get_label(self, input: bool, index: PAD_INDEX): return None + @abstractmethod + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + def get_input_pad( self, index_or_label: PAD_INDEX | str ) -> tuple[PAD_INDEX, str | None]: @@ -319,6 +345,25 @@ def add_label( """ + def has_label( + self, label: str, only_if: Literal["input", "output", "internal"] | None = None + ) -> bool: + """True if a linklabel is defined + + :param label: name of the link label + :param only_if: also check for the type of the label + :return: True if exists + """ + return False # reimplemented by Graph + + def remove_label(self, label: str, inpad: PAD_INDEX | None = None): + """remove an input/output label + + :param label: linkn label + :param inpad: specify input pad if multiple pads receives the same input + stream, defaults to `None` to delete all input pads. + """ + @abstractmethod def _connect( self, @@ -434,7 +479,14 @@ def rconnect( """ return fgb.connect( - left, self, from_left, to_right, chain_siso, replace_sws_flags + left, + self, + from_left, + to_right, + from_right, + to_left, + chain_siso, + replace_sws_flags, ) def join( @@ -620,8 +672,28 @@ def compose( :param show_unconnected_inputs: display [UNC#] on all unconnected input pads, defaults to True :param show_unconnected_outputs: display [UNC#] on all unconnected output pads, defaults to True + """ + # def __eq__(self, value: FilterGraphObject | str) -> bool: + def __eq__(self, value: object) -> bool: + + try: + value = fgb.convert.as_filtergraph_object_like(value, self) + except Exception: + return False + + return super().__eq__(value) + + # def __ne__(self, value: FilterGraphObject | str) -> bool: + def __ne__(self, value: object) -> bool: + try: + value = fgb.convert.as_filtergraph_object_like(value, self) + except Exception: + return True + + return super().__ne__(value) + def __str__(self) -> str: return self.compose(False, False) @@ -631,30 +703,30 @@ def __repr__(self) -> str: ... # Filtergraph math operators def __add__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: - return fgb.join(self, other) + return fgb.join(self, other, inplace=False) def __radd__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: - return fgb.join(other, self) + return fgb.join(other, self, inplace=False) def __mul__(self, __n: int) -> fgb.Graph: """duplicate-n-stack""" if not isinstance(__n, int): return NotImplemented - return fgb.stack(*((self,) * __n)) + return fgb.stack(*((self,) * __n), inplace=False) def __rmul__(self, __n: int) -> fgb.Graph: """duplicate-n-stack""" if not isinstance(__n, int): return NotImplemented - return fgb.stack(*((self,) * __n)) + return fgb.stack(*((self,) * __n), inplace=False) def __or__(self, other: FilterGraphObject | str) -> fgb.Graph: """stack""" - return fgb.stack(self, other) + return fgb.stack(self, other, inplace=False) def __ror__(self, other: FilterGraphObject | str) -> fgb.Graph: """stack""" - return fgb.stack(other, self) + return fgb.stack(other, self, inplace=False) def __rshift__( self, @@ -697,6 +769,9 @@ def parse_other(other): # if output is a list if isinstance(other, list): + if len(other) == 0: + raise ValueError("At least one `other` filtergraph must be specified.") + # match the pad indices first right, left_on, right_on = [ [*t] for t in zip(*(parse_other(o) for o in other)) @@ -705,7 +780,7 @@ def parse_other(other): # parse other argument, separate the indices if given right, left_on, right_on = parse_other(other) - return fgb.attach(self, right, left_on, right_on) + return fgb.attach(self, right, left_on, right_on, inplace=False) def __rrshift__( self, @@ -747,6 +822,9 @@ def parse_other(other): # if output is a list if isinstance(other, list): + if len(other) == 0: + raise ValueError("At least one `other` filtergraph must be specified.") + # match the pad indices first left, right_on, left_on = [ [*t] for t in zip(*(parse_other(o) for o in other)) @@ -755,7 +833,7 @@ def parse_other(other): # parse other argument, separate the indices if given left, right_on, left_on = parse_other(other) - return fgb.attach(left, self, left_on, right_on) + return fgb.attach(left, self, left_on, right_on, inplace=False) def resolve_pad_index( self, @@ -924,7 +1002,6 @@ def resolve_pad_indices( ] if resolve_omitted: - # assign unknown pad indices in the order of the following ranking: # indices ranking # - int, int, int = 3*6 = 18 diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index ad19e2c2..68ce005b 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -1,17 +1,73 @@ from __future__ import annotations -from itertools import islice +from copy import copy -from .typing import PAD_INDEX, JOIN_HOW, Literal, get_args - -from .exceptions import FiltergraphInvalidExpression from .. import filtergraph as fgb - -from ..utils import zip # pre-py310 compatibility +from .._utils import zip # pre-py310 compatibility +from .exceptions import FFmpegioError, FiltergraphInvalidExpression +from .GraphLinks import GraphLinks +from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args __all__ = ["connect", "join", "attach", "stack", "concatenate"] +def resolve_connect_pad_indices( + left: fgb.abc.FilterGraphObject, + right: fgb.abc.FilterGraphObject, + from_left: list[PAD_INDEX | str | None], + to_right: list[PAD_INDEX | str | None], + from_right: list[PAD_INDEX | str | None], + to_left: list[PAD_INDEX | str | None], + resolve_omitted: bool, +) -> tuple[list[tuple[PAD_INDEX, PAD_INDEX]]]: + """resolve and validate pad indices given for a filtergraph connect operation + + :param left: transmitting filtergraph object + :param right: receiving filtergraph object + :param from_left: output pad ids or labels of `left` fg (feedforward link sources) + :param to_right: input pad ids or labels of the `right` fg (feedforward link destinations) + :param from_right: output pad ids or labels of the `right` fg (feedback link sources) + :param to_left: input pad ids or labels of this `left` fg (feedback destinations) + :param resolve_omitted: True to resolve the `None`'s in the pad indices. If False, any + incomplete pad index (those with `None`) will raise FiltergraphPadNotFoundError + :return: tuple pairs of filter pad indices to be paired. Each tuple pair consists of two pad indices: the + first is the source/output pad and the second is the destination/input pad. + """ + + # make sure the pads to be linked are all pairable + try: + fwd_links = [(l, r) for l, r in zip(from_left, to_right, strict=True)] + except: + raise ValueError( + f"the number of pad indices in {from_left=} and {to_right=} must match." + ) + + try: + bwd_links = [(l, r) for l, r in zip(from_right, to_left, strict=True)] + except: + raise ValueError( + f"the number of pad indices in {from_right=} and {to_left=} must match." + ) + + # make sure all the link indices are 3-element tuples + fwd_links = [ + ( + left.resolve_pad_index(l, is_input=False, resolve_omitted=resolve_omitted), + right.resolve_pad_index(r, is_input=True, resolve_omitted=resolve_omitted), + ) + for l, r in fwd_links + ] + bwd_links = [ + ( + right.resolve_pad_index(r, is_input=False, resolve_omitted=resolve_omitted), + left.resolve_pad_index(l, is_input=True, resolve_omitted=resolve_omitted), + ) + for r, l in bwd_links + ] + + return fwd_links, bwd_links + + def connect( left: fgb.abc.FilterGraphObject | str, right: fgb.abc.FilterGraphObject | str, @@ -21,6 +77,7 @@ def connect( to_left: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, chain_siso: bool = True, replace_sws_flags: bool | None = None, + inplace: bool = False, ) -> fgb.Graph | fgb.Chain: """connect two filtergraph objects and make explicit connections @@ -34,6 +91,8 @@ def connect( :param replace_sws_flags: True to use `right` sws_flags if present, False to drop `right` sws_flags, None to throw an exception (default) + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: new filtergraph object Notes @@ -43,11 +102,9 @@ def connect( """ - from ..filtergraph.util import resolve_connect_pad_indices - # make sure right is a Graph object - left = fgb.as_filtergraph_object(left) - right = fgb.as_filtergraph_object(right) + left = fgb.as_filtergraph_object(left, copy=not inplace) + right = fgb.as_filtergraph_object(right, copy=not inplace) # present as a list of pad indices if not isinstance(from_left, list): @@ -75,6 +132,7 @@ def join( unlabeled_only: bool = False, chain_siso: bool = True, replace_sws_flags: bool = None, + inplace: bool = False, ) -> fgb.Graph | None: """filtergraph auto-connector @@ -96,9 +154,23 @@ def join( :param replace_sws_flags: True to use other's sws_flags if present, False to ignore other's sws_flags, None to throw an exception (default) + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: Graph with the appended filter chains or None if inplace=True. """ + # if one of the filtergraphs is empty, return the other (or a copy thereof) + if not fgb.as_filtergraph_object(right).get_num_filters(): + if inplace: + return left + else: + return fgb.as_filtergraph_object(left).copy() + if not fgb.as_filtergraph_object(left).get_num_filters(): + if inplace: + return right + else: + return copy(fgb.as_filtergraph_object(right)) + if how is None: how = "auto" if n_links is None: @@ -108,13 +180,15 @@ def join( raise ValueError(f"{how=} is an unknown matching method") # make sure right is a Graph, Chain, or Filter object - left = fgb.as_filtergraph_object(left) - right = fgb.as_filtergraph_object(right) + left = fgb.as_filtergraph_object(left, copy=not inplace) + right = fgb.as_filtergraph_object(right, copy=not inplace) # handle joining empty graph - if not right.get_num_chains(): + nright = right.get_num_chains() + if not nright: return left - if not left.get_num_chains(): + nleft = left.get_num_chains() + if not nleft: return right iter_kws = {"unlabeled_only": unlabeled_only, "full_pad_index": True} @@ -124,46 +198,36 @@ def join( if n_links == "all" or n_links < 0: n_links = 0 - def create_links(it_left, it_right): - if n_links: - it_left = islice(it_left, n_links) - it_right = islice(it_right, n_links) - - it_left = (v[0] for v in it_left) - it_right = (v[0] for v in it_right) - - try: - return list(zip([*it_left], [*it_right], strict=strict)) - except ValueError: - raise ValueError( - f"Available pads of left and right filtergraph objects do not match ({strict=})" - ) - - if how in ("per_chain", "auto"): - it_left_chain = left.iter_chains(skip_if_no_output=True) - it_right_chain = right.iter_chains(skip_if_no_input=True) + if how in ("per_chain", "auto") and nright == nleft: + # try: - chain_pairs = zip( - [*it_left_chain], [*it_right_chain], strict=strict or how == "auto" - ) - links = [ - ((il, *l[1:]), (ir, *r[1:])) # output -> input - for (il, lchain), (ir, rchain) in chain_pairs - for (l, r) in create_links( - lchain.iter_output_pads(**iter_kws), - rchain.iter_input_pads(**iter_kws), - ) - ] + links = [None] * nleft + for c in range(nleft): + # get the first available pad to join + left_pad, *_ = next(left.iter_output_pads(chain=c, **iter_kws)) + right_pad, *_ = next(right.iter_input_pads(chain=c, **iter_kws)) + links[c] = (left_pad, right_pad) except: if how == "auto": how = "all" else: raise - - if how in ("all", "chanable"): - links = create_links( - left.iter_output_pads(**iter_kws), right.iter_input_pads(**iter_kws) - ) + + if how in ("all", "chainable") or nright != nleft: + left_pads = [out[0] for out in left.iter_output_pads(**iter_kws)] + right_pads = [out[0] for out in right.iter_input_pads(**iter_kws)] + + nleft, nright = len(left_pads), len(right_pads) + if strict and nleft != nright: + raise FFmpegioError("`[stict=True] number of unconnected pads must match.") + n_max = min(nleft, nright) + n_links = n_max if n_links <= 0 else min(n_links, n_max) + + links = [None] * n_links + for i, (left_pad, right_pad) in enumerate( + zip(left_pads[:n_links], right_pads[:n_links]) + ): + links[i] = (left_pad, right_pad) fg = left._connect( right, @@ -183,19 +247,20 @@ def create_links(it_left, it_right): def attach( - left: fgb.Filter | fgb.Chain | str | list[fgb.Filter | fgb.Chain | str], - right: fgb.Filter | fgb.Chain | str | list[fgb.Filter | fgb.Chain | str], + left: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], + right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], left_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, + inplace: bool = False, ) -> fgb.Graph: """attach filter(s), chain(s), or label(s) to a filtergraph object :param left: input filtergraph object, filtergraph expression, or label, or list thereof - :param right: output filterchain, filtergraph expression, or label, or list thereof. + :param right: output filtergraph object, filtergraph expression, or label, or list thereof. :param left_on: pad_index, specify the pad on left, default to None (first available) :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) - :param right_first: True to preserve the chain indices of the right filtergraph object, defaults - to False to preserve the chain order of the left object + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: new filtergraph object One and only one of ``left`` or ``right`` may be a list or a label. @@ -208,7 +273,7 @@ def attach( def check_obj(obj): try: - obj_label = fgb.as_filtergraph_object(obj) + obj_label = fgb.as_filtergraph_object(obj, copy=not inplace) except FiltergraphInvalidExpression: try: obj_label = str(obj) @@ -221,10 +286,6 @@ def check_obj(obj): def analyze_fgobj(obj): attach_obj = isinstance(obj, list) obj = [check_obj(o) for o in obj] if attach_obj else check_obj(obj) - if attach_obj and any(isinstance(o, fgb.Graph) for o in obj): - raise ValueError( - "Filtergraph object list cannot include any Graph object. Only Filter and Chain objects are allowed." - ) if isinstance(obj, str): attach_obj = True obj = [obj] @@ -235,7 +296,6 @@ def analyze_fgobj(obj): right_objs_labels, attach_right = analyze_fgobj(right) if not (attach_left or attach_right): - if not len(right_objs_labels): return left_objs_labels if not len(left_objs_labels): @@ -299,9 +359,13 @@ def resolve_indices(base, branches, base_indices, branch_indices, base_is_input) ) if attach_right: - return left_objs_labels._attach(right_objs_labels, left_on, right_on) + return fgb.as_filtergraph_object(left_objs_labels, copy=not inplace)._attach( + right_objs_labels, left_on, right_on + ) else: - return right_objs_labels._rattach(left_objs_labels, left_on, right_on) + return fgb.as_filtergraph_object(right_objs_labels, copy=not inplace)._rattach( + left_objs_labels, left_on, right_on + ) def concatenate(*fgs): @@ -313,6 +377,7 @@ def stack( *fgs: fgb.abc.FilterGraphObject, auto_link: bool = False, use_last_sws_flags: bool | None = None, + inplace: bool = False, ) -> fgb.Graph: """stack filtergraph objects @@ -321,6 +386,8 @@ def stack( :param use_last_sws_flags: True to use ``sws_flags`` of the last object with one, False to use ``sws_flags`` of the first object with one ``, None to throw an exception if multiple ``sws_flags`` encountered (default) + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: new filtergraph object Remarks @@ -332,18 +399,31 @@ def stack( TO-CHECK/TO-DO: what happens if common link labels are already linked """ - fgs = [fg for fg in fgs if fg.get_num_chains()] + fgs = [ + fg + for fg in (fgb.as_filtergraph_object(fg1) for fg1 in fgs) + if fg.get_num_filters() + ] n = len(fgs) if not n: return fgb.Graph() + if len(fgs) == 1: + return fgb.as_filtergraph_object(fgs[0], copy=True) + + # re-label the links + for fg, links in zip(fgs, GraphLinks.relabel_duplicates([fg.links for fg in fgs])): + if links is not None: + fg._links = links + + fg = fgb.as_filtergraph(fgs[0], copy=not inplace) + if n == 1: - return fgs[0] + return fg - fg = fgb.as_filtergraph(fgs[0]) replace_sws_flags = None for other in fgs[1:]: if use_last_sws_flags is not None: replace_sws_flags = True if fg.sws_flags is None else use_last_sws_flags - fg = fg._stack(fgb.as_filtergraph_object(other), auto_link, replace_sws_flags) + fg = fg._stack(other, auto_link, replace_sws_flags) return fg diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py index e77d58be..4bc321ca 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -1,9 +1,9 @@ from __future__ import annotations -from ..utils import filter as filter_utils - -from .exceptions import FiltergraphConversionError, FiltergraphInvalidExpression from .. import filtergraph as fgb +from . import utils as filter_utils +from .exceptions import (FiltergraphConversionError, + FiltergraphInvalidExpression) def as_filter( @@ -114,6 +114,9 @@ def as_filtergraph_object( No copy is performed if the input is already a ``Graph`` and ``copy=False``. """ + if not filter_specs: + return fgb.Chain() + if isinstance(filter_specs, (fgb.Filter, fgb.Chain, fgb.Graph)): return type(filter_specs)(filter_specs) if copy else filter_specs diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py new file mode 100644 index 00000000..0e4dba4e --- /dev/null +++ b/src/ffmpegio/filtergraph/presets.py @@ -0,0 +1,208 @@ +"""ffmpegio.filtergraph.presets Module - a collection of preset filtergraph generators""" + +from __future__ import annotations + +from fractions import Fraction + +from .. import filtergraph as fgb +from .._typing import TYPE_CHECKING, Any, Literal, Sequence +from ..path import check_version +from ..stream_spec import StreamSpecDict + +if TYPE_CHECKING: + from .Chain import Chain + from .Graph import Graph + + +def remove_alpha( + fill_color: str, + pix_fmt: str | None = None, + *, + input_label: str | None = None, + output_label: str | None = None, +) -> Graph: + """generate a filter graph to remove alpha channel from a video + + :param fill_color: _description_ + :param input_label: _description_, defaults to None + :param output_label: _description_, defaults to None + :return: Resulting filter graph in the form: + + ``` + color,[in]scale2ref[main],[main]overlay[out] + ``` + + """ + + if input_label is None: + input_label = "in" + if output_label is None: + output_label = "out" + + if check_version("7.1.0", "<"): + expr = f"color=c={fill_color}[cout],[cout]scale2ref[l2],[l2]overlay=shortest=1" + inpad = (0, 1, 1) + outpad = (0, 2, 0) + else: + expr = ( + "split[in1][in2];" + f"color=c={fill_color}[cout];" + "[cout][in1]scale=rw:rh[sout];" + "[sout][in2]overlay=shortest=1" + ) + inpad = (0, 0, 0) + outpad = (3, 0, 0) + + fg = fgb.Graph(expr) + + if pix_fmt is not None: + fg += fgb.format(pix_fmts=pix_fmt) + outpad = (outpad[0], outpad[1] + 1, 0) + + fg.add_label(input_label, inpad) + fg.add_label(output_label, outpad=outpad) + + return fg + + +def filter_video_basic( + scale: str | Sequence | None = None, + crop: str | Sequence | None = None, + flip: Literal["horizontal", "vertical", "both"] | None = None, + transpose: str | Sequence | None = None, +) -> Chain: + + vfilters = [] + + if crop: + try: + assert not isinstance(crop, str) + vfilters.append(fgb.crop(*crop)) + except: + vfilters.append(fgb.crop(crop)) + + if flip: + try: + ftype = ("", "horizontal", "vertical", "both").index(flip) + except: + raise Exception("Invalid flip filter specified.") + if ftype % 2: + vfilters.append("hflip") + if ftype >= 2: + vfilters.append("vflip") + + if transpose is not None: + try: + assert not isinstance(transpose, str) + vfilters.append(fgb.transpose(*transpose)) + except: + vfilters.append(fgb.transpose(transpose)) + + if scale: + try: + scale = [int(s) for s in scale.split("x")] + except: + pass + try: + assert not isinstance(scale, str) + vfilters.append(fgb.scale(*scale)) + except: + vfilters.append(fgb.scale(scale)) + + return sum(vfilters, start=fgb.Chain()) + + +def square_pixels( + mode: Literal["upscale", "downscale", "upscale_even", "downscale_even"], +) -> Chain: + """a filter chain to square pixels of video frames + + :param mode: whether to 'upscale' by preserving the long side and elongating + the short side or 'downscale' by preserving the short side and + shrinking the long side. Both modes can be made to force an even + numbered frame size to accommodate video codecs like h264. + :return: a chain of `scale` and `setsar` filters + """ + try: + expr = { + "upscale": "scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1", + "downscale": "scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1", + "upscale_even": "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1", + "downscale_even": "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1", + }[mode] + except KeyError as e: + raise ValueError(f"unknown mode: {mode}") from e + + return fgb.Chain(expr) + + +def merge_audio( + streams: dict[StreamSpecDict, dict[str, Any]], + output_ar: int | None = None, + output_sample_fmt: str | None = None, + output_pad_label: str | None = "aout", +) -> Graph: + """Create a filtergraph to merge input audio streams. + + This preset filtergraph formats the input streams so that their sampling rates and sample formats are first converted + to the same satisfying the requirements of the `amerge` filter. + + :param streams: List of input audio streams to merge. Each stream is keyed by its FFmpeg stream specifier and must provide its input options. + The option must include the sampling rate (`ar`) and sample format (`sample_fmt`). + :param output_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first input stream + :param output_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first input stream + :param output_pad_label: label of the `amerge` filter output, defaults to None to leave the output pad unconnected + + """ + + # number of input audio streams to be merged + n_ain = len(streams) + + # if output sampling rate or sample format not given, use the first stream's setting + if output_ar is None or output_sample_fmt is None: + opts = next(iter(streams.values())) + if output_ar is None: + output_ar = opts["ar"] + if output_sample_fmt is None: + output_sample_fmt = opts["sample_fmt"] + + # build complex_filter to merge + + def match_sample(sspec, opts): + fopts = {} + if opts["ar"] != output_ar: + fopts["r"] = output_ar + if opts["sample_fmt"] != output_sample_fmt: + fopts["f"] = output_sample_fmt + + in_label = f"[{sspec}]" + return (in_label >> fgb.aformat(**fopts)) if len(fopts) else in_label + + afilt = [match_sample(*st) for st in streams.items()] >> fgb.amerge(inputs=n_ain) + + return (afilt >> output_pad_label) if output_pad_label else afilt + + +def temp_video_src(r: int | Fraction, pix_fmt: str, s: tuple[int, int]) -> fgb.Chain: + """temporary video source + + :param r: frame rate + :param pix_fmt: pixel format + :param s: frame shape (width x height) + :return: a chain of color and format filters + """ + fg = fgb.color(s=f"{s[0]}x{s[1]}", r=r) + return fg if pix_fmt == "unknown" else (fg + fgb.format(pix_fmts=pix_fmt)) + + +def temp_audio_src(ar: int, sample_fmt: str, ac: int) -> fgb.Chain: + """temporary audio source + + :param ar: sampling rate + :param sample_fmt: sample format + :param ac: number of channels + :return: a chain of aevalsrc and aformat + """ + return fgb.aevalsrc("|".join(["0"] * ac)) + fgb.aformat( + sample_fmts=sample_fmt or "dbl", r=ar + ) diff --git a/src/ffmpegio/filtergraph/typing.py b/src/ffmpegio/filtergraph/typing.py index 301664c7..66d5121b 100644 --- a/src/ffmpegio/filtergraph/typing.py +++ b/src/ffmpegio/filtergraph/typing.py @@ -1,8 +1,8 @@ from __future__ import annotations from typing import * -from typing_extensions import * +from typing_extensions import * PAD_INDEX = Union[ Tuple[Union[int, None], Union[int, None], int], diff --git a/src/ffmpegio/filtergraph/util.py b/src/ffmpegio/filtergraph/util.py deleted file mode 100644 index 8472eb2a..00000000 --- a/src/ffmpegio/filtergraph/util.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from .typing import PAD_INDEX - -from .. import filtergraph as fgb -from ..utils import zip # pre-py310 compatibility - - -def resolve_connect_pad_indices( - left: fgb.abc.FilterGraphObject, - right: fgb.abc.FilterGraphObject, - from_left: list[PAD_INDEX | str | None], - to_right: list[PAD_INDEX | str | None], - from_right: list[PAD_INDEX | str | None], - to_left: list[PAD_INDEX | str | None], - resolve_omitted: bool, -) -> tuple[list[tuple[PAD_INDEX, PAD_INDEX]]]: - """resolve and validate pad indices given for a filtergraph connect operation - - :param left: transmitting filtergraph object - :param right: receiving filtergraph object - :param from_left: output pad ids or labels of `left` fg (feedforward link sources) - :param to_right: input pad ids or labels of the `right` fg (feedforward link destinations) - :param from_right: output pad ids or labels of the `right` fg (feedback link sources) - :param to_left: input pad ids or labels of this `left` fg (feedback destinations) - :param resolve_omitted: True to resolve the `None`'s in the pad indices. If False, any - incomplete pad index (those with `None`) will raise FiltergraphPadNotFoundError - :return: tuple pairs of filter pad indices to be paired. Each tuple pair consists of two pad indices: the - first is the source/output pad and the second is the destination/input pad. - """ - - # make sure the pads to be linked are all pairable - try: - fwd_links = [(l, r) for l, r in zip(from_left, to_right, strict=True)] - except: - raise ValueError( - f"the number of pad indices in {from_left=} and {to_right=} must match." - ) - - try: - bwd_links = [(l, r) for l, r in zip(from_right, to_left, strict=True)] - except: - raise ValueError( - f"the number of pad indices in {from_right=} and {to_left=} must match." - ) - - # make sure all the link indices are 3-element tuples - fwd_links = [ - ( - left.resolve_pad_index(l, is_input=False, resolve_omitted=resolve_omitted), - right.resolve_pad_index(r, is_input=True, resolve_omitted=resolve_omitted), - ) - for l, r in fwd_links - ] - bwd_links = [ - ( - right.resolve_pad_index(r, is_input=False, resolve_omitted=resolve_omitted), - left.resolve_pad_index(l, is_input=True, resolve_omitted=resolve_omitted), - ) - for r, l in bwd_links - ] - - return fwd_links, bwd_links diff --git a/src/ffmpegio/utils/filter.py b/src/ffmpegio/filtergraph/utils.py similarity index 97% rename from src/ffmpegio/utils/filter.py rename to src/ffmpegio/filtergraph/utils.py index de46224e..cbd24a12 100644 --- a/src/ffmpegio/utils/filter.py +++ b/src/ffmpegio/filtergraph/utils.py @@ -1,6 +1,7 @@ -from fractions import Fraction -import re, itertools +import itertools +import re from collections.abc import Sequence +from fractions import Fraction # Filter string parser/composer # For FilterGraph class, see ../filtergraph.py @@ -87,7 +88,7 @@ def get_kw(arg): def compose_filter_args(*args): """compose once-escaped filter argument string - :param *args: list of argument strings; last element may be a dict of key-value pairs + :param args: list of argument strings; last element may be a dict of key-value pairs :type *args: list of str + dict :return: filter argument string :rtype: str @@ -228,7 +229,9 @@ def add_pad(label, output, *padspec): padspecs = sig[output] if padspecs is None: sig[output] = padspec - elif not output and sig[1] is None: # new input label with the same name as existing input label + elif ( + not output and sig[1] is None + ): # new input label with the same name as existing input label if isinstance(sig[output][0], int): # second matching input label sig[output] = [padspecs, padspec] @@ -237,7 +240,7 @@ def add_pad(label, output, *padspec): padspecs.append(padspec) else: raise ValueError( - f'Filter graph specifies multiple \'{label}\' {"output" if output else "input"} pads.' + f"Filter graph specifies multiple '{label}' {'output' if output else 'input'} pads." ) def parse_labels(expr, i, output, *cidfid): @@ -300,7 +303,6 @@ def parse_labels(expr, i, output, *cidfid): i = j else: - # add new filter to the chain fc.append(parse_filter(fs)) @@ -468,7 +470,6 @@ def assign_link(d, label, cid, fid, pid): labels = set() # collection of all the labels if links is not None and len(links): - # log all named link labels labels = {k for k in links.keys() if isinstance(k, str)} diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index f2b9cbef..07271821 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -1,101 +1,56 @@ -from . import ffmpegprocess, utils, configure, FFmpegError, plugins -from .probe import _video_info as _probe_video_info -from .utils import log as log_utils - -__all__ = ["create", "read", "write", "filter"] - - -def _run_read( +import logging +from fractions import Fraction + +from . import configure, utils +from . import filtergraph as fgb +from ._typing import Any, DTypeString, ProgressCallable, RawDataBlob, ShapeTuple +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, +) +from .errors import FFmpegioError +from .std_runners import run_and_return_encoded, run_and_return_raw + +__all__ = ["create", "read", "write", "filter", "detect"] + +logger = logging.getLogger("ffmpegio") + + +def create( + expr: str | fgb.abc.FilterGraphObject, *args, - shape=None, - pix_fmt_in=None, - s_in=None, - show_log=None, - sp_kwargs=None, - **kwargs -): - """run FFmpeg and retrieve audio stream data - :param *args ffmpegprocess.run arguments - :type *args: tuple - :param shape: output frame size if known, defaults to None - :type shape: (int, int), optional - :param pix_fmt_in: input pixel format if known but not specified in the ffmpeg arg dict, defaults to None - :type pix_fmt_in: str, optional - :param s_in: input frame size (wxh) if known but not specified in the ffmpeg arg dict, defaults to None - :type s_in: str or (int, int), optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param **kwargs ffmpegprocess.run keyword arguments - :type **kwargs: tuple - :return: image data, created by `bytes_to_video` plugin hook - :rtype: object - """ - - dtype, shape, _ = configure.finalize_video_read_opts(args[0], pix_fmt_in, s_in) - - if sp_kwargs is not None: - kwargs = {**sp_kwargs, **kwargs} - - if shape is None: - configure.clear_loglevel(args[0]) - - out = ffmpegprocess.run(*args, capture_log=True, **kwargs) - if show_log: - print(out.stderr) - if out.returncode: - raise FFmpegError(out.stderr) - - info = log_utils.extract_output_stream(out.stderr) - dtype, shape = utils.get_video_format(info["pix_fmt"], info["s"]) - else: - out = ffmpegprocess.run( - *args, - capture_log=None if show_log else True, - **kwargs, - ) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - - nbytes = utils.get_samplesize(shape, dtype) - - return plugins.get_hook().bytes_to_video( - b=out.stdout[-nbytes:], dtype=dtype, shape=shape, squeeze=True - ) - - -def create(expr, *args, show_log=None, sp_kwargs=None, **options): + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> RawDataBlob: """Create an image using a source video filter :param name: name of the source filter - :type name: str - :param \\*args: sequential filter option arguments. Only valid for + :param args: sequential filter option arguments. Only valid for a single-filter expr, and they will overwrite the options set by expr. - :type \\*args: seq, optional + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: Named filter options or FFmpeg options. Items are + :param options: Named filter options or FFmpeg options. Items are only considered as the filter options if expr is a single-filter graph, and take the precedents over general FFmpeg options. Append '_in' for input option names (see :doc:`options`), and '_out' for output option names if they conflict with the filter options. - :type \\**options: dict, optional - :return: image data, created by `bytes_to_video` plugin hook - :rtype: object + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 3D (row x column x comp) if colored/transparent. + or 2D (row x column) if it is a grayscale image. .. seealso:: See https://ffmpeg.org/ffmpeg-filters.html#Video-Sources for @@ -103,187 +58,203 @@ def create(expr, *args, show_log=None, sp_kwargs=None, **options): """ - input_options = utils.pop_extra_options(options, "_in") - output_options = utils.pop_extra_options(options, "_out") - - url, _, options = configure.config_input_fg(expr, args, options) - - options = {**options, **output_options, "frames:v": 1} + url, t_, options = configure.config_input_fg(expr, args, options) - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"}) - configure.add_url(ffmpeg_args, "output", "-", {**options, "f": "rawvideo"}) - - return _run_read( - ffmpeg_args, - pix_fmt_in=input_options.get("pix_fmt", "rgb24"), + return read( + url, + progress=progress, show_log=show_log, sp_kwargs=sp_kwargs, + **options, ) -def read(url, show_log=None, sp_kwargs=None, **options): +def read( + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + *, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> RawDataBlob: """Read an image file or a snapshot of a video frame :param url: URL of the image or video file to read. - :type url: str + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be a url string or a pair of + a url string and an option dict. + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: image data, created by `bytes_to_video` plugin hook - :rtype: object + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 3D (row x column x comp) if colored/transparent. + or 2D (row x column) if it is a grayscale image. Note on \\**options: To specify the video frame capture time, use `time` option which is an alias of `start` standard option. """ - # get pix_fmt of the input file only if needed - pix_fmt_in = s_in = None - if "pix_fmt" not in options and "pix_fmt_in" not in options: - try: - pix_fmt_in, *s_in, ra_in, rr_in = _probe_video_info(url, "v:0", sp_kwargs) - except: - pix_fmt_in = "rgb24" + # use user-specified map or default '0:V:0' map + output_map = options.pop("map", "0:V:0") - input_options = utils.pop_extra_options(options, "_in") + # make sure it reads only one file + options["vframes" if "vframes" in options else "frames:v"] = 1 - # get url/file stream - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_read( + [url] if utils.is_valid_input_url(url) else url, + [output_map], + options, + extra_outputs, + True, ) - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - outopts["f"] = "rawvideo" - if "frames:v" not in outopts: - outopts["frames:v"] = 1 - - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = stdin - sp_kwargs["input"] = input - - return _run_read( - ffmpeg_args, - pix_fmt_in=pix_fmt_in, - s_in=s_in, - show_log=show_log, - sp_kwargs=sp_kwargs, - ) + if output_info is None: + raise FFmpegioError( + "Unknown configuration error occurred. Necessary output information could not be collected." + ) + if output_info[0]["media_type"] != "video": + raise ValueError("Mapped stream is not a video stream.") + + return run_and_return_raw( + args, + input_info, + output_info, + progress, + show_log, + sp_kwargs, + )[1] def write( - url, - data, - overwrite=None, - show_log=None, - extra_inputs=None, - sp_kwargs=None, - **options -): + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + data: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + dtype: DTypeString | None = None, + shape: ShapeTuple | None = None, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> bytes | None: """Write a NumPy array to an image file. :param url: URL of the image file to write. - :type url: str :param data: image data, accessed by `video_info()` and `video_bytes()` plugin hooks - :type data: object :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :type extra_inputs: seq(str|(str,dict)) - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - url, stdout, _ = configure.check_url(url, True) + # if filter_complex is not defined use '0:V:0' as default mapping + if ( + not any( + (o in options) + for o in ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + ) + and "map" not in options + ): + options["map"] = "0:V:0" - input_options = utils.pop_extra_options(options, "_in") + options["vframes" if "vframes" in options else "frames:v"] = 1 - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(1, data=data, **input_options), + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_write( + url, [{"r": 1}], extra_inputs, options, [data], [dtype], [shape] ) - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - outopts = configure.add_url(ffmpeg_args, "output", url, options)[1][1] - outopts["frames:v"] = 1 - - configure.build_basic_vf(ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1)) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - { - "input": plugins.get_hook().video_bytes(obj=data), - "stdout": stdout, - "overwrite": overwrite, - } + return run_and_return_encoded( + progress, + overwrite, + show_log, + sp_kwargs, + args, + input_info, + output_info, ) - kwargs["capture_log"] = None if show_log else False - out = ffmpegprocess.run(ffmpeg_args, **kwargs) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - -def filter(expr, input, show_log=None, sp_kwargs=None, **options): +def filter( + expr: str | fgb.abc.FilterGraphObject | None, + input: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Filter image pixels. :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None :param input: input image data, accessed by `video_info` and `video_bytes` plugin hooks - :type input: object + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: output sampling rate and data, created by `bytes_to_video` plugin hook - :rtype: (int, object) + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 3D (row x column x comp) if colored/transparent. + or 2D (row x column) if it is a grayscale image. """ - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(1, data=input, **input_options), - ) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - outopts["f"] = "rawvideo" - if expr: - outopts["filter:v"] = expr - - return _run_read( - ffmpeg_args, - input=plugins.get_hook().video_bytes(obj=input), - show_log=show_log, - sp_kwargs=sp_kwargs + if expr is not None: + if extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:v"] = expr + options["map"] = "0:V:0" + else: + options["filter_complex"] = expr + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + [{"r": 1}], extra_inputs, None, extra_outputs, options, True, [input] ) + + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") + + return run_and_return_raw( + args, input_info, output_info, progress, show_log, sp_kwargs + )[1] diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 09bb2588..bb203630 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -1,93 +1,343 @@ -from io import BytesIO +from __future__ import annotations -from . import ffmpegprocess, utils, configure, FFmpegError -from .utils import avi +import logging +from collections.abc import Sequence +from fractions import Fraction -__all__ = ["read"] +from . import configure, ffmpegprocess, utils +from ._typing import ( + DTypeString, + FFmpegOptionDict, + InputInfoDict, + InputPipeInfoDict, + Literal, + OutputInfoDict, + OutputPipeInfoDict, + ProgressCallable, + RawDataBlob, + RawOutputInfoDict, + RawStreamDef, + ShapeTuple, + Unpack, +) +from .configure import ( + FFmpegArgs, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, + FFmpegOutputUrlNoPipe, +) +from .errors import FFmpegError +from .filtergraph.abc import FilterGraphObject +logger = logging.getLogger("ffmpegio") -def read(*urls, progress=None, show_log=None, **options): - """Read video and audio frames +__all__ = ["read", "write", "filter"] - :param *urls: URLs of the media files to read. - :type *urls: tuple(str) - :param progress: progress callback function, defaults to None - :type progress: callable object, optional + +def _runner( + args: FFmpegArgs, + input_info: list[InputInfoDict], + output_info: list[OutputInfoDict], + show_log: bool | None, + progress: ProgressCallable | None, + sp_kwargs: dict | None, + overwrite: bool | None = None, +) -> tuple[ + ffmpegprocess.Popen, dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict] +]: + # convert show_log to capture_log + capture_log = None if show_log else True + + # configure named pipes + input_pipes: dict[int, InputPipeInfoDict] = {} + output_pipes: dict[int, OutputPipeInfoDict] = {} + if len(input_info): + input_pipes, sp_kwargs = configure.assign_input_pipes(args, input_info, False) + if len(output_info): + output_pipes, sp_kwargs = configure.assign_output_pipes( + args, output_info, False + ) + stack = configure.init_named_pipes( + input_pipes, output_pipes, input_info, output_info, queue_size=0 + ) + + def on_exit(rc): + stack.close() + + # run the FFmpeg + try: + proc = ffmpegprocess.Popen( + args, + overwrite=overwrite, + progress=progress, + capture_log=capture_log, + sp_kwargs=sp_kwargs, + on_exit=on_exit, + ) + except: + # if Popen failed to start FFmpeg process, need to call the callback + stack.close() + raise + + # wait for the FFmpeg to finish processing + proc.wait() + + # throw error if failed + if proc.returncode: + raise FFmpegError(proc.stderr, capture_log) + + return proc, input_pipes, output_pipes + + +def _gather_outputs( + output_info: list[RawOutputInfoDict], + pipe_info: dict[int, OutputPipeInfoDict], +) -> tuple[dict[str, int | Fraction], dict[str, RawDataBlob]]: + rates = {} + data = {} + for i, pinfo in pipe_info.items(): + info = output_info[i] + if "media_type" not in info: + continue + + spec = info["user_map"] + b = pinfo["reader"].read() + dtype, shape, rate = info["raw_info"] + + data[spec] = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) + rates[spec] = rate + + return rates, data + + +def read( + *urls: *tuple[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]], + streams: ( + Sequence[str] + | Sequence[FFmpegOptionDict] + | dict[str, FFmpegOptionDict | None] + | None + ) = None, + extra_outputs: ( + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + squeeze: bool = False, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> tuple[dict[str, Fraction | int], dict[str, RawDataBlob]]: + """Read video and audio data from multiple media files + + :param *urls: URLs of the media files to read or a tuple of the URL and its input option dict. + :param streams: a list of FFmpeg output stream map options. Alternately, the list + may consist of an FFmpeg output option dict (with a required `'map'` item) + a dict keyed by the map option value to apply different set of + output options to each output. If not specified (default), it + outputs all the streams. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be a url string or a pair of + a url string and an option dict. + :param squeeze: False to return 4D data for video and 2D data for audio. True + eliminates any dimensions which only has the length of one. + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param use_ya: True if piped video streams uses `ya8` pix_fmt instead of `gray16le`, default to None - :type use_ya: bool, optional - :param \\**options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + :param options: FFmpeg options, append '_in[input_url_id]' for input option names for specific input url or '_in' to be applied to all inputs. The url-specific option gets the preference (see :doc:`options` for custom options) - :type \\**options: dict, optional - - :return: frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: (`fractions.Fraction`, object) + :return: frame/sampling rates and raw data for each requested stream Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run `ffmpegio.video.read()` for each url. + Specify the streams to return by `map` output option: + + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. + """ - For audio streams, if 'sample_fmt' output option is not specified, 's16'. + args, input_info, output_info = configure.init_media_read( + list(urls), streams, options, extra_outputs, squeeze + ) + # run FFmpeg + proc, input_pipes, output_pipes = _runner( + args, input_info, output_info, show_log, progress, sp_kwargs + ) - streams = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + # gather and return output + return _gather_outputs(output_info, output_pipes) + + +def write( + urls: ( + FFmpegOutputUrlComposite + | list[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ] + ), + stream_types: Sequence[Literal["a", "v"]], + *stream_args: *tuple[RawStreamDef, ...], + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + stream_dtypes: list[DTypeString | None] | None = None, + stream_shapes: list[ShapeTuple | None] | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +): + """write multiple streams to a url/file + + :param url: output url + :param stream_types: list/string of input stream media types, each element + is either 'a' (audio) or 'v' (video) + :param stream_args: raw input stream data arguments, each input stream is + either a tuple of a sample rate (audio) or frame rate + (video) followed by a data blob, or a tuple of a data + blob and a dict of input options. The option dict must + include `'ar'` (audio) or `'r'` (video) to specify the + rate. + :param extra_inputs: list of additional input sources, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param stream_dtypes: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param stream_shapes: list of shapes of input samples or frames of input + media streams, defaults to `None` (auto-detect). + :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param progress: progress callback function, defaults to None + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + + TIPS + ---- + + * All the input streams will be added to the output file by default, unless `map` option is specified + * If the input streams are of different durations, use `shortest=ffmpegio.FLAG` option to trim all streams to the shortest. + * Using merge_audio_streams: + - adds a `filter_complex` global option + - merged input streams are removed from the `map` option and replaced by the merged stream """ - ninputs = len(urls) - if not ninputs: - raise ValueError("At least one URL must be given.") - - # separate the options - spec_inopts = utils.pop_extra_options_multi(options, r"_in(\d+)$") - inopts = utils.pop_extra_options(options, "_in") - - # create a new FFmpeg dict - args = configure.empty() - configure.add_url(args, "output", "-", options) # add piped output - for i, url in enumerate(urls): # add inputs - opts = {**inopts, **spec_inopts.get(i, {})} - # check url (must be url and not fileobj) - configure.check_url( - url, nodata=True, nofileobj=True, format=opts.get("f", None) - ) - configure.add_url(args, "input", url, opts) + input_options, input_data = utils.raw_input_options(stream_types, stream_args) + + args, input_info, output_info = configure.init_media_write( + urls, + input_options, + extra_inputs, + options, + input_data, + stream_dtypes, + stream_shapes, + ) + + # run FFmpeg + _runner(args, input_info, output_info, show_log, progress, sp_kwargs, overwrite) + + # gather output + data = {} + for i, info in enumerate(output_info): + if info["dst_type"] == "buffer": + data[i] = info["reader"].read_all() + + return data if len(data) else None - # configure output options - use_ya = configure.finalize_media_read_opts(args) + +def filter( + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, + input_types: Sequence[Literal["a", "v"]], + *input_args: *tuple[RawStreamDef, ...], + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + output_streams: Sequence[str | FFmpegOptionDict] | None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = False, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> tuple[dict[str, Fraction | int], dict[str, RawDataBlob]]: + """write multiple streams to a url/file + + :param expr: complex filtergraph expression or a list of expressions + :param input_types: list/string of input stream media types, each element is + either 'a' (audio) or 'v' (video) + :param input_args: raw input stream data arguments, each input stream is + either a tuple of a sample rate (audio) or frame rate + (video) followed by a data blob or a tuple of a data blob + and a dict of input options. The option dict must include + `'ar'` (audio) or `'r'` (video) to specify the rate. + :param extra_inputs: list of additional input sources, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param output_streams: specific options for keyed filtergraph output pads. + :param input_dtypes: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param input_shapes: list of shapes of input samples or frames of input + media streams, defaults to `None` (auto-detect). + :param progress: progress callback function, defaults to None + :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + + TIPS + ---- + + * Unlike `media.read()` all filtergraph outputs are always captured. The output + options specified as keyword arguments for all outputs, and `output_options` + argument can be used to specify additional (overriding) FFmpeg output options + for some outputs as needed. + + """ + + if expr is not None: + options["filter_complex"] = expr + + input_options, input_data = utils.raw_input_options(input_types, input_args) + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + input_options, + extra_inputs, + output_streams, + extra_outputs, + options, + squeeze, + input_data, + input_dtypes, + input_shapes, + ) + + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") # run FFmpeg - out = ffmpegprocess.run( - args, - progress=progress, - capture_log=None if show_log else True, + proc, input_pipes, output_pipes = _runner( + args, input_info, output_info, show_log, progress, sp_kwargs ) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - - # fire up the AVI reader and process the stdout bytes - # TODO: Convert to use pipe/thread - reader = avi.AviReader() - reader.start(BytesIO(out.stdout), use_ya) - # get frame rates and sample rates of all media streams - rates = { - v["spec"]: v["frame_rate"] if v["type"] == "v" else v["sample_rate"] - for v in reader.streams.values() - } - data = {k: [] for k in reader.streams} - for st, frame in reader: - data[st].append(frame) - - data = { - reader.streams[k]["spec"]: reader.from_bytes(k, b"".join(v)) - for k, v in data.items() - } - return rates, data + # gather and return output + return _gather_outputs(output_info, output_pipes) diff --git a/src/ffmpegio/path.py b/src/ffmpegio/path.py index 329f43f1..9a65221a 100644 --- a/src/ffmpegio/path.py +++ b/src/ffmpegio/path.py @@ -1,14 +1,18 @@ -from os import path as _path, name as _os_name, devnull +import logging +import re +import shlex +from os import devnull +from os import name as _os_name +from os import path as _path from shutil import which -from subprocess import run, DEVNULL, PIPE, STDOUT -import re, shlex +from subprocess import DEVNULL, PIPE, STDOUT, run + from packaging.version import Version -import logging logger = logging.getLogger("ffmpegio") -from .errors import FFmpegioError from . import plugins +from .errors import FFmpegioError # fmt:off __all__ = [ @@ -228,7 +232,7 @@ def versions(): def check_version(ver, cond=None): - """check FFmpeg version + """check FFmpeg version against the given version for the specified condition :param ver: desired version string :type ver: str @@ -236,7 +240,35 @@ def check_version(ver, cond=None): :type cond: "==", "!=", "<", "<=", ">", ">=", optional :return: True if condition is met :rtype: bool + + Note "nightly" builds are assumed to be the latest. """ + + ver_nightly = ver == "nightly" + + # ffmpeg version is a nightly (assumed the latest) + if FFMPEG_VER == "nightly": + return { + "==": ver_nightly, + "!=": not ver_nightly, + "<": False, + "<=": ver_nightly, + ">": not ver_nightly, + ">=": True, + }[cond or ">="] + + # ffmpeg version is a release compared to nightly + if ver_nightly: + return { + "==": False, + "!=": True, + "<": True, + "<=": True, + ">": False, + ">=": False, + }[cond or ">="] + + # both are releases return { "==": FFMPEG_VER.__eq__, "!=": FFMPEG_VER.__ne__, diff --git a/src/ffmpegio/plugins/__init__.py b/src/ffmpegio/plugins/__init__.py index 28f14c07..ece2631a 100644 --- a/src/ffmpegio/plugins/__init__.py +++ b/src/ffmpegio/plugins/__init__.py @@ -5,12 +5,12 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -from typing import Literal, Any - +import os +import re from importlib import import_module -import re, os -import pluggy +from typing import Any, Literal +import pluggy from . import hookspecs diff --git a/src/ffmpegio/plugins/devices/dshow.py b/src/ffmpegio/plugins/devices/dshow.py index 6ea704c5..6449261f 100644 --- a/src/ffmpegio/plugins/devices/dshow.py +++ b/src/ffmpegio/plugins/devices/dshow.py @@ -1,11 +1,13 @@ """ DirectShow device""" -from subprocess import PIPE -from ffmpegio import path +import logging import re -from pluggy import HookimplMarker +from subprocess import PIPE + from packaging.version import Version -import logging +from pluggy import HookimplMarker + +from ffmpegio import path logger = logging.getLogger("ffmpegio") diff --git a/src/ffmpegio/plugins/finder_ffdl.py b/src/ffmpegio/plugins/finder_ffdl.py index e754cf38..63f5ebf7 100644 --- a/src/ffmpegio/plugins/finder_ffdl.py +++ b/src/ffmpegio/plugins/finder_ffdl.py @@ -1,9 +1,9 @@ """ffmpegio plugin to find ffmpeg and ffprobe installed by ffmpeg-downloader (ffdl) package""" import logging -from pluggy import HookimplMarker import ffmpeg_downloader as ffdl +from pluggy import HookimplMarker hookimpl = HookimplMarker("ffmpegio") @@ -17,7 +17,7 @@ def finder(): ffmpeg_path = ffdl.ffmpeg_path if ffmpeg_path is None: - logging.warning( + logging.info( """FFmpeg binaries not found in the ffmpegio-downloader's install directory. To install, run the following in the terminal: ffdl install diff --git a/src/ffmpegio/plugins/finder_static.py b/src/ffmpegio/plugins/finder_static.py index c5b186f1..f5ce7ca5 100644 --- a/src/ffmpegio/plugins/finder_static.py +++ b/src/ffmpegio/plugins/finder_static.py @@ -1,6 +1,7 @@ """ffmpegio plugin to find ffmpeg and ffprobe installed by static-ffmpeg package""" import logging + from pluggy import HookimplMarker from static_ffmpeg import run diff --git a/src/ffmpegio/plugins/finder_syspath.py b/src/ffmpegio/plugins/finder_syspath.py index df47bc4a..c2bc7659 100644 --- a/src/ffmpegio/plugins/finder_syspath.py +++ b/src/ffmpegio/plugins/finder_syspath.py @@ -1,11 +1,10 @@ """ffmpegio plugin to find ffmpeg and ffprobe on system path""" import logging +from shutil import which from pluggy import HookimplMarker -from shutil import which - hookimpl = HookimplMarker("ffmpegio") __all__ = ["finder"] @@ -16,6 +15,7 @@ def finder(): """find ffmpeg and ffprobe executables""" if which("ffmpeg") and which("ffprobe"): + logging.info('found ffmpeg and ffprobe on the system path') return "ffmpeg", "ffprobe" logging.warning("""FFmpeg and FFprobe binaries not found in the system path.""") diff --git a/src/ffmpegio/plugins/finder_win32.py b/src/ffmpegio/plugins/finder_win32.py index c680862b..aefab7ff 100644 --- a/src/ffmpegio/plugins/finder_win32.py +++ b/src/ffmpegio/plugins/finder_win32.py @@ -1,4 +1,6 @@ -import os, shutil +import os +import shutil + from pluggy import HookimplMarker hookimpl = HookimplMarker("ffmpegio") diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index 24be7e07..08c8e25a 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -1,36 +1,40 @@ from __future__ import annotations +from typing import Callable + import pluggy -from typing import Callable, Tuple + +from .._typing import DTypeString, ShapeTuple hookspec = pluggy.HookspecMarker("ffmpegio") @hookspec(firstresult=True) -def finder() -> Tuple[str, str]: +def finder() -> tuple[str, str]: """find ffmpeg and ffprobe executable""" + ... @hookspec(firstresult=True) -def video_info(obj: object) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: object) -> tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: object containing video frame data with arbitrary number of frames - :type obj: object - :return: shape (height,width,components) and data type in numpy dtype str expression - :rtype: Tuple[Tuple[int, int, int], str] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ + ... @hookspec(firstresult=True) -def audio_info(obj: object) -> Tuple[int, str]: +def audio_info(obj: object) -> tuple[ShapeTuple, DTypeString]: """get audio sample info :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: object - :return: number of channels and sample data type in numpy dtype str expression - :rtype: Tuple[Tuple[int], str] + :return ac: number of channels + :return dtype: sample data type in numpy dtype str expression """ + ... @hookspec(firstresult=True) @@ -38,10 +42,9 @@ def video_bytes(obj: object) -> memoryview: """return bytes-like object of packed video pixels, associated with `video_info()` :param obj: object containing video frame data with arbitrary number of frames - :type obj: object :return: packed bytes of video frames - :rtype: bytes-like object """ + ... @hookspec(firstresult=True) @@ -49,50 +52,61 @@ def audio_bytes(obj: object) -> memoryview: """return bytes-like object of packed audio samples :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: object :return: packed bytes of audio samples - :rtype: bytes-like object """ + ... + + +@hookspec(firstresult=True) +def video_frames(obj: object) -> int: + """get number of video frames in obj + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + """ + ... + + +@hookspec(firstresult=True) +def audio_samples(obj: object) -> int: + """get audio sample info + + :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples + :return: number of samples in obj + """ + ... @hookspec(firstresult=True) def bytes_to_video( - b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool ) -> object: """convert bytes to rawvideo object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: data type numpy dtype string (e.g., '|u1', ' object: +def bytes_to_audio( + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool +) -> object: """convert bytes to rawaudio object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: numpy dtype string of the bytes (e.g., ' Tuple[str, dict[str, Callable]]: +def device_source_api() -> tuple[str, dict[str, Callable]]: """return a source name and its set of interface functions keyword/signature Descriptions @@ -103,10 +117,11 @@ def device_source_api() -> Tuple[str, dict[str, Callable]]: Partial definition is OK """ + ... @hookspec -def device_sink_api() -> Tuple[str, dict[str, Callable]]: +def device_sink_api() -> tuple[str, dict[str, Callable]]: """return a sink name and its set of interface functions keyword/signature Descriptions @@ -117,3 +132,13 @@ def device_sink_api() -> Tuple[str, dict[str, Callable]]: Partial definition is OK """ + ... + + +@hookspec(firstresult=True) +def is_empty(obj: object) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + ... diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index 7034b825..ff96f8f9 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -1,34 +1,73 @@ -from .._utils import get_samplesize +from __future__ import annotations + +from typing import Tuple, TypedDict + from pluggy import HookimplMarker -from typing import Tuple + +from .._typing import DTypeString, ShapeTuple +from .._utils import get_samplesize + +__all__ = [ + "BytesRawDataBlob", + "video_info", + "audio_info", + "video_frames", + "audio_samples", + "video_bytes", + "audio_bytes", + "bytes_to_video", + "bytes_to_audio", +] hookimpl = HookimplMarker("ffmpegio") +class BytesRawDataBlob(TypedDict): + """raw data blob in bytes""" + + buffer: bytes + """data buffer""" + + dtype: DTypeString + """numpy-style data type string""" + + shape: ShapeTuple + """data shape""" + + @hookimpl -def video_info(obj: dict) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: BytesRawDataBlob) -> Tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: dict containing video frame data with arbitrary number of frames - :type obj: object - :return: shape (height,width,components) and data type in numpy dtype str expression - :rtype: Tuple[Tuple[int, int, int], str] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ try: - return obj["shape"][-3:], obj["dtype"] + shape = obj["shape"] + dtype = obj["dtype"] except: return None + ndim = len(shape) + if ndim == 2: + shape = (*shape, 1) + elif ndim == 3 and shape[-1] > 4: + shape = (*shape[1:], 1) + else: + shape = shape[-3:] + + return shape, dtype + @hookimpl -def audio_info(obj: object) -> Tuple[int, str]: +def audio_info(obj: BytesRawDataBlob) -> Tuple[ShapeTuple, DTypeString]: """get audio sample info :param obj: dict containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: dict - :return: number of channels and sample data type in numpy dtype str expression - :rtype: Tuple[Tuple[int], str] + :return ac: number of channels + :return dtype: sample data type in numpy dtype str expression """ try: return obj["shape"][-1:], obj["dtype"] @@ -37,13 +76,11 @@ def audio_info(obj: object) -> Tuple[int, str]: @hookimpl -def video_bytes(obj: object) -> memoryview: +def video_bytes(obj: BytesRawDataBlob) -> memoryview: """return bytes-like object of packed video pixels, associated with `video_info()` :param obj: dict containing video frame data with arbitrary number of frames - :type obj: dict :return: packed bytes of video frames - :rtype: bytes-like object """ try: @@ -53,13 +90,11 @@ def video_bytes(obj: object) -> memoryview: @hookimpl -def audio_bytes(obj: object) -> memoryview: +def audio_bytes(obj: BytesRawDataBlob) -> memoryview: """return bytes-like object of packed audio samples :param obj: dict containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: dict :return: packed bytes of audio samples - :rtype: bytes-like object """ try: @@ -68,22 +103,60 @@ def audio_bytes(obj: object) -> memoryview: return None +@hookimpl +def video_frames(obj: BytesRawDataBlob) -> int: + """get number of video frames in obj + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + + Note: if blob is squeezed, the returned value may not be accurate. + """ + + try: + shape = obj["shape"] + except KeyError: + return None + + ndim = len(shape) + if ndim > 3: + return shape[0] + elif ndim < 3: + return 1 + else: + # ndim==3, single frame if the last dim is likely number of components (1-4) + return shape[0] if shape[-1] > 4 else 1 + + +@hookimpl +def audio_samples(obj: BytesRawDataBlob) -> int: + """get audio sample info + + :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples + :return: number of samples in obj + + Note: assumes a blob of audio samples always consists of more one time sample. + """ + + try: + shape = obj["shape"] + except KeyError: + return None + else: + return shape[0] + + @hookimpl def bytes_to_video( - b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool -) -> object: + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool +) -> BytesRawDataBlob: """convert bytes to rawvideo object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: data type numpy dtype string (e.g., '|u1', ' object: +def bytes_to_audio( + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool +) -> BytesRawDataBlob: """convert bytes to rawaudio object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: numpy dtype string of the bytes (e.g., ' ob return { "buffer": b, "dtype": dtype, - "shape": tuple(((i for i in sh if i != 1))) if squeeze else sh, + "shape": tuple((i for i in sh if i != 1)) if squeeze else sh, } except: return None + + +@hookimpl +def is_empty(obj: BytesRawDataBlob) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + return not bool(obj["buffer"]) diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py index 81d6bc8c..c2cbbc5a 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -1,21 +1,24 @@ """ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" +import io + import matplotlib as Figure from pluggy import HookimplMarker -from typing import Tuple -import io + +from .._typing import DTypeString, Literal, ShapeTuple + +__all__ = ["video_info", "video_bytes"] hookimpl = HookimplMarker("ffmpegio") @hookimpl -def video_info(obj: Figure) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: Figure) -> tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: matplotlib Figure object - :type obj: Figure - :return: shape (height,width,4) and data type "|u1" (rgba) - :rtype: Tuple[Tuple[int, int, int], str] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ try: return (int(obj.bbox.bounds[3]), int(obj.bbox.bounds[2]), 4), "|u1" @@ -28,9 +31,7 @@ def video_bytes(obj: Figure) -> memoryview: """return bytes-like object of rawvideo NumPy array :param obj: video frame data with arbitrary number of frames - :type obj: Figure :return: memoryview of video frames - :rtype: memoryview """ try: @@ -40,4 +41,24 @@ def video_bytes(obj: Figure) -> memoryview: return io_buf.getvalue() except: None - \ No newline at end of file + + +@hookimpl +def is_empty(obj: Figure) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + return False + + +@hookimpl +def video_frames(obj: Figure) -> Literal[1]: + """get number of video frames in obj (always 1) + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + + """ + + return 1 diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index ff8487b7..9aef7876 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -1,16 +1,20 @@ """ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" +from __future__ import annotations + import numpy as np +from numpy.typing import ArrayLike from pluggy import HookimplMarker -from typing import Tuple -from numpy.typing import ArrayLike +from .._typing import DTypeString, ShapeTuple hookimpl = HookimplMarker("ffmpegio") __all__ = [ "video_info", "audio_info", + "video_frames", + "audio_samples", "video_bytes", "audio_bytes", "bytes_to_video", @@ -19,31 +23,76 @@ @hookimpl -def video_info(obj: ArrayLike) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: ArrayLike) -> tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: video frame data with arbitrary number of frames - :type obj: ArrayLike - :return: shape (height,width,components) and data type str - :rtype: Tuple[Tuple[int, int, int], str] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ try: - return obj.shape[-3:] if obj.ndim != 2 else [*obj.shape, 1], obj.dtype.str + a = np.asarray(obj) + if a.ndim == 2: + shape = (*a.shape, 1) + elif a.ndim == 3 and a.shape[-1] > 4: + shape = (*a.shape[1:], 1) + else: + shape = a.shape[-3:] + return shape, a.dtype.str except: return None @hookimpl -def audio_info(obj: ArrayLike) -> Tuple[int, str]: +def audio_info(obj: ArrayLike) -> tuple[ShapeTuple, DTypeString]: """get audio sample info :param obj: column-wise audio data with arbitrary number of samples - :type obj: ArrayLike - :return: number of channels and sample data type in data type str - :rtype: Tuple[Tuple[int], str] + :return ac: number of channels + :return dtype: sample data type in numpy dtype str expression """ try: - return obj.shape[-1:] if obj.ndim > 1 else [1], obj.dtype.str + a = np.asarray(obj) + return a.shape[-1:] if a.ndim > 1 else [1], a.dtype.str + except: + return None + + +@hookimpl +def video_frames(obj: ArrayLike) -> int: + """get number of video frames in obj + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + Note: if blob is squeezed, the returned value may not be accurate. + """ + + try: + a = np.asarray(obj) + shape = a.shape + ndim = a.ndim + if ndim > 3: + return shape[0] + elif ndim < 3: + return 1 + else: + return shape[0] if shape[-1] > 4 else 1 + except: + return None + + +@hookimpl +def audio_samples(obj: ArrayLike) -> int: + """get audio sample info + + :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples + :return: number of samples in obj + + Note: assumes a blob of audio samples always consists of more one time sample. + """ + + try: + return np.asarray(obj).shape[0] except: return None @@ -53,13 +102,11 @@ def video_bytes(obj: ArrayLike) -> memoryview: """return bytes-like object of rawvideo NumPy array :param obj: video frame data with arbitrary number of frames - :type obj: ArrayLike :return: memoryview of video frames - :rtype: memoryview """ try: - return np.ascontiguousarray(obj).view('b') + return np.ascontiguousarray(obj).reshape(-1).view("b") except: return None @@ -69,33 +116,26 @@ def audio_bytes(obj: ArrayLike) -> memoryview: """return bytes-like object of rawaudio NumPy array :param obj: column-wise audio data with arbitrary number of samples - :type obj: ArrayLike :return: memoryview of audio samples - :rtype: memoryview """ try: - return np.ascontiguousarray(obj).view('b') + return np.ascontiguousarray(obj).reshape(-1).view("b") except: return None @hookimpl def bytes_to_video( - b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool ) -> ArrayLike: """convert bytes to rawvideo NumPy array :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: data type string (e.g., '|u1', ' ArrayLike: +def bytes_to_audio( + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool +) -> ArrayLike: """convert bytes to rawaudio NumPy array :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: data type string (e.g., ' Ar return x.squeeze() if squeeze else x except: return None + + +@hookimpl +def is_empty(obj: bytes) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + return not bool(obj) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index b6a553af..adf8456c 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -1,17 +1,23 @@ from __future__ import annotations -from typing import BinaryIO, Any, Literal, Union, Tuple, Dict -from numbers import Number +import json +import logging +import re from collections.abc import Sequence -from typing_extensions import Buffer, IO -from io import IOBase - -import json, re from fractions import Fraction from functools import lru_cache +from io import IOBase +from numbers import Number +from typing import Any, BinaryIO, Dict, Literal, Tuple, Union -from .path import ffprobe, PIPE -from .utils import parse_stream_spec +from typing_extensions import IO, Buffer + +logger = logging.getLogger("ffmpegio") + +from .errors import FFmpegError +from .path import PIPE, ffprobe +from .stream_spec import StreamSpecDict +from .stream_spec import stream_spec as compose_stream_spec # fmt:off __all__ = ['full_details', 'format_basic', 'streams_basic', @@ -50,6 +56,8 @@ def try_conv(v): def _add_select_streams(args, stream_specifier): if stream_specifier: + if isinstance(stream_specifier, dict): + stream_specifier = compose_stream_spec(**stream_specifier) args.extend(["-select_streams", str(stream_specifier)]) return args @@ -165,11 +173,13 @@ def _exec( url: str | IO | Buffer, entries: str, sp_kwargs: tuple[tuple[str, Any]] | None = None, - streams: str | int | None = None, + streams: str | int | StreamSpecDict | None = None, intervals: IntervalSpec | Sequence[IntervalSpec] | None = None, count_frames: bool | None = False, count_packets: bool | None = False, keep_optional_fields: bool | None = None, + *, + f: str | None = None, ) -> dict[str, str]: """execute ffprobe and return stdout as dict""" @@ -180,6 +190,9 @@ def _exec( args = ["-hide_banner", "-of", "json", "-show_entries", entries] + if f is not None: + args.extend(("-f", f)) + if streams is not None: _add_select_streams(args, streams) @@ -201,10 +214,10 @@ def _exec( if isinstance(url, Buffer): sp_opts["input"] = url - url = 'pipe:0' + url = "pipe:0" elif isinstance(url, IOBase): sp_opts["stdin"] = url - url = 'pipe:0' + url = "pipe:0" else: url = str(url) @@ -213,7 +226,7 @@ def _exec( # run ffprobe ret = ffprobe(args, **sp_opts) if ret.returncode != 0: - raise Exception(f"ffprobe execution failed\n\n{ret.stderr.decode('utf8')}\n") + raise FFmpegError(f"ffprobe execution failed\n\n{ret.stderr.decode('utf8')}\n") # decode output JSON string return json.loads(ret.stdout) @@ -231,17 +244,23 @@ def _run( *args, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + f: str | None = None, **kwargs, ) -> dict[str, str]: """execute ffprobe, return stdout as dict, and cache its output""" + # TODO - enable caching + if cache_output: + logger.warning("caching of previous ffprobe outputs is disabled.") + cache_output = False + entries = _compose_entries(entries) if sp_kwargs is not None: sp_kwargs = tuple(sp_kwargs.items()) return ( - _exec_cached(url, entries, sp_kwargs, *args, **kwargs) + _exec_cached(url, entries, sp_kwargs, *args, **kwargs, f=f) if cache_output - else _exec(url, entries, sp_kwargs, *args, **kwargs) + else _exec(url, entries, sp_kwargs, *args, **kwargs, f=f) ) @@ -255,6 +274,8 @@ def full_details( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> dict[str, str | Number | Fraction]: """Retrieve full details of a media file or stream @@ -278,6 +299,7 @@ def full_details( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: media file information :rtype: dict[str, str|Number|Fraction] @@ -291,7 +313,7 @@ def full_details( ) results = _run( - url, modes, select_streams, cache_output=cache_output, sp_kwargs=sp_kwargs + url, modes, select_streams, cache_output=cache_output, sp_kwargs=sp_kwargs, f=f ) if not modes["stream"]: @@ -329,6 +351,8 @@ def format_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> dict[str, str | Number | Fraction]: """Retrieve basic media format info @@ -348,6 +372,7 @@ def format_basic( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: set of media format information. :rtype: dict @@ -382,6 +407,7 @@ def format_basic( keep_str_values, cache_output, sp_kwargs, + f=f, ) @@ -392,27 +418,26 @@ def streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, -) -> list[dict[str, str | Number | Fraction]]: + stream_spec: str | int | StreamSpecDict | None = None, + *, + f: str | None = None, +) -> list: """Retrieve basic info of media streams :param url: URL of the media file/stream - :type url: str or seekable file-like object or bytes-like object :param entries: specify to narrow which stream entries to retrieve. Default to None, returning all entries - :type entries: seq of str, optional :param keep_optional_fields: True to return a missing optional field in the returned dict with None or "N/A" (if keep_str_values is True) as its value - :type keep_optional_fields: bool, optional :param keep_str_values: True to keep all field values as str, defaults to False to convert numeric values - :type keep_str_values: bool, optional :param cache_output: True to cache FFprobe output, defaults to False - :type cache_output: bool, optional :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None - :type sp_kwargs: dict[str, Any], optional - :return: List of media stream information. - :rtype: list of dict + :param stream_spec: Specify stream specification, defaults to None + :type stream_spec: str | None, optional + :param f: Use the specified media container format, defaults to None (auto-detect) + :return: List of the requested information of the matching media streams. Media Stream Information dict Entries @@ -430,12 +455,13 @@ def streams_basic( return query( url, - True, + stream_spec or True, _resolve_entries("basic streams", entries, default_entries), keep_optional_fields, keep_str_values, cache_output, sp_kwargs, + f=f, ) @@ -447,29 +473,24 @@ def video_streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of video streams :param url: URL of the media file/stream - :type url: str or seekable file-like object or bytes-like object :param index: video stream index. 0=first video stream. Defaults to None, which returns info of all video streams - :type index: int, optional :param entries: specify to narrow which information entries to retrieve. Default to None, to return all entries - :type entries: seq of str :param keep_optional_fields: True to return a missing optional field in the returned dict with None or "N/A" (if keep_str_values is True) as its value - :type keep_optional_fields: bool, optional :param keep_str_values: True to keep all field values as str, defaults to False to convert numeric values - :type keep_str_values: bool, optional :param cache_output: True to cache FFprobe output, defaults to False - :type cache_output: bool, optional :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None - :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: List of video stream information. - :rtype: list of dict Video Stream Information Entries @@ -523,6 +544,7 @@ def video_streams_basic( keep_str_values, cache_output, sp_kwargs, + f=f, ) def adjust(res): @@ -564,29 +586,24 @@ def audio_streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of audio streams :param url: URL of the media file/stream - :type url: str or seekable file-like object or bytes-like object :param index: audio stream index. 0=first audio stream. Defaults to None, which returns info of all audio streams - :type index: int, optional :param entries: specify to narrow which information entries to retrieve. Default to None, to return all entries - :type entries: seq of str :param keep_optional_fields: True to return a missing optional field in the returned dict with None or "N/A" (if keep_str_values is True) as its value - :type keep_optional_fields: bool, optional :param keep_str_values: True to keep all field values as str, defaults to False to convert numeric values - :type keep_str_values: bool, optional :param cache_output: True to cache FFprobe output, defaults to False - :type cache_output: bool, optional :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None - :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: List of audio stream information. - :rtype: list of dict Audio Stream Information Entries @@ -633,6 +650,7 @@ def audio_streams_basic( keep_str_values, cache_output, sp_kwargs, + f=f, ) def adjust(res): @@ -664,12 +682,14 @@ def adjust(res): def query( url: str | BinaryIO | memoryview, - streams: str | int | bool | None = None, + streams: str | int | StreamSpecDict | bool | None = None, fields: Sequence[str] | None = None, keep_optional_fields: bool | None = None, keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> ( dict[str, Any] | Sequence[dict[str, Any]] @@ -695,6 +715,7 @@ def query( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: field name-value dict. If streams argument is given but does not specify index, a list of dict is returned instead :rtype: dict or list or dict @@ -716,6 +737,7 @@ def query( sp_kwargs=sp_kwargs, cache_output=cache_output, keep_optional_fields=keep_optional_fields, + f=f, ) if not keep_str_values: @@ -727,65 +749,22 @@ def query( if len(info) == 0: raise ValueError(f"Unknown or invalid stream specifier: {streams}") - if isinstance(streams, (str, int)) and "index" in parse_stream_spec(streams): - # return dict only if a specific stream requested - info = info[0] + # if isinstance(streams, (str, int)) and "index" in parse_stream_spec(streams): + # # return dict only if a specific stream requested + # info = info[0] return info -def _audio_info( - url: str | BinaryIO | memoryview, - stream: str | None, - sp_kwargs: dict[str, Any] | None, -) -> tuple[int | None, str | None, int | None]: - "returns (sample_rate, sample_fmt, channels) of the specified url/stream" - fields = ["sample_rate", "sample_fmt", "channels"] - q = query( - url, - "a:0" if stream is None else stream, - fields, - True, - False, - True, - sp_kwargs, - ) - return tuple(q[f] for f in fields) - - -def _video_info( - url: str | BinaryIO | memoryview, - stream: str | None, - sp_kwargs: dict[str, Any] | None, -) -> tuple[ - str | None, - int | None, - int | None, - Fraction | Literal["0/0"] | None, - Fraction | None, -]: - "returns (pix_fmt, width, height, avg_frame_rate, r_frame_rate) of the specified url/stream" - - fields = ["pix_fmt", "width", "height", "avg_frame_rate", "r_frame_rate"] - q = query( - url, - "v:0" if stream is None else stream, - fields, - True, - False, - True, - sp_kwargs, - ) - return tuple(q[f] for f in fields) - - def frames( url: str | BinaryIO | memoryview, entries: Sequence[str] | None = None, - streams: str | int | None = None, + streams: str | int | StreamSpecDict | None = None, intervals: IntervalSpec | Sequence[IntervalSpec] | None = None, accurate_time: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> list[dict] | list[str | int | float]: """get frame information @@ -803,6 +782,7 @@ def frames( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: frame information. list of dictionary if entries is None or a sequence; list of the selected entry if entries is str (i.e., a single entry) :rtype: list[dict] or list[str|int|float] @@ -842,6 +822,7 @@ def frames( sp_kwargs and tuple(sp_kwargs.items()), streams=streams, intervals=intervals, + f=f, ) out = [_items_to_numeric(d) for d in res["frames"]] diff --git a/src/ffmpegio/std_runners.py b/src/ffmpegio/std_runners.py new file mode 100644 index 00000000..21e99d7c --- /dev/null +++ b/src/ffmpegio/std_runners.py @@ -0,0 +1,148 @@ +"""FFmpeg runner functions for SISO operations over standard pipes""" + +from __future__ import annotations + +import logging + +from . import configure +from . import ffmpegprocess as fp +from ._typing import ( + TYPE_CHECKING, + Any, + EncodedInputInfoDict, + EncodedOutputInfoDict, + ProgressCallable, + RawInputInfoDict, + RawOutputInfoDict, +) +from .errors import FFmpegError, FFmpegioError + +if TYPE_CHECKING: + from .configure import FFmpegArgs + +logger = logging.getLogger("ffmpegio") + +__all__ = ["run_and_return_raw", "run_and_return_encoded"] + + +def run_and_return_raw( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + output_info: list[RawOutputInfoDict | EncodedOutputInfoDict], + progress: ProgressCallable | None, + show_log: bool | None, + sp_kwargs: dict[str, Any] | None, +): + # check configuration yields at most one piped input + # check configuration yields at most one piped output + n_piped_inputs = sum( + info["src_type"] in ("buffer", "fileobj") for info in input_info + ) + if n_piped_inputs > 1: + raise ValueError( + "Only at most one input source can be a pipe or a file-stream object." + ) + + # check configuration yields exactly one piped audio output + if len(output_info) == 0: + raise FFmpegioError("No audio stream found.") + if len(output_info) > 1: + raise ValueError("Too many audio stream found.") + if output_info[0]["dst_type"] != "buffer": + raise ValueError("Not outputting to pipe") + + n_piped_outputs = sum( + info["dst_type"] in ("buffer", "fileobj") for info in output_info + ) + if n_piped_outputs > 1: + raise ValueError( + "Only at most one output destination can be a pipe or a file-stream object." + ) + + # assign the stdin and stdout pipes + kwargs = { + **configure.assign_input_pipes(args, input_info, True, True)[1], + **configure.assign_output_pipes(args, output_info, True)[1], + } + + if sp_kwargs is not None: + # ignore user's stdin, stdout, stdout if specified + kwargs = {**sp_kwargs, **kwargs} + + out = fp.run( + args, + progress=progress, + capture_log=None if show_log else True, + **kwargs, + ) + if out.returncode: + raise FFmpegError(out.stderr, show_log) + + oinfo = output_info[0] + dtype, shape, rate = oinfo["raw_info"] + + return rate, oinfo["bytes2data"]( + b=out.stdout, dtype=dtype, shape=shape, squeeze=oinfo["squeeze"] + ) + + +def run_and_return_encoded( + progress, + overwrite, + show_log, + sp_kwargs, + args, + input_info, + output_info, + two_pass=False, + pass1_omits=None, + pass1_extras=None, +): + if output_info is None: + raise FFmpegioError("Unknown error occurred to complete FFmpeg configuration.") + + # check configuration yields at most one piped output + n_piped_inputs = sum( + info["src_type"] in ("buffer", "fileobj") for info in input_info + ) + if n_piped_inputs > 1: + raise ValueError( + "Only at most one input source can be a pipe or a file-stream object." + ) + + n_piped_outputs = sum( + info["dst_type"] in ("buffer", "fileobj") for info in output_info + ) + if n_piped_outputs > 1: + raise ValueError( + "Only at most one output destination can be a pipe or a file-stream object." + ) + + # assign the stdin and stdout pipes + kwargs = { + **configure.assign_input_pipes(args, input_info, True, True)[1], + **configure.assign_output_pipes(args, output_info, True)[1], + } + + if sp_kwargs is not None: + # ignore user's stdin, stdout, stdout if specified + kwargs = {**sp_kwargs, **kwargs} + + if two_pass: + if pass1_omits is not None: + kwargs["pass1_omits"] = pass1_omits + if pass1_extras is not None: + kwargs["pass1_extras"] = pass1_extras + + out = (fp.run_two_pass if two_pass else fp.run)( + args, + progress=progress, + capture_log=None if show_log else True, + overwrite=overwrite, + **kwargs, + ) + if out.returncode: + raise FFmpegError(out.stderr, show_log) + + if n_piped_outputs and any(info["dst_type"] == "buffer" for info in output_info): + return out.stdout diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py new file mode 100644 index 00000000..a2fb9f2e --- /dev/null +++ b/src/ffmpegio/stream_spec.py @@ -0,0 +1,471 @@ +"""streams and map specifier handling module + +parse & compose FFmpeg stream spec string from/to StreamSpec object + +""" + +from __future__ import annotations + +import re + +from ._typing import (FFmpegMediaType, Literal, MediaType, NotRequired, Tuple, + TypedDict, Union, get_args) + +StreamSpecStreamType = Literal["v", "a", "s", "d", "t", "V"] +# libavformat/avformat.c:match_stream_specifier() + + +class StreamSpecDict_Options(TypedDict): + stream_type: NotRequired[StreamSpecStreamType] + program_id: NotRequired[int] + group_index: NotRequired[int] + group_id: NotRequired[int] + stream_id: NotRequired[int] + + +class StreamSpecDict_Index(StreamSpecDict_Options): + index: int + + +class StreamSpecDict_Tag(StreamSpecDict_Options): + tag: Union[str, Tuple[str, str]] + + +class StreamSpecDict_Usable(StreamSpecDict_Options): + usable: bool + + +StreamSpecDict = Union[ + StreamSpecDict_Index, + StreamSpecDict_Tag, + StreamSpecDict_Usable, + StreamSpecDict_Options, +] + + +class InputMapOptionDict(TypedDict): + """Parsed dict of FFmpeg -map option when mapping input stream(s)""" + + negative: NotRequired[ + bool + ] # True to disables matching streams from already created mappings + input_file_id: int # index of the source index + stream_specifier: NotRequired[str | StreamSpecDict] # stream specifier + view_specifier: NotRequired[str] # view specifier + optional: NotRequired[str] # True if optional mapping + + +class GraphMapOptionDict(TypedDict): + """Parsed dict of FFmpeg -map option, when mapping filtergraph output(s)""" + + linklabel: str | None # link label of output of a filtergraph + + +MapOptionDict = Union[InputMapOptionDict, GraphMapOptionDict] +"""Parsed dict of FFmpeg -map option string""" + +################################# + + +def stream_type_to_media_type(s: StreamSpecStreamType | None) -> FFmpegMediaType | None: + """get media type string from stream type specifier + + :param s: stream type character or `None` + :return: media type string or `None` if input is `None` + + ## Stream-to-Media Type Conversion Table + + | stream | media | + |:-------|:---------------| + | `'v'` | `'video'` | + | `'V'` | `'video'` | + | `'a'` | `'audio'` | + | `'s'` | `'subtitle'` | + | `'d'` | `'data'` | + | `'t'` | `'attachments'`| + + """ + + if s is None: + return s + + return { + "v": "video", + "a": "audio", + "s": "subtitle", + "d": "data", + "t": "attachements", + "V": "video", + }[s] + + +def parse_stream_spec(spec: str | int) -> StreamSpecDict: + """Parse stream specifier string + + :param spec: stream specifier string. If int, it specifies the stream index. + :return: stream spec dict + + The reverse of `stream_spec()` + """ + + if isinstance(spec, str): + + out: StreamSpecDict = {} + spec_parts = spec.split(":") + nspecs = len(spec_parts) + i = 0 # current index + + def get_int(s, name): + try: + v = int( + s, + ( + 10 + if s[0] != "0" and len(s) > 1 + else 16 if s.startswith("0x") or s.startswith("0X") else 8 + ), + ) + assert v >= 0 + except Exception as e: + raise ValueError(f"Invalid {name} ({s})") from e + return v + + def get_id(i, name): + + try: + s = spec_parts[i + 1] + except IndexError as e: + raise ValueError(f"Missing {name}") from e + else: + return get_int(s, name) + + # process the optional parts + while i < nspecs: + spec = spec_parts[i] + # optional specifiers first + if spec in get_args(StreamSpecStreamType): + out["stream_type"] = spec + i += 1 + elif spec == "g": + i += 1 + spec = spec_parts[i] + if spec == "i": + out["group_id"] = get_id(i, "group_id") + i += 2 + elif spec.startswith("#"): + out["group_id"] = get_int(spec[1:], "group_id") + i += 1 + else: + out["group_index"] = get_int(spec, "group index") + i += 1 + elif spec == "p": + out["program_id"] = get_id(i, "program_id") + i += 2 + else: + # final primary specifier + if spec.startswith("#"): + out["stream_id"] = get_int(spec[1:], "stream_id") + elif spec == "i": + out["stream_id"] = get_id(i, "stream_id") + i += 1 + elif spec == "u": + out["usable"] = True + elif spec == "m": + try: + key, *value = spec_parts[i + 1 :] + assert len(value) <= 1 + except (IndexError, AssertionError) as e: + raise ValueError( + f"Invalid metadata tag specifier: {':'.join(spec_parts[i:])}" + ) from e + else: + i = nspecs - 1 + out["tag"] = (key, value[0]) if len(value) else key + else: + try: + out["index"] = get_int(spec, "stream_index") + except ValueError as e: + raise ValueError(f"Unknown stream specifier: {spec}") from e + break + + if i + 1 < nspecs: + raise ValueError(f"Not all specifiers resolved: {':'.join(spec_parts[i:])}") + + return out + + if not (isinstance(spec, int) and spec >= 0): + raise ValueError("Invalid stream specifier") + return {"index": int(spec)} + + +def is_stream_spec(spec: str | int) -> bool: + """True if valid stream specifier string + + :param spec: stream specifier string to be tested + :return: True if valid stream specifier + """ + try: + parse_stream_spec(spec) + return True + except ValueError: + return False + + +def stream_spec( + index: int | None = None, + stream_type: StreamSpecStreamType | None = None, + group_index: int | None = None, + group_id: int | None = None, + program_id: int | None = None, + stream_id: int | None = None, + tag: str | tuple[str, str] | None = None, + usable: bool | None = None, + *, + no_join: bool = False, +) -> str: + """Get stream specifier string + + :param index: Matches the stream with this index. If stream_index is used as + an additional stream specifier, then it selects stream number stream_index + from the matching streams. Stream numbering is based on the order of the + streams as detected by libavformat except when a program ID is also + specified. In this case it is based on the ordering of the streams in the + program., defaults to None + :param stream_type: One of following: 'v' or 'V' for video, 'a' for audio, 's' for + subtitle, 'd' for data, and 't' for attachments. 'v' matches all video + streams, 'V' only matches video streams which are not attached pictures, + video thumbnails or cover arts. If additional stream specifier is used, then + it matches streams which both have this type and match the additional stream + specifier. Otherwise, it matches all streams of the specified type, defaults + to None + :param group_index: Matches streams which are in the group with this group index. + Can be combined with other stream_specifiers, except for `group_index`. + :param group_index: Matches streams which are in the group with this group id. + Can be combined with other stream_specifiers, except for `group_id`. + :param program_id: Selects streams which are in the program with this id. If + additional_stream_specifier is used, then it matches streams which both are + part of the program and match the additional_stream_specifier, defaults to + None + :param stream_id: stream id given by the container (e.g. PID in MPEG-TS + container), defaults to None + :param tag: metadata tag key having the specified value. If value is not + given, matches streams that contain the given tag with any value, defaults + to None + :param usable: streams with usable configuration, the codec must be defined + and the essential information such as video dimension or audio sample rate + must be present, defaults to None + :param filter_output: True to append "out" to stream type, defaults to False + :param no_join: True to return list of stream specifier elements, defaults to False + :return: stream specifier string or empty string if all arguments are None + + Note matching by metadata will only work properly for input files. + + Note index, stream_id, tag, and usable are mutually exclusive. Only one of them + can be specified. + + """ + + if sum(v is not None for v in (index, stream_id, tag, usable)) > 1: + raise ValueError('Only one of "index", "tag", or "usable" may be specified.') + + if sum(v is not None for v in (group_index, group_id)) > 1: + raise ValueError('Only one of "group_index" or "group_id" may be specified.') + + spec = [] + + if stream_type is not None: + if stream_type not in get_args(StreamSpecStreamType): + raise ValueError(f"Unknown {stream_type=}.") + spec.append(stream_type) + + if group_index is not None: + spec.append(f"g:{group_index}") + elif group_id is not None: + spec.append(f"g:#{group_id}") + + if program_id is not None: + spec.append(f"p:{program_id}") + + if index is not None: + spec.append(str(index)) + elif stream_id is not None: + spec.append(f"#{stream_id}") + elif tag is not None: + spec.append(f"m:{tag}" if isinstance(tag, str) else f"m:{tag[0]}:{tag[1]}") + elif usable is not None and usable: + spec.append("u") + + return spec if no_join else ":".join(spec) + + +def is_unique_stream( + spec: StreamSpecDict, *, return_media_type: bool = False +) -> bool | MediaType: + """True if a stream is uniquely specified by the stream specifier dictionary + + :param spec: _description_ + :param return_media_type: True to return the media type (e.g., 'video' and 'audio'), + defaults to False + :return: True or a media type string if the stream specifier yields a unique stream. + """ + if "index" in spec: + if return_media_type and "stream_type" in spec: + return stream_type_to_media_type(spec["stream_type"]) + else: + return True + return False + + +################################# + + +def parse_map_option( + map: str | tuple[int, str], + *, + input_file_id: int | None = None, + parse_stream: bool = False, +) -> MapOptionDict: + """parse the FFmpeg -map option str + + :param map: option string value, optionally a tuple of a file id and a stream specifier. + :param input_file_id: if specified, auto-insert this id if a file id is missing in the given value, + defaults to None to error out if missing. + :param parse_stream: True to also parse stream spec (if given) + :return: dict containing the parsed parts of the option value, possibly containing the items: + - negative: bool + - input_file_id: int + - stream_specifier: str + - view_specifier: str + - optional: bool + - linklabel: str + + See the FFmpeg manual for the specification: https://ffmpeg.org/ffmpeg.html#Advanced-options + """ + + if isinstance(map, tuple): + map = f"{map[0]}:{map[1]}" + + map = str(map) + + # -map [-]input_file_id[:stream_specifier][:view_specifier][:?] | [linklabel] + if map[0] == "[" and map[-1] == "]": + return {"linklabel": map} + + if input_file_id is not None: + s1 = map.split(":", 1) + if not s1[0].isdigit(): + map = f"{input_file_id}:{map}" + + m = re.match(r"(-)?(\d+)(\:[^?]+?)?(\?)?$", map) + + if not m: + raise ValueError(f"Given str ({map}) is not a valid FFmpeg map option.") + + out = {"input_file_id": int(m[2])} + if m[1]: + out["negative"] = True + if m[3]: + s = re.search(r"\:(?:view|vidx|vpos)\:(?:[^:]+)$", m[3]) + if not s: + out["stream_specifier"] = m[3][1:] + elif s.start(0): + out["stream_specifier"] = m[3][1 : s.start(0)] + out["view_specifier"] = m[3][s.start(0) + 1 :] + else: + out["view_specifier"] = m[3][1:] + if m[4]: + out["optional"] = True + + if parse_stream and "stream_specifier" in out: + out["stream_specifier"] = parse_stream_spec(out["stream_specifier"]) + + return out + + +def is_map_option(spec: str, allow_missing_file_id: bool = False) -> bool: + """True if valid map option string + + :param spec: map option string to be tested + :param allow_missing_file_id: True to allow missing input file id + :return: True if valid map option. The validity of stream_specifier is also tested. + """ + + try: + parse_map_option( + spec, input_file_id=0 if allow_missing_file_id else None, parse_stream=True + ) + except Exception: + return False + return True + + +def map_option( + input_file_id: int | None = None, + linklabel: str | None = None, + stream_specifier: str | StreamSpecDict | None = None, + negative: bool | None = None, + view_specifier: str | None = None, + optional: bool | None = None, # True if optional mapping +) -> str: + """compose map option str + + :param input_file_id: index of the source index, defaults to None + :param stream_specifier: stream specifier, defaults to None + :param negative: True to disables matching streams from already created mappings, defaults to None + :param view_specifier: view specifier, defaults to None + :param optional: True if optional mapping, defaults to None + :param linklabel: output label of a filtergraph + :return: map option string + + Either input_file_id or linklabel must be non-`None`. + """ + + is_linklabel = input_file_id is None + + if (linklabel is None) == is_linklabel: + raise ValueError("Either linklabel or input_file_id must be non-None") + + if is_linklabel: + return linklabel + + map = str(input_file_id) + if stream_specifier: + if isinstance(stream_specifier, dict): + stream_specifier = stream_spec(**stream_specifier) + map = f"{map}:{stream_specifier}" + if negative: + map = f"-{map}" + if view_specifier: + map = f"{map}:{view_specifier}" + if optional: + map = f"{map}?" + + return map + + +def stream_spec_to_map_option( + stream_spec_or_link_label: str | StreamSpecDict, + input_file_id: int = 0, +) -> str: + """Form map option string from stream_spec/link_label + + :param stream_spec_or_link_label: stream_spec or link_label string or + stream_spec dict + :param input_file_id: id of the file, defaults to "0" + """ + + link_label = None + if isinstance(stream_spec_or_link_label, str): + try: + stream_spec_dict = parse_stream_spec(stream_spec_or_link_label) + except ValueError: + stream_spec_dict = None + link_label = stream_spec_or_link_label + else: + stream_spec_dict = stream_spec_or_link_label + + return map_option( + None if stream_spec_dict is None else input_file_id, + link_label, + stream_spec_dict, + ) diff --git a/src/ffmpegio/streams/AviStreams.py b/src/ffmpegio/streams/AviStreams.py deleted file mode 100644 index 5c85f5a0..00000000 --- a/src/ffmpegio/streams/AviStreams.py +++ /dev/null @@ -1,238 +0,0 @@ -from .. import configure, threading, utils, ffmpegprocess - -__all__ = ["AviMediaReader"] - - -class AviMediaReader: - """Read video frames - - :param *urls: URLs of the media files to read. - :type *urls: tuple(str) - :param streams: list of file + stream specifiers or filtergraph label to output, alias of `map` option, - defaults to None, which outputs at most one video and one audio, selected by FFmpeg - :type streams: seq(str), optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - :type \\**options: dict, optional - - :return: frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: (`fractions.Fraction`, object) - - Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run - `ffmpegio.video.read()` for each url. - - - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. - - For audio streams, if 'sample_fmt' output option is not specified, 's16'. - - - streams = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - - """ - - readable = True - writable = False - multi_read = True - multi_write = False - - def __init__( - self, - *urls, - ref_stream=None, - blocksize=None, - progress=None, - show_log=None, - queuesize=0, - sp_kwargs=None, - **options - ): - - self.ref_stream = ref_stream - #:str: specifier of reference output stream for iterator - self.blocksize = blocksize or 0 - #:int: if >0 number of samples of reference stream to include in each read; <=0 one chunk per read - - ninputs = len(urls) - if not ninputs: - raise ValueError("At least one URL must be given.") - - # separate the options - spec_inopts = utils.pop_extra_options_multi(options, r"_in(\d+)$") - inopts = utils.pop_extra_options(options, "_in") - - # create a new FFmpeg dict - args = configure.empty() - configure.add_url(args, "output", "-", options) # add piped output - for i, url in enumerate(urls): # add inputs - opts = {**inopts, **spec_inopts.get(i, {})} - # check url (must be url and not fileobj) - configure.check_url( - url, nodata=True, nofileobj=True, format=opts.get("f", None) - ) - configure.add_url(args, "input", url, opts) - - # configure output options - use_ya = configure.finalize_media_read_opts(args) - - self._reader = threading.AviReaderThread(queuesize) - - # create logger without assigning the source stream - self._logger = threading.LoggerThread(None, show_log) - - # start FFmpeg - self._proc = ffmpegprocess.Popen(args, progress=progress, capture_log=True) - - # start the reader thrad - self._reader.start(self._proc.stdout, use_ya) - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - def specs(self): - """:list(str): list of specifiers of the streams""" - self._reader.wait() - return self._reader.streams and [ - v["spec"] for v in self._reader.streams.values() - ] - - def types(self): - """:dict(str:str): media type associated with the streams (key)""" - self._reader.wait() - ts = {"v": "video", "a": "audio"} - return self._reader.streams and { - v["spec"]: ts[v["type"]] for v in self._reader.streams.values() - } - - def rates(self): - """:dict(str:int|Fraction): sample or frame rates associated with the streams (key)""" - self._reader.wait() - rates = self._reader.rates - return self._reader.streams and { - v["spec"]: rates[k] for k, v in self._reader.streams.items() - } - - def dtypes(self): - """:dict(str:str): frame/sample data type associated with the streams (key)""" - self._reader.wait() - return self._reader.streams and { - v["spec"]: v["dtype"] for v in self._reader.streams.values() - } - - def shapes(self): - """:dict(str:tuple(int)): frame/sample shape associated with the streams (key)""" - self._reader.wait() - return self._reader.streams and { - v["spec"]: v["shape"] for v in self._reader.streams.values() - } - - def get_stream_info(self, spec): - id = self._reader.find_id(spec) - return self._reader.streams[id] - - def close(self): - """Flush and close this stream. This method has no effect if the stream is already - closed. Once the stream is closed, any read operation on the stream will raise - a ValueError. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - - """ - try: - self._proc.terminate() - except: - pass - self._proc.stdout.close() - self._proc.stderr.close() - self._reader.join() - self._logger.join() - - @property - def closed(self): - """:bool: True if the FFmpeg has been terminated.""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError: TODO Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def __bool__(self): - """True if FFmpeg stdout stream is still open or there are more frames in the buffer""" - return bool(self._reader) - - def __iter__(self): - return self - - def __next__(self): - try: - if self.blocksize > 0: # per time block (multiple streams) - frames = self._reader.read(self.blocksize, self.ref_stream) - try: - shapes = [f["shape"] for f in frames.values()] - except IndexError: - shapes = [f.shape for f in frames.values()] - else: # per AVI frame (1 stream at a time) - frames = self._reader.readchunk() - try: - shapes = [frames[1]["shape"]] - except IndexError: - shapes = [frames[1].shape] - - assert any(s[0] for s in shapes) - - return frames - except (AssertionError, threading.ThreadNotActive): - raise StopIteration - - def readlog(self, n=None): - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def readnext(self, timeout=None): - return self._reader.readchunk(timeout) - - def read(self, n=-1, ref_stream=None, timeout=None): - """Read and return video or audio data objects up to n frames/samples. If - the argument is omitted, None, or negative, data is read and - returned until EOF is reached. An empty bytes object is returned - if the stream is already at EOF. - - If the argument is positive, and the underlying raw stream is not - interactive, multiple raw reads may be issued to satisfy the byte - count (unless EOF is reached first). But for interactive raw streams, - at most one raw read will be issued, and a short result does not - imply that EOF is imminent. - - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" - - return self._reader.read(n, ref_stream, timeout) - - def readall(self, timeout=None): - return self._reader.readall(timeout) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py new file mode 100644 index 00000000..eaeb26e1 --- /dev/null +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -0,0 +1,2053 @@ +from __future__ import annotations + +import logging +from abc import ABCMeta +from contextlib import ExitStack +from enum import IntEnum +from fractions import Fraction +from functools import cached_property + +from .. import configure, ffmpegprocess, stream_spec, utils +from .._typing import ( + Any, + Callable, + DTypeString, + FFmpegOptionDict, + InputInfoDict, + InputPipeInfoDict, + Iterator, + MediaType, + OutputInfoDict, + OutputPipeInfoDict, + ProgressCallable, + RawDataBlob, + Sequence, + ShapeTuple, + override, +) +from ..configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegMediaKwsDict, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, + MediaFilterKwsDict, + MediaReadKwsDict, + MediaTranscoderKwsDict, + MediaWriteKwsDict, +) +from ..errors import FFmpegError, FFmpegioError, FFmpegioInsufficientInputData +from ..threading import LoggerThread + +logger = logging.getLogger("ffmpegio") + +__all__ = [ + "BaseFFmpegRunner", + "StdFFmpegRunner", + "PipedFFmpegRunner", + "SISOFFmpegFilter", +] + + +class FFmpegStatus(IntEnum): + """FFmpeg runner status enum + + FFmpeg runners are in one of the following 5 states: + + ============= ===== ====================================================== + member value description + ============= ===== ====================================================== + PREOPEN 0 Runner is not opened yet + BUFFERING 1 Runner was opened but requires buffering input to + complete analysis before running FFmpeg + ANALYSIS_DONE 2 Runner has completed analyzing the input, starting + FFmpeg subprocess + RUNNING 3 FFmpeg subprocess is running + STOPPED 4 FFmpeg subprocess has stopped + ============= ===== ====================================================== + + + """ + + PREOPEN = 0 + BUFFERING = 1 + ANALYSIS_DONE = 2 + RUNNING = 3 + STOPPED = 4 + + +class InitMediaKeywordsWithInputBuffer(dict): + """class to buffer FFmpeg input data before running it to probe configuration information""" + + # pre-analysis/buffering variables + _nraw = 0 + _raw_pipe_buffer: None | list[list[RawDataBlob] | None] # for 'input_data' + _enc_pipe_buffer: dict[int, bytes | None] # for 'input_urls' or 'extra_inputs' + # end-of-stream flags: True if buffer contains the entirety of the stream + _raw_pipe_eos: list[bool] + _enc_pipe_eos: dict[int, bool] + + def __init__(self, init_kws: dict): + """identify which input init_fun keyword arguments require data from pipe""" + super().__init__(init_kws) + self._raw_input = "input_options" in self + self._enc_pipe_buffer = {} + self._raw_pipe_buffer = None + self._enc_pipe_eos = {} + self._raw_pipe_eos = [] + + # analyze the keywords and replace items to be tweaked + if self._raw_input: + # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] + self._nraw = nin = len(self["input_options"]) + + self["input_data"] = [None for _ in range(nin)] + + self._raw_pipe_buffer = [None] * self._nraw + self._raw_pipe_eos = [False] * self._nraw + + if "extra_inputs" in self and self["extra_inputs"] is not None: + # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + self["extra_inputs"] = [*self["extra_inputs"]] + + for i, (url, _) in enumerate(self["extra_inputs"]): + if utils.is_pipe(url): + self._enc_pipe_buffer[i] = b"" + self._enc_pipe_eos[i] = False + + if "output_urls" in self: + self["output_urls"] = [ + out_args if isinstance(out_args, tuple) else (out_args, {}) + for out_args in self["output_urls"] + ] + + else: + # encoded: list[FFmpegInputUrlComposite|tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + self["input_urls"] = [ + in_args if isinstance(in_args, tuple) else (in_args, {}) + for in_args in self["input_urls"] + ] + + for i, (url, _) in enumerate(self["input_urls"]): + if utils.is_pipe(url): + self._enc_pipe_buffer[i] = None + self._enc_pipe_eos[i] = False + + def put_data(self, stream: int, data: RawDataBlob | bytes, last: bool) -> bool: + """write data to a buffer prior to running ffmpeg + + :param stream: input stream id, index to self._input_info + :param data: data blob if raw media data or bytes if encoded data + :param last: True if data is the last blob for the stream + :returns: the first data blob of the raw stream or all received bytes of + encoded stream (repeats every time) or None if no new raw + stream was buffered + + If ffprobe analysis is necessary to configure the FFmpeg arguments, + every input pipe must be filled with the first batch of data. This + function is to be called from a write function to sets pre-run written + data aside. + + if it contains data for a new stream, attempts to configure ffmpeg args + """ + + if self._raw_pipe_buffer is None: # encoded input + if self._enc_pipe_eos[stream]: + raise FFmpegioError(f"No more data can be written to the {stream=}") + + buf = self._enc_pipe_buffer[stream] + if buf is None: # first write + buf = data + else: + buf += data + self._enc_pipe_buffer[stream] = buf + + # replace the keyword's pipe url with the data + urls = self["input_urls"] + urls[stream] = (buf, urls[stream][1]) + + if last: + self._enc_pipe_eos[stream] = True + + else: # raw or encoded input + if isinstance(data, bytes): + if self._enc_pipe_eos[stream]: + raise FFmpegioError(f"No more data can be written to the {stream=}") + stream = stream - self._nraw + assert stream >= 0 + buf = self._enc_pipe_buffer[stream] + if buf is None: # first write + buf = data + else: + buf += data + self._enc_pipe_buffer[stream] = buf + if last: + self._enc_pipe_eos[stream] = True + + urls = self["extra_inputs"] + urls[stream] = (buf, urls[stream][1]) + else: + if self._raw_pipe_eos[stream]: + raise FFmpegioError(f"No more data can be written to the {stream=}") + buffer = self._raw_pipe_buffer[stream] + if buffer is None: # first write + self._raw_pipe_buffer[stream] = [data] + self["input_data"][stream] = data + else: + buffer.append(data) + return False + if last: + self._raw_pipe_eos[stream] = True + return True + + def clear_keywords(self): + """remove all the buffered data from the keywords""" + + if self._raw_pipe_buffer is not None: + del self["input_data"] + + kw = self["extra_inputs"] + for i, buf in self._enc_pipe_buffer.items(): + if buf is not None: + kw[i] = ("-", kw[i][1]) + else: + kw = self["input_urls"] + for i, buf in self._enc_pipe_buffer.items(): + if buf is not None: + kw[i] = ("-", kw[i][1]) + + def iter_raw_data(self) -> Iterator[tuple[int, RawDataBlob, bool]]: + """iterate over all items in the raw media pipe buffer + + :yield index: raw stream index + :yield data: buffered data blob + :yield last: True if data is the last blob of the stream + + If multiple blobs are buffered for a stream, iterator yields one blob at + a time. + """ + + if self._raw_pipe_buffer is None: + return + + for i, (buf, eos) in enumerate(zip(self._raw_pipe_buffer, self._raw_pipe_eos)): + if buf is not None: + for blob in buf[:-1]: + yield i, blob, False + yield i, buf[-1], eos + + def iter_enc_data(self) -> Iterator[tuple[int, bytes, bool]]: + """iterate over all items in the encoded pipe buffer + + :yield index: encoded stream index + :yield data: buffered data + :yield last: True if data is the entirety of the stream content + """ + for i, buf in self._enc_pipe_buffer.items(): + if buf is not None: + yield i, buf, self._enc_pipe_eos[i] + + def clear_data(self): + """release all the data blobs""" + if self._raw_pipe_buffer is not None: + self._raw_pipe_buffer = [None] * self._nraw + self._enc_pipe_buffer = {i: None for i in self._enc_pipe_buffer} + + @property + def encoded_inputs_only(self) -> bool: + """True if no raw stream""" + return self._raw_pipe_buffer is None + + @property + def num_encoded_inputs(self) -> int: + """Number of encoded streams""" + return len(self._enc_pipe_buffer) + + @property + def num_raw_inputs(self) -> int: + """Number of raw streams""" + return self._nraw + + def iter_encoded_input_pipes(self) -> Iterator[int]: + """iterates over encoded input pipes + + :yield: index of an encoded input pipe + """ + n0 = self._nraw + return (i + n0 for i in self._enc_pipe_buffer) + + @cached_property + def input_pipes(self) -> list[int]: + """list of the indices of all input pipes""" + return [*range(self._nraw), *self.iter_encoded_input_pipes()] + + +class BaseFFmpegRunner(metaclass=ABCMeta): + Status = FFmpegStatus + + _probesize: int = 32 + _dynamic_output: bool = False + _use_std_pipes: bool = False + _use_named_pipes: bool = False + + # object status enum + _status: Status = Status.PREOPEN + + # configure.init_media_xxx function & its keyword arguments + _init_func: Callable + _init_kws: InitMediaKeywordsWithInputBuffer + _pipe_kws: dict[str, Any] + _primary_output: int | None = None + _blocksize: int | None # read/queue blocksize in primary output's + + # ffmpeg arguments and associated input/output information + _args: dict[str, Any] + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] + + # ffmpeg subprocess and associated objects + _proc: ffmpegprocess.Popen | None = None + _input_pipes: dict[int, InputPipeInfoDict] + _output_pipes: dict[int, OutputPipeInfoDict] + _stack: ExitStack + _logger: LoggerThread + + def __init__( + self, + init_func: Callable, + init_kws: FFmpegMediaKwsDict, + *, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ): + """Streaming FFmpeg runner using std pipes and/or named pipes + + :param init_func: FFmpeg initialization function from :py:module:`configure` + :param init_kws: keyword arguments to call the FFmpeg initialization function + :param primary_output: (only for multi-stream readable) index of a raw + media output stream which serves as a frame count + reference , defaults to ``0``. + :param blocksize: (only for readable) iterator block size in frames/samples + to read raw media streams. If multiple output streams, + this size specifies the size for the ``primary_output`` + stream. If named pipes are used, this size is also the + size of queue items of the primary stream, defaults to + use ``1`` for a video stream and ``1024`` for audio stream. + :param enc_blocksize: (only for decodable with named pipes) the queue + item size of encoded output stream in bytes, defaults to 64 MB + (2**16 bytes). + :param queuesize: the depth of named pipe queues, defaults to None (16). + Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to `None` to + wait indefinitely. Note this timeout does not apply to stdout pipe + operation. + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + :param overwrite: _description_, defaults to None + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + """ + + self._init_func = staticmethod(init_func) + self._init_kws = InitMediaKeywordsWithInputBuffer(init_kws) + self._pipe_kws = { + "queue_size": queuesize, + "timeout": timeout, + "enc_blocksize": enc_blocksize, + } + self._primary_output = primary_output + self._blocksize = blocksize + + self._stack: ExitStack = ExitStack() + + # create logger without assigning the source stream + self._logger = LoggerThread(None, bool(show_log)) + + # prepare FFmpeg keyword arguments + self._args = { + "progress": progress, + "capture_log": True, + "sp_kwargs": sp_kwargs, + } + if overwrite is not None: + self._args["overwrite"] = overwrite + + def __bool__(self) -> bool: + """True if prebuffering or FFmpeg is running""" + + return self._status in (FFmpegStatus.BUFFERING, FFmpegStatus.RUNNING) + + @property + def status(self) -> FFmpegStatus: + """current status of the object""" + return self._status + + def _try_config_ffmpeg( + self, + stream: int = -1, + data: bytes | RawDataBlob | None = None, + last: bool = False, + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :param last: optional ``True`` if ``data`` is the last data blob of ``stream`` + :return: ``True`` if FFmpeg arguments are successfully configured + and `_input_info` and `_output_info` lists are fully + populated. Excludes the pipe information. + + + If this function returns ``True``, the class object is ready to call + `_run_ffmpeg() and input and output stream information (``_input_info`` + and ``_output_info``) are successfully lists are fully populated, except + for the pipe assignments. + + """ + + if self._status > FFmpegStatus.BUFFERING: + raise FFmpegioError("FFmpeg options have already been configured.") + + kws = self._init_kws + + if stream >= 0 and data is not None: + # load the new data blob/bytes to the respective keyword argument + if not kws.put_data(stream, data, last): + return False # no useful new data given (i.e., data was a second + # or later raw data blob) + + try: + ffmpeg_args, input_info, output_info = self._init_func(**kws) + except FFmpegioInsufficientInputData: + # fail only if the error was caused by insufficient input data + return False + + # Clear buffered data from the keywords dict + kws.clear_keywords() + + # Clear buffered data from input_info + for st in kws.input_pipes: + info = input_info[st] + info.pop("buffer", None) + + # save the final arguments and info lists + self._args["ffmpeg_args"] = ffmpeg_args + self._input_info = input_info + self._output_info = output_info + + # add probesize option to the input streams if not user specified + input_args = ffmpeg_args["inputs"] + for st in kws.input_pipes: + opts = input_args[st][1] + if "probesize" not in opts: + opts["probesize"] = self._probesize + + # ready to run + self._status = FFmpegStatus.ANALYSIS_DONE + + return True + + def _on_exit(self, rc): + if self._status == FFmpegStatus.RUNNING: + logger.debug("FFmpeg process has stopped") + self._stack.close() + self._status = FFmpegStatus.STOPPED + logger.debug("closed pipes and their threads") + + @property + def _output_rate(self) -> int | Fraction | None: + return None + + def _run_ffmpeg(self): + """configure pipes and run ffmpeg + + ``BaseFFmpegRunner`` neither configure/start pipes nor dump the pre-buffer + in ``_init_kws``. + """ + + if self._status != FFmpegStatus.ANALYSIS_DONE: + if self._status < FFmpegStatus.ANALYSIS_DONE: + raise FFmpegioError( + "FFmpeg configuration not set. Run `config_ffmpeg()` first." + ) + raise FFmpegioError("FFmpeg pipes have already configured.") + + args = self._args["ffmpeg_args"] + + # set up and activate standard pipes and read/write threads + # configure named pipes + more_args = {} + input_pipes = {} + output_pipes = {} + + # configure the pipes + if len(self._input_info): + input_pipes, more_args = configure.assign_input_pipes( + args, self._input_info, self._use_std_pipes + ) + + if len(self._output_info): + output_pipes, sp_kwargs = configure.assign_output_pipes( + args, self._output_info, self._use_std_pipes + ) + more_args.update(sp_kwargs) + + self._args.update(more_args) + + # find the primary output stream's rate + if self._use_named_pipes: + configure.init_named_pipes( + input_pipes, + output_pipes, + self._input_info, + self._output_info, + ref_stream=self.primary_output, + ref_blocksize=self.primary_output_blocksize, + stack=self._stack, + **self._pipe_kws, + ) + + # run the FFmpeg + try: + self._status = FFmpegStatus.RUNNING + self._proc = ffmpegprocess.Popen(**self._args, on_exit=self._on_exit) + except: + if self._stack is not None: + self._stack.close() + raise + + # set the log source and start the logger + self._logger.stderr = self._proc.stderr + self._stack.enter_context(self._logger) + + # # if stdin/stdout is used, attach StdWriter/StdReader object to each + if self._use_std_pipes: + configure.init_std_pipes( + input_pipes, output_pipes, self._output_info, self._proc + ) + + self._input_pipes = input_pipes + self._output_pipes = output_pipes + + # write pre-buffered data + for st, data, last in self._init_kws.iter_raw_data(): + self.write(data, st, last=last) + for st, data, last in self._init_kws.iter_enc_data(): + self.write_encoded(data, st, last=last) + + # clear pre-buffered data + self._init_kws.clear_data() + + def _terminate(self): + """Kill FFmpeg process and close the streams""" + + if self._proc is None or self._proc.poll() is not None: + return + + writers = [pinfo["writer"] for pinfo in self._input_pipes.values()] + readers = [pinfo["reader"] for pinfo in self._output_pipes.values()] + + # switch the readers to the cool-down (auto-flushing) mode + for reader in readers: + reader.cool_down() + + # write the sentinel to each input queue (if not already closed) + for writer in writers: + if not writer.closed(): + writer.write(None) + + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + + def open(self): + """start FFmpeg processing + + Note + ---- + + It may flag to defer starting the FFmpeg process if the input streams + are not fully specified and must wait to deduce them from the written + data. + + """ + + if self._status != FFmpegStatus.PREOPEN: + raise FFmpegioError("Already opened once.") + + # try configure FFmpeg arguments without any pre-buffered data + ok = self._try_config_ffmpeg() + + # if failed to configure, need to buffer input data first + if ok: + # ready to roll + self._run_ffmpeg() + + else: + # need input data to start ffmpeg + self._status = FFmpegStatus.BUFFERING + + def close(self): + """Kill FFmpeg process and close the streams""" + + if self._status != FFmpegStatus.RUNNING: + self._status = FFmpegStatus.STOPPED + else: + self._terminate() + + @property + def closed(self) -> bool: + """True if the stream is closed.""" + return self._proc is None or self._proc.poll() is not None + + def __enter__(self): + if self._status == FFmpegStatus.PREOPEN: + self.open() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + @property + def lasterror(self) -> FFmpegError | None: + """Last error FFmpeg posted""" + if self._proc and self._proc.poll(): + return self._logger.Exception + else: + return None + + def readlog(self, n: int | None = None) -> str: + """read FFmpeg log lines + + :param n: number of lines to read or None to read all currently found in the buffer + :return: logged messages + """ + + with self._logger._newline_mutex: + return "\n".join(self._logger.logs if n is None else self._logger.logs[:n]) + + def wait(self, timeout: float | None = None) -> int | None: + """flushes and close all input pipes and waits for FFmpeg to exit + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait indefinitely + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to + catch this exception and retry the wait. + :return returncode: return subprocess Popen returncode attribute + """ + + if self._proc: + # write the sentinel to each input queue + for pinfo in self._input_pipes.values(): + pinfo["writer"].write(None) + + # wait until the FFmpeg finishes the job + self._proc.wait(timeout) + + rc = self._proc.returncode + if rc is not None: + self._proc = None + else: + rc = None + return rc + + @property + def _args_not_ready(self): + return self._status < FFmpegStatus.ANALYSIS_DONE + + ########################################################## + ### RAW MEDIA INPUT STREAM PROPERTIES/METHODS + ########################################################## + + @property + def writable(self) -> bool: + """Return ``True`` if there is at least one raw media stream to write to. + If ``False``, ``write()`` will raise ``FFmpegioError``. + + See also: ``BaseFFmpegRunner.num_input_streams`` + """ + + return self.num_input_streams > 0 + + @cached_property + def num_input_streams(self) -> int: + """Return the number of raw media input streams. + If ``0``, ``write()`` will raise ``FFmpegioError``.""" + + try: + return len(self._init_kws["input_options"]) + except KeyError: + return 0 + + def write(self, data: RawDataBlob, stream: int = 0, *, last: bool = False): + """write a raw media data blob to the specified stream + + :param data: raw media data blob, which is supported by one of loaded + plugins (e.g., a NumPy array if numpy is importable in the + Python workspace). The shape and dtype of the data must be + compatible with the stream's shape and pix_fmt/sample_fmt. + :param stream: stream index in accordance to the ``input_options`` + input array, defaults to 0 (write to the first stream). + :param last: ``True`` to indicate ``data`` is the last frame of the stream. + Once called with ``last=True``, the input stream can no longer + be written. + + """ + + try: + data2bytes = self._input_info[stream]["data2bytes"] + except AttributeError as e: + if self._status == FFmpegStatus.BUFFERING: + if self._try_config_ffmpeg(stream, data, last): + self._run_ffmpeg() + else: + raise FFmpegioError( + "unknown error occurred (_input_info missing)" + ) from e + except KeyError as e: + raise FFmpegioError(f"Specified {stream=} is not a raw stream.") from e + else: + b = data2bytes(obj=data) + writer = self._input_pipes[stream]["writer"] + if len(b): + writer.write(b) + if last: + writer.write(None) # write the sentinel + + @property + def input_types(self) -> list[MediaType]: + """media types (list of 'audio' or 'video') of raw input pipes""" + + try: + return [ + "video" if "r" in opts else "audio" + for opts in self._init_kws["input_options"] + ] + except KeyError: + return [] + + @property + def input_rates(self) -> list[int | Fraction]: + """audio sample or video frame rates associated with the input media streams""" + + kws = self._init_kws + try: + sopts = kws["input_options"] + except KeyError: + return [] # no input streams + + return [opts["r"] if "r" in opts else opts["ar"] for opts in sopts] + + @property + def input_dtypes(self) -> list[DTypeString] | None: + """frame/sample data type associated with the input raw media streams + + ``None`` is returned if input stream exists but FFmpeg is not running yet + and ``input_dtypes`` argument is not given or not fully populated. + """ + + nin = self.num_input_streams + if nin == 0: + return [] + + try: + # ffmpeg running + return [v["raw_info"][0] for v in self._input_info[:nin]] + except AttributeError: + # not running yet, gather as much as we can + dtypes = self._init_kws["input_dtypes"] + return ( + None + if dtypes is None + or len(dtypes) != nin + or any(dtype is None for dtype in dtypes) + else dtypes + ) + + @property + def input_shapes(self) -> list[ShapeTuple] | None: + """frame/sample shape associated with the input raw media streams + + ``None`` is returned if input stream is expected but FFmpeg is not running yet + and ``input_shapes`` argument is not given or not fully populated. + """ + + nin = self.num_input_streams + if nin == 0: + return [] + + try: + # ffmpeg running + return [v["raw_info"][1] for v in self._input_info[:nin]] + except AttributeError: + # not running yet, gather as much as we can + shapes = self._init_kws["input_shapes"] + return ( + None + if shapes is None + or len(shapes) != nin + or any(shape is None for shape in shapes) + else shapes + ) + + ########################################################## + ### ENCODED INPUT STREAM PROPERTIES/METHODS + ########################################################## + + @property + def decodable(self) -> bool: + """Return ``True`` if there is at least one encoded stream to write to. + If ``False``, ``write_encoded()`` will raise ``FFmpegioError``.""" + + return self.num_encoded_input_streams > 0 + + @cached_property + def num_encoded_input_streams(self) -> int: + """Return the number of encoded input streams. + If ``0``, ``write_encoded()`` will raise ``FFmpegioError``.""" + + return len(self.encoded_input_streams) + + @cached_property + def encoded_input_streams(self) -> list[int]: + """Return a list of encoded piped input streams. + If empty, write_encoded() will raise FFmpegioError.""" + + kws = self._init_kws + url_kw_or_none = kws.get("input_urls", kws.get("extra_inputs", None)) + return ( + [] + if url_kw_or_none is None + else [i for i, (url, _) in enumerate(url_kw_or_none) if utils.is_pipe(url)] + ) + + def write_encoded(self, data: bytes, stream: int = 0, *, last: bool = False): + """write encoded media data to the specified encoded stream + + :param data: encoded media data bytes to be written. + :param stream: encoded input stream index, defaults to 0 (write to the + first stream). Note that this stream index is that of all + encoded inputs. For example, if the runner is set up with + ``input_urls = ['video.mp4','-']`` then ``stream=0`` points + to `'video.mp4'` thus the write would fail, and ``stream=1`` + must be specified to write to the input pipe. + :param last: ``True`` to indicate ``data`` is the last frame of the stream. + Once called with ``last=True``, the input stream can no longer + be written. + + """ + + if stream not in self.encoded_input_streams: + raise FFmpegioError(f"Specified {st=} is not a valid input encoded stream.") + if len(data) == 0: + return # no data to write + + st = stream + self.num_input_streams + try: + writer = self._input_pipes[st]["writer"] + except AttributeError as e: + # _input_info wouldn't exist if FFmpeg is not running, write to prebuffer + if self._status == FFmpegStatus.BUFFERING: + if self._try_config_ffmpeg(st, data, last): + self._run_ffmpeg() + else: + raise FFmpegioError( + "unknown error occurred (_input_info missing)" + ) from e + else: + writer.write(data) + if last: + writer.write(None) + + ########################################################## + ### OUTPUT PROPERTIES + ########################################################## + + @cached_property + def readable(self) -> bool | None: + """Return ``True`` if there is at least one raw media stream to read from. + If ``False``, ``read()`` will raise ``FFmpegioError``.""" + nout = self.num_output_streams + return nout and nout > 0 + + @cached_property + def num_output_streams(self) -> int | None: + """Return the number of raw media stream to read from. If ``0``, ``read()`` + will raise ``FFmpegioError``.""" + + # assuming that ``output_stream`` keyword only =specifies unique map + + try: + output_info = self._output_info + except AttributeError: + ostreams = self._init_kws.get("output_streams", None) + return ostreams and len(ostreams) + else: + return sum("media_type" in info for info in output_info) + + def read(self, n: int, stream: int = 0) -> RawDataBlob: + """read selected output stream (shared backend)""" + + try: + info = self._output_info[stream] + assert "media_type" in self._output_info[stream] + except AttributeError as e: + raise FFmpegioError("FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{stream} is not a raw stream.") from e + + (dtype, shape, _) = info["raw_info"] + b = self._output_pipes[stream]["reader"].read(n) + + data = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) + + return data + + @property + def output_types(self) -> list[MediaType] | None: + """media types of the raw media output pipes. + + Note: If a pipe outputs a filtergraph output (or streamspec is not + unique), ``None`` is returned prior to FFmpeg starts""" + + nout = self.num_output_streams + + if nout == 0: # no media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + kw = self._init_kws["output_streams"] + out = [""] * nout + for i, opts in enumerate( + kw if isinstance(kw, list) else (v for v in kw.values()) + ): + mapopts = stream_spec.parse_map_option( + opts["map"], input_file_id=0, parse_stream=True + ) + if "linklabel" in mapopts: + return None # linklabel requires filtergraph analysis + + media_type = stream_spec.is_unique_stream(mapopts["stream_specifier"]) + if media_type is False: + return None # just in case + out[i] = media_type + return out + else: + return [info["media_type"] for info in stream_info[:nout]] + + @property + def output_labels(self) -> list[str] | None: + """labels of the raw media output pipes. + + If the same input stream is mapped to multiple outputs without unique + user labels, ``None`` is returned prior to FFmpeg starts""" + + nout = self.num_output_streams + + if nout == 0: # no media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + kw = self._init_kws["output_streams"] + out = [""] * nout + for i, (name, opts) in enumerate( + ((None, v) for v in kw) if isinstance(kw, list) else kw.items() + ): + out[i] = opts["map"] if name is None else name + return out if len(set(out)) == nout else None + else: + return [v["user_map"] for v in stream_info[:nout]] + + @property + def output_rates(self) -> list[int | Fraction] | None: + """sample or frame rates associated with the output streams + + ``None`` is returned before FFmpeg starts unless user specify the + rates of all output streams (i.e., resample/change frame rate). + """ + + nout = self.num_output_streams + + if nout == 0: # no output media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet, get user options + rates = [0] * nout + kw = self._init_kws["output_streams"] + if isinstance(kw, dict): + kw = kw.values() + for i, opts in enumerate(kw): + r = opts.get("r", opts.get("ar", None)) + if r is None: + return None + rates[i] = r + return rates + else: + return [v["raw_info"][2] for v in stream_info[:nout]] + + @property + def output_dtypes(self) -> list[DTypeString] | None: + """frame/sample data type associated with the output streams + + Each element is a Numpy-style dtype string like '|u1' for unsigned 8-bit + integer. + + If FFmpeg process has not been started, this property returns ``None``. + """ + + nout = self.num_output_streams + + if nout == 0: # no output media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet + return None + else: + return [v["raw_info"][0] for v in stream_info[:nout]] + + @property + def output_shapes(self) -> list[ShapeTuple] | None: + """frame/sample shape associated with the output streams + + Each element is a Numpy-style shape integer tuple of each time sample. + For a video stream, it has 3 elements (height, width, components); for + an audio stream, it has 1 element (channels,). + + If FFmpeg process has not been started, this property returns ``None``. + """ + + nout = self.num_output_streams + + if nout == 0: # no output media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet + return None + else: + return [v["raw_info"][1] for v in stream_info[:nout]] + + @property + def output_itemsizes(self) -> list[int] | None: + """frame/sample item sizes in bytes or ``None`` if accessed before ffmpeg + is configured. + """ + + nout = self.num_output_streams + if nout == 0: + return [] + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet + return None + else: + return [v["item_size"] for v in stream_info[:nout]] + + ### PRIMARY OUTPUT SETTING + + @property + def primary_output(self) -> int: + """index of the primary output stream or ``-1`` if no output raw media stream""" + + _user_val = self._primary_output + if _user_val is None: + return 0 if self.readable else -1 + nout = self.num_output_streams + if _user_val < 0 or _user_val >= nout: + raise FFmpegioError( + f"FFmpeg runner object was created with an invalid primary stream ({_user_val})" + ) + + return _user_val + + @property + def primary_output_blocksize(self) -> int | None: + """blocksize for iterator-based read and if queued-stream size of block in queue""" + + if not self.readable: + return None + + bsize = self._blocksize + if bsize is None: + media_types = self.output_types + if media_types is not None: + mtype = media_types[self.primary_output] + bsize = {"audio": 1024, "video": 1}[mtype] + + return bsize + + @property + def primary_output_label(self) -> str | None: + """primary raw media stream label (None if FFmpeg not started or no output raw stream)""" + + st = self.primary_output + if st < 0 or self._output_info is None: + return None + return self._output_info[st].get("user_map", None) + + @property + def primary_output_rate(self) -> int | Fraction | None: + """sample/frame rate of the primary raw media stream (None if FFmpeg not started or no output raw stream)""" + st = self.primary_output + try: + return self._output_info[st]["raw_info"][-1] + except (AttributeError, IndexError): + return None + + def output_frames( + self, primary_frames: int | None = None + ) -> list[int | Fraction] | None: + """calculate the number of frames of raw output streams + + :param primary_frames: number of frames of the reference output stream, + defaults to ``primary_output_blocksize`` + :return: numbers of frames of all the output streams. If FFmpeg process + has not been started, it returns None + """ + if primary_frames is None: + primary_frames = self.primary_output_blocksize + rates = self.output_rates + rate0 = self.primary_output_rate + if primary_frames is None or rates is None or rate0 is None: + return None + + fr = Fraction(primary_frames, rate0) + return [r * fr for r in rates] + + def output_pending(self) -> bool: + """True if FFmpeg is running or at least one output buffer has data""" + return bool(self) or any( + pipe["reader"].qsize() for pipe in self._output_pipes.values() + ) + + ########################################################## + ### ENCODED INPUT STREAM PROPERTIES/METHODS + ########################################################## + + @property + def encodable(self) -> bool: + """Return ``True`` if there is at least one encoded stream to read. + If ``False``, ``read_encoded()`` will raise ``FFmpegioError``.""" + + return self.num_encoded_output_streams > 0 + + @cached_property + def num_encoded_output_streams(self) -> int: + """Return the number of encoded output streams. + If ``0``, ``read_encoded()`` will raise ``FFmpegioError``.""" + + return len(self.encoded_output_streams) + + @cached_property + def encoded_output_streams(self) -> list[int]: + """Return a list of encoded piped output streams. + If empty, ``read_encoded()`` will raise ``FFmpegioError``.""" + + kws = self._init_kws + url_kw_or_none = kws.get("output_urls", kws.get("extra_outputs", None)) + return ( + [] + if url_kw_or_none is None + else [i for i, (url, _) in enumerate(url_kw_or_none) if utils.is_pipe(url)] + ) + + def read_encoded(self, n: int, stream: int = 0) -> bytes: + """read encoded media data from the specified encoded stream + + :param n: number of bytes to be read. If <=0 to read + :param stream: encoded output stream index, defaults to 0 (write to the + first stream). Note that this stream index is that of all + encoded inputs. For example, if the runner is set up with + ``input_urls = ['video.mp4','-']`` then ``stream=0`` points + to `'video.mp4'` thus the write would fail, and ``stream=1`` + must be specified to write to the input pipe. + :returns: bytes + """ + + if stream not in self.encoded_output_streams: + raise FFmpegioError( + f"Specified {stream=} is not a valid output encoded stream." + ) + + st = stream + self.num_output_streams + + try: + pipe = self._output_pipes[st] + except AttributeError as e: + raise FFmpegioError("FFmpeg is not running yet.") from e + + return pipe["reader"].read(n) + + +class SISOMixin: + input_rates: list[int | Fraction] + output_rates: list[int | Fraction] + input_dtypes: list[DTypeString] | None + output_dtypes: list[DTypeString] | None + input_shapes: list[ShapeTuple] | None + output_shapes: list[ShapeTuple] | None + + @property + def rate_in(self) -> int | Fraction | None: + """frame/sample rate input raw stream (``None`` if no input)""" + rates = self.input_rates + return None if rates is None else rates[0] + + @property + def rate(self) -> int | Fraction | None: + """frame/sample rate output raw stream (``None`` if no output)""" + rates = self.output_rates + return None if rates is None else rates[0] + + @property + def dtype_in(self) -> DTypeString | None: + """NumPy-style data type string of the input raw stream (``None`` if no input)""" + dtypes = self.input_dtypes + return None if dtypes is None else dtypes[0] + + @property + def dtype(self) -> DTypeString | None: + """NumPy-style data type string of the output raw stream (``None`` if no output)""" + dtypes = self.output_dtypes + return None if dtypes is None else dtypes[0] + + @property + def shape_in(self) -> ShapeTuple | None: + """shape tuple of input data frame (``None`` if no input) + + - audio frame: ``(channels,)`` + - video frame: ``(height, width, components)`` + """ + shapes = self.input_shapes + return None if shapes is None else shapes[0] + + @property + def shape(self) -> ShapeTuple | None: + """shape tuple of output data frame (``None`` if no output) + + - audio frame: ``(channels,)`` + - video frame: ``(height, width, components)`` + """ + shapes = self.output_shapes + return None if shapes is None else shapes[0] + + +class StdFFmpegRunner(SISOMixin, BaseFFmpegRunner): + _dynamic_output: bool = False + _use_std_pipes: bool = True + _use_named_pipes: bool = False + + def __init__( + self, + init_func: Callable, + init_kws: MediaReadKwsDict | MediaWriteKwsDict, + blocksize: int | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ): + """FFmpeg runner with only 1 buffered std pipe + + :param init_func: FFmpeg initialization function from :py:module:`configure` + :param init_kws: keyword arguments to call the FFmpeg initialization function + :param blocksize: (only for readable) iterator block size in frames/samples + to read raw media streams, defaults to use ``1`` (frame) + for a video stream and ``1024`` (samples) for audio stream. + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, defaults + to None (no show/capture) + :param overwrite: True to overwrite existing file, defaults to False to + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + + """ + super().__init__( + init_func, + init_kws, + blocksize=blocksize, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + + def _try_config_ffmpeg( + self, + stream: int = -1, + data: bytes | RawDataBlob | None = None, + last: bool = False, + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :param last: optional ``True`` if ``data`` is the last data blob of ``stream`` + :return: ``True`` if FFmpeg arguments are successfully configured + and ``_input_info`` and ``_output_info`` lists are fully + populated. + + This subclass overloading adds additional validation for having only one + pipe to guarantee a simple operation with one buffered std pipe. + + """ + + ok = super()._try_config_ffmpeg(stream, data, last) + if ok: + # validate + nin = self.num_input_streams + nout = self.num_output_streams + nein = self.num_encoded_input_streams + neout = self.num_encoded_output_streams + if nin + nout + nein + neout != 1: + if max(nin, nein) > 1: + raise FFmpegioError( + "More than one input stream assigned to use stdin" + ) + if max(nout, neout) > 1: + raise FFmpegioError( + "More than one output stream assigned to use stdout" + ) + else: + raise FFmpegioError( + "StdFFmpegRunner can only use either stdin or stdout" + ) + + return ok + + @override + def __iter__(self) -> Iterator[RawDataBlob]: + """iterator to read raw media data + + :yield: data blob containing at most ``primary_output_blocksize`` + frames/samples of the output stream. + + Note: The iterator of :py:class:`streams.StdFFmpegRunner` is not compatible with + :py:class:`streams.BaseFFmpegRunner` and :py:class:`streams.PipedFFmpegRunner`. + The other classes yield a list of data blobs as they allow multiple output + raw output streams. + """ + + nout = self.num_output_streams + if nout == 0: + raise FFmpegioError("No output stream to create a frame iterator") + + if self.decodable or self.encodable or self.writable: + raise FFmpegioError("Frame iterator is only supported for a pure reader") + + ref_st = self.primary_output + ref_sz = self.primary_output_blocksize + + isempty = self._output_info[ref_st]["data_is_empty"] + + F = self.read(ref_sz, ref_st) + while not isempty(obj=F): + yield F + F = self.read(ref_sz, ref_st) + + @staticmethod + def open_simple_reader( + input_urls: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + output_stream: str | FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_outputs: ( + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + *, + blocksize: int | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> StdFFmpegRunner: + """create a single-pipe media reader + + :param input_urls: URL string of the file or format/device object. It + can be an input filtergraph object or other input ffmpegio objects. + The input could also be fed by a readable file object. Multiple + input sources could be assigned to feed a complex filtergraph. + :param output_stream: Either an FFmpeg map option value or a dict of + FFmpeg output options. If dict, it must include a ``'map'`` key. The + ``'map'`` must resolve to only one stream. + :param options: optional FFmpeg option dict including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. + :param blocksize: Read block size (in frames for video or samples in + audio) when the reader object is used as an iterator + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination + files, defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to + ``None`` + """ + + init_kws: MediaReadKwsDict = { + "input_urls": input_urls, + "output_streams": [output_stream], + "options": options, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + } + runner = StdFFmpegRunner( + init_func=configure.init_media_read, + init_kws=init_kws, + blocksize=blocksize, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_simple_writer( + output_urls: ( + FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + ), + input_options: FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> StdFFmpegRunner: + """single-pipe media writer + + :param input_options: ffmpeg input options for the raw media input + must contain a rate option (``r`` or ``ar``). + :param output_urls: Specify encoded output file(s) in one of the + following styles: + + - an output file url + - a pair of the url and FFmpeg output options + - a sequence of the urls or the pairs or a mixture thereof. This + commands FFmpeg to generate multiple files simultaneously from + the same input streams. + + :param options: optional ffmpeg option dict including input, output, and + global options. For input options, append ``'_in'`` to the + end of ffmpeg option names. + :param extra_inputs: extra encoded input urls, Each element is a tuple + pair of url and input option dict. The url must be a url and not + pipes or pipe objects. + :param input_shape: input video frame size (height, width) or number of + input audio channel, defaults to auto-detect + :param input_dtype: input data format in a Numpy dtype string, defaults + to auto-detect + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination + files, defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to + ``None`` + """ + + init_kws: MediaWriteKwsDict = { + "input_options": [input_options], + "output_urls": output_urls, + "extra_inputs": extra_inputs, + "options": options, + "input_dtypes": None if input_dtype is None else [input_dtype], + "input_shapes": None if input_shape is None else [input_shape], + } + runner = StdFFmpegRunner( + init_func=configure.init_media_write, + init_kws=init_kws, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + +class PipedFFmpegRunner(BaseFFmpegRunner): + """Streaming FFmpeg runner using named pipes""" + + _dynamic_output: bool = False + _use_std_pipes: bool = False + _use_named_pipes: bool = True + + def read_nowait(self, n: int, stream: int = 0) -> RawDataBlob: + """read selected output stream (shared backend)""" + + try: + info = self._output_info[stream] + assert "media_type" in self._output_info[stream] + except AttributeError as e: + raise FFmpegioError("FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{stream} is not a raw stream.") from e + + (dtype, shape, _) = info["raw_info"] + b = self._output_pipes[stream]["reader"].read_nowait( + n * info["item_size"] if n > 0 else n + ) + + data = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) + + return data + + def read_encoded_nowait(self, n: int, stream: int = 0) -> bytes: + """read encoded media data from the specified encoded stream + + :param n: number of bytes to be read. If <=0 to read + :param stream: encoded output stream index, defaults to 0 (write to the + first stream). Note that this stream index is that of all + encoded inputs. For example, if the runner is set up with + ``input_urls = ['video.mp4','-']`` then ``stream=0`` points + to `'video.mp4'` thus the write would fail, and ``stream=1`` + must be specified to write to the input pipe. + :returns: bytes + """ + + if stream not in self.encoded_output_streams: + raise FFmpegioError( + f"Specified {stream=} is not a valid output encoded stream." + ) + + if self.status == FFmpegStatus.BUFFERING: + return b"" + + st = stream + self.num_output_streams + + try: + pipe = self._output_pipes[st] + except AttributeError: + return b"" + + return pipe["reader"].read_nowait(n) + + def __iter__(self) -> Iterator[list[RawDataBlob]]: + """iterator to read raw media data + + :yield: a list of raw data blobs, one for each output raw media stream, + containing at most ``primary_output_blocksize`` frames of + the primary stream given by ``primary_output``. The frame sizes + of other streams are proportional to their ``output_rates`` wrt + the primary output. + """ + nout = self.num_output_streams + if nout == 0: + raise FFmpegioError("No output stream to create a frame iterator") + + if self.decodable or self.encodable or self.writable: + raise FFmpegioError("Frame iterator is only supported for a pure reader") + + nperread = self.output_frames() + count = [self._output_info[i]["data_count"] for i in range(nout)] + nf = nperread.copy() + nread = [1] * nout + + # loop while FFmpeg is running + while self: + # read the next block of the reference stream + out = [ + (self.read)(round(max(ni, 0)), st) for st, ni in zip(range(nout), nf) + ] + nread = [counti(obj=Fi) for counti, Fi in zip(count, out)] + + # yield the last read frames + yield out + + # calculate how many frames to read next (fractional) + nf = [nfi - nr + nnext for nfi, nr, nnext in zip(nf, nread, nperread)] + + # if there is any secondary streams with leftover frames, do the last yield + if self.output_pending() and any(n > 0 for n in nread): + out = [self.read(round(max(ni, 0)), st) for st, ni in zip(range(nout), nf)] + yield out + + @staticmethod + def open_media_reader( + input_urls: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + output_streams: ( + str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None + ) = None, + options: FFmpegOptionDict | None = None, + squeeze: bool = False, + extra_outputs: ( + list[FFmpegOutputOptionTuple] | dict[str, FFmpegOptionDict] | None + ) = None, + *, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + output_streams = utils.expand_raw_output_streams( + output_streams, input_urls, options + ) + init_kws: MediaReadKwsDict = { + "input_urls": input_urls, + "output_streams": output_streams, + "options": options, + "squeeze": squeeze, + "extra_outputs": extra_outputs, + } + runner = PipedFFmpegRunner( + configure.init_media_read, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_writer( + output_urls: ( + FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + ), + input_options: list[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + *, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + init_kws: MediaWriteKwsDict = { + "output_urls": output_urls, + "input_options": input_options, + "options": options, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + "extra_inputs": extra_inputs, + } + runner = PipedFFmpegRunner( + configure.init_media_write, + init_kws, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_filter( + input_options: list[FFmpegOptionDict], + output_streams: str | FFmpegOptionDict | Sequence[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + *, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + init_kws: MediaFilterKwsDict = { + "input_options": input_options, + "output_streams": output_streams, + "options": options, + "extra_inputs": extra_inputs, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + } + runner = PipedFFmpegRunner( + configure.init_media_filter, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_encoder( + input_options: list[FFmpegOptionDict], + output_options: list[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + *, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + output_urls: list[FFmpegOutputOptionTuple] = [ + ("-", opts) for opts in output_options + ] + if extra_outputs is not None: + output_urls.extend(extra_outputs) + + init_kws: MediaWriteKwsDict = { + "output_urls": output_urls, + "input_options": input_options, + "options": options, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + "extra_inputs": extra_inputs, + } + runner = PipedFFmpegRunner( + configure.init_media_write, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_decoder( + input_options: Sequence[FFmpegOptionDict], + output_streams: str | FFmpegOptionDict | Sequence[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[FFmpegOutputOptionTuple] | None = None, + *, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + input_urls: list[FFmpegInputOptionTuple] = [ + ("-", opts) for opts in input_options + ] + if extra_inputs is not None: + input_urls.extend(extra_inputs) + + init_kws: MediaReadKwsDict = { + "input_urls": input_urls, + "output_streams": output_streams, + "options": options, + "squeeze": squeeze, + "extra_outputs": extra_outputs, + } + runner = PipedFFmpegRunner( + configure.init_media_read, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_transcoder( + input_options: list[FFmpegOptionDict], + output_options: list[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + *, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + input_urls = [("pipe", opts) for opts in input_options] + output_urls = [("pipe", opts) for opts in output_options] + + if extra_inputs is not None: + input_urls.extend(extra_inputs) + if extra_outputs is not None: + output_urls.extend(extra_outputs) + + init_kws: MediaTranscoderKwsDict = { + "input_urls": input_urls, + "output_urls": output_urls, + "options": options, + } + runner = PipedFFmpegRunner( + configure.init_media_transcode, + init_kws, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + +class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): + """Streaming FFmpeg runner for a SISO filtering using named pipes. + + This class mixes in the single input convenience properties to + the py::class`PipedFFmpegRunner`. + """ + + @staticmethod + def create_and_open( + input_options: FFmpegOptionDict, + output_stream: str | FFmpegOptionDict | None = None, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: ( + list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: Callable[[dict[str, Any], bool], bool] | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> SISOFFmpegFilter: + runner = SISOFFmpegFilter( + input_options, + output_stream, + squeeze=squeeze, + extra_inputs=extra_inputs, + extra_outputs=extra_outputs, + input_dtype=input_dtype, + input_shape=input_shape, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + options=options, + ) + runner.open() + return runner + + def __init__( + self, + input_options: FFmpegOptionDict, + output_stream: str | FFmpegOptionDict | None = None, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: ( + list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: Callable[[dict[str, Any], bool], bool] | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ): + init_func = configure.init_media_filter + init_kws: MediaFilterKwsDict = { + "input_options": [input_options], + "output_streams": output_stream and [output_stream], + "options": options, + "extra_inputs": extra_inputs, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + "input_dtypes": None if input_dtype is None else [input_dtype], + "input_shapes": None if input_shape is None else [input_shape], + } + super().__init__( + init_func, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + + def _try_config_ffmpeg( + self, + stream: int = -1, + data: bytes | RawDataBlob | None = None, + last: bool = False, + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :param last: ``True`` if ``data`` is the last blob of ``stream`` + :return: ``True`` if FFmpeg arguments are successfully configured + and ``_input_info`` and ``_output_info`` lists are fully + populated. + + This subclass overloading adds additional validation for having only one + pipe to guarantee a simple operation with one buffered std pipe. + + """ + + ok = super()._try_config_ffmpeg(stream, data, last) + if ok: + # validate + nin = self.num_input_streams + nout = self.num_output_streams + if nin != 1 or nout != 1: + raise FFmpegioError( + "SISOFFmpegFilter takes only one each of raw input and output." + ) + if self.num_encoded_input_streams or self.num_encoded_output_streams: + raise FFmpegioError( + "SISOFFmpegFilter does not accept any encoded input or output." + ) + + return ok + + # def filter(self, data: RawDataBlob, *, last: bool = False) -> RawDataBlob: + # """filter a raw media data blob to the specified stream + + # :param data: raw media data blob, which is supported by one of loaded + # plugins (e.g., a NumPy array if numpy is importable in the + # Python workspace). The shape and dtype of the data must be + # compatible with the stream's shape and pix_fmt/sample_fmt. + # :param last: ``True`` to mark ``data`` the last input blob, defaults to + # ``False`` + # :returns: filter output blob. + + # This method shall be used with caution especially if the input and output + # rates are not the same. It is recommended to set a timeout. + # """ + + # self.write(data, last=last) + + # if self.rate_in is None or self.rate is None: + # raise FFmpegioError("FFmpeg is not running yet.") + + # n = self._input_info[0]["data_count"](obj=data) + # nout = int((n * self.rate / self.rate_in)) + + # return self.read(nout) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py deleted file mode 100644 index abc89272..00000000 --- a/src/ffmpegio/streams/SimpleStreams.py +++ /dev/null @@ -1,1188 +0,0 @@ -from time import time -import logging - -logger = logging.getLogger("ffmpegio") - -from .. import utils, configure, ffmpegprocess, plugins -from ..probe import _audio_info as _probe_audio_info, _video_info as _probe_video_info -from ..threading import LoggerThread, ReaderThread, WriterThread - -# fmt:off -__all__ = [ "SimpleVideoReader", "SimpleAudioReader", "SimpleVideoWriter", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter"] -# fmt:on - - -class SimpleReaderBase: - """base class for SISO media read stream classes""" - - def __init__( - self, - converter, - viewer, - url, - show_log=None, - progress=None, - blocksize=None, - sp_kwargs=None, - **options, - ) -> None: - self._converter = converter # :Callable: f(b,dtype,shape) -> data_object - self._memoryviewer = viewer #:Callable: f(data_object)->bytes-like object - self.dtype = None # :str: output data type - self.shape = ( - None # :tuple of ints: dimension of each video frame or audio sample - ) - self.samplesize = ( - None #:int: number of bytes of each video frame or audio sample - ) - self.blocksize = None #:positive int: number of video frames or audio samples to read when used as an iterator - self.sp_kwargs = sp_kwargs #:dict[str,Any]: additional keyword arguments for subprocess.Popen - - # get url/file stream - input_options = utils.pop_extra_options(options, "_in") - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) - ) - - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options) - configure.add_url(ffmpeg_args, "output", "-", options) - - # abstract method to finalize the options => sets self.dtype and self.shape if known - self._finalize(ffmpeg_args) - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update({"stdin": stdin, "progress": progress, "capture_log": True}) - - # start FFmpeg - self._proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - # if byte data is given, feed it - if input is not None: - self._proc.stdin.write(input) - - # wait until output stream log is captured if output format is unknown - try: - if self.dtype is None or self.shape is None: - logger.debug( - "[reader main] waiting for logger to provide output stream info" - ) - info = self._logger.output_stream() - logger.debug(f"[reader main] received {info}") - self._finalize_array(info) - else: - self._logger.index("Output") - except: - if self._proc.poll() is None: - raise self._logger.Exception - else: - raise ValueError("failed retrieve output data format") - - self.samplesize = utils.get_samplesize(self.shape, self.dtype) - - self.blocksize = blocksize or max(1024**2 // self.samplesize, 1) - logger.debug("[reader main] completed init") - - def close(self): - """Flush and close this stream. This method has no effect if the stream is already - closed. Once the stream is closed, any read operation on the stream will raise - a ValueError. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - - """ - - if self._proc is None: - return - - self._proc.stdout.close() - self._proc.stderr.close() - - if self._proc.poll() is None: - try: - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - except: - print("failed to terminate") - pass - - logger.debug(f"[reader main] FFmpeg closed? {self._proc.poll()}") - - try: - self._proc.stdin.close() - except: - pass - self._logger.join() - - @property - def closed(self): - """:bool: True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError: Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self.blocksize) - if F is None: - raise StopIteration - return F - - def readlog(self, n=None): - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def read(self, n=-1): - """Read and return numpy.ndarray with up to n frames/samples. If - the argument is omitted, None, or negative, data is read and - returned until EOF is reached. An empty bytes object is returned - if the stream is already at EOF. - - If the argument is positive, and the underlying raw stream is not - interactive, multiple raw reads may be issued to satisfy the byte - count (unless EOF is reached first). But for interactive raw streams, - at most one raw read will be issued, and a short result does not - imply that EOF is imminent. - - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" - logger.debug(f"[reader main] reading {n} samples") - b = self._proc.stdout.read(n * self.samplesize if n > 0 else n) - logger.debug(f"[reader main] read {len(b)} bytes") - if not len(b): - self._proc.stdout.close() - return None - return self._converter(b=b, shape=self.shape, dtype=self.dtype, squeeze=False) - - def readinto(self, array): - """Read bytes into a pre-allocated, writable bytes-like object array and - return the number of bytes read. For example, b might be a bytearray. - - Like read(), multiple reads may be issued to the underlying raw stream, - unless the latter is interactive. - - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" - - return ( - self._proc.stdout.readinto(self._memoryviewer(obj=array)) // self.samplesize - ) - - -class SimpleVideoReader(SimpleReaderBase): - readable = True - writable = False - multi_read = False - multi_write = False - - def __init__( - self, url, show_log=None, progress=None, blocksize=1, sp_kwargs=None, **options - ): - hook = plugins.get_hook() - super().__init__( - hook.bytes_to_video, - hook.video_bytes, - url, - show_log, - progress, - blocksize, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args): - # finalize FFmpeg arguments and output array - - inurl, inopts = ffmpeg_args.get("inputs", [])[0] - outopts = ffmpeg_args.get("outputs", [])[0][1] - has_fg = configure.has_filtergraph(ffmpeg_args, "video") - - pix_fmt = outopts.get("pix_fmt", None) - pix_fmt_in = s_in = r_in = None - if ( - pix_fmt is None - and not has_fg - and inurl not in ("-", "pipe:", "pipe:0") - and not inopts.get("pix_fmt", None) - ): - try: - # must assign output rgb/grayscale pixel format - pix_fmt_in, *s_in, ra_in, rr_in = _probe_video_info( - inurl, "v:0", self.sp_kwargs - ) - r_in = rr_in if ra_in is None or ra_in == "0/0" else ra_in - except: - pix_fmt_in = "rgb24" - - if pix_fmt_in is None and pix_fmt is None: - raise ValueError("pix_fmt must be specified.") - - ( - self.dtype, - self.shape, - self.rate, - ) = configure.finalize_video_read_opts(ffmpeg_args, pix_fmt_in, s_in, r_in) - - # construct basic video filter if options specified - configure.build_basic_vf( - ffmpeg_args, utils.alpha_change(pix_fmt_in, pix_fmt, -1) - ) - - def _finalize_array(self, info): - # finalize array setup from FFmpeg log - - self.rate = info["r"] - self.dtype, self.shape = utils.get_video_format(info["pix_fmt"], info["s"]) - - -class SimpleAudioReader(SimpleReaderBase): - readable = True - writable = False - multi_read = False - multi_write = False - - def __init__( - self, - url, - show_log=None, - progress=None, - blocksize=None, - sp_kwargs=None, - **options, - ): - hook = plugins.get_hook() - super().__init__( - hook.bytes_to_audio, - hook.audio_bytes, - url, - show_log, - progress, - blocksize, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args): - # finalize FFmpeg arguments and output array - - inurl, inopts = ffmpeg_args.get("inputs", [])[0] - has_fg = configure.has_filtergraph(ffmpeg_args, "audio") - - sample_fmt_in = inopts.get("sample_fmt", None) - ac_in = ar_in = None - if not has_fg and sample_fmt_in is None: - # use the same format as the input - try: - # use the same format as the input - ar_in, sample_fmt_in, ac_in = _probe_audio_info( - inurl, "a:0", self.sp_kwargs - ) - except: - sample_fmt_in = "s16" - - ( - self.dtype, - ac, - self.rate, - ) = configure.finalize_audio_read_opts(ffmpeg_args, sample_fmt_in, ac_in, ar_in) - - if ac is not None: - self.shape = (ac,) - - def _finalize_array(self, info): - # finalize array setup from FFmpeg log - - self.rate = info["ar"] - self.dtype, self.shape = utils.get_audio_format( - info["sample_fmt"], info.get("ac", 1) - ) - - @property - def channels(self): - return self.shape[-1] - - -########################################################################### - - -class SimpleWriterBase: - def __init__( - self, - viewer, - url, - shape_in=None, - dtype_in=None, - show_log=None, - progress=None, - overwrite=None, - extra_inputs=None, - sp_kwargs=None, - **options, - ) -> None: - self._proc = None - self._viewer = viewer - self.dtype_in = dtype_in - self.shape_in = shape_in - - # get url/file stream - url, stdout, _ = configure.check_url(url, True) - - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", "-", input_options) - configure.add_url(ffmpeg_args, "output", url, options) - - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - # abstract method to finalize the options only if self.dtype and self.shape are given - ready = self._finalize(ffmpeg_args) - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - # FFmpeg Popen arguments - self._cfg = {**sp_kwargs} if sp_kwargs else {} - self._cfg.update( - { - "ffmpeg_args": ffmpeg_args, - "progress": progress, - "capture_log": True, - "overwrite": overwrite, - "stdout": stdout, - } - ) - - if ready: - self._open() - - def _open(self, data=None): - # if data array is given, finalize the FFmpeg configuration with it - if data is not None: - self._finalize_with_data(data) - - # start FFmpeg - self._proc = ffmpegprocess.Popen(**self._cfg) - self._cfg = False - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - def close(self): - """close the output stream""" - if self._proc is None: - return - - if self._proc.stdin and not self._proc.stdin.closed: - try: - self._proc.stdin.close() # flushes the buffer first before closing - except OSError as e: - logger.error(e) - self._proc.wait() - if self._proc.stderr and not self._proc.stderr.closed: - try: - self._proc.stderr.close() - except OSError as e: - logger.error(e) - - self._logger.join() - - @property - def closed(self): - """:bool: True if stream is closed""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError or None: Last caught FFmpeg error""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def readlog(self, n=None): - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def write(self, data): - """Write the given numpy.ndarray object, data, and return the number - of bytes written (always equal to the number of data frames/samples, - since if the write fails an OSError will be raised). - - When in non-blocking mode, a BlockingIOError is raised if the data - needed to be written to the raw stream but it couldn’t accept all - the data without blocking. - - The caller may release or mutate data after this method returns, - so the implementation should only access data during the method call. - - """ - - if self._cfg: - # if FFmpeg not yet started, finalize the configuration with - # the data and start - self._open(data) - - logger.debug("[writer main] writing...") - - try: - self._proc.stdin.write(self._viewer(obj=data)) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() - - def flush(self): - self._proc.stdin.flush() - - -class SimpleVideoWriter(SimpleWriterBase): - readable = False - writable = True - multi_read = False - multi_write = False - - def __init__( - self, - url, - rate_in, - shape_in=None, - dtype_in=None, - show_log=None, - progress=None, - overwrite=None, - extra_inputs=None, - sp_kwargs=None, - **options, - ): - options["r_in"] = rate_in - if "r" not in options: - options["r"] = rate_in - - super().__init__( - plugins.get_hook().video_bytes, - url, - shape_in, - dtype_in, - show_log, - progress, - overwrite, - extra_inputs, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args) -> None: - inopts = ffmpeg_args["inputs"][0][1] - inopts["f"] = "rawvideo" - - ready = "s" in inopts and "pix_fmt" in inopts - - if not (ready or (self.dtype_in is None or self.shape_in is None)): - s, pix_fmt = utils.guess_video_format((self.shape_in, self.dtype_in)) - if "s" not in inopts: - inopts["s"] = s - if "pix_fmt" not in inopts: - inopts["pix_fmt"] = pix_fmt - ready = True - - if ready: - # set basic video filter chain if related options are specified - configure.build_basic_vf( - ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1) - ) - return ready - - def _finalize_with_data(self, data): - ffmpeg_args = self._cfg["ffmpeg_args"] - inopts = ffmpeg_args["inputs"][0][1] - shape, dtype = plugins.get_hook().video_info(obj=data) - s, pix_fmt = utils.guess_video_format(shape, dtype) - - configure.build_basic_vf( - ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1) - ) - - if "s" not in inopts: - inopts["s"] = s - if "pix_fmt" not in inopts: - inopts["pix_fmt"] = pix_fmt - - self.shape_in = shape - self.dtype_in = dtype - - -class SimpleAudioWriter(SimpleWriterBase): - readable = False - writable = True - multi_read = False - multi_write = False - - def __init__( - self, - url, - rate_in, - shape_in=None, - dtype_in=None, - show_log=None, - progress=None, - overwrite=None, - extra_inputs=None, - sp_kwargs=None, - **options, - ): - options["ar_in"] = rate_in - if "ar" not in options: - options["ar"] = rate_in - - super().__init__( - plugins.get_hook().audio_bytes, - url, - shape_in, - dtype_in, - show_log, - progress, - overwrite, - extra_inputs, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args): - # ffmpeg_args must have sample format & sampling rate specified - inopts = ffmpeg_args["inputs"][0][1] - ready = "sample_fmt" in inopts and "ac" in inopts - - if not ready and (self.dtype_in is not None or self.shape_in is not None): - inopts = ffmpeg_args["inputs"][0][1] - inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.dtype_in, self.shape_in - ) - ready = True - - if ready and not ("c:a" in inopts or "acodec" in inopts): - # fill audio codec and format options - inopts["c:a"], inopts["f"] = utils.get_audio_codec(inopts["sample_fmt"]) - if "acodec" in inopts: - del inopts["acodec"] - - return ready - - def _finalize_with_data(self, data): - self.shape_in, self.dtype_in = plugins.get_hook().audio_info(obj=data) - - inopts = self._cfg["ffmpeg_args"]["inputs"][0][1] - inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.dtype_in, self.shape_in - ) - inopts["c:a"], inopts["f"] = utils.get_audio_codec(inopts["sample_fmt"]) - - -############################################################################### - - -class SimpleFilterBase: - """base class for SISO media filter stream classes - - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None - :param rate_in: input sample rate - :type rate_in: int, float, Fraction, str - :param shape_in: input single-sample array shape, defaults to None - :type shape_in: seq of ints, optional - :param dtype_in: input data type string, defaults to None - :type dtype_in: str, optional - :param rate: output sample rate, defaults to None (auto-detect) - :type rate: int, float, Fraction, str, optional - :param shape: output single-sample array shape, defaults to None - :type shape: seq of ints, optional - :param dtype: output data type string, defaults to None - :type dtype: str, optional - :param blocksize: read buffer block size in samples, defaults to None - :type blocksize: int, optional - :param default_timeout: default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - - :type show_log: bool, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - - """ - - # fmt:off - def _set_options(self, options, shape, dtype, rate=None, expr=None): ... - def _pre_open(self, ffmpeg_args): ... - def _finalize_output(self, info): ... - # fmt:on - - def __init__( - # fmt:off - self, converter, data_viewer, info_viewer, expr, rate_in, shape_in=None, dtype_in=None, - rate=None, shape=None, dtype=None, blocksize=None, default_timeout=None, - progress=None, show_log=None, sp_kwargs=None, -**options, - # fmt:on - ) -> None: - if not rate_in: - if rate: - rate_in = rate - else: - raise ValueError("Either rate_in or rate must be defined.") - - # :Callable: create a new data block object - self._converter = converter - - # :Callable: get bytes-like object of the data block obj - self._memoryviewer = data_viewer - - # :Callable: get bytes-like object of the data block obj - self._infoviewer = info_viewer - - #:float: default filter operation timeout in seconds - self.default_timeout = default_timeout or 10e-3 - - #:int|Fraction: input sample rate - self.rate_in = rate_in - #:int|Fraction: output sample rate - self.rate = rate - - #:str: input array dtype - self.dtype_in = None - #:tuple(int): input array shape - self.shape_in = None - #:str: output array dtype - self.dtype = None - #:tuple(int): output array shape - self.shape = None - - self.nin = 0 #:int: total number of input samples sent to FFmpeg - self.nout = 0 #:int: total number of output sampless received from FFmpeg - # :float: # of output samples per 1 input sample - self._out2in = None - - # set this to false in _finalize() if guaranteed for the logger to have output stream info - self._loggertimeout = True - - self._proc = None - - ffmpeg_args = configure.empty() - inopts = configure.add_url( - ffmpeg_args, "input", "-", utils.pop_extra_options(options, "_in") - )[1][1] - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - - # configuration process - # 1. during __init__ - # 1.0. set filter - # 1.1. if dtype_in or shape_in is given, deduce the input options - # 1.2. if dtype or shape is given, deduce the output options - # 1.3. if input options are incomplete, defer starting the FFmpeg until - # the first data block is given - # 2. during _open - # 2.1. if data is given (i.e., input was not completely defined) - # 2.1.1. get dtype_in and shape_in from data - # 2.1.2. deduce the input ffmpeg options - # 2.2. start ffmpeg - # 2.3. start reader if dtype & shape are already set - - self.shape_in, self.dtype_in = self._set_options( - inopts, shape_in, dtype_in, rate_in - ) - - self.shape, self.dtype = self._set_options(outopts, shape, dtype, rate, expr) - - # create the stdin writer without assigning the sink stream - self._writer = WriterThread(None, 0) - - # create the stdout reader without assigning the source stream - self._reader = ReaderThread(None, blocksize, 0) - self._reader_needs_info = True - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - # FFmpeg Popen arguments - self._cfg = {**sp_kwargs} if sp_kwargs else {} - self._cfg.update( - { - "ffmpeg_args": ffmpeg_args, - "progress": progress, - "capture_log": True, - } - ) - - # if input is fully configured, start FFmpeg now - if self.shape_in is not None and self.dtype_in is not None: - self._open() - - def _open(self, data=None): - ffmpeg_args = self._cfg["ffmpeg_args"] - - # if data array is given, finalize the FFmpeg configuration with it - if data is not None: - self.shape_in, self.dtype_in = self._set_options( - ffmpeg_args["inputs"][0][1], *self._infoviewer(obj=data) - ) - - # final argument tweak before opening the ffmpeg - self._pre_open(ffmpeg_args) - - # start FFmpeg - self._proc = ffmpegprocess.Popen(**self._cfg) - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - # start the writer - self._writer.stdin = self._proc.stdin - self._writer.start() - - if self.rate is not None and self.dtype is not None and self.shape is not None: - self._reader_needs_info = False - self._start_reader() - self._cfg = False - - def _get_output_info(self, timeout): - # run after the first input block is sent to FFmpeg - try: - info = self._logger.output_stream( - timeout=timeout if self._loggertimeout else None - ) - except TimeoutError as e: - raise e - except Exception as e: - if self._proc.poll() is None: - raise self._logger.Exception - else: - raise ValueError("failed retrieve output data format") - - self._finalize_output(info) - self._reader_needs_info = False - - def _start_reader(self): - self._bps_out = utils.get_samplesize(self.shape, self.dtype) - self._bps_in = utils.get_samplesize(self.shape_in, self.dtype_in) - self._out2in = self.rate / self.rate_in - - # start the FFmpeg output reader - self._reader.itemsize = self._bps_out - self._reader.stdout = self._proc.stdout - self._reader.start() - - self._reader_needs_info = False - - def close(self): - """Close the stream. - - This method has no effect if the stream is already closed. Once the - stream is closed, any read operation on the stream will raise a ThreadNotActive. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - """ - - if self._proc is None: - return - - self._proc.stdout.close() - self._proc.stderr.close() - - # kill the process - try: - self._proc.terminate() - except: - pass - - self._proc.stdin.close() - - try: - self._logger.join() - except: - # possibly close before opening the logger thread - pass - try: - self._reader.join() - except: - # possibly close before opening the reader thread - pass - try: - self._writer.join() - except: - # possibly close before opening the writer thread - pass - - @property - def closed(self): - """:bool: True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError: Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def readlog(self, n=None): - """get FFmpeg log lines - - :param n: number of lines to return, defaults to None (every line logged) - :type n: int, optional - :return: string containing the requested logs - :rtype: str - - """ - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def filter(self, data, timeout=None): - """Run filter operation - - :param data: input data block - :type data: numpy.ndarray - :param timeout: timeout for the operation in seconds, defaults to None - :type timeout: float, optional - :return: output data block - :rtype: numpy.ndarray - - The input `data` array is expected to have the datatype specified by - Filter class' `dtype_in` property and the array shape to match Filter - class' `shape_in` property or with an additional dimension prepended. - - .. important:: - [audio only] For the first 2-seconds or 50000-samples, whichever - is smaller, TimeoutError may be raised because the necessary output - format information is not yet made available from FFmpeg. This - exception, however, only indicate the lack of output data and - the input data can be assumed properly enqueued to be sent to - FFmpeg process - - .. important:: - Once the output format is resolved, this method always return - numpy.ndarray object as output. However, the exact number of - samples is unknown, and it could be a properly shaped empty - array. Additional buffering may be required if the following - process requires a fixed number of samples. - - .. important:: - Filtering operation is always timed because the buffering - protocols used by various subsystems of FFmpeg are undeterminable - from Python. The operation timeout is controlled by `timeout` - argument if specified or else by `default_timeout` property. The - default timeout duration is 10 ms, but it could be optimized for - each use case (`blocksize` property, I/O rate ratio, typical size of - `data` argument, etc.). - - """ - - timeout = timeout or self.default_timeout - - timeout += time() - - if self._cfg: - # if FFmpeg not yet started, finalize the configuration with - # the data and start - self._open(data) - - inbytes = self._memoryviewer(obj=data) - - try: - self._writer.write(inbytes, timeout - time()) - except BrokenPipeError as e: - # TODO check log for error in FFmpeg - raise e - - if self._reader_needs_info: - # with the data written, FFmpeg should inform the output setup - self._get_output_info(timeout - time()) - self._start_reader() - - self.nin += len(inbytes) // self._bps_in - nread = (int(self.nin * self._out2in) - self.nout) * self._bps_out - y = self._reader.read(-nread, timeout - time()) - self.nout += len(y) // self._bps_out - return self._converter(b=y, dtype=self.dtype, shape=self.shape, squeeze=False) - - def flush(self, timeout=None): - """Close the stream input and retrieve the remaining output samples - - :param timeout: timeout duration in seconds, defaults to None - :type timeout: float, optional - :return: remaining output samples - :rtype: numpy.ndarray - """ - - timeout = timeout or self.default_timeout - - # If no input, close stdin and read all remaining frames - y = self._reader.read_all(timeout) - self._proc.stdin.close() - self._proc.wait() - y += self._reader.read_all(None) - self.nout += len(y) // self._bps_out - return self._converter(b=y, dtype=self.dtype, shape=self.shape, squeeze=False) - - -class SimpleVideoFilter(SimpleFilterBase): - """SISO video filter stream class - - .. important:: - Number of output frames is not predetermined although it is - generally close to the expected number of frames based on the - number of input frames and the ratio of input and output frame - rate - - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None - :param rate_in: input frame rate - :type rate_in: int, float, Fraction, str - :param shape_in: input single-frame array shape, defaults to None - :type shape_in: seq of ints, optional - :param dtype_in: input numpy data type, defaults to None - :type dtype_in: str, optional - :param rate: output frame rate, defaults to None (auto-detect) - :type rate: int, float, Fraction, str, optional - :param shape: output single-frame array shape, defaults to None - :type shape: seq of ints, optional - :param dtype: output numpy data type, defaults to None - :type dtype: str, optional - :param blocksize: read buffer block size in frames, defaults to None (=1) - :type blocksize: int, optional - :param default_timeout: default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - :type show_log: bool, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - - """ - - readable = True - writable = True - multi_read = False - multi_write = False - - def __init__( - # fmt:off - self, expr, rate_in, shape_in=None, dtype_in=None, rate=None, shape=None, dtype=None, - blocksize=None, default_timeout=None, progress=None, show_log=None, sp_kwargs=None, -**options, - # fmt:on - ) -> None: - hook = plugins.get_hook() - # fmt:off - super().__init__( - hook.bytes_to_video, hook.video_bytes, hook.video_info, - expr, rate_in, shape_in, dtype_in, rate, shape, dtype, - blocksize, default_timeout, progress, show_log, sp_kwargs,**options, - ) - # fmt:on - self._loggertimeout = False - - def _pre_open(self, ffmpeg_args): - # append basic video filter chain - configure.build_basic_vf( - ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1) - ) - - def _set_options(self, options, shape, dtype, rate=None, expr=None): - if rate: - options["r"] = rate - if expr is not None: - options["vf"] = expr - - options["f"] = "rawvideo" - - if shape is None or dtype is None: - # deduce them from options - if shape is not None or dtype is not None: - logger.warn( - "[SimpleVideoFilter] both dtype and shape must be defined for the arguments to take effect." - ) - - try: - dtype, shape = utils.get_video_format(options["pix_fmt"], options["s"]) - except: - return None, None - else: - options["s"], options["pix_fmt"] = utils.guess_video_format(shape, dtype) - - return shape, dtype - - def _finalize_output(self, info): - # finalize array setup from FFmpeg log - self.rate = info["r"] - self.dtype, self.shape = utils.get_video_format(info["pix_fmt"], info["s"]) - - -class SimpleAudioFilter(SimpleFilterBase): - """SISO audio filter stream class - - .. important:: - If the total duration of the stream is less than 2 seconds, use - :py:func:`audio.filter` function instead. FFmpeg does not start - the filtering process until about 2-seconds or about 50000-samples - worth of data are first accumulated. No output data will be produced - during this initial accumulation period. - - .. important:: - The exact number of output samples after each :py:meth:`filter` - call is not known and can be zero. - - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None - :param rate_in: input sample rate - :type rate_in: int, float, Fraction, str - :param shape_in: input single-sample array shape, defaults to None - :type shape_in: seq of ints, optional - :param dtype_in: input numpy data type, defaults to None - :type dtype_in: str, optional - :param rate: output sample rate, defaults to None (auto-detect) - :type rate: int, float, Fraction, str, optional - :param shape: output single-sample array shape, defaults to None - :type shape: seq of ints, optional - :param dtype: output numpy data type, defaults to None - :type dtype: str, optional - :param blocksize: read buffer block size in samples, defaults to None (=>1024) - :type blocksize: int, optional - :param default_timeout: default filter timeout in seconds, defaults to None (100 ms) - :type default_timeout: float, optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - :type show_log: bool, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - - ..note:: - Use of larger `blocksize` parameter could improve the processing speed - - """ - - readable = True - writable = True - multi_read = False - multi_write = False - - def __init__( - self, - expr, - rate_in, - shape_in=None, - dtype_in=None, - rate=None, - shape=None, - dtype=None, - blocksize=None, - default_timeout=None, - progress=None, - show_log=None, - sp_kwargs=None, - **options, - ) -> None: - hook = plugins.get_hook() - # fmt: off - super().__init__(hook.bytes_to_audio, hook.audio_bytes, hook.audio_info, - expr, rate_in, shape_in, dtype_in, rate, shape, dtype, - blocksize, default_timeout, progress, show_log, sp_kwargs,**options) - # fmt: on - - def _pre_open(self, ffmpeg_args): - if self.dtype is None: - inopts = ffmpeg_args["inputs"][0][1] - outopts = ffmpeg_args["outputs"][0][1] - sample_fmt = outopts["sample_fmt"] = inopts["sample_fmt"] - outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) - - def _set_options(self, options, shape, dtype, rate=None, expr=None): - if rate: - options["ar"] = rate - if expr is not None: - options["af"] = expr - - if shape is None: - try: - shape = (options["ac"],) - except: - shape = None - else: - options["ac"] = shape[-1] - - if dtype is None: - try: - dtype, _ = utils.get_audio_format(options["sample_fmt"]) - except: - dtype = None - else: - options["sample_fmt"], _ = utils.guess_audio_format(dtype) - options["c:a"], options["f"] = utils.get_audio_codec(options["sample_fmt"]) - - return shape, dtype - - def _finalize_output(self, info): - # finalize array setup from FFmpeg log - self.rate = info["ar"] - self.dtype, self.shape = utils.get_audio_format(info["sample_fmt"], info["ac"]) - - @property - def channels(self): - """:int: Number of output channels (None if not yet determined)""" - return self.shape and self.shape[-1] - - @property - def channels_in(self): - """:int: Number of input channels (None if not yet determined)""" - return self.shape_in and self.shape_in[-1] diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index 861ebda0..248c90b5 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -1,18 +1,33 @@ -from .SimpleStreams import ( - SimpleVideoReader, - SimpleVideoWriter, - SimpleAudioReader, - SimpleAudioWriter, - SimpleVideoFilter, - SimpleAudioFilter, +"""media streamer classes + +=============== ===================== ==================== +Class Name Input(s) Output(s) +=============== ===================== ==================== +SimpleReader multiple urls single audio/video +SimpleWriter single audio/video single url + +MediaReader multiple urls/encoded multiple audio/video +MediaWriter multiple audio/video multiple urls/encoded +MediaTranscoder multiple encoded multiple encoded +SISOMediaFilter single audio/video single audio/video +MISOMediaFilter multiple audio/video single audio/video +SIMOMediaFilter single audio/video multiple audio/video +MIMOMediaFilter multiple audio/video multiple audio/video +=============== ==================== ==================== +""" + +from .BaseFFmpegRunner import ( + BaseFFmpegRunner, + PipedFFmpegRunner, + SISOFFmpegFilter, + StdFFmpegRunner, ) -from .AviStreams import AviMediaReader +from .open import open # TODO multi-stream write # TODO Buffered reverse video read # fmt: off -__all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", - "AviMediaReader"] +__all__ = ['StdFFmpegRunner', 'PipedFFmpegRunner', 'BaseFFmpegRunner', + "SISOFFmpegFilter", "open"] # fmt: on diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py new file mode 100644 index 00000000..8c16e497 --- /dev/null +++ b/src/ffmpegio/streams/open.py @@ -0,0 +1,1480 @@ +from __future__ import annotations + +"""Open a multimedia file/stream for read/write + +:param url_fg: URL of the media source/destination for file read/write or filtergraph definition + for filter operation. +:type url_fg: str or seq(str) +:param mode: specifies the mode in which the FFmpeg is used, see below +:type mode: str + +Start FFmpeg and open I/O link to it to perform read/write/filter operation and return +a corresponding stream object. If the file cannot be opened, an error is raised. +See :ref:`quick-streamio` for more examples of how to use this function. + +Just like built-in `open()`, it is good practice to use the with keyword when dealing with +ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are +properly closed after ffmpeg terminates, even if an exception is raised at some point. +Using with is also much shorter than writing equivalent try-finally blocks. + +:Examples: + +Open an MP4 file and process all the frames:: + + with ffmpegio.open('video_source.mp4', 'rv') as f: + frame = f.read() + while frame: + # process the captured frame data + frame = f.read() + +Read an audio stream of MP4 file and write it to a FLAC file as samples +are decoded:: + + with ffmpegio.open('video_source.mp4','ra') as rd: + fs = rd.sample_rate + with ffmpegio.open('video_dst.flac','wa',input_rate=fs) as wr: + frame = rd.read() + while frame: + wr.write(frame) + frame = rd.read() + +:Additional Notes: + +`urls_fgs` can be a string specifying either the path name (absolute or relative to the current +working directory) of the media target (file or streaming media) to be opened or a string describing +the filtergraph to be implemented. Its interpretation depends on the `mode` argument. + +`mode` is an optional string that specifies the mode in which the FFmpeg is opened. + +==== ======================================================= +Mode Description +==== ======================================================= +'r' read from encoded url/file/stream +'w' write to encoded url/file/stream +'f' filter data defined by fg +'t' transcode data +'->' I/O operator +'v' operate on video stream, 'vv' if multiple video streams +'a' operate on audio stream, 'aa' if multiple audio streams +'e' encoded data stream, 'ee' if multiple encoded streams +==== ======================================================= + +Each mode string is has one and only one operation specifier +(`'r'`, `'w'`, `'f'`, `'t'`, or `'->'`). For the operators `'rwf'`, accompany +them with a combination of the media specifiers `'v'` and `'a'` (repeated as +necessary). For the `'r'` operation, media specifiers specify the output +streams while they specify the input streams for `'w'` and `'f'`. + +""" + +"""`open()` module + +`rate` and `input_rate`: Video frame rates shall be given in frames/second and +may be given as a number, string, or `fractions.Fraction`. Audio sample rate in +samples/second (per channel) and shall be given as an integer or string. + +Optional `shape` or `input_shape` for video defines the video frame size and +number of components with a 2 or 3 element sequence: `(width, height[, ncomp])`. +The number of components and other optional `dtype` (or `input_dtype`) implicitly +define the pixel format (FFmpeg pix_fmt option): + +===== ===== ========= =================================== +ncomp dtype pix_fmt Description +===== ===== ========= =================================== + 1 \|u8 gray grayscale + 1 [va]{2,}'`` specify the input and output media types +========================= ================================================ +""" + +DecoderModeLiteral = LiteralString +"""decoder mode + +To configure FFmpeg as a decoder (encoded input, raw output), use + +================ ================================= +mode (regexp) description +================ ================================= +``'e+-\>[va]+'`` repeat ``'e'`` if multiple inputs +================ ================================= + +For example, ``'ee->vva'`` takes 2 encoded input streams and produces 3 raw +media output streams (video, video, audio) +""" + +EncoderModeLiteral = LiteralString +"""encoder mode + +To configure FFmpeg as an encoder (raw input, encoded output), use + +================ ================================== +mode (regexp) description +================ ================================== +``'[va]+-\>e+'`` repeat ``'e'`` if multiple outputs +================ ================================== + +For example, ``'vva->ee'`` takes 2 3 raw media output streams (video, video, +audio) and produces encoded input streams +""" + +TranscoderModeLiteral = LiteralString +"""transcoder mode + +To specify FFmpeg to transcode, use + +============= ========================================= +mode (regexp) description +============= ========================================= +``'e+-\>e+'`` repeat ``'e'`` if multiple inputs/outputs +============= ========================================= +""" + +MultiReaderModeLiteral = LiteralString +"""multiple-output reader mode + +To specify reading all media streams or multiple-streams, use + +============== ========================= +mode (regexp) description +============== ========================= +``'r'`` read all streams +``'r[va]{2}'`` read more than one stream +============== ========================= +""" + + +@overload +def open( + urls_fgs: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + mode: Literal["rv", "ra"], + /, + *, + map: str | None = None, + squeeze: bool = False, + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + | None = None, + blocksize: int | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> StdFFmpegRunner: + """open a single-stream reader + + :param urls_fgs: Specify encoded input file(s)/devices/filters in one of the + following styles: + + - an input file url or other input stream/device supported by FFmpeg + - a Python readable file object + - an ``ffmpegio`` input format/device class object + (e.g., ``FFConcat``) + - an FFmpeg input filtergraph expression or + ``ffmpegio.FilterGraphObject`` + - a pair of the url/filtergraph and a dict of FFmpeg input options + - a sequence of the urls/filtergraphs or the pairs, or a mixture + thereof. Use multiple inputs only to supply the data to a complex + filtergraph combining multiple streams into one. + + :param urls_fgs: URL string of the file or format/device object. It can be + an input filtergraph object or other input ffmpegio objects. The input + could also be fed by a readable file object. Multiple input sources + could also be assigned to feed a complex filtergraph. + :param mode: ``'rv'`` to read video data or ``'ra'`` to read audio + :param map: FFmpeg map output option, defaults to ``"0:v:0"`` for video and + ``"0:a:0"`` for audio. The map option is required if ``options`` + contains the ``filter_complex`` option. + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param blocksize: Read block size (in frames for video or samples in audio) + when the reader object is used as an iterator + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: a reader stream object + """ + + +@overload +def open( + urls_fgs: FFmpegOutputUrlNoPipe + | FFmpegNoPipeOutputOptionTuple + | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], + mode: Literal["wv", "wa"], + /, + input_rate: int | Fraction, + *, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + overwrite: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> StdFFmpegRunner: + """open a single-stream media writer + + :param urls_fgs: Specify encoded output file(s) in one of the following + styles: + + - an output file url + - a pair of an output url and a dict of FFmpeg output options + - a sequence of the urls or the pairs or a mixture thereof. This + commands FFmpeg to generate multiple files simultaneously from + the same input streams. + + :param mode: ``'wv'`` to create a media file from a raw video stream or + ``'wa'`` from a raw audio stream. + :param input_rate: input frame rate in frames/second (video) or sampling + rate in samples/second (audio) + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param input_shape: input video frame size (height, width) or number of + input audio channel, defaults to auto-detect + :param input_dtype: input data format in a Numpy dtype string, defaults to + auto-detect + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: str | FilterGraphObject | Literal["-"], + mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], + /, + input_rate: int | Fraction, + *, + squeeze: bool = False, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> SISOFFmpegFilter: + """open a single-input single-output media filter + + :param urls_fgs: Specify the filtergraph to be used with an FFmpeg + filtergraph expression or an ``ffmpegio.FilterGraphObject`` object. Use + ``'-'`` if the filtering is implicitly specified via output options + (such as rate or format changes). + :param mode: Specify the SISO filter mode by one of the following: + + - ``'fv'`` or ``'v->v'`` to take a video stream and produce a video stream + - ``'fa'`` or ``'a->a'`` to take an audio stream and produce an audio stream + - ``'v->a'`` to take a video stream and produce an audio stream + - ``'a->v'`` to take an audio stream and produce a video stream + + Note that currently the output stream media types are not checked. + :param input_rate: Input frame rate (video) or sampling rate (audio) + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param input_shape: input video frame size (height, width) or number of + input audio channels, defaults to None (auto-detect) + :param input_dtype: input data format as a Numpy dtype string, defaults to + None (auto-detect) + :param blocksize: Read queue item size of the in frames for video or samples + in audio Read block size. This size is also used when the reader object + is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: audio writer stream object + + """ + + +@overload +def open( + urls_fgs: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + mode: MultiReaderModeLiteral, + /, + *, + output_streams: Sequence[MapString | FFmpegOptionDict] | None = None, + squeeze: bool = False, + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a multi-stream reader + + :param urls_fgs: Specify encoded input file(s)/devices/filters in one of the + following styles: + + - an input file url or other input stream/device supported by FFmpeg + - a Python readable file object + - an ``ffmpegio`` input format/device class object + (e.g., ``FFConcat``) + - an FFmpeg input filtergraph expression or + ``ffmpegio.FilterGraphObject`` + - a pair of the url/filtergraph and a dict of FFmpeg input options + - a sequence of the urls/filtergraphs or the pairs, or a mixture + thereof. + + :param mode: Specify the multi-stream reader by one of the following: + + - ``'r'`` to include all the streams in the input urls or all the + outputs of the complex filtergraphs. + - ``'r'`` followed by a mixture of ``'v'`` and ``'a'`` to specify the + number of streams to read and their media types, e.g., ``'rvva'`` + reads two video streams and an audio stream. + + :param output_streams: output stream options: + + - `None` to auto-select. If ``mode='r'`` then all input streams (or + all filtergraph outputs) are selected. If ``mode`` specifies the + number of streams, then the streams are selected in their stream + indices if only one url is specified without any complex filtergraph. + - a sequence of str to specify map output option + - a sequence of output option dict with `'map'` item to output-specific + options + - a dict with map specifier or user keys to specify output options, + again to specify output-specific options. The keys will be used + as the keys of the raw data output, and can be different from + the `'map'` option so long as the `'map'` option is given in the + dict. + + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param primary_output: Index of a raw media output stream which serves as + the reference frame to sync all output streams, + defaults to ``0``. + :param blocksize: Read queue item size of the primary output stream in + frames for video or samples in audio Read block size. This size is also + used when the reader object is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: reader stream object + """ + + +@overload +def open( + urls_fgs: FFmpegOutputUrlNoPipe + | FFmpegNoPipeOutputOptionTuple + | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], + mode: MultiWriterModeLiteral, + /, + input_rates: list[int | Fraction], + *, + input_options: Sequence[FFmpegOptionDict] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + input_shapes: Sequence[ShapeTuple] | None = None, + input_dtypes: Sequence[DTypeString] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + overwrite: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a multi-stream media writer + + :param urls_fgs: Specify encoded output file(s) in one of the following + styles: + + - an output file url + - a pair of an output url and a dict of FFmpeg output options + - a sequence of the urls or the pairs or a mixture thereof. This + commands FFmpeg to generate multiple files simultaneously from + the same input streams. + + :param mode: Specify the multi-stream writer by one of the following: + + - ``'w'`` to set the input streams solely by ``input_options`` argument + - ``'w'`` followed by a mixture of ``'v'`` and ``'a'`` to specify the + number of streams to write and their media types, e.g., ``'wvva'`` + writes two video streams and an audio stream to the url. + + :param input_rates: list of input frame rates (video) and sampling rates + (audio) + :param input_options: Specify per-stream FFmpeg options of the raw input + streams. These option values are added to the default input options + specified in ``options``. If ``input_rates`` is not provided, the frame/ + sampling rate keys (i.e., ``'r'`` or ``'ar'``) must be present. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param input_shapes: input video frame sizes (height, width) or a number of + input audio channels, defaults to auto-detect. 'input_dtypes' must also + be specified for this argument to be processed. + :param input_dtypes: input data format as a Numpy dtype string, defaults to + auto-detect. 'input_shapes' must also be specified for this argument to + be processed. + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: str | FilterGraphObject | list[str | FilterGraphObject] | Literal["-"], + mode: MIMOFilterModeLiteral, + /, + input_rates: list[int | Fraction], + *, + input_options: Sequence[FFmpegOptionDict] | None = None, + output_streams: Sequence[MapString | FFmpegOptionDict] + | dict[str, MapString | FFmpegOptionDict] + | None = None, + squeeze: bool = False, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + input_shapes: list[ShapeTuple] | None = None, + input_dtypes: list[DTypeString] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """Open a multiple-input-multiple-output media filter + + :param urls_fgs: Specify the filtergraph to be used with an FFmpeg + filtergraph expression or an ``ffmpegio.FilterGraphObject`` object. Use + ``'-'`` if the filtering is implicitly specified via output options + (such as rate or format changes). If multiple complex filtergraphs are + needed, provide them as a list. + :param mode: Specify MIMO filter mode by one of the following: + + - ``'f'`` to auto-detect the numbers of input and output streams + - ``'f'`` followed by a mixture of ``'v'`` and ``'a'`` to specify the + number of input streams and their media types, e.g., ``'fvva'`` + takes two video input streams and an audio input stream. The output + stream is auto-detected. + - an arrow notation (``'->'``) with its input and output each specified + by a mixture of ``'v'`` and ``'a'``. For example, ``'vva->v'`` takes + two video input streams and an audio input stream to produce one video + stream. Note the output designators are currently not checked. + + :param input_rates: list of input frame rates (video) and sampling rates + (audio) + :param input_options: Specify per-stream FFmpeg options of the raw input + streams. These option values are added to the default input options + specified in ``options``. If ``input_rates`` is not provided, the frame/ + sampling rate keys (i.e., ``'r'`` or ``'ar'``) must be present. + :param output_streams: output stream options: + + - `None` to auto-select. If ``mode='r'`` then all input streams (or + all filtergraph outputs) are selected. If ``mode`` specifies the + number of streams, then the streams are selected in their stream + indices if only one url is specified without any complex filtergraph. + - a sequence of str to specify map output option + - a sequence of output option dict with `'map'` item to output-specific + options + - a dict with map specifier or user keys to specify output options, + again to specify output-specific options. The keys will be used + as the keys of the raw data output, and can be different from + the `'map'` option so long as the `'map'` option is given in the + dict. + + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param input_shapes: input video frame sizes (height, width) or a number of + input audio channels, defaults to auto-detect. 'input_dtypes' must also + be specified for this argument to be processed. + :param input_dtypes: input data format as a Numpy dtype string, defaults to + auto-detect. 'input_shapes' must also be specified for this argument to + be processed. + :param primary_output: Index of a raw media output stream which serves as + the reference frame to sync all output streams, + defaults to ``0``. + :param blocksize: Read queue item size of the primary output stream in + frames for video or samples in audio Read block size. This size is also + used when the reader object is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: reader stream object + """ + + +@overload +def open( + urls_fgs: Literal["-"], + mode: DecoderModeLiteral, # r"e+-\>[av]+", + /, + *, + output_streams: Sequence[MapString | FFmpegOptionDict] + | dict[str, MapString | FFmpegOptionDict] + | None = None, + squeeze: bool = False, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a media decoder (encoded streams in, raw streams out) + + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: Specify the decoder mode by an arrow notation (``'->'``) with + its input by a repeated ``'e'`` and output by a mixture of ``'v'`` and + ``'a'``. For example, ``'ee->vva'`` takes two encoded media streams and + produces two video output streams and an audio output stream. + :param output_streams: output stream options: + + - `None` to auto-select. If ``mode='r'`` then all input streams (or + all filtergraph outputs) are selected. If ``mode`` specifies the + number of streams, then the streams are selected in their stream + indices if only one url is specified without any complex filtergraph. + - a sequence of str to specify map output option + - a sequence of output option dict with `'map'` item to output-specific + options + - a dict with map specifier or user keys to specify output options, + again to specify output-specific options. The keys will be used + as the keys of the raw data output, and can be different from + the `'map'` option so long as the `'map'` option is given in the + dict. + + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param primary_output: Index of a raw media output stream which serves as + the reference frame to sync all output streams, + defaults to ``0``. + :param blocksize: Read queue item size of the primary output stream in + frames for video or samples in audio Read block size. This size is also + used when the reader object is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: Literal["-"], + mode: EncoderModeLiteral, + /, + input_rates: list[int | Fraction], + *, + input_options: list[FFmpegOptionDict] | None = None, + output_options: list[FFmpegOptionDict], + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + input_shapes: list[ShapeTuple] | None = None, + input_dtypes: list[DTypeString] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: FFmpegOptionDict, +) -> PipedFFmpegRunner: + """open a media encoder (raw streams in, encoded streams out) + + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: Specify the encoder mode by an arrow notation (``'->'``) with + its input by a mixture of ``'v'`` and ``'a'`` and output by a repeated + ``'e'``. For example, ``'vva->ee'`` takes two video input streams and + an audio input stream to produce two encoded media streams. + :param input_rates: list of input frame rates (video) and sampling rates + (audio) + :param input_options: Specify per-stream FFmpeg options of the raw input + streams. These option values are added to the default input options + specified in ``options``. If ``input_rates`` is not provided, the frame/ + sampling rate keys (i.e., ``'r'`` or ``'ar'``) must be present. + :param output_options: Specify per-stream FFmpeg options of the encoded + output streams. These option values are added to the default output + options specified in ``options``. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param input_shapes: input video frame sizes (height, width) or a number of + input audio channels, defaults to auto-detect. 'input_dtypes' must also + be specified for this argument to be processed. + :param input_dtypes: input data format as a Numpy dtype string, defaults to + auto-detect. 'input_shapes' must also be specified for this argument to + be processed. + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: Literal["-"], + mode: TranscoderModeLiteral, # r"e+-\>e+", + /, + *, + input_options: list[FFmpegOptionDict] | None = None, + output_options: list[FFmpegOptionDict] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a media transcoder (encoded streams in, encoded streams out) + + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: Specify the transcoder mode by an arrow notation (``'->'``) + with its inputs and outputs each by a repeated ``'e'``. For example, + ``'e->ee'`` transcodes one encoded stream to two encoded streams. + :param input_options: Specify per-stream FFmpeg options of the encoded input + streams. These option values are added to the default input options + specified in ``options``. + :param output_options: Specify per-stream FFmpeg options of the encoded + output streams. These option values are added to the default output + options specified in ``options``. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: writer stream object + + """ + + +def open( + urls_fgs, + mode, + /, + *args, + **kwargs, +) -> PipedFFmpegRunner | SISOFFmpegFilter | StdFFmpegRunner: + + # possible keywords, excluding FFmpeg options + # 'input_shape', 'input_dtype', 'input_rate', 'input_rates', + # 'input_options', 'input_dtypes', 'input_shapes', 'extra_inputs', + # 'output_streams', 'extra_outputs', 'squeeze' + + op_mode, in_types, out_types = _parse_mode(mode) + if urls_fgs == "-" and op_mode in "rw": + raise ValueError( + f"{'Input of a reader' if op_mode == 'r' else 'Output of a writer'} cannot be piped ('-'). Provide at least one URL." + ) + + runner_kws = { + k: kwargs.pop(k) + for k in ( + "input_shape", + "input_dtype", + "input_shapes", + "input_dtypes", + "primary_output", + "blocksize", + "enc_blocksize", + "queuesize", + "timeout", + "progress", + "show_log", + "overwrite", + "sp_kwargs", + ) + if k in kwargs + } + + if op_mode in "rdt" and len(args): + raise TypeError( + "Too many positional arguments. Only 2 positional arguments are allowed for reader/decoder/transcoder." + ) + + if op_mode == "r": + runner = _open_reader(out_types, urls_fgs, kwargs, runner_kws) + elif op_mode == "w": + runner = _open_writer(in_types, urls_fgs, args, kwargs, runner_kws) + elif op_mode == "f": + runner = _open_filter(in_types, out_types, urls_fgs, args, kwargs, runner_kws) + elif op_mode == "d": + runner = _open_decoder( + len(in_types), out_types, urls_fgs, args, kwargs, runner_kws + ) + elif op_mode == "e": + runner = _open_encoder( + in_types, len(out_types), urls_fgs, args, kwargs, runner_kws + ) + else: + runner = _open_transcoder( + len(in_types), len(out_types), urls_fgs, args, kwargs, runner_kws + ) + + return runner + + +def _parse_mode(mode: str) -> tuple[Literal["r", "w", "f", "d", "e", "t"], str, str]: + """parse operating mode literal string + + :return op_mode: operating mode character + :return input_types: input stream type sequence + :return output_types: output stream type sequence + """ + m = re.fullmatch( + r"(t)|([av]*?)([rwfed])([av]*?)|((?:[av]+|e+))-\>((?:[av]+|e+))", mode + ) + if m is None: + raise ValueError(f"{mode=} is an invalid operation mode specifier") + + op_mode = m[1] or m[3] + + if op_mode is not None: + inputs = m[2] or "" + outputs = m[4] or "" + if op_mode == "t": + inputs = outputs = "e" + elif op_mode in "efw": + # writer & (single-output) decoder -> output media types + inputs = inputs + outputs + outputs = "e" if op_mode == "e" else "" + else: + # others -> input media types + outputs = inputs + outputs + inputs = "e" if op_mode == "d" else "" + else: # arrow convention + inputs = m[5] or "" + outputs = m[6] or "" + in_encoded = all(c == "e" for c in inputs) + out_encoded = all(c == "e" for c in outputs) + op_mode = { + (False, False): "f", + (False, True): "e", + (True, False): "d", + (True, True): "t", + }[(in_encoded, out_encoded)] + + return op_mode, inputs, outputs + + +def _open_kws_set() -> list[str]: + return set( + [ + "input_shape", + "input_dtype", + "input_rate", + "input_rates", + "input_options", + "input_dtypes", + "input_shapes", + "extra_inputs", + "output_streams", + "extra_outputs", + "squeeze", + ] + ) + + +def _process_raw_input_args( + in_types: str, args: tuple, kwargs: dict +) -> tuple[ + set[str], + bool, + list[FFmpegOptionDict], + Sequence[str | tuple[str, FFmpegOptionDict]] | None, +]: + """process raw input arguments + + :param in_types: input media type sequence + :param args: positional arguments (3rd-) + :param kwargs: keyword arguments + :return used_kws: popped keyword arguments + :return signel_input: True if only one input stream + :return input_options: list of per-stream ffmpeg input options + :return extra_inputs: keyword arguemnt to define extra inputs + """ + nargs = len(args) + if nargs > 1: + raise TypeError( + f"ffmpegio.open() takes two to three positional arguments ({2 + len(args)} given) to open a writer" + ) + + input_options = kwargs.pop("input_options", None) + extra_inputs = kwargs.pop("extra_inputs", None) + used_kws = {"extra_inputs"} + single_input = len(in_types) == 1 # + + # establish input_options + if single_input: + input_rate = kwargs.pop("input_rate", None) + used_kws.add("input_rate") + if nargs: + if input_rate is None: + input_rate = args[0] + else: + raise TypeError("'input_rate' specified multiple times") + + rate_opt = {"ar" if in_types[0] == "a" else "r": input_rate} + input_options = [ + rate_opt if input_options is None else {**input_options, **rate_opt} + ] + + else: + input_rates = kwargs.pop("input_rates", None) + used_kws.add("input_rates") + input_options = kwargs.pop("input_options", None) + used_kws.add("input_options") + + if nargs: + if input_rates is None: + input_rates = args[0] + else: + raise TypeError("'input_rates' specified multiple times") + + if len(in_types) == 0: + # expects input_options to define the rates + if input_rates is not None and input_options is None: + raise ValueError("Cannot resolve the input streams.") + elif input_options is None: + input_options = [ + {"ar" if mtype == "a" else "r": r} + for mtype, r in zip(in_types, input_rates) + ] + else: + input_options = [ + {**opts, "ar" if mtype == "a" else "r": r} + for mtype, r, opts in zip(in_types, input_rates, input_options) + ] + + return used_kws, single_input, input_options, extra_inputs + + +def _process_raw_output_args( + out_types: Sequence[Literal["a", "v"]], kwargs: dict, nin: int +) -> tuple[ + set[str], + bool, + list[FFmpegOptionDict], + Sequence[FFmpegOutputOptionTuple] | None, + bool, +]: + """process arguments for raw output options + + :param out_types: output media sequence + :param kwargs: keyword arguments + :param nin: number of input urls + :return used_kws: set of keyword arguments consumed + :return single_output: True if single output + :return output_streams: ffmpeg output options + :return extra_outputs: extra output urls (+options) + :return squeeze: True to squeeze raw output blobs + """ + nout = len(out_types) + single_output = nout == 1 # single encoded stream + + output_streams = None + extra_outputs = kwargs.pop("extra_outputs", None) + squeeze = kwargs.pop("squeeze", None) + + used_kws = set(["extra_outputs", "squeeze"]) + if single_output: + used_kws.add("output_streams") + else: + output_streams = kwargs.pop("output_streams", None) + + if isinstance(output_streams, (str, dict)): + output_streams = [output_streams] + + if len(out_types) == 0: # autodetect + single_output = False # -> multi-output + else: + # resolve the output streams + nout = len(out_types) + + if output_streams is None: + output_streams = [{} for _ in range(nout)] + elif nout != len(output_streams): + raise ValueError( + "number of outputs in mode does not match the number of output options specified." + ) + else: + output_streams = [ + {**s} if isinstance(s, dict) else s for s in output_streams + ] + + if ( + "map" not in kwargs + and nin == 1 + and not utils.find_filter_complex_option(kwargs) + ): + stream_counts = {"a": 0, "v": 0} + for mtype, opts in zip(out_types, output_streams): + st = stream_counts[mtype] + stream_counts[mtype] += 1 + + if isinstance(opts, dict) and "map" not in opts: + opts["map"] = f"0:{mtype}:{st}" + return used_kws, single_output, output_streams, extra_outputs, squeeze + + +def _open_reader( + out_types: str, + urls: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + kwargs: dict, + runner_kws: dict, +) -> StdFFmpegRunner | PipedFFmpegRunner: + + # need to resolve if multiple input urls are given + urls = [urls] if utils.is_valid_input_url(urls) or isinstance(urls, tuple) else urls + nin = len(urls) + + used_kws, single_output, output_streams, extra_outputs, squeeze = ( + _process_raw_output_args(out_types, kwargs, nin) + ) + + open_kws = _open_kws_set() - used_kws + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the reader mode.") + + return ( + StdFFmpegRunner.open_simple_reader( + urls, + output_streams[0], + kwargs, + squeeze, + extra_outputs, + **runner_kws, + ) + if single_output + else PipedFFmpegRunner.open_media_reader( + urls, output_streams, kwargs, squeeze, extra_outputs, **runner_kws + ) + ) + + +def _open_writer( + in_types: str, + urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner | StdFFmpegRunner: + + used_kws, single_input, input_options, extra_inputs = _process_raw_input_args( + in_types, args, kwargs + ) + + open_kws = _open_kws_set() - used_kws + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + # insert default output mapping + if "map" not in kwargs and utils.find_filter_complex_option(kwargs) is None: + kwargs["map"] = [f"{i}:{mtype}:0" for i, mtype in enumerate(in_types)] + + return ( + StdFFmpegRunner.open_simple_writer( + urls, + input_options[0], + kwargs, + extra_inputs, + **runner_kws, + ) + if single_input + else PipedFFmpegRunner.open_media_writer( + urls, + input_options, + kwargs, + extra_inputs, + **runner_kws, + ) + ) + + +def _open_filter( + in_types: str, + out_types: str, + fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> SISOFFmpegFilter: + + used_kws, single_input, input_options, extra_inputs = _process_raw_input_args( + in_types, args, kwargs + ) + open_kws = _open_kws_set() - used_kws + + used_kws, single_output, output_streams, extra_outputs, squeeze = ( + _process_raw_output_args(out_types, kwargs, len(in_types)) + ) + open_kws -= used_kws + + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the filter mode.") + + single = single_input and (single_output or output_streams is None) + + if fgs is not None and fgs != "-": + kwargs["filter_complex"] = fgs + + return ( + SISOFFmpegFilter.create_and_open( + input_options[0], + output_streams and output_streams[0], + kwargs, + squeeze, + extra_inputs, + extra_outputs, + **runner_kws, + ) + if single + else PipedFFmpegRunner.open_media_filter( + input_options, + output_streams, + kwargs, + squeeze, + extra_inputs, + extra_outputs, + **runner_kws, + ) + ) + + +def _open_decoder( + nb_in: int, + out_types: str, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner: + + if urls != "-": + raise TypeError("urls_fgs argument for a decoder must be '-'.") + + if len(args): + raise TypeError( + "ffmpegio.open() does not take more than 2 positional arguments for a decoder." + ) + + used_kws, _, output_streams, extra_outputs, squeeze = _process_raw_output_args( + out_types, kwargs, nb_in + ) + open_kws = _open_kws_set() - used_kws + + input_options = kwargs.pop("input_options", None) + + if input_options is None: + input_options = [{} for i in range(nb_in)] + elif nb_in > 0 and len(input_options) != nb_in: + raise ValueError( + "the length of 'input_options' must match the number of encoded inputs" + ) + extra_inputs = kwargs.pop("extra_inputs", None) + + open_kws -= {"input_options", "extra_inputs"} + + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the decoder mode.") + + return PipedFFmpegRunner.open_media_decoder( + input_options, + output_streams, + kwargs, + squeeze, + extra_inputs, + extra_outputs, + **runner_kws, + ) + + +def _open_encoder( + in_types: str, + nb_out: int, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner: + + if urls != "-": + raise TypeError("urls_fgs argument for an encoder must be '-'.") + + used_kws, _, input_options, extra_inputs = _process_raw_input_args( + in_types, args, kwargs + ) + open_kws = _open_kws_set() - used_kws + + output_options = kwargs.pop("output_options", None) + + if output_options is None: + output_options = [{} for i in range(nb_out)] + elif nb_out > 0 and len(output_options) != nb_out: + raise ValueError( + "the length of 'input_options' must match the number of encoded inputs" + ) + extra_outputs = kwargs.pop("extra_outputs", None) + + open_kws -= {"output_options", "extra_outputs"} + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the encoder mode.") + + return PipedFFmpegRunner.open_media_encoder( + input_options, output_options, kwargs, extra_inputs, extra_outputs, **runner_kws + ) + + +def _open_transcoder( + nb_in: int, + nb_out: int, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner: + + if urls != "-": + raise TypeError("urls_fgs argument for a decoder must be '-' for a transcoder.") + + if len(args): + raise TypeError( + "ffmpegio.open() takes only two positional arguments in a transcoder." + ) + + input_options = kwargs.pop("input_options", None) or [] + if len(input_options) == 0: + input_options = [{} for i in range(nb_in)] + elif nb_in > 0 and len(input_options) != nb_in: + raise ValueError( + f"input_options argument must have {nb_in} elements to match the specified transcoder mode." + ) + + output_streams = kwargs.pop("output_streams", None) or [] + if len(output_streams) == 0: + output_streams = [{} for i in range(nb_out)] + elif nb_out > 0 and len(output_streams) != nb_out: + raise ValueError( + f"output_streams argument must have {nb_out} elements to match the specified transcoder mode." + ) + + extra_inputs = kwargs.pop("extra_inputs", None) + extra_outputs = kwargs.pop("extra_outputs", None) + + used_kws = {"input_options", "output_options", "extra_inputs", "extra_outputs"} + open_kws = _open_kws_set() - used_kws + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid transcoder keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the transcoder mode.") + + return PipedFFmpegRunner.open_media_transcoder( + input_options, output_streams, kwargs, extra_inputs, extra_outputs, **runner_kws + ) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 2514ffe8..5f76171e 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -1,29 +1,38 @@ -"""collection of thread classes for handling FFmpeg streams -""" +"""collection of thread classes for handling FFmpeg streams""" from __future__ import annotations -from copy import deepcopy -import re, os -from threading import Thread, Condition, Lock, Event + +import logging +import os +import re from io import TextIOBase, TextIOWrapper -from time import sleep, time -from tempfile import TemporaryDirectory from queue import Empty, Full, Queue -from math import ceil -import logging +from shutil import copyfileobj +from tempfile import TemporaryDirectory +from threading import Condition, Event, Lock, Thread +from time import sleep, time +from typing import BinaryIO -logger = logging.getLogger("ffmpegio") +from namedpipe import NPopen -from .utils.avi import AviReader -from .utils.log import extract_output_stream as _extract_output_stream from .errors import FFmpegError +from .utils.log import extract_output_stream as _extract_output_stream + +logger = logging.getLogger("ffmpegio") + # fmt:off -__all__ = ['AviReader', 'FFmpegError', 'ThreadNotActive', 'ProgressMonitorThread', - 'LoggerThread', 'ReaderThread', 'WriterThread', 'AviReaderThread', 'Empty', 'Full'] +__all__ = ['FFmpegError', 'ThreadNotActive', 'ProgressMonitorThread', + 'LoggerThread', 'ReaderThread', 'WriterThread', 'Empty', 'Full'] # fmt:on +class NotEmpty(Exception): + "Exception raised by WriterThread.flush(timeout) if timedout." + + pass + + class ThreadNotActive(RuntimeError): pass @@ -66,6 +75,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): self.join() + return False def run(self): callback, tempdir, timeout = self._args @@ -151,7 +161,7 @@ def __enter__(self): def __exit__(self, *_): self.stderr.close() self.join() # will wait until stderr is closed - return self + return False def run(self): logger.debug("[logger] starting") @@ -191,7 +201,21 @@ def run(self): self.newline.notify_all() logger.debug("[logger] exiting") - def index(self, prefix, start=None, block=True, timeout=None): + def index( + self, + prefix: str, + start: int = 0, + block: bool = True, + timeout: float | None = None, + ) -> int | None: + """Return an index of the first log line which starts with the specified prefix + + :param prefix: look for log lines starting with this string + :param start: log line index to start searching, defaults to 0 + :param block: True to block until the specified log line appears, default is True + :param timeout: blocking timeout, defaults to None (wait indefinitely) + :return: index of the matching line of the LoggerThread.logs or None if none found + """ start = int(start or 0) with self.newline: logs = self.logs[start:] if start else self.logs @@ -201,7 +225,7 @@ def index(self, prefix, start=None, block=True, timeout=None): next((i for i, log in enumerate(logs) if log.startswith(prefix))) + start ) - except: + except StopIteration: if not self.is_alive(): raise ThreadNotActive("LoggerThread is not running") @@ -214,7 +238,7 @@ def index(self, prefix, start=None, block=True, timeout=None): timeout = time() + timeout start = len(self.logs) while True: - tout = timeout and timeout - time() + tout = timeout and max(timeout - time(), 0) # wait till the next log update if (tout is not None and tout < 0) or not self.newline.wait(tout): raise TimeoutError("Specified line not found") @@ -235,7 +259,7 @@ def index(self, prefix, start=None, block=True, timeout=None): ) + start ) - except: + except StopIteration: # still no match, update the starting position start = len(self.logs) @@ -247,7 +271,7 @@ def output_stream(self, file_id=0, stream_id=0, block=True, timeout=None): raise e except TimeoutError: raise TimeoutError("Specified output stream not found") - except Exception as e: + except Exception: raise ValueError("Specified output stream not found") with self._newline_mutex: @@ -261,8 +285,8 @@ def join_and_raise(self, timeout: float | None = None): :raises e: FFmpegError only if log is present Note: This method throws the exception regardless of the thread's status if log is available. - """ - + """ + self.join(timeout) e = self.Exception if e is not None: @@ -275,14 +299,30 @@ def Exception(self) -> FFmpegError | None: class ReaderThread(Thread): - def __init__(self, stdout, nmin=None, queuesize=None): + def __init__( + self, + stdout_or_pipe: BinaryIO | NPopen, + nmin: int | None = None, + queuesize: int | None = None, + itemsize: int | None = None, + retry_delay: float | None = None, + timeout: float | None = None, + ): super().__init__() - self.stdout = stdout #:readable stream: data source + is_pipe = isinstance(stdout_or_pipe, NPopen) + self.pipe = stdout_or_pipe if is_pipe else None # readable named pipe + self.stdout = None if is_pipe else stdout_or_pipe #:readable stream self.nmin = nmin #:positive int: expected minimum number of read()'s n arg (not enforced) - self.itemsize = None #:int: number of bytes per time sample - self._queue = Queue(queuesize or 0) # inter-thread data I/O - self._carryover = None # extra data that was not previously read by user - self._collect = True + self.itemsize = itemsize or 2**20 #:int: number of bytes per time sample + self._queue = Queue(16 if queuesize is None else queuesize) + self._carryover: bytes | None = ( + None #:bytes: extra data that was not previously read by user + ) + self._halt = Event() + self._cooling = Event() + self._running = Event() + self._retry_delay = 0.01 if retry_delay is None else retry_delay + self._timeout = float(timeout) if timeout else None def start(self): if self.itemsize is None: @@ -294,162 +334,313 @@ def start(self): def cool_down(self): # stop enqueue read samples - self._collect = False - try: - self._queue.get_nowait() - except: - pass + self._cooling.set() def join(self, timeout=None): - if self._queue.full(): - if timeout: - self._queue.not_full.wait(timeout) - if self._queue.full(): - return - else: - with self._queue.mutex: - self._queue.queue.clear() + if timeout is None: + timeout = self._timeout + + if self.pipe is None: + self.stdout.close() + else: + if self.stdout is None: + # FFmpeg never opened the pipe, open it to release the runner from waiting + with open(self.pipe.path, "w"): + ... + self.pipe.close() + + # set flag to terminate the thread loop + self._cooling.set() + self._halt.set() + + # if queue is full, the thread loop is likely stuck. + is_full = self._queue.full() + if is_full and timeout: + # if timeout is set, wait to see if dequeued by another thread + self._queue.not_full.wait(timeout) + is_full = self._queue.full() + + if is_full: + raise Full("Cannot join reader thread because the queue is full.") # if queue is full, super().join(timeout) + def is_running(self): + return self._running.is_set() + + def wait_till_running(self, timeout: float | None = None) -> bool: + return self._running.wait(timeout or self._timeout) + def __enter__(self): self.start() return self def __exit__(self, *_): - self.stdout.close() self.join() # will wait until stdout is closed - return self + return False def run(self): + is_npipe = self.stdout is None blocksize = ( self.nmin if self.nmin is not None else 1 if self.itemsize > 1024 else 1024 ) * self.itemsize - while True: + if self._halt.is_set(): + return + logger.debug("waiting for pipe to open") + if is_npipe: + self.stdout = self.pipe.wait() + stream = self.stdout + queue = self._queue + + logger.debug("starting to read") + self._running.set() + while not self._cooling.is_set(): try: - data = self.stdout.read(blocksize) + data = stream.read(blocksize) + # logger.debug("read %d bytes", len(data)) except: # stdout stream closed/FFmpeg terminated, end the thread as well - break + data = None + # print(f"reader thread: read {len(data)} bytes") - if not data: - if self.stdout.closed: # just in case - break - else: + if data: + logger.debug("ReaderThread putting data into the queue") + while not self._cooling.is_set(): + try: + queue.put(data, timeout=0.01) + break + except Full: + if self._cooling.is_set(): + break + + logger.debug("ReaderThread data in the reader queue") + + elif stream.closed: # just in case + logger.info("ReaderThread no data, stream is closed, exiting") + self._cooling.set() + self._halt.set() + break + else: + # pause a bit then try again + # logger.info("ReaderThread no data, reader thread pausing") + sleep(self._retry_delay) + + logger.debug("stopping to read") + logger.info("ReaderThread sending the sentinel") + while not self._halt.is_set(): + try: + queue.put(None, timeout=0.01) + break + except Full: + if self._halt.is_set(): break - if self._collect: # True until self.cooloff - self._queue.put(data) - # print(f"reader thread: queued samples") + # cooling loop (no queuing, flush all read) + logger.info("ReaderThread enters cool-down mode") + while not self._halt.is_set(): + stream.read(blocksize) + + logger.info("ReaderThread exiting") + self._running.clear() - def read(self, n=-1, timeout=None): + def read(self, n: int = -1, timeout: float | None = None) -> bytes: """read n samples - :param n: number of samples/frames to read, if non-positive, read all, defaults to -1 - :type n: int, optional - :param timeout: timeout in seconds, defaults to None - :type timeout: float, optional + :param n: number of samples/frames to read, if non-positive, read all + (until the pipe is broken), defaults to -1 + :param timeout: timeout in seconds, defaults to wait indefinitely :return: n*itemsize bytes - :rtype: bytes """ + # no sample requested, return empty bytes object + if n == 0: + return b"" + + read_all = n < 0 + # wait till matching line is read by the thread - block = self.is_alive() and n != 0 + if timeout is None: + timeout = self._timeout if timeout is not None: timeout = time() + timeout arrays = [] - n_new = max(n, -n) + m = n * self.itemsize # bytes needed + mread = 0 # bytes read # grab any leftover data from previous read if self._carryover: + mread = len(self._carryover) arrays = [self._carryover] - if n_new != 0: - n_new -= len(self._carryover) // self.itemsize + m -= mread self._carryover = None # loop till enough data are collected - nreads = 1 if n <= 0 else max(n_new, 0) - nr = 0 - while True: - tout = timeout and timeout - time() - if tout <= 0: - break + while read_all or m > 0: + tout = timeout and max(timeout - time(), 0) + block = self.is_alive() and timeout is None try: - b = self._queue.get(block, tout) - self._queue.task_done() - arrays.append(b) + b = self._queue.get(block, tout or 0.01) except Empty: - break - - nr += len(b) // self.itemsize - if nr >= nreads: # enough read - if n < 0: - block = False # keep reading until queue is empty - else: + if not block: + break + else: + if b is None: + # encountered sentinel break + self._queue.task_done() + arrays.append(b) + mr = len(b) + m -= mr + mread += mr + assert mr and ( + read_all or tout is None or tout > 0 + ) # no more read time left + # combine all the data and return requested amount - if not len(arrays): + all_data = b"".join(arrays) + + nread = mread // self.itemsize # number of frames read + if n >= 0: + nread = min(nread, n) # adjust to number of frames needed + + mbytes = nread * self.itemsize # number of bytes needed + + # update carryover buffer + self._carryover = all_data[mbytes:] if mbytes < mread else None + + # return retrieved bytes array + return all_data[:mbytes] + + def read_all(self, timeout: float | None = None) -> bytes: + return self.read(-1, timeout) + + def read_nowait(self, n: int = -1) -> bytes: + """read at most n samples + + :param n: number of samples/frames to read, if non-positive, read all + in the queue, defaults to -1 + :return: <= n*itemsize bytes + """ + + # no sample requested, return empty bytes object + if n == 0: return b"" - all_data = b"".join(arrays) - if n <= 0: - return all_data - nbytes = self.itemsize * n - if len(all_data) > nbytes: - self._carryover = all_data[nbytes:] - return all_data[:nbytes] - - def read_all(self, timeout=None): + read_all = n < 0 + # wait till matching line is read by the thread - if timeout is not None: - timeout = time() + timeout + arrays = [] + m = n * self.itemsize # bytes needed + mread = 0 # bytes read - arrays = arrays = [self._carryover] if self._carryover else [] - self._carryover = None + # grab any leftover data from previous read + if self._carryover: + mread = len(self._carryover) + arrays = [self._carryover] + m -= mread + self._carryover = None # loop till enough data are collected - while not self.is_alive() or timeout and timeout > time(): + while read_all or m > 0: try: - data = self._queue.get(self.is_alive(), timeout and timeout - time()) + b = self._queue.get_nowait() self._queue.task_done() - arrays.append(data) + if b is None: + # sentinel + break + mr = len(b) + assert mr > 0 # just in case + + arrays.append(b) + m -= mr + mread += mr except Empty: break # combine all the data and return requested amount - if not len(arrays): - return b"" + all_data = b"".join(arrays) + + nread = mread // self.itemsize # number of frames read + if n >= 0: + nread = min(nread, n) # adjust to number of frames needed + + mbytes = nread * self.itemsize # number of bytes needed + + # update carryover buffer + self._carryover = all_data[mbytes:] if mbytes < mread else None - return b"".join(arrays) + # return retrieved bytes array + return all_data[:mbytes] + + def qsize(self) -> int: + """Return the approximate size of the queue. + + Note, qsize() > 0 doesn't guarantee that a subsequent write() will not block, + nor will qsize() < maxsize guarantee that put() will not block. + """ + return self._queue.qsize() + + def empty(self) -> bool: + """Return True if the queue is empty, False otherwise. + + If empty() returns False it doesn't guarantee that a subsequent call to + read() will not block. + """ + return self._queue.empty() + + def full(self) -> bool: + """Return True if the queue is full, False otherwise. + + If full() returns True it doesn't guarantee that a subsequent call to + read() will not block. + + """ + return self._queue.full() class WriterThread(Thread): """a thread to write byte data to a writable stream :param stdin: stream to write data to - :type stdin: writable stream :param queuesize: depth of a queue for inter-thread data transfer, defaults to None - :type queuesize: int, optional + :param timeout: maximum number of bytes to write at once, defaults to None (1048576 bytes) """ - def __init__(self, stdin, queuesize=None): + def __init__( + self, + stdin_or_pipe: BinaryIO | NPopen, + queuesize: int | None = None, + timeout: float | None = None, + ): super().__init__() - self.stdin = stdin #:writable stream: data sink - self._queue = Queue(queuesize or 0) # inter-thread data I/O - - def join(self, timeout=None): - # close the stream if not already closed - self.stdin.close() + is_pipe = isinstance(stdin_or_pipe, NPopen) + self.pipe = stdin_or_pipe if is_pipe else None + self.stdin = None if is_pipe else stdin_or_pipe #:writable stream: data sink + self._queue = Queue(16 if queuesize is None else queuesize) + self._empty_cond = Condition() + self._empty = True + self._no_more = False # true if sentinel has been written to the queue + self._timeout = float(timeout) if timeout else None + + def join(self, timeout: float | None = None): + if self.stdin is None: + # pipe not yet connected, open it to release the runner + with open(self.pipe.path, "rb"): + ... # if empty, queue a dummy item to wake up the thread if self._queue.empty(): self._queue.put(None) # if queue is full, - super().join(timeout) + super().join(timeout or self._timeout) + + def closed(self) -> bool: + """``True`` if thread no longer accepts data to write""" + return self._no_more def __enter__(self): self.start() @@ -457,316 +648,178 @@ def __enter__(self): def __exit__(self, *_): self.join() # will wait until stdout is closed - return self + return False def run(self): + if self.stdin is None: + self.stdin = self.pipe.wait() + + stream = self.stdin + queue = self._queue + while True: # get next data block - data = self._queue.get() - self._queue.task_done() + logger.debug("WriterThread getting data to the queue") + try: + data = queue.get_nowait() + except Empty: + # if empty, set the flag and block + with self._empty_cond: + self._empty = True + self._empty_cond.notify_all() + data = queue.get() + logger.debug("WriterThread getting data from the queue") + + queue.task_done() if data is None: + logger.debug("WriterThread: received a sentinel to stop the writer") break - # print(f"writer thread: received {data.shape[0]} samples to write") + else: + logger.debug("WriterThread: writing %d bytes", len(data)) + try: - nbytes = self.stdin.write(data) - # print(f"writer thread: written {nbytes} written") - except: + nwritten = 0 + nwritten = stream.write(data) + logger.debug("WriterThread: written %d written", nwritten) + except Exception as e: # stdout stream closed/FFmpeg terminated, end the thread as well + logger.debug("WriterThread exception: %s", e) break - if not nbytes and self.stdin.closed: # just in case + if not nwritten and stream.closed: # just in case + logger.debug("WriterThread: somethin' else happened") break - def write(self, data, timeout=None): - if not self.is_alive(): - raise ThreadNotActive("WriterThread is not running") - - data = self._queue.put(data, timeout) + # set flag to prevent any more writes + with self._empty_cond: + self._no_more = True + # close the pipe/stream + if self.pipe is not None: + self.pipe.close() + elif not self.stdin.closed: + self.stdin.close() -class AviReaderThread(Thread): - class InvalidAviStream(FFmpegError): ... - - def __init__(self, queuesize=None): - super().__init__() - self.reader = AviReader() #:utils.avi.AviReader: AVI demuxer - self.streamsready = Event() #:Event: Set when received stream header info - self.rates = None # :dict(int:int|Fraction) - self._queue = Queue(queuesize or 0) # inter-thread data I/O - self._ids = None #:dict(int:int): stream indices - self._nread = None #:dict(int:int): number of samples read/stream - self._carryover = ( - None #:dict(int:ndarray) extra data that was not previously read by user - ) - - @property - def streams(self): - return self.reader.streams if self.streamsready else None + # completely flush the queue + # check if queue has any remaining items + not_empty = True + while True: + try: + queue.get_nowait() + except Empty: + not_empty = False + break - def start(self, stdout, use_ya=None): - self._args = (stdout, use_ya) - super().start() + # if queue was not empty, notify its empty state now + if not_empty: + with self._empty_cond: + self._empty = True + self._empty_cond.notify_all() - def join(self, timeout=None): - # if queue is full, - super().join(timeout) + logger.info("WriterThread exiting") - def __bool__(self): - """True if FFmpeg stdout stream is still open or there are more frames in the buffer""" - return self.is_alive() or not self._queue.empty() + def write(self, data, timeout=None): + with self._empty_cond: + if self._no_more: + if data is None: + return + else: + raise ThreadNotActive("WriterThread is no longer running") - # def __enter__(self): - # self.start() - # return self + if data is None: + self._no_more = True - # def __exit__(self, *_): - # self.join() # will wait until stdout is closed - # return self + self._queue.put(data, timeout != 0, timeout or self._timeout) + self._empty = False - def run(self): - reader = self.reader + def flush(self, timeout: float | None = None): + """block until the write buffer is emptied - try: - # start the AVI reader to process stdout byte stream - reader.start(*self._args) - - # initialize the stream properties - self._ids = ids = [i for i in reader.streams] - self._nread = {k: 0 for k in ids} - self.rates = { - k: v["frame_rate"] if v["type"] == "v" else v["sample_rate"] - for k, v in reader.streams.items() - } - except Exception as e: - logger.critical(e) - return - finally: - self.streamsready.set() - - reader = self.reader - for id, data in reader: - self._queue.put((id, data)) - self._queue.put(None) # end of stream - - def wait(self, timeout: float | None = None) -> bool: - """wait till stream is ready to be read - - :param timeout: timeout in seconds, defaults to None (waits indefinitely) - :type timeout: float, optional - :raises InvalidAviStream: if thread has been terminated before stream header info was read - :return: tuple of stream specifier and data array - :rtype: (str, object) + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait until empty + :raise NotEmpty: if a timeout is set, and the buffer is not emptied in time """ - flag = self.streamsready.wait(timeout) - if not (flag or self.is_alive()): - raise self.InvalidAviStream( - "No stream header info was found in FFmpeg's AVI stream." - ) - return flag + with self._empty_cond: + if not ( + self._no_more + or self._empty + or self._empty_cond.wait(timeout or self._timeout) + ): + raise NotEmpty() - def readchunk(self, timeout=None): - """read the next avi chunk + def qsize(self) -> int: + """Return the approximate size of the queue. - :param timeout: timeout in seconds, defaults to None (waits indefinitely) - :type timeout: float, optional - :raises TimeoutError: if terminated due to timeout - :return: tuple of stream specifier and data array - :rtype: (str, object) + Note, qsize() > 0 doesn't guarantee that a subsequent write() will not block """ + return self._queue.qsize() - # wait till matching line is read by the thread - tend = timeout and time() + timeout - - # if stream header not received in time, raise error - if not self.wait(timeout): - raise TimeoutError("timed out waiting for the stream headers") - - block = self.is_alive() + def empty(self) -> bool: + """Return True if the write queue is empty, False otherwise. - # if any leftover data available, return the first one - if self._carryover is not None: - (id, data) = next( - ((k, v) for k, v in self._carryover.items() if v is not None) - ) - self._carryover[id] = None - if all((k for k, v in self._carryover.items() if v is None)): - self._carryover = None - return self.reader.streams[id]["spec"], self.reader.from_bytes(id, data) - - # get next chunk - try: - if timeout is not None: - timeout = tend - time() - assert timeout > 0 - chunk = self._queue.get(block, timeout) - if chunk is None: - raise ThreadNotActive("reached end-of-stream") - id, data = chunk - except Empty: - raise TimeoutError("timed out waiting for next chunk") - self._queue.task_done() - - return self.reader.streams[id]["spec"], self.reader.from_bytes(id, data) - - def find_id(self, ref_stream): - self.wait() - try: - return next( - (k for k, v in self.reader.streams.items() if v["spec"] == ref_stream) - ) - except: - ValueError(f"{ref_stream} is not a valid stream specifier") - - def read(self, n=-1, ref_stream=None, timeout=None): - """read data from all streams - - :param n: number of samples, negate to non-blocking, defaults to -1 - :type n: int, optional - :param ref_stream: stream specifier to count the samples, - defaults to None (first stream) - :type ref_stream: str, optional - :param timeout: timeout in seconds, defaults to None (waits indefinitely) - :type timeout: float, optional - :raises TimeoutError: if terminated due to timeout - :return: tuple of stream specifier and data array - :return: dict of data object keyed by stream specifier string, each data object is - created by `bytes_to_video` or `bytes_to_image` plugin hook. If all frames - have been read, dict items would be all empty - :rtype: dict(spec:str, object) + If empty() returns True it doesn't guarantee that a subsequent call to + write() will not block. """ + return self._queue.empty() - # wait till matching line is read by the thread - block = self.is_alive() and n != 0 - tend = timeout and (time() + timeout) - - # if stream header not received in time, raise error - if not self.wait(timeout): - raise TimeoutError("timed out waiting for the stream headers") - - # get the reference stream id - if ref_stream is None: - ref_stream = self._ids[0] - else: - ref_stream = self.find_id(ref_stream) - - # identify how many samples are needed for each stream - nref = max(n, -n) - tref = (self._nread[ref_stream] + nref) / self.rates[ref_stream] - n_need = { - k: ceil(tref * self.rates[k]) - self._nread[k] if k != ref_stream else nref - for k in self._ids - } - nremain = deepcopy(n_need) - - # initialize output arrays - arrays = {k: [] for k in self._ids} - - itemsizes = self.reader.itemsizes - - # grab any leftover data from previous read - if self._carryover is not None: - for k, v in self._carryover.items(): - if v is not None: - arrays[k] = [v] - nremain[k] -= len(v) // itemsizes[k] - self._carryover = None - - # loop till enough data are collected - while any((v > 0 for k, v in nremain.items() if n_need[k] > 0)): - try: - if timeout: - timeout = tend - time() - if timeout <= 0: - break - chunk = self._queue.get(block, timeout) - if chunk is None: - break - k, data = chunk - self._queue.task_done() - arrays[k].append(data) - nremain[k] -= len(data) // itemsizes[k] - - except Empty: - break - - def combine(id, array, n, nr): - # combine all the data and return requested amount - if not len(array): - return (id, None, None) - all_data = b"".join(array) - nbytes = n * itemsizes[id] - return ( - (id, all_data, None) - if nr >= 0 - else (id, all_data[:nbytes], all_data[nbytes:]) - ) - - ids, data, excess = zip( - *( - combine(id, array, n_need[id], nremain[id]) - for id, array in arrays.items() - ) - ) + def full(self) -> bool: + """Return True if the queue is full, False otherwise. - # any excess samples, store as a _carryover dict - if any((sdata is not None for sdata in excess)): - self._carryover = {id: sdata for id, sdata in zip(ids, excess)} - - # final formatting of data - out = {} - for id, sdata in zip(ids, data): - info = self.reader.streams[id] - spec = info["spec"] - if sdata is None: - out[spec] = self.reader.from_bytes(id, b"") - else: - self._nread[id] += len(sdata) // itemsizes[id] - out[spec] = self.reader.from_bytes(id, sdata) + If full() returns False it doesn't guarantee that a subsequent call to + write() will not block. + """ + return self._queue.full() - return out - def readall(self, timeout=None): - # wait till matching line is read by the thread - if timeout is not None: - timeout = time() + timeout +class CopyFileObjThread(Thread): + """run shutil.copyfileobj in the thread - # if stream header not received in time, raise error - if not self.wait(timeout): - raise TimeoutError("timed out waiting for the stream headers") + :param fsrc: source file object + :param fout: destination file object + :param length: The integer length, if given, is the buffer size. In particular, a negative length + value means to copy the data without looping over the source data in chunks; + defaults to 0; the data is read in chunks to avoid uncontrolled memory consumption. + :param auto_close: True for the thread to close fsrc and fdst after copy, + defaults to False - # initialize output arrays - arrays = {k: [] for k in self._ids} + Thread terminates when the copy operation is completed. - itemsizes = self.reader.itemsizes + Note that if the current file position of the fsrc object is not 0, + only the contents from the current file position to the end of the file will be copied. + """ - # grab any leftover data from previous read - if self._carryover is not None: - for k, v in self._carryover.items(): - if v is not None: - arrays[k] = [v] - self._nread[k] += len(v) // itemsizes[k] - self._carryover = None + def __init__( + self, + fsrc: BinaryIO | NPopen, + fdst: BinaryIO | NPopen, + length: int = 0, + *, + auto_close: bool = False, + ): + super().__init__() + self._fsrc = fsrc + self._fdst = fdst + self.length = length + self.auto_close = auto_close - # loop till enough data are collected - while True: - try: - chunk = self._queue.get(self.is_alive(), timeout and timeout - time()) - if chunk is None: - break # end of stream - k, data = chunk - self._queue.task_done() - arrays[k].append(data) - self._nread[k] += len(data) // itemsizes[k] - except Empty: - break + def __enter__(self): + self.start() + return self - # final formatting of data - out = {} - for id, sdata in arrays.items(): - info = self.reader.streams[id] - spec = info["spec"] - out[spec] = self.reader.from_bytes( - id, b"" if sdata is None else b"".join(sdata) - ) + def __exit__(self, *_): + self.join() + return False - return out + def run(self): + src_is_namedpipe = isinstance(self._fsrc, NPopen) + src = self._fsrc.wait() if src_is_namedpipe else self._fsrc + dst_is_namedpipe = isinstance(self._fdst, NPopen) + dst = self._fdst.wait() if dst_is_namedpipe else self._fdst + try: + copyfileobj(src, dst, self.length) + except: + # TODO - test the behavior when FFmpeg is prematurely terminated + logger.warning("CopyFileObjThread runner failed to complete the job.") + if self.auto_close: + src.close() + dst.close() diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index 192fd386..e3e429d2 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -1,122 +1,127 @@ -from . import ffmpegprocess as fp, configure, utils, FFmpegError +from __future__ import annotations + +import logging + +from . import FFmpegError, configure, utils +from . import ffmpegprocess as fp +from ._typing import FFmpegOptionDict, ProgressCallable, Sequence, Unpack +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, +) from .path import check_version -from .errors import scan_stderr + +logger = logging.getLogger("ffmpegio") __all__ = ["transcode"] def transcode( - inputs, - outputs, - progress=None, - overwrite=None, - show_log=None, - two_pass=False, - pass1_omits=None, - pass1_extras=None, - sp_kwargs=None, - **options, -): + inputs: ( + FFmpegInputUrlComposite + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + outputs: ( + FFmpegOutputUrlComposite + | Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + ), + *, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + two_pass: bool = False, + pass1_omits: ( + Sequence[str] | Sequence[Sequence[str]] | dict[int, Sequence[str]] | None + ) = None, + pass1_extras: ( + FFmpegOptionDict + | Sequence[FFmpegOptionDict] + | dict[int, FFmpegOptionDict] + | None + ) = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> bytes | None: """Transcode media files to another format/encoding - :param inputs: url/path of the input media file or a sequence of tuples, each - containing an input url and its options dict - :type inputs: str or a list of str or a sequence of (str,dict) - :param outputs: url/path of the output media file or a sequence of tuples, each - containing an output url and its options dict - :type outputs: str or sequence of (str, dict) - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param overwrite: True to overwrite if output url exists, defaults to None - (auto-select) - :type overwrite: bool, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional + :param inputs: url/path of the input media file or a sequence of tuples, + each containing an input url and its options dict + :param outputs: url/path of the output media file or a sequence of tuples, + each containing an output url and its options dict + :param progress: progress callback function, defaults to ``None`` + :param overwrite: True to overwrite if output url exists, defaults to auto- + select + :param show_log: True to show FFmpeg log messages on the console, defaults + to ``None`` (no show/capture) Ignored if stream format must be retrieved + automatically. :param two_pass: True to encode in 2-pass - :param pass1_omits: list of output arguments to ignore in pass 1, defaults to - None (removes 'c:a' or 'acodec'). For multiple outputs, - specify use list of the list of arguments, matching the - length of outputs, for per-output omission. - :type pass1_omits: seq(str), or seq(seq(str)) optional - :param pass1_extras: list of additional output arguments to include in pass 1, - defaults to None (add 'an' if `pass1_omits` also None) - :type pass1_extras: dict(int:dict(str)), optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options. For output and global options, use FFmpeg - option names as is. For input options, append "_in" to the - option name. For example, r_in=2000 to force the input frame - rate to 2000 frames/s (see :doc:`options`). - - If multiple inputs or outputs are specified, these input - or output options specified here are treated as common - options, and the url-specific duplicate options in the - ``inputs`` or ``outputs`` sequence will overwrite those - specified here. - :type \\**options: dict, optional + :param pass1_omits: list of output arguments to ignore in pass 1, defaults + to ``None`` (removes ``'c:a'`` or ``'acodec'``). For multiple outputs, + specify use list of the list of arguments, matching the length of + outputs, for per-output omission. + :param pass1_extras: list of additional output arguments to include in pass + 1, defaults to ``None`` (add 'an' if ``pass1_omits`` also ``None``) + :param sp_kwargs: dictionary with keywords passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults + to None + :param options: FFmpeg options. For output and global options, use FFmpeg + option names as is. For input options, append ``"_in"`` to the option + name. For example, ``r_in=2000`` to force the input frame rate to 2000 + frames/s (see :doc:`options`). + + If multiple inputs or outputs are specified, these input or output + options specified here are treated as common options, and the url- + specific duplicate options in the ``inputs`` or ``outputs`` sequence + will overwrite those specified here. :returns: if any of the outputs is stdout, returns output bytes - :rtype: bytes | None """ - # split input and global options from options - input_options = utils.pop_extra_options(options, "_in") - global_options = utils.pop_global_options(options) - - def format_arg(arg, defopts): - def test(a, is_list): - try: - assert len(a) == 2 - assert isinstance(a[1], dict) - return (a[0], {**defopts, **a[1]}) - except: - if is_list: - return (a, defopts) - raise + if utils.is_valid_input_url(inputs): + inputs = [inputs] + if utils.is_valid_output_url(outputs): + outputs = [outputs] - # special case: a list of inputs w/out options - if type(arg) == list: - return [test(a, True) for a in arg] + args, input_info, output_info = configure.init_media_transcode( + inputs, outputs, options + ) - # attempt to map url-options pairs - try: - return [test(a, False) for a in arg] - except: - return [(arg, defopts)] + # check number of pipes + nb_inpipes = sum(info["src_type"] == "buffer" for info in input_info) + nb_outpipes = sum(info["dst_type"] == "buffer" for info in output_info) - inputs = format_arg(inputs, input_options) - outputs = format_arg(outputs, options) + # if 0 or 1 buffered input and 0 or 1 buffered output, just use stdin/stdout + simple_mode = (nb_inpipes + nb_outpipes) < 2 - # initialize FFmpeg argument dict - args = configure.empty(global_options) + if not simple_mode: + raise NotImplementedError( + "transcoding with multiple input or output pipes is not yet implemented." + ) - for url, opts in inputs: - input_url, stdin, input = configure.check_url(url, False, opts.get("f", None)) - configure.add_url(args, "input", input_url, opts) + # convert basic VF options to vf option + # for i in range(len(output_info)): + # configure.build_basic_vf(args, None, i) - for url, opts in outputs: - output_url, stdout, _ = configure.check_url(url, True) - i, _ = configure.add_url(args, "output", output_url, opts) + kwargs = {**sp_kwargs} if sp_kwargs else {} - # convert basic VF options to vf option - configure.build_basic_vf(args, None, i) + # configure a std pipe if used + if nb_inpipes: + kwargs.update(configure.assign_input_pipes(args, input_info, True, True)[1]) + elif nb_outpipes: + kwargs.update(configure.assign_output_pipes(args, output_info, True)[1]) - kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( { "progress": progress, "overwrite": overwrite, - "stdin": stdin, - "stdout": stdout, - "input": input, "capture_log": None if show_log else True, } ) if two_pass: + if len(output_info) > 1: + raise ValueError("transcode() only supports two_pass mode for one output.") kwargs["pass1_omits"] = pass1_omits kwargs["pass1_extras"] = pass1_extras diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py new file mode 100644 index 00000000..cba2436e --- /dev/null +++ b/src/ffmpegio/typing.py @@ -0,0 +1,11 @@ +"""type hint definition for external use""" +from __future__ import annotations + +from typing import * + +from typing_extensions import * + +from ._typing import * +from .configure import FFmpegArgs, FFmpegUrlType +from .filtergraph.abc import FilterGraphObject +from .stream_spec import StreamSpecDict, StreamSpecStreamType diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 2f845c32..060272af 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -1,367 +1,80 @@ -from __future__ import annotations +"""utility functions for the main modules""" -from collections.abc import Sequence -from numbers import Number +from __future__ import annotations +import logging +import re +from collections import defaultdict +from collections.abc import Callable, Sequence +from fractions import Fraction from math import cos, radians, sin -import re, fractions -from .. import caps -from .._utils import * +from numbers import Number -from .typing import get_args, MediaType, StreamSpec, Any +from .. import caps, plugins, probe, stream_spec +from .. import filtergraph as fgb +from .._typing import ( + IO, + Any, + Buffer, + DTypeString, + FFmpegOptionDict, + FFmpegUrlType, + FilterGraphInfoDict, + InputInfoDict, + Literal, + MediaType, + OutputInfoDict, + RawDataBlob, + RawStreamDef, + ShapeTuple, +) +from .._utils import ( + as_multi_option, + escape, + get_samplesize, + is_fileobj, + is_namedpipe, + is_non_str_sequence, + is_pipe, + is_url, + prod, + unescape, +) +from ..errors import FFmpegioError +from ..filtergraph.abc import FilterGraphObject +from ..filtergraph.presets import temp_audio_src, temp_video_src +from ..stream_spec import is_unique_stream, parse_map_option +from .concat import FFConcat + +# from .._utils import * + +logger = logging.getLogger("ffmpegio") + + +FFmpegInputUrlComposite = FFmpegUrlType | FFConcat | FilterGraphObject | IO | Buffer +"""all input types supported by ffmpegio""" +FFmpegOutputUrlComposite = FFmpegUrlType | IO +"""all output types supported by ffmpegio""" + +FFmpegInputUrlNoPipe = FFmpegUrlType | FFConcat | FilterGraphObject +"""all non-piped input types supported by ffmpegio""" + +FFmpegOutputUrlNoPipe = FFmpegUrlType +"""all non-piped output types supported by ffmpegio""" # TODO: auto-detect endianness # import sys # sys.byteorder -from builtins import zip as builtin_zip - - -def zip(*args, strict=False): - - # backwards compatibility for pre-py3.10 - - try: - return builtin_zip(*args, strict=strict) - except TypeError: - if strict is False: - return builtin_zip(*args) - - def strict_zip(): - # strict=True case, excerpted from PEP618: https://peps.python.org/pep-0618/ - iterators = tuple(iter(iterable) for iterable in args) - try: - while True: - items = [] - for iterator in iterators: - items.append(next(iterator)) - yield tuple(items) - except StopIteration: - pass - - if items: - i = len(items) - plural = " " if i == 1 else "s 1-" - msg = f"zip() argument {i+1} is shorter than argument{plural}{i}" - raise ValueError(msg) - sentinel = object() - for i, iterator in enumerate(iterators[1:], 1): - if next(iterator, sentinel) is not sentinel: - plural = " " if i == 1 else "s 1-" - msg = f"zip() argument {i+1} is longer than argument{plural}{i}" - raise ValueError(msg) - - return strict_zip() - - -def escape(txt: str) -> str: - """apply FFmpeg single quote escaping - - :param txt: Unescaped string - :return: Escaped string - - See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping - """ - - txt = str(txt) - - if re.search(r"\s", txt, re.MULTILINE): - # quote if txt has any white space - txt = txt.replace("'", r"'\''") - return f"'{txt}'" - else: - # if not quoted, escape quotes and backslashes - return re.sub(r"(['\\])", r"\\\1", txt) - - -def unescape(txt: str) -> str: - """undo FFmpeg single quote escaping - - :param txt: Escaped string - :return: Original string - - See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping - """ - - n = len(txt) - if not n: - return txt - - re_start = re.compile(r"[^\\](?:\\\\)*'") - re_sub = re.compile(r"\\([\\'])") - - blks = [] - - # look for a first quoted text block - m = re.search(r"(?:^|[^\\])(?:\\\\)*'", txt) - if m: - i0 = m.end() - if i0 > 1: - # unescape the initial unquoted block - blks.append(re_sub.sub(r"\1", txt[0 : i0 - 1])) - else: - # no quoted text block, unescape the whole string - return re_sub.sub(r"\1", txt) - - # always starts with quoted block - in_quote = True - - while i0 < n: - - if in_quote: - # find the end quote - i1 = txt.find("'", i0) - if i1 < 0: - raise ValueError("incorrectly escaped text: missing a closing quote.") - blks.append(txt[i0:i1]) - else: - # find the next starting quote - m = re_start.search(txt, i0 - 1) - i1 = m.end() - 1 if m else n - blks.append(re_sub.sub(r"\1", txt[i0:i1])) - i0 = i1 + 1 - in_quote = not in_quote - - return "".join(blks) - - -def parse_stream_spec( - spec: str | int | Sequence[int, int], file_index=False -) -> StreamSpec: - """Parse stream specifier string - - :param spec: stream specifier string. If file_index=False and given an int - value, it specifies the stream index. If file_index=True and given - a 2-element sequence, it specifies the file index in spec[0] and - stream index in spec[1]. - :param file_index: True to expect spec to start with a file index, defaults to False - :return: stream spec dict - - The reverse of `stream_spec()` - """ - - if isinstance(spec, str): - - out: StreamSpec = {} - spec_parts = spec.split(":") - nspecs = len(spec_parts) - i = 0 # current index - - def get_int(s, name): - try: - v = int( - s, - ( - 10 - if s[0] != "0" and len(s) > 1 - else 16 if s.startswith("0x") or s.startswith("0X") else 8 - ), - ) - assert v >= 0 - except Exception as e: - raise ValueError(f"Invalid {name} ({s})") from e - return v - - def get_id(i, name): - - try: - s = spec_parts[i + 1] - except IndexError as e: - raise ValueError(f"Missing {name}") from e - else: - return get_int(s, name) - - # add file index only if expected - if file_index: - out["file_index"] = get_int(spec_parts[0], "file index") - i += 1 - - # process the optional parts - while i < nspecs: - spec = spec_parts[i] - # optional specifiers first - if spec in get_args(MediaType): - out["media_type"] = spec - i += 1 - elif spec == "g": - i += 1 - spec = spec_parts[i] - if spec == "i": - out["group_id"] = get_id(i, "group_id") - i += 2 - elif spec.startswith("#"): - out["group_id"] = get_int(spec[1:], "group_id") - i += 1 - else: - out["group_index"] = get_int(spec, "group index") - i += 1 - elif spec == "p": - out["program_id"] = get_id(i, "program_id") - i += 2 - else: - # final primary specifier - if spec.startswith("#"): - out["stream_id"] = get_int(spec[1:], "stream_id") - elif spec == "i": - out["stream_id"] = get_id(i, "stream_id") - i += 1 - elif spec == "u": - out["usable"] = True - elif spec == "m": - try: - key, *value = spec_parts[i + 1 :] - assert len(value) <= 1 - except (IndexError, AssertionError) as e: - raise ValueError( - f"Invalid metadata tag specifier: {':'.join(spec_parts[i:])}" - ) from e - else: - i = nspecs - 1 - out["tag"] = (key, value[0]) if len(value) else key - else: - try: - out["index"] = get_int(spec, "stream_index") - except ValueError as e: - raise ValueError(f"Unknown stream specifier: {spec}") from e - break - - if i + 1 < nspecs: - raise ValueError(f"Not all specifiers resolved: {':'.join(spec_parts[i:])}") - - return out - - if file_index: - if not ( - isinstance(spec, Sequence) - and len(spec) == 2 - and all(isinstance(v, int) and v >= 0 for v in spec) - ): - raise ValueError("Invalid stream specifier") - return {"file_index": int(spec[0]), "index": int(spec[1])} - - if not (isinstance(spec, int) and spec >= 0): - raise ValueError("Invalid stream specifier") - return {"index": int(spec)} - - -def is_stream_spec(spec, file_index: bool | None = None) -> bool: - """True if valid stream specifier string - - :param spec: stream specifier string to be tested - :param file_index: True if spec starts with a file index, None to allow with or without file_index defaults to False - :return: True if valid stream specifier - """ - try: - parse_stream_spec(spec, True if file_index is None else file_index) - return True - except ValueError: - if file_index is None: - try: - parse_stream_spec(spec, False) - return True - except ValueError: - pass - return False - - -def stream_spec( - index: int | None = None, - media_type: MediaType | None = None, - group_index: int | None = None, - group_id: int | None = None, - program_id: int | None = None, - stream_id: int | None = None, - tag: str | tuple[str, str] | None = None, - usable: bool | None = None, - file_index: int | None = None, - no_join: bool = False, -) -> str: - """Get stream specifier string - - :param index: Matches the stream with this index. If stream_index is used as - an additional stream specifier, then it selects stream number stream_index - from the matching streams. Stream numbering is based on the order of the - streams as detected by libavformat except when a program ID is also - specified. In this case it is based on the ordering of the streams in the - program., defaults to None - :param media_type: One of following: ’v’ or ’V’ for video, ’a’ for audio, ’s’ for - subtitle, ’d’ for data, and ’t’ for attachments. ’v’ matches all video - streams, ’V’ only matches video streams which are not attached pictures, - video thumbnails or cover arts. If additional stream specifier is used, then - it matches streams which both have this type and match the additional stream - specifier. Otherwise, it matches all streams of the specified type, defaults - to None - :param group_index: Matches streams which are in the group with this group index. - Can be combined with other stream_specifiers, except for `group_index`. - :param group_index: Matches streams which are in the group with this group id. - Can be combined with other stream_specifiers, except for `group_id`. - :param program_id: Selects streams which are in the program with this id. If - additional_stream_specifier is used, then it matches streams which both are - part of the program and match the additional_stream_specifier, defaults to - None - :param stream_id: stream id given by the container (e.g. PID in MPEG-TS - container), defaults to None - :param tag: metadata tag key having the specified value. If value is not - given, matches streams that contain the given tag with any value, defaults - to None - :param usable: streams with usable configuration, the codec must be defined - and the essential information such as video dimension or audio sample rate - must be present, defaults to None - :param file_index: file index to be prepended if specified, defaults to None - :param filter_output: True to append "out" to stream type, defaults to False - :param no_join: True to return list of stream specifier elements, defaults to False - :return: stream specifier string or empty string if all arguments are None - - Note matching by metadata will only work properly for input files. - - Note index, stream_id, tag, and usable are mutually exclusive. Only one of them - can be specified. - - """ - - if sum(v is not None for v in (index, stream_id, tag, usable)) > 1: - raise ValueError('Only one of "index", "tag", or "usable" may be specified.') - - if sum(v is not None for v in (group_index, group_id)) > 1: - raise ValueError('Only one of "group_index" or "group_id" may be specified.') - - spec = [] if file_index is None else [str(file_index)] - - if media_type is not None: - if media_type not in get_args(MediaType): - raise ValueError(f"Unknown {media_type=}.") - spec.append(media_type) - - if group_index is not None: - spec.append(f"g:{group_index}") - elif group_id is not None: - spec.append(f"g:#{group_id}") - - if program_id is not None: - spec.append(f"p:{program_id}") - - if index is not None: - spec.append(str(index)) - elif stream_id is not None: - spec.append(f"#{stream_id}") - elif tag is not None: - spec.append(f"m:{tag}" if isinstance(tag, str) else f"m:{tag[0]}:{tag[1]}") - elif usable is not None and usable: - spec.append("u") - - return spec if no_join else ":".join(spec) - - -def get_pixel_config( - input_pix_fmt: str, pix_fmt: str | None = None -) -> tuple[str, int, str, bool]: +def get_pixel_config(input_pix_fmt: str) -> tuple[str, int, DTypeString, bool]: """get best pixel configuration to read video data in specified pixel format :param input_pix_fmt: input pixel format - :param pix_fmt: desired output pixel format, defaults to None (auto-select) - :return: output pix_fmt, number of components, data type string, and whether - alpha component must be removed + :return pix_fmt_out: output pix_fmt + :return ncomp: number of components + :return dtype: data type string + :return has_alpha: True if alpha component must be removed ===== ===== ========= =================================== ncomp dtype pix_fmt Description @@ -380,6 +93,7 @@ def get_pixel_config( 4 bool | int | None: - """get best pixel configuration to read video data in specified pixel format - - :param input_pix_fmt: input pixel format - :param output_pix_fmt: output pixel format - :param dir: specify the change direction for boolean answer, defaults to None - :return: dir None: 0 if no change, 1 if alpha added, -1 if alpha removed, None if indeterminable - dir int: True if changes in the specified direction or False - - """ - if input_pix_fmt is None or output_pix_fmt is None: - return None if dir is None else False - n_in = caps.pix_fmts()[input_pix_fmt]["nb_components"] - n_out = caps.pix_fmts()[output_pix_fmt]["nb_components"] - d = (n_in % 2) - (n_out % 2) - return d if dir is None else d > 0 if dir > 0 else d < 0 if dir < 0 else d == 0 - - -def get_pixel_format(fmt: str) -> tuple[str, int]: +def get_pixel_format(fmt: str) -> tuple[DTypeString, int]: """get data format and number of components associated with video pixel format :param fmt: ffmpeg pix_fmt - :return: data type string and the number of components associated with the pix_fmt + :return dtype: data type string compatible with `pix_fmt` + :return nb_components: the number of components of `pix_fmt` + + If `fmt` is not rgb or grayscale, the format must have byte-aligned pixel depth. + Also, such `fmt`'s are assumed to have integer pixel values. As a result, + floating-point pixel format may lead to an incorrect `dtype` return value, and + requires a post-read type casting. + """ try: return dict( @@ -459,13 +158,21 @@ def get_pixel_format(fmt: str) -> tuple[str, int]: rgba=("|u1", 4), rgba64le=(" 1 else "|u1" + return dtype, fmt_info["nb_components"] def get_video_format( fmt: str, s: tuple[int, int] | str -) -> tuple[str, tuple[int, int, int]]: +) -> tuple[DTypeString, ShapeTuple]: """get pixel data type and frame array (height,width,ncomp) :param fmt: ffmpeg pix_fmt or data type string @@ -478,13 +185,14 @@ def get_video_format( def guess_video_format( - shape: Sequence[int, int, int], dtype: str + shape: ShapeTuple, dtype: DTypeString ) -> tuple[tuple[int, int], str]: """get video format :param shape: frame data shape :param dtype: frame data type - :return: frame size and pix_fmt + :return s: frame size + :return pix_fmt: frame pixel format ``` X = np.ones((100,480,640,3),'|u1') @@ -498,7 +206,7 @@ def guess_video_format( ndim = len(shape) if ndim < 2 or ndim > 4: raise ValueError( - f"invalid video data dimension: data shape must be must be 2d, 3d or 4d" + "invalid video data dimension: data shape must be must be 2d, 3d or 4d" ) has_comp = ndim != 2 and (ndim != 3 or shape[-1] < 5) @@ -520,48 +228,38 @@ def guess_video_format( return size, pix_fmt -def get_rotated_shape(w: int, h: int, deg: float) -> tuple[int, int]: - """compute the shape of rotated rectangle - - :param w: rectangle width - :param h: rectangle height - :param deg: rotation angle in degrees, positive in clockwise direction - :return: the (width, height) after rotation - """ - theta = radians(deg) - C = cos(theta) - S = sin(theta) - return int(round(abs(C * w - S * h))), int(round(abs(S * w + C * h))), theta - # X = [[C, -S], [S, C]], [[w, w, 0.0], [0.0, h, h]] - # return int(round(abs(X[0, 0] - X[0, 2]))), int(round(abs(X[1, 1]))), theta +audio_codecs = dict( + u8=("pcm_u8", "u8"), + s16=("pcm_s16le", "s16le"), + s32=("pcm_s32le", "s32le"), + s64=("pcm_s64le", "s64le"), + flt=("pcm_f32le", "f32le"), + dbl=("pcm_f64le", "f64le"), +) def get_audio_codec(fmt: str) -> tuple[str, str]: """get pcm audio codec & format :param fmt: ffmpeg sample_fmt - :return: tuple of pcm codec name and container format + :return acodec: pcm codec name + :return f: container format """ try: - return dict( - u8=("pcm_u8", "u8"), - s16=("pcm_s16le", "s16le"), - s32=("pcm_s32le", "s32le"), - s64=("pcm_s64le", "s64le"), - flt=("pcm_f32le", "f32le"), - dbl=("pcm_f64le", "f64le"), - )[fmt] - except: - raise ValueError(f"{fmt} is not a valid raw audio sample_fmt") + return audio_codecs[fmt] + except KeyError as e: + raise ValueError(f"{fmt} is not a valid raw audio sample_fmt") from e -def get_audio_format(fmt: str, ac: int | None = None) -> str | tuple[str, tuple[int]]: +def get_audio_format(fmt: str, ac: int | None = None) -> tuple[DTypeString, ShapeTuple]: """get audio sample data format :param fmt: ffmpeg sample_fmt or data type string :param ac: number of channels, default to None (to return only dtype) - :return: data type string and array shape tuple + :return dtype: numpy-style dtype string + :return shape: array shape tuple """ + try: dtype = { "u8": "|u1", @@ -576,27 +274,24 @@ def get_audio_format(fmt: str, ac: int | None = None) -> str | tuple[str, tuple[ raise ValueError(f"Unsupported or unknown sample_fmt ({fmt}) specified.") -def guess_audio_format( - dtype: str, shape: Sequence[int] | None = None -) -> tuple[int, str]: +def guess_audio_format(shape: ShapeTuple, dtype: DTypeString) -> tuple[int, str]: """get audio format - :param dtype: sample data type :param shape: sample data shape + :param dtype: sample data type :return: tuple of # of channels and sample_fmt ``` X = np.ones((1000,2),np.int16) - sample_fmt, ac = guess_audio_format(X.dtype, X.shape) + sample_fmt, ac = guess_audio_format(X.shape, X.dtype) # => sample_fmt='s16', ac=2 """ - if shape is not None: - ndim = len(shape) - if ndim < 1 or ndim > 2: - raise ValueError( - f"invalid audio data dimension: data shape must be must be 1d or 2d" - ) + ndim = len(shape) + if ndim < 1 or ndim > 2: + raise ValueError( + "invalid audio data dimension: data shape must be must be 1d or 2d" + ) try: sample_fmt = { @@ -614,7 +309,6 @@ def guess_audio_format( def parse_video_size(expr: str | tuple[int, int]) -> tuple[int, int]: - if isinstance(expr, str): m = re.match(r"(\d+)x(\d+)", expr) if m: @@ -625,62 +319,6 @@ def parse_video_size(expr: str | tuple[int, int]) -> tuple[int, int]: return expr -def parse_frame_rate(expr) -> fractions.Fraction: - try: - return fractions.Fraction(expr) - except ValueError: - return caps.frame_rate_presets[expr] - - -def parse_color(expr) -> tuple[int, int, int, int | None]: - m = re.match( - r"([^@]+)?(?:@(0x[\da-f]{2}|[0-1]\.[0-9]+))?$", - expr, - re.IGNORECASE, - ) - expr = m[1] - alpha = m[2] and (int(m[2], 16) if m[2][1] == "x" else float(m[2])) - - m = re.match( - r"(?:0x|#)?([\da-f]{6})([\da-f]{2})?$", - expr, - re.IGNORECASE, - ) - if m: - rgb = m[1] - if m[2] and alpha is None: - alpha = int(m[2], 16) - else: - colors = caps.colors() - name = next((k for k in colors.keys() if k.lower() == expr.lower()), None) - if name is None: - raise Exception("invalid color expression") - rgb = colors[name][1:] - - return int(rgb[:2], 16), int(rgb[2:4], 16), int(rgb[4:], 16), alpha - - -def compose_color(r: str | Sequence[Number], *args: tuple[Number]) -> str: - - if isinstance(r, str): - colors = caps.colors() - name = next((k for k in colors.keys() if k.lower() == r.lower()), None) - if name is None: - raise Exception("invalid predefined color name") - return name - else: - - def conv(x): - if isinstance(x, float): - x = int(x * 255) - return f"{x:02X}" - - if len(args) < 4: - args = (*args, *([255] * (3 - len(args)))) - - return "".join((conv(x) for x in (r, *args))) - - def layout_to_channels(layout: str) -> int: layouts = caps.layouts()["layouts"] names = caps.layouts()["channels"].keys() @@ -733,18 +371,6 @@ def parse_time_duration(expr: str | float) -> float: return expr -def find_stream_options(options: dict[str, Any], name: str) -> dict[str, Any]: - """find option keys, which may be stream-specific - - :param options: source option dict (content will be modified) - :param suffix: matching suffix - :return: popped options - """ - - re_opt = re.compile(rf"{name}(?=\:|$)") - return [k for k in options if re_opt.match(k)] - - def pop_extra_options(options: dict[str, Any], suffix: str) -> dict[str, Any]: """pop matching keys from options dict @@ -802,3 +428,718 @@ def pop_global_options(options: dict[str, Any]) -> dict[str, Any]: all_gopts = caps.options("global") return {k: options.pop(k) for k in [k for k in options.keys() if k in all_gopts]} + + +def array_to_audio_options( + data: RawDataBlob | None, +) -> tuple[FFmpegOptionDict, tuple[ShapeTuple, DTypeString]]: + """create an input option dict for the given raw audio data blob + :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) + :returns: dict of audio options + """ + + shape, dtype = info = plugins.get_hook().audio_info(obj=data) + if shape is None: + return ({}, info) + sample_fmt, ac = guess_audio_format(shape, dtype) + codec, f = get_audio_codec(sample_fmt) + return ({"f": f, "c:a": codec, "ac": ac, "sample_fmt": sample_fmt}, info) + + +def array_to_video_options( + data: RawDataBlob | None = None, +) -> tuple[FFmpegOptionDict, tuple[ShapeTuple, DTypeString]]: + """create an input option dict for the given raw video data blob + + :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) + :return : option dict + """ + + shape, dtype = info = plugins.get_hook().video_info(obj=data) + if shape is None: + return ({}, info) + s, pix_fmt = guess_video_format(shape, dtype) + return ( + ( + {"f": "rawvideo", "c:v": "rawvideo"} + if s is None + else {"f": "rawvideo", "c:v": "rawvideo", "s": s, "pix_fmt": pix_fmt} + ), + info, + ) + + +def set_sp_kwargs_stdin( + url: str | None, info: InputInfoDict, sp_kwargs: dict = {} +) -> tuple[str, dict | None, Callable]: + """configure sp_kwargs for ffprobe/ffmpeg call to pipe-in the data via stdin + + :param url: input URL + :param info: input info + :param sp_kwargs: initial sp_kwargs keyword options + :return: tuple of url (or "pipe:0" if stdin data), updated sp_kwargs, and cleanup function + """ + + # ffprobe subprocess keywords + src_type = info["src_type"] + exit_fcn = lambda: None + + if src_type not in ("url", "filtergraph"): + url = "pipe:0" + if src_type == "buffer": + if "buffer" in info: + sp_kwargs = {**sp_kwargs, "input": info["buffer"]} + elif src_type == "fileobj": + f = info["fileobj"] + sp_kwargs = {**sp_kwargs, "stdin": f} + if f.readable() and f.seekable(): + pos = f.tell() + exit_fcn = lambda: f.seek(pos) # restore the read cursor position + else: + logger.warning("file object must be seekable.") + else: + logger.warning("unknown input source type.") + sp_kwargs = None + + return url, sp_kwargs, exit_fcn + + +def analyze_input_file( + fields: list[str], + input_url: str | None, + input_opts: dict, + input_info: InputInfoDict, + stream: str | StreamSpecDict | None = None, +) -> list[dict]: + """analyze a file and return requested field values of all returned streams + + :param fields: a list of stream properties + :param input_url: url or None if piped or fileobj + :param input_opts: input options + :param input_info: input infomration + :param stream: stream specifier, defaults to None to return all streams + :return values of the requested fields of the stream + """ + + input_url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(input_url, input_info) + try: + return probe.query( + input_url, + stream or True, + fields, + keep_optional_fields=True, + keep_str_values=False, + cache_output=True, + sp_kwargs=sp_kwargs, + f=input_opts.get("f", None), + ) + except: + raise + finally: + # rewind fileobj if possible + exit_fcn() + + +def analyze_input_stream( + fields: list[str], + stream: str, + media_type: MediaType, + input_url: FFmpegUrlType | None, + input_opts: FFmpegOptionDict, + input_info: InputInfoDict, +) -> list: + """analyze a stream and return requested field values + + :param fields: a list of stream properties + :param stream: stream specifier, first one is returned if it yields more than one stream, + :param input_url: url or None if piped or fileobj + :param input_opts: input options + :param input_info: input infomration + :raises FFmpegError: if provided data in input_info is insufficient + :return values of the requested fields of the stream + """ + + # run ffprobe on the input file for the stream to be used + q = analyze_input_file( + [*fields, "codec_type"], input_url, input_opts, input_info, stream + ) + + q = [i for i in q if media_type is None or i["codec_type"] == media_type] + if len(q) != 1: + raise FFmpegioError( + f"Specified {stream=} must resolve to one and only one {media_type} stream." + ) + + q = q[0] + return [q.get(f, None) for f in fields] + + +def video_fields_to_options( + pix_fmt: str, width: int, height: int, r1: Fraction | int, r2: Fraction | int +) -> tuple[Fraction | int, str, tuple[int, int]]: + return r1 or r2, pix_fmt, (width, height) if width and height else None + + +def analyze_video_stream( + stream_specifier: str, + inurl: FFmpegUrlType, + inopts: FFmpegOptionDict, + input_info: InputInfoDict, +) -> tuple[int | Fraction | None, str | None, tuple[int, int] | None]: + """analyze video stream core attributes + + :param args: FFmpeg arguments (will be modified) + :param ofile: output index, defaults to 0 + :param input_info: source information of the inputs, defaults to [] + :return r: video framerate + :return pix_fmt: pixel format + :return s: video shape tuple (width, height) + """ + + options = ["r", "pix_fmt", "s"] + fields = ["pix_fmt", "width", "height", "r_frame_rate", "avg_frame_rate"] + + # get input options + inopt_vals = [inopts.get(o, None) for o in options] + + # directly from the input url (if not forced via input options) + if not all(inopt_vals): + st_vals = video_fields_to_options( + *analyze_input_stream( + fields, stream_specifier, "video", inurl, inopts, input_info + ) + ) + inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + + return inopt_vals + + +def analyze_audio_stream( + stream_specifier: str, + inurl: FFmpegUrlType, + inopts: FFmpegOptionDict, + input_info: InputInfoDict, +) -> tuple[int | None, str | None, int | None]: + """analyze input audio stream + + :param args: FFmpeg arguments. The option dict in args['outputs'][ofile][1] may be modified. + :param ofile: output file index, defaults to 0 + :param input_info: list of input information, defaults to None + :return ar: sampling rate + :return sample_fmt: input sample format + :return ac: number of channels + + * Possible Output Options Modification + - "f" and "c:a" - raw audio format and codec will always be set + - "sample_fmt" - planar format to non-planar equivalent format or 'dbl' if format is unknown + - + + * args['outputs'][ofile]['map'] is a valid mapping str (not a list of str) + * If complex filtergraph(s) is used, args['global_options']['filter_complex'] must be a list of fgb.Graph objects + + """ + + options = ["ar", "sample_fmt", "ac"] + fields = ["sample_rate", "sample_fmt", "channels"] + + inopt_vals = [inopts.get(o, None) for o in options] + + # fill the still missing values directly from the input url + if not all(inopt_vals): + st_vals = analyze_input_stream( + fields, stream_specifier, "audio", inurl, inopts, input_info + ) + inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + + return inopt_vals + + +def analyze_complex_filtergraphs( + filtergraphs: list[FilterGraphObject | str], + inputs: list[tuple[FFmpegUrlType | None, FFmpegOptionDict]], + inputs_info: list[InputInfoDict], +) -> tuple[list[FilterGraphObject], dict[str, FilterGraphInfoDict]]: + """analyze filtergraphs and return requested field values + + :param fields: a list of stream properties + :param stream: stream specifier, first one is returned if it yields more than one stream, + :param inputs: input url/options pairs + :param input_info: input information + :return filters_complex: list of filtergraphs with all the unnamed outputs auto-named + :return fg_info: all the output pads and their properties + """ + + filtergraphs = [ + fgb.as_filtergraph_object(fg, copy=True) + for fg in as_multi_option(filtergraphs, (str, FilterGraphObject)) + ] + + # label unlabeled outputs (and return modified fg's) + i = 0 + for j, fg in enumerate(filtergraphs): + new_labels = [] + for padidx, filt, _ in fg.iter_output_pads( + full_pad_index=True, unlabeled_only=True + ): + label = f"out{i}" + while fg.has_label(label): + i += 1 + label = f"out{i}" + i += 1 + new_labels.append((padidx, label)) + for padidx, label in new_labels: + fg = fg.add_label(label, outpad=padidx) + filtergraphs[j] = fg + + # combine all filtergraphs + fg = fgb.stack(*filtergraphs, auto_link=True) + + # get list of connected input streams + sources = [] + labels = set() + + # for a filter or a filterchain, no labels. Connect all its inputs + for i, (padidx, filt, _) in enumerate( + fg.iter_input_pads(full_pad_index=True, exclude_stream_specs=False) + ): + label = fg.get_label(inpad=padidx) + media_type = filt.get_pad_media_type("input", padidx[-1]) + + if label is None: + file_id = 0 + sspec = None + if i > 0: + raise FFmpegioError( + "All the input pads of a filtergraph with more than one inputs must have them labeled." + ) + else: + map_option = parse_map_option(label) + file_id = map_option["input_file_id"] + sspec = map_option.get("stream_specifier", None) + labels.add(label) + + if media_type == "audio": + src = temp_audio_src( + *analyze_audio_stream( + sspec or "a:0", *inputs[file_id], inputs_info[file_id] + ) + ) + elif media_type == "video": + src = temp_video_src( + *analyze_video_stream( + sspec or "v:0", *inputs[file_id], inputs_info[file_id] + ) + ) + else: + raise FFmpegioError("unknown media type of a filter") + + sources.append((src, (0, len(src) - 1, 0), padidx)) + + # remove all the input labels + for label in labels: + fg.remove_label(label) + + # add sources to the filtergraph + fg = sources >> fg + + # rename the output + fg_outputs = [] + for i, (padidx, filt, _) in enumerate(fg.iter_output_pads(full_pad_index=True)): + label = fg.get_label(outpad=padidx) + fg_outputs.append((label, f"out{i}", padidx)) + + for label, new_label, padidx in fg_outputs: + fg = fg.add_label(new_label, outpad=padidx, force=True) + + # query the filtergraph + fields = [ + "codec_type", + "pix_fmt", + "width", + "height", + "r_frame_rate", + "avg_frame_rate", + "sample_rate", + "sample_fmt", + "channels", + ] + streams = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + ) + + fg_info = {} + for (label, *_), q in zip(fg_outputs, streams): + label = f"[{label}]" + if q["codec_type"] == "audio": + fg_info[label] = { + "media_type": "audio", + "sample_fmt": q["sample_fmt"], + "ac": q["channels"], + "ar": q["sample_rate"], + } + elif q["codec_type"] == "video": + fg_info[label] = { + "media_type": "video", + **{ + k: v + for k, v in zip( + ("r", "pix_fmt", "s"), + video_fields_to_options(*(q[f] for f in fields[1:6])), + ) + }, + } + + return filtergraphs, fg_info + + +def analyze_output_video_filter( + filtergraph: FilterGraphObject, + r_in: Fraction | int, + pix_fmt_in: str, + s_in: tuple[int, int], + s: tuple[int, int] | None = None, +) -> tuple[int | Fraction, str, tuple[int, int]]: + """analyze an output video filter + + :param filtergraph: simple filter graph. + :param r_in: input frame rate + :param pix_fmt_in: input pixel format + :param s_in: input frame shape (width, height) + :param s: -s output option, defaults to None (not given) + :return r: output frame rate + :return pix_fmt: output pixel format + :return s: output frame shape (width, height) + + """ + + # append a color source filter to the filtergraph + fg = temp_video_src(r_in, pix_fmt_in, s_in) + fgb.as_filtergraph_object(filtergraph) + + if s is not None: + fg += fgb.scale(*s) + + # query the filtergraph + fields = ["pix_fmt", "width", "height", "r_frame_rate", "avg_frame_rate"] + stream = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + )[0] + + return video_fields_to_options(*(stream[f] for f in fields)) + + +def analyze_output_audio_filter( + filtergraph: FilterGraphObject, + ar_in: int, + sample_fmt_in: str, + ac_in: int, +) -> tuple[int, str, tuple[int, int]]: + """analyze an output audio filter + + :param filtergraph: simple filter graph. + :param ar: input sampling rate + :param sample_fmt: input sample format + :param ac: input number of channels + :return ar: output sampling rate + :return sample_fmt: output sample format + :return ac: output number of channels + + """ + + # append a color source filter to the filtergraph + fg = temp_audio_src(ar_in, sample_fmt_in, ac_in) + fgb.as_filtergraph_object( + filtergraph + ) + + # query the filtergraph + fields = ["sample_rate", "sample_fmt", "channels"] + stream = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + )[0] + + return (*stream.values(),) + + +def is_valid_input_url(url: FFmpegInputUrlComposite) -> bool: # get the option dict + # check url (must be url and not fileobj) + valid = isinstance(url, (str, FilterGraphObject, FFConcat)) + if not valid: + valid = is_fileobj(url, readable=True) + + if not valid: + try: + memoryview(url) + except TypeError: + pass + else: + valid = True + + return valid + + +def is_valid_output_url(url: FFmpegOutputUrlComposite) -> bool: + valid = isinstance(url, str) + + # check url (must be url and not fileobj) + if not valid: + valid = is_fileobj(url, writable=True) + + return valid + + +def find_filter_simple_option( + options: FFmpegOptionDict, media_type: MediaType | None = None +) -> ( + Literal[ + "filter_complex_script", + "filter", + "/filter", + "af", + "/af", + "filter:a", + "/filter:a", + "vf", + "/vf", + "filter:v", + "/filter:v", + ] + | None +): + """Returns FFmpeg argument which specify a simple filter graph + + :param options: FFmpeg argument dict + :param media_type: for output stream filter, specify to check a particular + media type, defaults to checking both types of filters + :return: FFmpeg option name if filter graph is specified else None + """ + + optnames = { + None: ( + "filter", + "/filter", + "af", + "/af", + "filter:a", + "/filter:a", + "vf", + "/vf", + "filter:v", + "/filter:v", + ), + "audio": ("af", "/af", "filter:a", "/filter:a"), + "video": ("vf", "/vf", "filter:v", "/filter:v"), + }[media_type] + + return next((o for o in optnames if o in options), None) + + +def find_filter_complex_option( + options: FFmpegOptionDict, +) -> ( + Literal[ + "filter_complex", + "/filter_complex", + "lavfi", + "/lavfi", + "filter_complex_script", + ] + | None +): + """Return FFmpeg option name, which specifies a complex filter graph + + :param options: FFmpeg option argument dict + :return: FFmpeg option name if filter graph is specified else None + """ + + optnames = ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + + return next((o for o in optnames if o in options), None) + + +def input_file_stream_specs( + url: FFmpegUrlType | FilterGraphObject | None, + stream_spec: str | None = None, + stream_options: FFmpegOptionDict | None = None, + stream_info: InputInfoDict | None = None, +) -> dict[int, str]: + """probe a url and return stream index to stream spec mapping + + :param url: media file url + :return: mapping of audio or video stream indices to stream specs. + """ + + info = stream_info or {"src_type": "url"} + opts = stream_options or {} + + # check raw formats first + if "media_type" in info: + # raw input format, always single-stream + return {0: f"{info['media_type'][0]}:0"} + + # file/network input - process only if seekable + # get ffprobe subprocess keywords + url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(url, info) + if sp_kwargs is None: + # something failed (warning logged) + return {} + + try: + streams = [ + st + for st in analyze_input_file( + ["index", "codec_type"], url, opts, {"src_type": "url"}, stream_spec + ) + if st["codec_type"] in ("audio", "video") + ] + finally: + exit_fcn() + + specs = {} + counts = defaultdict(int) + for st in streams: + media_type = st["codec_type"] + specs[st["index"]] = f"{media_type[0]}:{counts[media_type]}" + counts[media_type] += 1 + return specs + + +def expand_raw_output_streams( + output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, + input_urls: list[FFmpegInputOptionTuple], + options: FFmpegOptionDict, +) -> list[FFmpegOptionDict] | dict[str, FFmpegOptionDict]: + """resolve the raw output streams from given sequence of map options + + :param stream_opts: output raw stream options + :param stream_names: user-specified names of output streams keyed by the index of `stream_opts` + :param args: FFmpeg argument dict + :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` + :return: list of individual output streams. Each item is a tuple of + (stream_index, output_opts, partial_RawOutputInfoDict) + + -stream_index - index of streams + -map_spec - final output option + -partial_RawOutputInfoDict - to-be-completed output_info entry + + Since a map option value may yield multiple media streams (e.g., '0' or '0:v'), + the length of returned outputs may be longer than the number of streams given. + The user specified map value is returned in the 'user_label' field of the returned + dicts while the + + simpler version of configure.resolve_raw_output_streams() + + """ + + if output_streams is not None and len(output_streams) == 0: + output_streams = None + + # if no complex filtergraph + fg_opt = find_filter_complex_option(options) + if fg_opt is None: + if output_streams is None: + # nothing specified, use all streams + input_streams = {} + for i, (url, opts) in enumerate(input_urls): + if not is_url(url): + raise ValueError( + "output_streams cannot be autoassigned for a non-url input." + ) + + input_streams |= { + (i, j): f"{i}:{spec}" + for j, spec in input_file_stream_specs(url).items() + } + return [{"map": v} for v in input_streams.values()] + + # parse all mapping option values + input_file_id = None if len(input_urls) > 1 else 0 + + if isinstance(output_streams, dict): + stream_names = list[output_streams] + output_streams = list[output_streams.values()] + else: + stream_names = [None] * len(output_streams) + + # expand + new_streams = [] + new_names = [] + for name, opts in zip(stream_names, output_streams): + map_opt = stream_spec.parse_map_option( + opts["map"], input_file_id=input_file_id + ) + if "linklabel" in map_opt: + raise FFmpegioError( + f"linklabel {map_opt['linklabel']} is mapped but no complex filter defined." + ) + + file_id = map_opt["input_file_id"] + url = input_urls[file_id] + stream_info = input_file_stream_specs(url, map_opt["stream_specifier"]) + for st_map in stream_info.values(): + new_streams.append(opts | {"map": f"{file_id}:{st_map}"}) + new_names.append(name) + + return ( + new_streams + if new_names[0] is None + else {k: v for k, v in zip(new_names, new_streams)} + ) + + else: + if output_streams is None: + # assign all the output linklabels + fg = fgb.as_filtergraph(options[fg_opt]) + return [{"map": f"[{label}]"} for label in fg.iter_output_labels()] + else: + # filtergraph output label must be uniquely mapped + return output_streams + + +def raw_input_options( + stream_types: Sequence[Literal["a", "v"]], + stream_args: Sequence[RawStreamDef], +) -> tuple[list[FFmpegOptionDict], list[RawDataBlob]]: + """convert raw input stream type+args specification to options+data format + + :param input_stream_types: list/string of 'a' or 'v', specifying the media types + :param input_stream_args: list of a tuple pair of rate & data or data & options + If option dict specified, it must include `'ar'` + (audio) or `'r'` (video) to specify the stream rate. + :return options: list of input options dict + :return data: list of input data + """ + opts = [] + data = [] + for mtype, arg in zip(stream_types, stream_args): + try: + ropt = {"v": "r", "a": "ar"}[mtype] # rate option + except KeyError as e: + raise FFmpegioError( + "Invalid stream type specification (must be 'a' or 'v')" + ) from e + + a1, a2 = arg + if isinstance(a1, (int, Fraction, float)): + # rate specified + if not isinstance(a1, (int, Fraction)): + try: + a1 = Fraction.from_float(float(a1)) + except ValueError as e: + raise ValueError( + "Stream rate must be given as an int or Fraction" + ) from e + data.append(a2) + opts.append({ropt: a1}) + else: + # options specified + if ropt not in a2: + raise ValueError(f"Missing the required rate option: {ropt}") + data.append(a1) + opts.append(a2) + + return opts, data diff --git a/src/ffmpegio/utils/avi.py b/src/ffmpegio/utils/avi.py deleted file mode 100644 index 3d9485e9..00000000 --- a/src/ffmpegio/utils/avi.py +++ /dev/null @@ -1,644 +0,0 @@ -from io import SEEK_CUR -import fractions, re -from struct import Struct -from collections import namedtuple -from itertools import accumulate - -from ..utils import get_video_format, get_audio_format, stream_spec, get_samplesize -from .. import plugins - -# https://docs.microsoft.com/en-us/previous-versions//dd183376(v=vs.85)?redirectedfrom=MSDN - - -class FlagProcessor: - def __init__(self, name, flags, masks, defaults): - self.template = namedtuple( - name, - flags, - defaults=defaults, - ) - self.masks = self.template._make(masks) - - def default(self): - return self.template() - - def unpack(self, flags): - return self.template._make((bool(flags & mask) for mask in self.masks)) - - def pack(self, flags): - return sum((mask if flag else 0 for flag, mask in zip(flags, self.masks))) - - -class StructProcessor: - def __init__(self, name, format, fields, defaults=None, **flags): - if "S" in format or "C" in format: - # expand the format - m = re.match(r"([<>!=])?(.+)", format) - fmt_items = [ - (int(m[1]) if m[1] else 1, m[2]) - for m in re.finditer(r"(\d*)([xcCbB?hHiIlLqQnNefdsSpP])", m[2]) - ] - fmt_counts = [1 if f in "sSp" else count for count, f in fmt_items] - fmt_offsets = list((0, *accumulate(fmt_counts))) - is_str = [False] * fmt_offsets[-1] - for itm, offset in zip(fmt_items, fmt_offsets[:-1]): - is_str[offset] = itm[1] in "SC" - self.is_str = [fields[i] for i, tf in enumerate(is_str) if tf] - format = format.replace("C", "c").replace("S", "s") - else: - self.is_str = () - - self.struct = Struct(format) - self.template = namedtuple(name, fields, defaults=defaults) - self.flags = ((k, FlagProcessor(*v)) for k, v in flags.items()) - - def default(self): - data = self.template() - return data._replace(**{k: proc.default() for k, proc in self.flags}) - - def _unpack(self, data): - data = self.template._make(data) - return data._replace( - **{field: getattr(data, field).decode("utf-8") for field in self.is_str}, - **{k: proc.unpack(getattr(data, k)) for k, proc in self.flags}, - ) - - def unpack(self, buffer): - return self._unpack(self.struct.unpack(buffer)) - - def unpack_from(self, buffer, offset=0): - return self._unpack(self.struct.unpack_from(buffer, offset)) - - def _pack(self, ntuple): - return ntuple._replace( - **{k: proc.pack(getattr(ntuple, k)) for k, proc in self.flags}, - **{field: ntuple[field].encode("utf-8") for field in self.is_str}, - ) - - def pack(self, ntuple): - return self.struct.pack(*self._pack(ntuple)) - - def pack_into(self, buffer, offset, ntuple): - self.struct.pack_into(buffer, offset, *self._pack(ntuple)) - - @property - def size(self): - return self.struct.size - - -AVIMainHeader = StructProcessor( - "Avih", - "<10I", - ( - "micro_sec_per_frame", - "max_bytes_per_sec", - "padding_granularity", - "flags", - "total_frames", - "initial_frames", - "streams", - "suggested_buffer_size", - "width", - "height", - ), - (0,) * 10, - flags=( - "AvihFlags", - ( - "copyrighted", - "has_index", - "is_interleaved", - "must_use_index", - "was_capture_file", - ), - ( - int("0x00020000", 0), - int("0x00000010", 0), - int("0x00000100", 0), - int("0x00000020", 0), - int("0x00010000", 0), - ), - (False,) * 5, - ), -) - - -AVIStreamHeader = StructProcessor( - "AVISTREAMHEADER", - "<4S4SI2H8I4h", - ( - "fcc_type", # 'auds','mids','txts','vids' - "fcc_handler", - "flags", - "priority", - "language", - "initial_frame", - "scale", - "rate", - "start", - "length", - "suggested_buffer_size", - "quality", - "sample_size", - "frame_left", - "frame_top", - "frame_right", - "frame_bottom", - ), - (b"\0" * 4, b"\0" * 4, *((0,) * 15)), - flags=( - "StrhFlags", - ( - "video_pal_changes", - "disabled", - ), - ( - int("0x00000001", 0), - int("0x00010000", 0), - ), - (False,) * 2, - ), -) - -# PCM audio -WAVE_FORMAT_PCM = 1 -# IEEE floating-point audio -WAVE_FORMAT_IEEE_FLOAT = 3 -WAVE_FORMAT_EXTENSIBLE = int("FFFE", 16) # /* Microsoft, 65534 */ - -BitmapInfoHeader = StructProcessor( - "BITMAPINFOHEADER", - "IiiHH4sIiiII", - ( - "size", - "width", - "height", - "planes", - "bit_count", - "compression", # convert to str if 1st byte is >=4 - "size_image", - "x_pels_per_meter", - "y_pels_per_meter", - "clr_used", - "clr_important", - ), - (0,) * 11, -) - -WaveFormatEx = StructProcessor( - "WAVEFORMATEX", - "HHIIHH", - ( - "format_tag", - "channels", - "samples_per_sec", - "avg_bytes_per_sec", - "block_align", - "bits_per_sample", - ), - (0,) * 6, -) - -WaveFormatExtensible = StructProcessor( - "WAVEFORMATEXTENSIBLE", - "HHIH14s", - ( - "size", - "samples", - "channel_mask", - "sub_format_wave", - "sub_format_rest", - ), - (*((0,) * 3), 0, "\0" * 14), -) - - -VideoPropHeader = StructProcessor( - "VPRP", - "5IHH3I", - ( - "video_format_token", - "video_standard", - "vertical_refresh_rate", - "h_total_in_t", - "v_total_in_lines", - "frame_aspect_ratio_y", - "frame_aspect_ratio_x", - "frame_width_in_pixels", - "frame_height_in_lines", - "field_per_frame", - ), - ((0,) * 10), -) - -VPRP_VideoField = StructProcessor( - "VPRP_VIDEO_FIELD_DESC", - "8I", - ( - "compressed_bm_height", - "compressed_bm_width", - "valid_bm_height", - "valid_bm_width", - "valid_bm_x_offset", - "valid_bm_y_offset", - "video_x_offset_in_t", - "video_y_valid_start_line", - ), - ((0,) * 8), -) - - -ChunkHeader = StructProcessor("CHDR", "<4SI", ("id", "datasize")) - - -fcc_types = dict(vids="v", auds="a", txts="s") # , mids="midi") - - -def read_chunk_header(f): - b = f.read(ChunkHeader.size) - id, datasize = ChunkHeader.unpack(b) - list_type = None - if id in ("RIFF", "LIST"): - list_type = f.read(4).decode("utf-8") - datasize -= 4 - chunksize = datasize + 1 if datasize % 2 else datasize - return id, datasize, chunksize, list_type - - -def get_chunk_header(b, offset=0): - id, datasize = ChunkHeader.unpack_from(b, offset) - offset += ChunkHeader.size - list_type = None - if id in ("RIFF", "LIST"): - list_type = b[offset : offset + 4].decode("utf-8") - offset += 4 - datasize -= 4 - chunksize = datasize + 1 if datasize % 2 else datasize - return offset, chunksize, id, list_type - - -def get_stream_header(b, offset, end): - data = {} - - offset, chunksize, id, _ = get_chunk_header(b, offset) - data[id] = strh = AVIStreamHeader.unpack_from(b, offset) - offset += chunksize - - offset, chunksize, id, _ = get_chunk_header(b, offset) - if strh.fcc_type == "vids": - data[id] = BitmapInfoHeader.unpack_from(b, offset) - - # if 1st byte is a readable ascii char - compression = data[id].compression - comp_val = compression[0] - data[id] = data[id]._replace( - compression=comp_val if comp_val < 32 else compression.decode("utf-8") - ) - - # offset += chunksize - # while offset < end: - # offset, chunksize, id, _ = get_chunk_header(b, offset) - # if id == "vprp": - # vprp = VideoPropHeader.unpack_from(b, offset) - # offset += VideoPropHeader.size - # ninfo = VPRP_VideoField.size - # field_info = [ - # VPRP_VideoField.unpack_from(b, i) - # for i in range(offset, offset + ninfo * vprp.field_per_frame, ninfo) - # ] - # data[id] = namedtuple( - # type(vprp).__name__, (*vprp._fields, "field_info") - # )(*vprp, field_info) - # break - # else: - # offset += chunksize - - elif strh.fcc_type == "auds": - strf = WaveFormatEx.unpack_from(b, offset) - if strf.format_tag == WAVE_FORMAT_EXTENSIBLE: - strfext = WaveFormatExtensible.unpack_from(b, offset + WaveFormatEx.size) - strf = namedtuple( - type(strfext).__name__, (*strf._fields, *strfext._fields) - )(strfext.sub_format_wave, *strf[1:], *strfext) - data[id] = strf - else: - raise RuntimeError(f"Unsupported stream type: {strh.fcc_type}") - - return data - - -def _seek(f, n): - try: - f.seek(n, SEEK_CUR) - except: - f.read(n) - - -def read_header(f, pix_fmt=None): - - # read the RIFF header - id, datasize, chunksize, list_type = read_chunk_header(f) - if id != "RIFF" or list_type != "AVI ": - raise RuntimeError(f"File stream is not AVI") - - # read the hdrl chunk - id, datasize, chunksize, list_type = read_chunk_header(f) - if id != "LIST" and list_type != "hdrl": - raise RuntimeError(f"AVI is missing header chunk") - b = f.read(datasize) - if chunksize > datasize: - _seek(f, 1) - - # read until encountering the movi list - while True: - id, _, chunksize, list_type = read_chunk_header(f) - if list_type == "movi": - break - _seek(f, chunksize) - - # parse hdrl LIST chunk - offset, chunksize, id, list_type = get_chunk_header(b) - if id != "avih": - raise RuntimeError("missing avi chunk") - avih = AVIMainHeader.unpack_from(b, offset) - offset += chunksize - streams = [] - while True: - try: - offset, chunksize, id, list_type = get_chunk_header(b, offset) - except: - break - if list_type != "strl": - break - - streams.append(get_stream_header(b, offset, offset + chunksize)) - offset += chunksize - - def get_stream_info(i, strl, use_ya): - strh = strl["strh"] - strf = strl["strf"] - type = fcc_types[strh.fcc_type] # raises if not valid type - info = dict(index=i, type=type) - if type == fcc_types["vids"]: - info["frame_rate"] = fractions.Fraction(strh.rate, strh.scale) - info["width"] = strf.width - info["height"] = abs(strf.height) - bpp = strf.bit_count - compression = strf.compression - # force unsupported pixel formats - info["pix_fmt"] = ( - {"Y800": "gray", "RGBA": "rgba"}.get(compression, None) - if isinstance(compression, str) - else (compression, bpp) - if compression - else "rgba64le" - if bpp == 64 - else "rgb48le" - if bpp == 48 - else ("ya16le" if use_ya else "grayf32le") - if bpp == 32 - else "rgb24" - if bpp == 24 - else ("ya8" if use_ya else "gray16le") - if bpp == 16 - else None - ) - # vprp = strl.get("vprp", None) - # info["dar"] = ( - # fractions.Fraction(vprp.frame_aspect_ratio_x, vprp.frame_aspect_ratio_y) - # if vprp - # else None - # ) - info["dtype"], info["shape"] = get_video_format( - info["pix_fmt"], (info["width"], info["height"]) - ) - elif type == fcc_types["auds"]: #'audio' - info["sample_rate"] = strf.samples_per_sec - info["channels"] = strf.channels - - strf_format = ( - strf.format_tag, - strf.bits_per_sample, - ) - - info["sample_fmt"] = { - (WAVE_FORMAT_PCM, 8): "u8", - (WAVE_FORMAT_PCM, 16): "s16", - (WAVE_FORMAT_PCM, 32): "s32", - (WAVE_FORMAT_PCM, 64): "s64", - (WAVE_FORMAT_IEEE_FLOAT, 32): "flt", - (WAVE_FORMAT_IEEE_FLOAT, 64): "dbl", - }.get(strf_format, strf_format) - # TODO: if need arises, resolve more formats, need to include codec names though - info["dtype"], info["shape"] = get_audio_format( - info["sample_fmt"], info["channels"] - ) - return info - - return [get_stream_info(i, strl, pix_fmt) for i, strl in enumerate(streams)], ( - avih, - streams, - ) - - -re_movi = re.compile(r"\d{2}(?:wb|db|dc|tx)") - - -def read_frame(f): - while True: - id, datasize, chunksize, list_type = read_chunk_header(f) - if not list_type: - m = re_movi.match(id) - if m: # data chunk found - b = f.read(datasize) - if chunksize > datasize: - _seek(f, chunksize - datasize) - return int(id[:2]), b - else: - _seek(f, chunksize) - - id, datasize, chunksize, list_type = read_chunk_header(f) - - -####################################################################################################### - - -class AviReader: - def __init__(self): - self._f = None - self.ready = False #:bool: True if AVI headers has been processed - self.streams = None #:dict: Stream headers keyed by stream id (int key) - self.itemsizes = None #:dict: sample size of each stream in bytes - - hook = plugins.get_hook() - self.converters = {"v": hook.bytes_to_video, "a": hook.bytes_to_audio} - #:dict : bytes to media data object conversion functions keyed by stream type - - def start(self, f, pix_fmt=None): - self._f = f - hdr = read_header(self._f, pix_fmt)[0] - - cnt = {"v": 0, "a": 0, "s": 0} - - def set_stream_info(hdr): - st_type = hdr["type"] - id = cnt[st_type] - cnt[st_type] += 1 - return { - "spec": stream_spec(id, st_type), - **hdr, - } - - self.streams = {v["index"]: set_stream_info(v) for v in hdr} - self.itemsizes = { - v["index"]: get_samplesize(v["shape"], v["dtype"]) for v in hdr - } - self.ready = True - - def __next__(self): - i = d = None - while i is None: # None if unknown frame format, skip - try: - i, d = read_frame(self._f) - except: - raise StopIteration - return i, d - - def __iter__(self): - return self - - def from_bytes(self, id, b): - info = self.streams[id] - return self.converters[info["type"]]( - b=b, dtype=info["dtype"], shape=info["shape"], squeeze=False - ) - - -# ( -# "hdrl", -# [ -# ( -# "avih", -# { -# "micro_sec_per_frame": 66733, -# "max_bytes_per_sec": 3974198, -# "padding_granularity": 0, -# "flags": 0, -# "total_frames": 0, -# "initial_frames": 0, -# "streams": 2, -# "suggested_buffer_size": 1048576, -# "width": 352, -# "height": 240, -# }, -# ), -# ( -# "strl", -# [ -# ( -# "strh", -# { -# "fcc_type": "vids", -# "fcc_handler": "\x00\x00\x00\x00", -# "flags": 0, -# "priority": 0, -# "language": 0, -# "initial_frames": 0, -# "scale": 200, -# "rate": 2997, -# "start": 0, -# "length": 1073741824, -# "suggested_buffer_size": 1048576, -# "quality": 4294967295, -# "sample_size": 0, -# "frame_left": 0, -# "frame_top": 0, -# "frame_right": 352, -# "frame_bottom": 240, -# }, -# ), -# ( -# "strf", -# { -# "size": 40, -# "width": 352, -# "height": -240, -# "planes": 1, -# "bit_count": 24, -# "compression": "rgb24", -# "size_image": 253440, -# "x_pels_per_meter": 0, -# "y_pels_per_meter": 0, -# "clr_used": 0, -# "clr_important": 0, -# }, -# ), -# ( -# "vprp", -# { -# "video_format_token": 0, -# "video_standard": 0, -# "vertical_refresh_rate": 15, -# "h_total_in_t": 352, -# "v_total_in_lines": 240, -# "frame_aspect_ratio": Fraction(15, 22), -# "frame_width_in_pixels": 352, -# "frame_height_in_lines": 240, -# "field_per_frame": 1, -# "field_info": ( -# { -# "compressed_bm_height": 240, -# "compressed_bm_width": 352, -# "valid_bm_height": 240, -# "valid_bm_width": 352, -# "valid_bmx_offset": 0, -# "valid_bmy_offset": 0, -# "video_x_offset_in_t": 0, -# "video_y_valid_start_line": 0, -# }, -# ), -# }, -# ), -# ], -# ), -# ( -# "strl", -# [ -# ( -# "strh", -# { -# "fcc_type": "auds", -# "fcc_handler": "\x01\x00\x00\x00", -# "flags": 0, -# "priority": 0, -# "language": 0, -# "initial_frames": 0, -# "scale": 1, -# "rate": 44100, -# "start": 0, -# "length": 1073741824, -# "suggested_buffer_size": 12288, -# "quality": 4294967295, -# "sample_size": 4, -# "frame_left": 0, -# "frame_top": 0, -# "frame_right": 0, -# "frame_bottom": 0, -# }, -# ), -# ( -# "strf", -# { -# "format_tag": 1, -# "channels": 2, -# "samples_per_sec": 44100, -# "avg_bytes_per_sec": 176400, -# "block_align": 4, -# "bits_per_sample": 16, -# }, -# ), -# ], -# ), -# ], -# 368, -# ) diff --git a/src/ffmpegio/utils/concat.py b/src/ffmpegio/utils/concat.py index 5cd89ebf..52eaa010 100644 --- a/src/ffmpegio/utils/concat.py +++ b/src/ffmpegio/utils/concat.py @@ -1,16 +1,17 @@ """FFConcat class to build/use ffconcat list file for concat demuxer """ -from glob import glob -import io, re +import io +import logging import os -from tempfile import NamedTemporaryFile +import re from functools import partial -import logging +from glob import glob +from tempfile import NamedTemporaryFile logger = logging.getLogger("ffmpegio") -from . import escape, unescape +from .._utils import escape, unescape # https://trac.ffmpeg.org/wiki/Concatenate # https://ffmpeg.org/ffmpeg-formats.html#concat @@ -494,7 +495,7 @@ def url(self): @property def script(self): """:str: composed concat listing script""" - return (self._temp_file or self.compose()).getvalue() + return self.compose().getvalue() @property def input(self): diff --git a/src/ffmpegio/utils/log.py b/src/ffmpegio/utils/log.py index b254e3cb..708e6397 100644 --- a/src/ffmpegio/utils/log.py +++ b/src/ffmpegio/utils/log.py @@ -1,8 +1,10 @@ import re from fractions import Fraction -from . import layout_to_channels +from .. import utils +from .._typing import RawStreamInfoTuple, Sequence from ..caps import sample_fmts +from . import layout_to_channels _re_audio = re.compile(r"(?:(\d+) Hz, )?(.+)") @@ -111,19 +113,19 @@ def parse_log_video_stream(info): ) -def extract_output_stream(logs, file_id=0, stream_id=0, hint=None): +def extract_output_stream( + logs: str | Sequence[str], + file_id: int = 0, + stream_id: int = 0, + hint: int | None = None, +) -> dict: """extract output stream info from the log lines :param logs: lines of FFmpeg log messages - :type logs: seq(str) :param file_id: output file id, defaults to 0 - :type file_id: int, optional :param stream_id: output stream id, defaults to 0 - :type stream_id: int, optional :param hint: starting log line index to search, defaults to None - :type hint: int, optional :return: stream information - :rtype: dict """ if isinstance(logs, str): logs = re.split(r"[\n\r]+", logs) @@ -155,3 +157,36 @@ def extract_output_stream(logs, file_id=0, stream_id=0, hint=None): raise RuntimeError(f"parser for {type.lower()} codec is not defined.") return sinfo + + +def extract_output_audio_raw_info( + logs: str | Sequence[str], file_id=0, stream_id=0, hint=None +) -> RawStreamInfoTuple: + """extract output stream info from the log lines + + :param logs: lines of FFmpeg log messages + :param file_id: output file id, defaults to 0 + :param stream_id: output stream id, defaults to 0 + :param hint: starting log line index to search, defaults to None + :return: stream information + """ + + info = extract_output_stream(logs, file_id, stream_id, hint) + return utils.get_audio_format(info["sample_fmt"])[0], info["ac"], info["ar"] + + +def extract_output_video_raw_info( + logs: str | Sequence[str], file_id=0, stream_id=0, hint=None +) -> RawStreamInfoTuple: + """extract output stream info from the log lines + + :param logs: lines of FFmpeg log messages + :param file_id: output file id, defaults to 0 + :param stream_id: output stream id, defaults to 0 + :param hint: starting log line index to search, defaults to None + :return: stream information + """ + + info = extract_output_stream(logs, file_id, stream_id, hint) + dtype, nb_comp = utils.get_pixel_format(info["pix_fmt"]) + return dtype, (*info["s"][::-1], nb_comp), info["r"] diff --git a/src/ffmpegio/utils/parser.py b/src/ffmpegio/utils/parser.py index c07487a0..509a3953 100644 --- a/src/ffmpegio/utils/parser.py +++ b/src/ffmpegio/utils/parser.py @@ -1,8 +1,10 @@ -import re, os, shlex +import os +import re +import shlex from collections import abc -from ..filtergraph import Graph, Chain, Filter from .. import devices +from ..filtergraph import Chain, Filter, Graph __all__ = ["parse", "compose", "FLAG"] @@ -145,12 +147,6 @@ def finalize_global(key, val): def finalize_output(key, val): if re.match(r"s(?:\:|$)", key) and not isinstance(val, str): val = "x".join((str(v) for v in val)) - elif key == "map" and not isinstance(val, str): - # if an entry is a seq, join with ':' - val = [ - v if isinstance(v, str) else ":".join((str(vi) for vi in v)) - for v in val - ] return key, val def finalize_input(key, val): diff --git a/src/ffmpegio/utils/typing.py b/src/ffmpegio/utils/typing.py deleted file mode 100644 index 3f4bf871..00000000 --- a/src/ffmpegio/utils/typing.py +++ /dev/null @@ -1,33 +0,0 @@ -from __future__ import annotations - -from typing import * -from typing_extensions import * - -# from typing_extensions import * - -MediaType = Literal["v", "a", "s", "d", "t", "V"] -# libavformat/avformat.c:match_stream_specifier() - - -class StreamSpec_Options(TypedDict): - media_type: MediaType # py3.11 NotRequired[MediaType] - file_index: int # py3.11 NotRequired[int] - program_id: int # py3.11 NotRequired[int] - group_index: int # py3.11 NotRequired[int] - group_id: int # py3.11 NotRequired[int] - stream_id: int # py3.11 NotRequired[int] - - -class StreamSpec_Index(StreamSpec_Options): - index: int - - -class StreamSpec_Tag(StreamSpec_Options): - tag: Union[str, Tuple[str, str]] - - -class StreamSpec_Usable(StreamSpec_Options): - usable: bool - - -StreamSpec = Union[StreamSpec_Index, StreamSpec_Tag, StreamSpec_Usable] diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 23ac43a5..b141f46b 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -1,106 +1,70 @@ +import logging import warnings -from . import ffmpegprocess as fp, utils, configure, FFmpegError, plugins, analyze -from .probe import _video_info as _probe_video_info -from .utils import log as log_utils +from fractions import Fraction + +from . import analyze, configure, utils +from . import filtergraph as fgb +from ._typing import ( + Any, + DTypeString, + FFmpegOptionDict, + ProgressCallable, + RawDataBlob, + ShapeTuple, +) +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, +) +from .errors import FFmpegioError +from .std_runners import run_and_return_encoded, run_and_return_raw __all__ = ["create", "read", "write", "filter", "detect"] - -def _run_read( - *args, - shape=None, - pix_fmt_in=None, - r_in=None, - s_in=None, - show_log=None, - sp_kwargs=None, - **kwargs, -): - """run FFmpeg and retrieve audio stream data - :param *args ffmpegprocess.run arguments - :type *args: tuple - :param shape: output frame size if known, defaults to None - :type shape: (int, int), optional - :param pix_fmt_in: input pixel format if known but not specified in the ffmpeg arg dict, defaults to None - :type pix_fmt_in: str, optional - :param s_in: input frame size (wxh) if known but not specified in the ffmpeg arg dict, defaults to None - :type s_in: str or (int, int), optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**kwargs: All additional keyword arguments to call `ffmpegprocess.run`. - These keywords take precedence over `sp_kwargs`. - :type \\**kwargs: dict, optional - :return: video data, created by `bytes_to_video` plugin hook - :rtype: object - """ - - dtype, shape, r = configure.finalize_video_read_opts( - args[0], pix_fmt_in, s_in, r_in - ) - - if sp_kwargs is not None: - kwargs = {**sp_kwargs, **kwargs} - - if shape is None or r is None: - configure.clear_loglevel(args[0]) - - out = fp.run(*args, capture_log=True, **kwargs) - if show_log: - print(out.stderr) - if out.returncode: - raise FFmpegError(out.stderr) - - info = log_utils.extract_output_stream(out.stderr) - dtype, shape = utils.get_video_format(info["pix_fmt"], info["s"]) - r = info["r"] - else: - out = fp.run( - *args, - capture_log=None if show_log else False, - **kwargs, - ) - if out.returncode: - raise FFmpegError(out.stderr) - return r, plugins.get_hook().bytes_to_video( - b=out.stdout, dtype=dtype, shape=shape, squeeze=False - ) +logger = logging.getLogger("ffmpegio") -def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options): +def create( + expr: str | fgb.abc.FilterGraphObject, + *args, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Create a video using a source video filter - :param name: name of the source filter - :type name: str - :param \\*args: sequential filter option arguments. Only valid for + :param expr: source filter graph + :param args: sequential filter option arguments. Only valid for a single-filter expr, and they will overwrite the options set by expr. - :type \\*args: seq, optional + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, + eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: Named filter options or FFmpeg options. Items are + :param options: Named filter options or FFmpeg options. Items are only considered as the filter options if expr is a single-filter graph, and take the precedents over general FFmpeg options. Append '_in' for input option names (see :doc:`options`), and '_out' for output option names if they conflict with the filter options. - :type \\**options: dict, optional - :return: frame rate and video data, created by `bytes_to_video` plugin hook - :rtype: tuple[Fraction,object] + :return rate: frame rate in frames/second + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 4D (time x row x column x comp). + (since v0.12.0) With `squeeze=True` the shape dimensions with + length 1 are removed. ...seealso:: https://ffmpeg.org/ffmpeg-filters.html#Video-Sources for available @@ -108,226 +72,232 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) """ - input_options = utils.pop_extra_options(options, "_in") - output_options = utils.pop_extra_options(options, "_out") url, t_, options = configure.config_input_fg(expr, args, options) - options = {**options, **output_options} - if ( - t_ is None - and not any(a in input_options for a in ("t", "to")) - and not any(a in options for a in ("t", "to", "frames:v", "vframes")) + if t_ is None and not any( + a in options for a in ("t_in", "to_in", "t", "to", "frames:v", "vframes") ): warnings.warn( "neither input nor output duration specified. this function call may hang." ) - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"}) - configure.add_url(ffmpeg_args, "output", "-", {**options, "f": "rawvideo"}) - - return _run_read( - ffmpeg_args, - pix_fmt_in=input_options.get("pix_fmt", "rgb24"), + return read( + url, + squeeze=squeeze, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs, + **options, ) -def read(url, progress=None, show_log=None, sp_kwargs=None, **options): +def read( + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + *, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Read video frames - :param url: URL of the video file to read. - :type url: str + :param url: URL of the video file to read or a list of URLs to be used by + complex filtergraph. Each url may be accompanied by its own input + options (a tuple pair of url and its option dict). These options + supersede the input options given with keyword arguments with `'_in'` + suffix. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be a url string or a pair of + a url string and an option dict. + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, + eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) :return: frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: (fractions.Fraction, object) """ - pix_fmt = options.get("pix_fmt", None) - - # get pix_fmt of the input file only if needed - pix_fmt_in = s_in = r_in = None - if pix_fmt is None and "pix_fmt_in" not in options: - try: - pix_fmt_in, *s_in, ra_in, rr_in = _probe_video_info(url, "v:0", sp_kwargs) - r_in = rr_in if ra_in is None or ra_in == "0/0" else ra_in - except: - pix_fmt_in = "rgb24" - - input_options = utils.pop_extra_options(options, "_in") + # use user-specified map or default '0:a:0' map + output_map = options.pop("map", "0:V:0") - # get url/file stream - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_read( + [url] if utils.is_valid_input_url(url) else url, + [output_map], + options, + extra_outputs, + squeeze, ) - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options) - configure.add_url(ffmpeg_args, "output", "-", options) - - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = stdin - sp_kwargs["input"] = input - - return _run_read( - ffmpeg_args, - progress=progress, - show_log=show_log, - pix_fmt_in=pix_fmt_in, - s_in=s_in, - r_in=r_in, - sp_kwargs=sp_kwargs, + if output_info is None: + raise FFmpegioError( + "Unknown configuration error occurred. Necessary output information could not be collected." + ) + if output_info[0]["media_type"] != "video": + raise ValueError("Mapped stream is not a video stream.") + + return run_and_return_raw( + args, + input_info, + output_info, + progress, + show_log, + sp_kwargs, ) def write( - url, - rate_in, - data, - progress=None, - overwrite=None, - show_log=None, - two_pass=False, - pass1_omits=None, - pass1_extras=None, - extra_inputs=None, - sp_kwargs=None, + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + rate_in: Fraction | int, + data: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + dtype: DTypeString | None = None, + shape: ShapeTuple | None = None, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + two_pass: bool = False, + pass1_omits: list[str] | None = None, + pass1_extras: list[FFmpegOptionDict] | None = None, + sp_kwargs: dict[str, Any] | None = None, **options, -): - """Write Numpy array to a video file +) -> bytes | None: + """Write raw video data blob :param url: URL of the video file to write. - :type url: str :param rate_in: frame rate in frames/second - :type rate_in: `float`, `int`, or `fractions.Fraction` :param data: video frame data object, accessed by `video_info` and `video_bytes` plugin hooks - :type data: object :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param two_pass: True to encode in 2-pass :param pass1_omits: list of output arguments to ignore in pass 1, defaults to None - :type pass1_omits: seq(str), optional :param pass1_extras: list of additional output arguments to include in pass 1, defaults to None - :type pass1_extras: dict(int:dict(str)), optional :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :type extra_inputs: seq(str|(str,dict)) :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - url, stdout, _ = configure.check_url(url, True) - - input_options = utils.pop_extra_options(options, "_in") + # if filter_complex is not defined use '0:V:0' as default mapping + if ( + not any( + (o in options) + for o in ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + ) + and "map" not in options + ): + options["map"] = "0:V:0" - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(rate_in, data=data, **input_options), + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_write( + url, [{"r": rate_in}], extra_inputs, options, [data] ) - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - configure.add_url(ffmpeg_args, "output", url, options) - - configure.build_basic_vf(ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1)) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - { - "input": plugins.get_hook().video_bytes(obj=data), - "stdout": stdout, - "progress": progress, - "overwrite": overwrite, - } + return run_and_return_encoded( + progress, + overwrite, + show_log, + sp_kwargs, + args, + input_info, + output_info, + two_pass, + pass1_omits, + pass1_extras, ) - kwargs["capture_log"] = None if show_log else False - if pass1_omits is not None: - kwargs["pass1_omits"] = [pass1_omits] - if pass1_extras is not None: - kwargs["pass1_extras"] = [pass1_extras] - - out = (fp.run_two_pass if two_pass else fp.run)(ffmpeg_args, **kwargs) - if out.returncode: - raise FFmpegError(out.stderr, show_log) -def filter(expr, rate, input, progress=None, show_log=None, sp_kwargs=None, **options): +def filter( + expr: str | fgb.abc.FilterGraphObject | None, + input_rate: Fraction | int, + input: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Filter video frames. - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None + :param expr: filter graph or None if implicit filtering via output options. :param rate: input frame rate in frames/second - :type rate: `float`, `int`, or `fractions.Fraction` - :param input: input video frame data object, accessed by `video_info` and `video_bytes` plugin hooks - :type input: object + :param input: input video frame data blob, accessed by `video_info` and `video_bytes` plugin hooks :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) :return: output frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: object """ - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(rate, data=input, **input_options), + if expr is not None: + if extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:v"] = expr + options["map"] = "0:V:0" + else: + options["filter_complex"] = expr + # expects map option is set + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + [{"r": input_rate}], + extra_inputs, + None, + extra_outputs, + options, + squeeze, + [input], ) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - - if expr: - outopts["filter:v"] = expr - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = None - sp_kwargs["input"] = plugins.get_hook().video_bytes(obj=input) + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") - return _run_read( - ffmpeg_args, - progress=progress, - show_log=show_log, - sp_kwargs=sp_kwargs, + return run_and_return_raw( + args, input_info, output_info, progress, show_log, sp_kwargs ) diff --git a/tests/test_audio.py b/tests/test_audio.py index 5b7456a6..b12a4974 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -2,6 +2,10 @@ import tempfile, re, logging from os import path import pytest +import numpy as np +from io import BytesIO + +from namedpipe import NPopen logging.basicConfig(level=logging.DEBUG) @@ -27,11 +31,11 @@ def test_create(): fs, x = audio.create("anoisesrc", d=60, c="pink", r=44100, a=0.5) print(x["shape"], 60 * 44100) - assert x["shape"] == (60 * 44100, 1) + assert x["shape"] == (60 * 44100,) fs, x = audio.create("sine", f=220, b=4, d=5) print(x["shape"], 5 * 44100) - assert x["shape"] == (5 * 44100, 1) + assert x["shape"] == (5 * 44100,) @pytest.mark.skip(reason="takes too long to test") @@ -66,6 +70,28 @@ def test_read(): # assert np.array_equal(x1, x2) +def test_read_af(): + + url = "tests/assets/testaudio-1m.mp3" + + T = 0.51111 + T = 0.49805 + fs, x = audio.read( + url, t=T, show_log=True, af="aresample=8000,channelmap=map=FL-FC" + ) + assert fs == 8000 + assert len(x["shape"]) == 1 + +def test_read_filter(): + + url = "tests/assets/testaudio-1m.mp3" + + T = 0.51111 + T = 0.49805 + fs, x = audio.read( + [url,url], t=T, show_log=True, filter_complex="[0][1]amix[mixed]",map='[mixed]' + ) + def test_read_write(): url = "tests/assets/testaudio-1m.mp3" outext = ".flac" @@ -104,7 +130,9 @@ def test_filter(): ], ) - output_rate, output = audio.filter(expr, input_rate, input) + output_rate, output = audio.filter( + expr, input_rate, input, show_log=True, loglevel="verbose" + ) assert output_rate == 22050 assert output["shape"] == (22050, 2) assert output["dtype"] == input["dtype"] @@ -125,7 +153,52 @@ def test_filter(): output_rate, output = audio.filter(expr, input_rate, input) assert output_rate == 44100 assert output["shape"] == (44100, 2) - assert output["dtype"] == input["dtype"] + assert output["dtype"] == " 0 + + +def test_write_fileobj(): + + fs = 16000 + x = np.random.randint(-(2**15), 2**15, fs, np.int16) + with tempfile.TemporaryDirectory() as tmpdirname: + + url = path.join(tmpdirname, "test.flv") + with open(url, "wb") as f: + audio.write(f, fs, x, f="flv", acodec="aac", show_log=True) if __name__ == "__main__": diff --git a/tests/test_avistreams.py b/tests/test_avistreams.py deleted file mode 100644 index 71c43dee..00000000 --- a/tests/test_avistreams.py +++ /dev/null @@ -1,86 +0,0 @@ -from ffmpegio.streams import AviStreams -from ffmpegio import open - - -def test_open(): - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - with open((url1, url2), "rav", t=1, blocksize=0) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - - print('testing "rvv"') - with open( - url1, - "rvv", - t=1, - blocksize=0, - filter_complex="[0:v]split=2[out1][out2]", - map=["[out1]", "[out2]"], - ) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - - print('testing "raa"') - with open( - url2, - "raa", - t=1, - blocksize=0, - filter_complex="[0:a]asplit=2[out1][out2]", - map=["[out1]", "[out2]"], - ) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - # print(reader.readlog()) - -def test_avireadstream(): - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - with AviStreams.AviMediaReader(url1, url2, t=1, blocksize=0) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - - with AviStreams.AviMediaReader(url1, url2, t=1, blocksize=1) as reader: - for data in reader: - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) - - with AviStreams.AviMediaReader( - url1, url2, t=1, blocksize=1000, ref_stream="a:0" - ) as reader: - for data in reader: - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) - - with AviStreams.AviMediaReader(url1, url2, t=1) as reader: - print(reader.specs()) - print(reader.types()) - print(reader.rates()) - print(reader.dtypes()) - print(reader.shapes()) - print(reader.get_stream_info("v:0")) - print(reader.get_stream_info("a:0")) - - data = reader.readall() - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) - - -if __name__ == "__main__": - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - - from pprint import pprint - - with AviStreams.AviMediaReader(url1, url2, t=1) as reader: - reader._reader.wait() - print(f'thread is running {reader._reader.is_alive()}') - pprint(reader.specs()) - print(reader.types()) - print(reader.rates()) - print(reader.dtypes()) - print(reader.shapes()) - print(reader.get_stream_info("v:0")) - print(reader.get_stream_info("a:0")) - - data = reader.readall() - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) diff --git a/tests/test_caps.py b/tests/test_caps.py index afbaa426..fd615f22 100644 --- a/tests/test_caps.py +++ b/tests/test_caps.py @@ -48,6 +48,11 @@ def test_options(): pprint(caps.options('video',True)) pprint(caps.options('per-file')) +def test_filters(): + for f in caps.filters(): + print(f) + pprint(caps.filter_info(f)) + if __name__ == '__main__': - caps.encoder_info('mpeg1video') + caps.filter_info('aresample') diff --git a/tests/test_configure.py b/tests/test_configure.py index 13562ca9..b96101d6 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1,53 +1,24 @@ +from pprint import pprint + +import pytest + from ffmpegio import configure +from ffmpegio import filtergraph as fgb +from ffmpegio.utils import analyze_complex_filtergraphs vid_url = "tests/assets/testvideo-1m.mp4" img_url = "tests/assets/ffmpeg-logo.png" -aud_url = "tests/assets/testaudio-1m.wav" - - -def test_array_to_audio_input(): - fs = 44100 - N = 44100 - nchmax = 4 - data = {"buffer": b"0" * N * nchmax * 2, "dtype": " PAD_INDEX: -# def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: -# def _output_pad_is_available(self, index: tuple[int, int, int]) -> bool: +# def is_input_linkable(self, inpad: PAD_INDEX)->bool: +# def is_output_linkable(self, outpad: PAD_INDEX)->bool: # def _check_partial_pad_index( # self, index: tuple[int | None, int | None, int | None], is_input: bool # ) -> bool: diff --git a/tests/test_filtergraph_build.py b/tests/test_filtergraph_build.py index 62279fca..38ee491f 100644 --- a/tests/test_filtergraph_build.py +++ b/tests/test_filtergraph_build.py @@ -1,6 +1,6 @@ from os import path from tempfile import TemporaryDirectory -from ffmpegio import ffmpegprocess, filtergraph as fgb +from ffmpegio import ffmpegprocess, filtergraph as fgb, FFmpegioError from ffmpegio.filtergraph import Chain from pprint import pprint import pytest @@ -16,7 +16,7 @@ ("split", "vstack",[(0,0,0),(0,0,1)],[(0,0,1),(0,0,0)],True,'[UNC0]split[L0][L1];[L1][L0]vstack[UNC1]'), ("scale", "fps,eq",(0,0,0),(0,0,0),True,'scale,fps,eq'), ("scale,fps", "eq",(0,1,0),(0,0,0),True,'scale,fps,eq'), - ("scale", "[0:v]vstack[out]",(0,0,0),(0,0,1),True,'[UNC0]scale[L0];[0:v][L0]vstack[out]'), + ("scale", "[0:v]vstack[out]",(0,0,0),(0,0,1),True,'[UNC0]scale,[0:v]vstack[out]'), ("scale", "[in1][0:v]vstack[out]",(0,0,0),(0,0,0),True,'[UNC0]scale[L0];[L0][0:v]vstack[out]'), # fmt: on ], @@ -37,18 +37,18 @@ def test_connect(left, right, from_left, to_right, chain_siso, ret): ("scale,fps","eq",'all',0,False,False,'scale,fps,eq'), ("split","vstack",'all',0,False,False,'[UNC0]split[L0][L1];[L0][L1]vstack[UNC1]'), ("split","vstack",'all',1,False,False,'[UNC0]split[L0][UNC2];[L0][UNC1]vstack[UNC3]'), - ("[vin]scale;[ain]asplit","vstack[vout];atrim[aout]",'all',0,False,False,'[vin]scale[L0];[ain]asplit[L1][L2];[L0][L1]vstack[vout];[L2]atrim[aout]'), + ("[vin1]scale;[vin2]split","vstack[vout1];trim[vout2]",'all',0,False,False,'[vin1]scale[L0];[vin2]split[L1],trim[vout2];[L0][L1]vstack[vout1]'), ("[vin]scale;[ain]asplit","vstack[vout];atrim[aout]",'per_chain',0,False,False,'[vin]scale[L0];[ain]asplit[L1][UNC1];[L0][UNC0]vstack[vout];[L1]atrim[aout]'), ("[vin]scale;[ain]asplit","vstack[vout]",'all',0,False,False,'[vin]scale[L0];[ain]asplit[L1][UNC0];[L0][L1]vstack[vout]'), ("[vin]scale;[ain]asplit","vstack[vout]",'all',0,True,False,None), - ("split[out]","[in]vstack",'all',0,False,True,'[UNC0]split[out][L0];[in][L0]vstack[UNC1]'), + ("split[out]","[in]vstack",'all',0,False,True,'[UNC0]split[out],[in]vstack[UNC1]'), # fmt: on ], ) def test_join(left, right, how, n_links, strict, unlabeled_only, ret): if ret is None: - with pytest.raises(ValueError): + with pytest.raises(FFmpegioError): fgb.join(left, right, how, n_links, strict, unlabeled_only) else: fg = fgb.join(left, right, how, n_links, strict, unlabeled_only) @@ -76,3 +76,13 @@ def test_attach(left, right, left_on, right_on, ret): else: fg = fgb.attach(left, right, left_on, right_on) assert fg.compose() == ret + + +def test_join_bug(): + af1 = fgb.Chain("aevalsrc,aformat") + af2 = fgb.Graph("channelmap,bandpass,aresample") + af3 = fgb.Chain("channelmap,bandpass,aresample") + af_a = af1 + af2 + af_b = af1 + af3 + + assert af_a==af_b \ No newline at end of file diff --git a/tests/test_filtergraph_chain.py b/tests/test_filtergraph_chain.py index ecfb18a3..fe61c44d 100644 --- a/tests/test_filtergraph_chain.py +++ b/tests/test_filtergraph_chain.py @@ -139,7 +139,7 @@ def test_iter_chains(expr, skip_if_no_input, skip_if_no_output, chainable_only, (operator.__rshift__, ("split",(0,1)), fgb.Chain("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), (operator.__rshift__, ("split[out]",1), fgb.Chain("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), (operator.__rshift__, ("split[out]", '[out]',None), fgb.Chain("overlay"), "[UNC0]split[L0][UNC2];[L0][UNC1]overlay[UNC3]"), - (operator.__rshift__, ["scale","fps"], fgb.Chain("hstack"), "[UNC0]scale[L0];[UNC1]fps[L1];[L0][L1]hstack[UNC2]"), + (operator.__rshift__, ["scale","fps"], fgb.Chain("hstack"), "[UNC0]scale[L0];[UNC1]fps,[L0]hstack[UNC2]"), (operator.__rshift__, fgb.Chain("split"), ["[v1]","[v2]"], "[UNC0]split[v1][v2]"), # (operator.__rshift__, fgb.Graph("split[out1][out2]"), ('[out1]', '[over]', "[base][over]overlay"), "split[out1][out2];[base][out1]overlay"), # fmt:on diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index 6cfa5526..3b422ce2 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -24,7 +24,6 @@ def test_iter_inpad_ids(dsts, expects): [ (("0:v",), True), (("label",), True), - (("-label",), False), ((0, True), True), ((0.0, True), False), ((0, False), False), @@ -153,11 +152,11 @@ def test_init(base_links): (["a", "b"], ["a", "b"]), ], ) -def test_resolve_label(labels, expects): +def testresolve_label(labels, expects): links = GraphLinks() def update(label): - links.data[links._resolve_label(label)] = None + links.data[links.resolve_label(label)] = None for label in labels: update(label) diff --git a/tests/test_filtergraph_presets.py b/tests/test_filtergraph_presets.py new file mode 100644 index 00000000..8f58d528 --- /dev/null +++ b/tests/test_filtergraph_presets.py @@ -0,0 +1,26 @@ +import pytest +from pprint import pprint + +import ffmpegio.filtergraph.presets as presets + + +@pytest.mark.parametrize( + "kwargs", + [ + dict(crop=None, flip=None, transpose=None), + dict(scale=1.2, crop=100, flip="both", transpose=90), + ], +) +def test_video_basic_filter(kwargs): + print(presets.filter_video_basic(**kwargs)) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"fill_color": "red"}, + {"fill_color": "red", "input_label": "in", "output_label": "[out]"}, + ], +) +def test_remove_alpha(kwargs): + print(presets.remove_alpha(**kwargs)) diff --git a/tests/test_image.py b/tests/test_image.py index 3171aa6c..1c4cd0ee 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,7 +1,12 @@ -from ffmpegio import image, probe, transcode, FFmpegError -import tempfile, re +import re +import tempfile from os import path +import pytest + +from ffmpegio import filtergraph as fgb +from ffmpegio import image, transcode + outext = ".png" @@ -46,15 +51,10 @@ def test_read_write(): A = image.read(url) print(A["dtype"] == "|u1") B = image.read(url, pix_fmt="ya8") - print(B["shape"]) - C = image.read(url, pix_fmt="rgb24") - D = image.read(url, pix_fmt="gray") with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - print(out_url, C['shape']) - image.write(out_url, C) - print(probe.video_streams_basic(out_url)) - C = image.read(out_url, pix_fmt="rgba", show_log=True) + image.write(out_url, B) + image.read(out_url, pix_fmt="rgba", show_log=True) # with open(path.join(tmpdirname, "progress.txt")) as f: # print(f.read()) @@ -75,54 +75,59 @@ def test_read_write(): def test_read_basic_filter(): url = "tests/assets/ffmpeg-logo.png" - - B = image.read( - url, - show_log=True, - pix_fmt="rgb24", - fill_color="red", + vf = fgb.presets.filter_video_basic( crop=(300, 50), flip="horizontal", transpose="clock", ) + image.read(url, show_log=True, vf=vf) - B = image.read(url, show_log=True, s=(100, -2)) - print(B['shape']) + +def test_filter(): url = "tests/assets/ffmpeg-logo.png" - B = image.read( - url, - show_log=True, - fill_color="red", + I = image.read(url, vf=fgb.presets.remove_alpha("red", "rgb24")) + vf = fgb.presets.filter_video_basic( + crop=(10, 50), + flip="horizontal", + transpose="clock", ) + J = image.filter(vf, I, show_log=True) - B = image.read(url, show_log=True, fill_color="red", pix_fmt="rgb24") +@pytest.mark.parametrize( + "fill_color,pix_fmt,ncomp", + [("red", "rgb24", 3), ("red", None, 4), ("red", "gray", 0)], +) +def test_remove_alpha_filter(fill_color, pix_fmt, ncomp): -def test_square_pixels(): + url = "tests/assets/ffmpeg-logo.png" + vf = fgb.presets.remove_alpha(fill_color=fill_color, pix_fmt=pix_fmt) + print(str(vf)) + I = image.read(url, show_log=True, vf=vf) + assert I["shape"] == ((100, 396, ncomp) if ncomp else (100, 396)) + + +@pytest.fixture(scope="module") +def nonsquarepix_url(): url = "tests/assets/testvideo-1m.mp4" with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, path.basename(url)) - transcode(url, out_url, show_log=True, vf="setsar=11/13", t=0.5) + transcode(url, out_url, show_log=True, vf="setsar=11/13", t=0.5, pix_fmt="gray") + yield out_url + - B = image.read(out_url) - Bu = image.read(out_url, square_pixels="upscale") - Bd = image.read(out_url, square_pixels="downscale") - Bue = image.read(out_url, square_pixels="upscale_even") - Bde = image.read(out_url, square_pixels="downscale_even") +@pytest.mark.parametrize( + "mode", ["upscale", "downscale", "upscale_even", "downscale_even"] +) +def test_square_pixels(nonsquarepix_url, mode): - print(B['shape']) - print(Bu['shape']) - print(Bd['shape']) - print(Bue['shape']) - print(Bde['shape']) + vf = fgb.presets.square_pixels(mode) + image.read(nonsquarepix_url, vf=vf, show_log=None) if __name__ == "__main__": from matplotlib import pyplot as plt - import logging - from ffmpegio import utils, ffmpegprocess - from ffmpegio.utils import filter as filter_utils, log as log_utils # logging.basicConfig(level=logging.DEBUG) diff --git a/tests/test_media.py b/tests/test_media.py index f3119d61..46dc8f2d 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,52 +1,83 @@ -from ffmpegio import media +from os import path +from pprint import pprint +from tempfile import TemporaryDirectory +import pytest -def test_media_read(): - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - rates, data = media.read(url, t=1) - rates, data = media.read(url, map=("v:0", "v:1", "a:1", "a:0"), t=1) - rates, data = media.read(url1, url2, t=1) - rates, data = media.read(url2, url, map=("1:v:0", (0, "a:0")), t=1) +import ffmpegio as ff +url = "tests/assets/testmulti-1m.mp4" +url1 = "tests/assets/testvideo-1m.mp4" +url2 = "tests/assets/testaudio-1m.mp3" + + +@pytest.mark.parametrize( + "urls,kwargs,nout", + [ + ((url,), dict(t=1, show_log=True), 4), + ((url,), dict(streams=("v:0", "v:1", "a:1", "a:0"), t=1, show_log=True), 4), + ((url1, url2), dict(t=1, show_log=True), 2), + ((url1,), dict(t=1, filter_complex="[0:0]split[out1][out2]", show_log=True), 2), + ], +) +def test_media_read(urls, kwargs, nout): + rates, data = ff.media.read(*urls, **kwargs, timeout=1.0) + assert len(rates) == nout + print(rates) + print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) + + +def test_media_read_filter_complex(): + urls = (url2, url) # aud + mul + kwargs = dict( + t=1, + show_log=True, + filter_complex="[0:a]aformat=f=dbl:r=8000:cl=mono;[1:v:1]setpts=0.5*PTS", + ) + # kwargs = dict(map=(['[vout]','[aout]']), t=1, show_log=True, filter_complex='[0:a]aformat=f=dbl:r=8000:cl=mono[aout];[1:v:1]setpts=0.5*PTS[vout]') + rates, data = ff.media.read(*urls, **kwargs) print(rates) - print([(k, x['shape'], x['dtype']) for k, x in data.items()]) - - -if __name__ == "__main__": - from matplotlib import pyplot as plt - - pass - # out = ffmpegprocess.run( - # { - # "inputs": [(url, None)], - # "outputs": [ - # ( - # "-", - # { - # "ss": 0.1, - # "t": 1, - # "f": "avi", - # "c:v": "rawvideo", - # "pix_fmt": "ya8", - # "c:a": "pcm_f32le", - # "sample_fmt": "flt", - # }, - # ) - # ], - # "global_options": None, - # }, - # capture_log=False, - # ) - # reader = avi.AviReader(BytesIO(out.stdout), True) - # print(reader.streams) - # n = len(reader.streams) - # out = {v["spec"]: [] for v in reader.streams.values()} - # for st, data in reader: - # out[st].append(data) - # out = {k: np.concatenate(v) for k, v in out.items()} - # print({k: (v.shape, v.dtype) for k, v in out.items()}) - - # plt.imshow(out["v:0"][0, ..., 0], alpha=out["v:0"][0, ..., 1]/255) - # plt.show() + print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) + + +def test_media_write(): + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") + + fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) + + outext = ".mp4" + + print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") + + with TemporaryDirectory() as tmpdirname: + outfile = path.join(tmpdirname, f"out{outext}") + ff.media.write( + outfile, "va", (fps, F), (fs, x), show_log=True, shortest=ff.FLAG + ) + + pprint(ff.probe.format_basic(outfile)) + pprint(ff.probe.streams_basic(outfile)) + + +def test_media_filter(): + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") + + fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) + + print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") + + outrates, outdata = ff.media.filter( + ["[0:V:0][1:V:0]vstack,split[out0]", "[2:a:0][3:a:0]amerge[out2]"], + "vvaa", # 4 inputs + (fps, F), + (fps, F), + (fs, x), + (fs, x), + output_streams=["[out0]", "out1", {"map": "[out2]"}], + show_log=True, + shortest=ff.FLAG, + ) + + assert all(k in ("out0", "out1", "out2") for k in outrates) + + print(outrates) diff --git a/tests/test_open.py b/tests/test_open.py index 712af36a..35275efc 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -1,17 +1,260 @@ -from logging import DEBUG -import ffmpegio +import builtins +from os import path +from tempfile import TemporaryDirectory +import numpy as np +import pytest -def test_fg(): - with ffmpegio.open( - "color=c=red:d=1:r=10", "rv", f_in="lavfi", pix_fmt="rgb24" - ) as f: - I = f.read(-1) - assert I["shape"][0] == 10 +import ffmpegio as ff +from ffmpegio.streams.BaseFFmpegRunner import ( + PipedFFmpegRunner, + SISOFFmpegFilter, + StdFFmpegRunner, +) +# import ffmpegio.streams as ff_streams +from ffmpegio.streams.open import ( + _parse_mode, + open, +) -if __name__ == "__main__": - import logging - logging.basicConfig(level=DEBUG) - test_fg() +@pytest.mark.parametrize( + "mode,ret", + [ + ("r", ("r", "", "")), + ("w", ("w", "", "")), + ("f", ("f", "", "")), + ("d", ("d", "e", "")), + ("e", ("e", "", "e")), + ("t", ("t", "e", "e")), + ("re", None), + ("rav", ("r", "", "av")), + ("avra", ("r", "", "ava")), + ("wva", ("w", "va", "")), + ("awv", ("w", "av", "")), + ("fa", ("f", "a", "")), + ("dav", ("d", "e", "av")), + ("eav", ("e", "av", "e")), + ("ea->ev", None), + ("ee->av", ("d", "ee", "av")), + ("av->ee", ("e", "av", "ee")), + ("av->va", ("f", "av", "va")), + ], +) +def test_mode_parser(mode, ret): + if ret is None: + with pytest.raises(ValueError): + _parse_mode(mode) + else: + assert _parse_mode(mode) == ret + + +url = "tests/assets/testmulti-1m.mp4" + + +@pytest.mark.parametrize( + "mode,output_streams,cls", + [ + ("ra", ["0:a:0"], StdFFmpegRunner), + ("rva", ["0:v:0", "0:a:0"], PipedFFmpegRunner), + ], +) +def test_open_reader(mode, output_streams, cls): + runner = open( + url, + mode, + ouput_streams=output_streams, + squeeze=False, + extra_outputs=None, + blocksize=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) + assert isinstance(runner, cls) + assert runner.readable + assert not runner.writable + assert not runner.decodable + assert not runner.encodable + + +@pytest.mark.parametrize( + "mode,input_rates,cls", + [ + ("wa", 8000, StdFFmpegRunner), + ("wva", [30, 8000], PipedFFmpegRunner), + ], +) +def test_open_writer(mode, input_rates, cls): + + opts = ( + {"input_shape": None, "input_dtype": None} + if cls == StdFFmpegRunner + else { + "input_options": None, + "input_shapes": None, + "input_dtypes": None, + "enc_blocksize": None, + "queuesize": None, + "timeout": None, + } + ) + + with TemporaryDirectory() as tmpdirname: + outfile = path.join(tmpdirname, "out.mp4") + with open( + outfile, + mode, + input_rates, + **opts, + extra_inputs=None, + progress=None, + show_log=False, + overwrite=True, + sp_kwargs=None, + to=1, + ) as runner: + assert isinstance(runner, cls) + assert not runner.readable + assert runner.writable + assert not runner.decodable + assert not runner.encodable + + +@pytest.mark.parametrize( + "mode,input_rates,data,cls", + [ + ("fa", 8000, [np.zeros((128, 1), np.int16)], SISOFFmpegFilter), + ( + "fva", + [30, 8000], + [np.zeros((100, 100, 1), np.uint8), np.zeros((128, 1), np.int16)], + PipedFFmpegRunner, + ), + ], +) +def test_open_filter(mode, input_rates, data, cls): + + ff.use("read_numpy") + + opts = ( + {"input_shape": None, "input_dtype": None} + if cls == SISOFFmpegFilter + else { + "input_options": None, + "output_streams": None, + "input_shapes": None, + "input_dtypes": None, + "primary_output": None, + } + ) + + with open( + "-", + mode, + input_rates, + **opts, + squeeze=False, + extra_inputs=None, + extra_outputs=None, + blocksize=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + r=20, + ar=4000, + ) as runner: + for i, blob in enumerate(data): + runner.write(blob, stream=i) + assert isinstance(runner, cls) + assert runner.readable + assert runner.writable + assert not runner.decodable + assert not runner.encodable + + +def test_open_decoder(): + + with builtins.open(url, "rb") as f: + b = f.read(1024) + + with open( + "-", + "e->a", + ouput_streams=None, + squeeze=False, + extra_inputs=None, + extra_outputs=None, + primary_output=None, + blocksize=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) as runner: + runner.write_encoded(b) + assert isinstance(runner, PipedFFmpegRunner) + assert runner.readable + assert not runner.writable + assert runner.decodable + assert not runner.encodable + + +def test_open_encoder(): + + with open( + "-", + "a->e", + 8000, + input_options=None, + output_options=None, + extra_inputs=None, + extra_outputs=None, + input_shapes=None, + input_dtypes=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) as runner: + assert isinstance(runner, PipedFFmpegRunner) + assert not runner.readable + assert runner.writable + assert not runner.decodable + assert runner.encodable + + +def test_open_transcoder(): + + with open( + "-", + "e->e", + input_options=None, + output_options=None, + extra_inputs=None, + extra_outputs=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) as runner: + assert isinstance(runner, PipedFFmpegRunner) + assert not runner.readable + assert not runner.writable + assert runner.decodable + assert runner.encodable diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 24598353..c2cd4795 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,8 +1,11 @@ -from ffmpegio.utils import prod from ffmpegio import plugins +from ffmpegio.utils import prod def test_rawdata_bytes(): + + plugins.use("read_bytes") + hook = plugins.get_hook() dtype = "|u1" @@ -28,6 +31,7 @@ def test_rawdata_bytes(): assert hook.audio_info(obj=data) == (shape, dtype) assert hook.audio_bytes(obj=data) == b + def test_use(): import numpy as np diff --git a/tests/test_probe.py b/tests/test_probe.py index 64103bf6..24bc10cf 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -57,7 +57,7 @@ def test_query(): list, ) assert isinstance( - probe.query(url, "a:0", fields=("duration", "sample_rate", "sample_fmt")), dict + probe.query(url, "a:0", fields=("duration", "sample_rate", "sample_fmt")), list ) assert all( @@ -70,7 +70,7 @@ def test_query(): assert ( probe.query( url, "v:0", fields=("duration", "max_bit_rate"), keep_optional_fields=True - )["max_bit_rate"] + )[0]["max_bit_rate"] is None ) diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py deleted file mode 100644 index 3d5a2075..00000000 --- a/tests/test_simplestreams.py +++ /dev/null @@ -1,207 +0,0 @@ -import logging - -logging.basicConfig(level=logging.DEBUG) - -import ffmpegio -import tempfile, re -from os import path -from ffmpegio import streams, utils - -url = "tests/assets/testmulti-1m.mp4" -outext = ".mp4" - - -def test_read_video(): - w = 420 - h = 360 - with ffmpegio.open( - url, "rv", vf="transpose", pix_fmt="gray", s=(w, h), show_log=True - ) as f: - F = f.read(10) - print(f.rate) - assert f.shape == (h, w, 1) - assert f.samplesize == w * h - assert F["shape"] == (10, h, w, 1) - assert F["dtype"] == f.dtype - - -def test_read_write_video(): - fs, F = ffmpegio.video.read(url, t=1) - bps = utils.get_samplesize(F["shape"][-3:], F["dtype"]) - F0 = { - "buffer": F["buffer"][:bps], - "shape": (1, *F["shape"][1:]), - "dtype": F["dtype"], - } - F1 = { - "buffer": F["buffer"][bps:], - "shape": (F["shape"][0] - 1, *F["shape"][1:]), - "dtype": F["dtype"], - } - - with tempfile.TemporaryDirectory() as tmpdirname: - out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with ffmpegio.open(out_url, "wv", rate_in=fs) as f: - f.write(F0) - f.write(F1) - - -def test_read_audio(caplog): - # caplog.set_level(logging.DEBUG) - - fs, x = ffmpegio.audio.read(url) - bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) - - with ffmpegio.open(url, "ra", show_log=True, blocksize=1024 ** 2) as f: - # x = f.read(1024) - # assert x['shape'] == (1024, f.ac) - blks = [blk["buffer"] for blk in f] - x1 = b"".join(blks) - assert x["buffer"] == x1 - - n0 = int(0.5 * fs) - n1 = int(1.2 * fs) - t0 = n0 / fs - t1 = n1 / fs - - with ffmpegio.open( - url, "ra", ss_in=t0, to_in=t1, show_log=True, blocksize=1024 ** 2 - ) as f: - blks, shapes = zip(*[(blk["buffer"], blk["shape"][0]) for blk in f]) - log = f.readlog() - shape = sum(shapes) - - print(log) - - x2 = b"".join(blks) - # # print("# of blks: ", len(blks), x1['shape']) - # for i, xi in enumerate(x2): - # print(i, xi-x[n0 + i]) - # assert np.array_equal(xi, x[n0 + i]) - assert shape == n1 - n0 - assert x["buffer"][n0 * bps : n1 * bps] == x2 - - -def test_read_write_audio(): - outext = ".flac" - - with ffmpegio.open(url, "ra") as f: - F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) - fs = f.rate - shape = f.shape - dtype = f.dtype - bps = f.samplesize - - out = {"dtype": dtype, "shape": shape} - - with tempfile.TemporaryDirectory() as tmpdirname: - out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with ffmpegio.open(out_url, "wa", rate_in=fs, show_log=True) as f: - f.write({**out, "buffer": F[: 100 * bps]}) - f.write({**out, "buffer": F[100 * bps :]}) - - -def test_video_filter(): - url = "tests/assets/testvideo-1m.mp4" - - fps = 10 # fractions.Fraction(60000,1001) - - with ffmpegio.open(url, "rv", blocksize=30, t=30) as src, ffmpegio.open( - "scale=200:100", "fv", rate_in=src.rate, rate=fps, show_log=True - ) as f: - - def process(i, frames): - print(f"{i} - output {frames['shape'][0]} frames ({f.nin},{f.nout})") - - for i, frames in enumerate(src): - process(i, f.filter(frames)) - assert f.rate_in == src.rate - assert f.rate == fps - process("end", f.flush()) - - -def test_audio_filter(): - url = "tests/assets/testaudio-1m.mp3" - - sps = 4000 # fractions.Fraction(60000,1001) - - with streams.SimpleAudioReader(url, blocksize=1024 * 8, t=10, ar=32000) as src: - - samples = src.read(src.blocksize) - - with streams.SimpleAudioFilter( - "lowpass", - rate_in=src.rate, - rate=sps, - show_log=True, - # ac=src.channels, - # dtype=src['dtype'], - ) as f: - - def process(i, samples): - if len(samples): - print( - f"{i} - output {samples['shape'][0]} samples ({f.nin, f.nout})" - ) - - try: - process(-1, f.filter(samples)) - except TimeoutError: - pass - for i, samples in enumerate(src): - try: - process(i, f.filter(samples)) - except TimeoutError: - pass - assert f.rate_in == src.rate - assert f.rate == sps - process("end", f.flush()) - - -def test_write_extra_inputs(): - url_aud = "tests/assets/testaudio-1m.mp3" - - fs, F = ffmpegio.video.read(url, t=1) - F = { - "buffer": F["buffer"], - "shape": F["shape"], - "dtype": F["dtype"], - } - - with tempfile.TemporaryDirectory() as tmpdirname: - out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with ffmpegio.open( - out_url, - "wv", - rate_in=fs, - extra_inputs=[url_aud], - map=["0:v", "1:a"], - show_log=True, - ) as f: - f.write(F) - - info = ffmpegio.probe.streams_basic(out_url) - assert len(info) == 2 - - with ffmpegio.open( - out_url, - "wv", - rate_in=fs, - extra_inputs=[("anoisesrc", {"f": "lavfi"})], - map=["0:v", "1:a"], - shortest=None, - show_log=True, - overwrite=True, - ) as f: - f.write(F) - - info = ffmpegio.probe.streams_basic(out_url) - assert len(info) == 2 - - -if __name__ == "__main__": - print("starting test") - logging.debug("logging check") - test_video_filter() - - # python tests\test_simplestreams.py diff --git a/tests/test_stream_spec.py b/tests/test_stream_spec.py new file mode 100644 index 00000000..847e488c --- /dev/null +++ b/tests/test_stream_spec.py @@ -0,0 +1,78 @@ +from ffmpegio import stream_spec as utils +import pytest + + +@pytest.mark.parametrize( + ("arg", "ret"), + [ + (1, {"index": 1}), + ("1", {"index": 1}), + ("v", {"stream_type": "v"}), + ("p:1", {"program_id": 1}), + ("p:1:V", {"program_id": 1, "stream_type": "V"}), + ( + "p:1:a:#6", + { + "program_id": 1, + "stream_type": "a", + "stream_id": 6, + }, + ), + ("d:i:6", {"stream_type": "d", "stream_id": 6}), + ("t:m:key", {"stream_type": "t", "tag": "key"}), + ("m:key:value", {"tag": ("key", "value")}), + ("u", {"usable": True}), + ], +) +def test_parse_stream_spec(arg, ret): + assert utils.parse_stream_spec(arg) == ret + + +def test_stream_spec(): + assert utils.stream_spec() == "" + assert utils.stream_spec(0) == "0" + assert utils.stream_spec(stream_type="a") == "a" + assert utils.stream_spec(1, stream_type="v") == "v:1" + assert utils.stream_spec(program_id=1) == "p:1" + assert utils.stream_spec(1, stream_type="v", program_id=1) == "v:p:1:1" + assert utils.stream_spec(stream_id=342) == "#342" + assert utils.stream_spec(tag="creation_time") == "m:creation_time" + assert ( + utils.stream_spec(tag=("creation_time", "2018-05-26T19:36:24.000000Z")) + == "m:creation_time:2018-05-26T19:36:24.000000Z" + ) + assert utils.stream_spec(usable=True) == "u" + + # test cases: + + +@pytest.mark.parametrize( + ("map", "input_file_id", "ret"), + [ + ("4", None, {"input_file_id": 4}), + ("0:1", None, {"input_file_id": 0, "stream_specifier": "1"}), + ("0:v:0", None, {"input_file_id": 0, "stream_specifier": "v:0"}), + ( + "-1:v:2:view:back?", + None, + { + "negative": True, + "input_file_id": 1, + "stream_specifier": "v:2", + "view_specifier": "view:back", + "optional": True, + }, + ), + ( + "0:vidx:0", + None, + { + "input_file_id": 0, + "view_specifier": "vidx:0", + }, + ), + ("1:vpos:left", None, {"input_file_id": 1, "view_specifier": "vpos:left"}), + ], +) +def test_parse_map_option(map, input_file_id, ret): + assert ret==utils.parse_map_option(map, input_file_id=input_file_id) diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py new file mode 100644 index 00000000..d170a53b --- /dev/null +++ b/tests/test_streams_piped.py @@ -0,0 +1,221 @@ +import logging + +import numpy as np + +import ffmpegio as ff +from ffmpegio import streams + +logging.basicConfig(level=logging.DEBUG) + +mult_url = "tests/assets/testmulti-1m.mp4" +video_url = "tests/assets/testvideo-1m.mp4" +audio_url = "tests/assets/testaudio-1m.mp3" +outext = ".mp4" + + +def test_MediaReader(): + with streams.PipedFFmpegRunner.open_media_reader( + [(mult_url, {})], None, options={"t_in": 1}, squeeze=False + ) as reader: + nframes = [0] * reader.num_output_streams + for i, data in enumerate(reader): + nframes = [n0 + v["shape"][0] for n0, v in zip(nframes, data)] + + assert nframes == [30, 44100, 25, 44100] + + +def test_MediaWriter_audio(): + ff.use("read_numpy") + + rates, data = ff.media.read(audio_url, t=1, ar=8000, sample_fmt="s16") + + with streams.PipedFFmpegRunner.open_media_encoder( + [{"ar": rates["0:a:0"]}], + [{"f": "matroska"}], + show_log=True, + ) as writer: + for i, frame in enumerate(data.values()): + writer.write(frame, i) + # read the encoded bytes if any available + b = writer.read_encoded_nowait(0) + + # close the input and wait for FFmpeg to finish encoding and terminate + writer.wait(timeout=10) + + # read the rest + b = writer.read_encoded(0) + + +def test_MediaWriter(): + ff.use("read_numpy") + + rates, data = ff.media.read(mult_url, t=1) + stream_types = [spec.split(":", 2)[1] for spec in data] + + rate_opt_name = {"a": "ar", "v": "r"} + stream_opts = [ + {rate_opt_name[mtype]: r} for mtype, r in zip(stream_types, rates.values()) + ] + + with streams.PipedFFmpegRunner.open_media_encoder( + stream_opts, + [{"f": "matroska", "map": range(len(stream_types))}], + show_log=True, + ) as writer: + # write full audio streams + video_frames = {} + for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): + if mtype == "a": + writer.write(frame, i) + else: + video_frames[i] = frame.shape[0] + + # write video stream one frame at a time + frame_count = {k: 0 for k in video_frames} + while any( + n < nall for n, nall in zip(frame_count.values(), video_frames.values()) + ): + for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): + if i in frame_count: + j = frame_count[i] + print(j) + try: + writer.write(frame[j], i) + except IndexError: + pass + else: + b = writer.read_encoded_nowait(0) + frame_count[i] = j + 1 + + writer.wait(10) + b = writer.read_encoded(-1) + assert isinstance(b, bytes) and len(b) > 0 + + +def test_SimpleMediaFilter(): + ff.use("read_numpy") + + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=0.1) + + nin = 1024 + nblocks = len(x) // nin + + X = x[: nin * nblocks, ...].reshape(nblocks, nin, -1) + + with ff.streams.SISOFFmpegFilter.create_and_open( + {"ar": fs}, + {"map": "[out]"}, + options={"filter_complex": "[0:a:0]showcqt=s=vga[out]"}, + show_log=True, + squeeze=False, + ) as f: + # write the first frame (so the output rate is resolved) + f.write(X[0]) + + dt = nin / f.rate_in + ntotal = int(nin * nblocks * f.rate / f.rate_in) # total # of frames + cumnout = np.astype(np.arange(1, nblocks) * dt * f.rate, int) + nread = 0 + + for i, (n, Xn) in enumerate(zip(cumnout, X[1:])): + assert bool(f) + + ntry = n - nread + if ntry > 0: + out = f.read_nowait(n - nread) + nread += out.shape[0] + print(f"[{i:2}] expects {ntry} new frames, {out.shape[0]} frames read") + f.write(Xn, last=i == nblocks - 2) + + ntry = ntotal - nread + if ntry > 0: + print(f"[last] reading the remaining {ntry} frames") + out = f.read(ntry) + nread += out.shape[0] + print(f"[last] final read obtained {out.shape[0]} frames") + assert nread == ntotal + + +def test_MediaFilter(): + ff.use("read_bytes") + + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) + + fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", to=1) + + print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") + + with ff.streams.PipedFFmpegRunner.open_media_filter( + [{"r": fps}, {"r": fps}, {"ar": fs}, {"ar": fs}], + output_streams=["[out0]", {"map": "[out1]"}], + options={"filter_complex": ["[0:V:0][1:V:0]vstack", "[2:a:0][3:a:0]amerge"]}, + show_log=True, + # loglevel="debug", + # queuesize=4, + ) as f: + # f.write([F, F]) + for i, frame in enumerate([F, F, x, x]): + f.write(frame, i, last=True) + # sleep(1) + + assert ["out0", "out1"] == f.output_labels + assert f.num_output_streams == 2 + + frames_per_read = f.output_frames() + nnext = list(frames_per_read) + + for i in range(F["shape"][0]): + for st in range(2): + n = int(nnext[st]) + Fout = f.read(n, st) + print(Fout["shape"], n) + # assert Fout["shape"][0] == n + nnext[st] = nnext[st] - n + frames_per_read[st] + + # just in case + f.wait(1) + + +def test_MediaTranscoder(): + url = "tests/assets/sample.mp4" + + data = b"" + + # 1. transcode from a file to pipe + with streams.PipedFFmpegRunner.open_media_transcoder( + [], + [{"f": "matroska", "to": 1}], + extra_inputs=[(url, {})], + show_log=True, + # loglevel="debug", + ) as f: + while f: + b = f.read_encoded_nowait(-1) + data += b + b = f.read_encoded_nowait(-1) + data += b + + assert len(data) > 0 + + print(f"FIRST TRANCODING YIELDED {len(data)} bytes") + + with streams.PipedFFmpegRunner.open_media_transcoder( + [{}], + [{"f": "flac", "vn": None}, {"f": "matroska", "codec": "copy"}], + show_log=True, + # loglevel="debug", + ) as f: + f.write_encoded(data, last=True) + + out = [b"", b""] + + while f: + for st in range(2): + out[st] += f.read_encoded_nowait(-1, stream=st) + + assert len(out[0]) > 0 + assert len(out[1]) > 0 + + print( + f"SECOND TRANCODING YIELDED {len(out[0])} bytes for flac and {len(out[1])} bytes for matroska" + ) diff --git a/tests/test_streams_simple.py b/tests/test_streams_simple.py new file mode 100644 index 00000000..cd260307 --- /dev/null +++ b/tests/test_streams_simple.py @@ -0,0 +1,160 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import re +import tempfile +from os import path + +import ffmpegio +from ffmpegio import utils +from ffmpegio.streams import StdFFmpegRunner + +url = "tests/assets/testmulti-1m.mp4" +outext = ".mp4" + + +def test_read_video(): + w = 420 + h = 360 + with StdFFmpegRunner.open_simple_reader( + [(url, {})], + {"map": "0:V:0", "vf": "transpose", "pix_fmt": "gray", "s": (w, h), "r": 30}, + show_log=True, + ) as f: + F = f.read(10) + assert f.output_rates[0] == 30 + assert f.output_shapes[0] == (h, w, 1) + assert F["shape"] == (10, h, w) + assert F["dtype"] == f.output_dtypes[0] + + +def test_read_write_video(): + fs, F = ffmpegio.video.read(url, t=1) + bps = utils.get_samplesize(F["shape"][-3:], F["dtype"]) + F0 = { + "buffer": F["buffer"][:bps], + "shape": (1, *F["shape"][1:]), + "dtype": F["dtype"], + } + F1 = { + "buffer": F["buffer"][bps:], + "shape": (F["shape"][0] - 1, *F["shape"][1:]), + "dtype": F["dtype"], + } + + with tempfile.TemporaryDirectory() as tmpdirname: + out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) + with StdFFmpegRunner.open_simple_writer([(out_url, {})], {"r": fs}) as f: + f.write(F0) + f.write(F1) + f.wait() + fs, F = ffmpegio.video.read(out_url) + assert len(F["buffer"]) + + +def test_read_audio(): + fs, x = ffmpegio.audio.read(url) + bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) + + # validate read iterator obtains all the samples + with StdFFmpegRunner.open_simple_reader( + [(url, {})], {"map": "0:a:0"}, show_log=True, blocksize=1024**2 + ) as f: + # x = f.read(1024) + # assert x['shape'] == (1024, f.ac) + blks = [blk["buffer"] for blk in f] + x1 = b"".join(blks) + assert x["buffer"] == x1 + + # validate starting + n0 = int(0.5 * fs) + n1 = int(1.2 * fs) + t0 = n0 / fs + t1 = n1 / fs + + with StdFFmpegRunner.open_simple_reader( + [(url, {})], + {"map": "0:a:0"}, + show_log=True, + blocksize=1024**2, + options={"ss_in": t0, "to_in": t1}, + ) as f: + blks, shapes = zip(*[(blk["buffer"], blk["shape"][0]) for blk in f]) + shape = sum(shapes) + + x2 = b"".join(blks) + # # print("# of blks: ", len(blks), x1['shape']) + # for i, xi in enumerate(x2): + # print(i, xi-x[n0 + i]) + # assert np.array_equal(xi, x[n0 + i]) + assert shape == n1 - n0 + assert x["buffer"][n0 * bps : n1 * bps] == x2 + + +def test_read_write_audio(): + outext = ".flac" + + with StdFFmpegRunner.open_simple_reader([(url, {})], {"map": "0:a:0"}) as f: + F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) + fs = f.output_rates[0] + shape = f.output_shapes[0] + dtype = f.output_dtypes[0] + bps = f.output_itemsizes[0] + + out = {"dtype": dtype, "shape": shape} + + print(len(F[: 100 * bps])) + + with tempfile.TemporaryDirectory() as tmpdirname: + out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) + with StdFFmpegRunner.open_simple_writer( + [(out_url, {})], {"ar": fs}, show_log=True + ) as f: + f.write({**out, "buffer": F[: 100 * bps]}) + f.write({**out, "buffer": F[100 * bps :]}) + f.wait() + assert path.exists(out_url) + + +def test_write_extra_inputs(): + url_aud = "tests/assets/testaudio-1m.mp3" + + fs, F = ffmpegio.video.read(url, t=1) + F = { + "buffer": F["buffer"], + "shape": F["shape"], + "dtype": F["dtype"], + } + print(len(F["buffer"])) + + with tempfile.TemporaryDirectory() as tmpdirname: + out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) + with StdFFmpegRunner.open_simple_writer( + [(out_url, {})], + {"r": fs}, + extra_inputs=[(url_aud, {})], + show_log=True, + options={"map": ["0:v", "1:a"], "loglevel": "debug"}, + ) as f: + f.write(F) + f.wait() + print(f.readlog()) + + info = ffmpegio.probe.streams_basic(out_url) + assert len(info) == 2 + + with StdFFmpegRunner.open_simple_writer( + [(out_url, {})], + {"r": fs}, + extra_inputs=[("anoisesrc", {"f": "lavfi"})], + show_log=True, + overwrite=True, + options={"map": ["0:v", "1:a"], "shortest": None}, + ) as f: + f.write(F) + f.wait() + print(f.readlog()) + + info = ffmpegio.probe.streams_basic(out_url) + assert len(info) == 2 diff --git a/tests/test_threading.py b/tests/test_threading.py index 4177e6c7..b944a8e5 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -1,61 +1,45 @@ from ffmpegio import threading from ffmpegio import ffmpegprocess -from ffmpegio.ffmpegprocess import Popen, run +from ffmpegio.ffmpegprocess import Popen from tempfile import TemporaryDirectory from os import path -import re from pprint import pprint def test_log_popen(): # with exec({"inputs": [(url, None)], "outputs": [("-", None)], "global_options": None},sp_run=sp.Popen,capture_log=True) as f: url = "tests/assets/testmulti-1m.mp4" - with TemporaryDirectory() as tmpdir, Popen( - { - "inputs": [(url, {"t": 0.1})], - "outputs": [(path.join(tmpdir, "test.mp4"), None)], - "global_options": None, - }, - capture_log=True, - ) as proc, threading.LoggerThread(proc.stderr, True) as logger: + with ( + TemporaryDirectory() as tmpdir, + Popen( + { + "inputs": [(url, {"t": 0.1})], + "outputs": [(path.join(tmpdir, "test.mp4"), None)], + "global_options": None, + }, + capture_log=True, + ) as proc, + threading.LoggerThread(proc.stderr, True) as logger, + ): logger.index("Output") pprint(logger.output_stream(0, 0)) -if __name__ == "__main__": +def test_copyfileobj(): + url = "tests/assets/testaudio-1m.mp3" + with ( + TemporaryDirectory() as tmpdir, + open(url, "rb") as fsrc, + open(path.join(tmpdir, "out.mp3"), "w+b") as fdst, + threading.CopyFileObjThread(fsrc, fdst) as copier, + ): - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - - from pprint import pprint - - from ffmpegio import ffmpeg, configure - import io - - args = { - "inputs": [(url1, None), (url2, None)], - "outputs": [ - ( - "-", - { - "vframes": 16, - }, - ) - ], - } - use_ya = configure.finalize_media_read_opts(args) - pprint(args) + copier.join() - # create a short example with both audio & video - f = io.BytesIO(ffmpegprocess.run(args).stdout) + fsrc.seek(0) + data = fsrc.read() + fdst.seek(0) + data_out = fdst.read() + + assert data == data_out - reader = threading.AviReaderThread() - reader.start(f, use_ya) - try: - reader.wait() - print(f"thread is running {reader.is_alive()}") - pprint(reader.streams) - pprint(reader.rates) - except: - reader.join() diff --git a/tests/test_transcode.py b/tests/test_transcode.py index 1a5622bb..d69d1161 100644 --- a/tests/test_transcode.py +++ b/tests/test_transcode.py @@ -66,7 +66,7 @@ def test_transcode_2pass(): show_log=True, two_pass=True, t=1, - **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"} + **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}, ) transcode( @@ -78,7 +78,7 @@ def test_transcode_2pass(): pass1_extras={"an": None}, overwrite=True, t=1, - **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"} + **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}, ) @@ -91,21 +91,5 @@ def test_transcode_vf(): assert path.isfile(out_url) -def test_transcode_image(): - url = "tests/assets/ffmpeg-logo.png" - with tempfile.TemporaryDirectory() as tmpdirname: - # print(probe.audio_streams_basic(url)) - out_url = path.join(tmpdirname, path.basename(url) + ".jpg") - transcode( - url, - out_url, - show_log=True, - remove_alpha=True, - s=[300, -1], - transpose=0, - vframes=1, - ) - - if __name__ == "__main__": test_transcode_from_filter() diff --git a/tests/test_utils.py b/tests/test_utils.py index 19633555..6fec56cb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ -import math -from ffmpegio import utils import pytest +from ffmpegio import utils + def test_string_escaping(): raw = "Crime d'Amour" @@ -35,51 +35,6 @@ def test_string_escaping(): assert utils.unescape(esc) == raw -@pytest.mark.parametrize( - ("arg", "file_index", "ret"), - [ - (1, False, {"index": 1}), - ("1", False, {"index": 1}), - ("v", False, {"media_type": "v"}), - ("p:1", False, {"program_id": 1}), - ("p:1:V", False, {"program_id": 1, "media_type": "V"}), - ( - "p:1:a:#6", - False, - { - "program_id": 1, - "media_type": "a", - "stream_id": 6, - }, - ), - ("d:i:6", False, {"media_type": "d", "stream_id": 6}), - ("t:m:key", False, {"media_type": "t", "tag": "key"}), - ("m:key:value", False, {"tag": ("key", "value")}), - ("u", False, {"usable": True}), - ("0:1", True, {"index": 1, "file_index": 0}), - ([0, 1], True, {"index": 1, "file_index": 0}), - ], -) -def test_parse_stream_spec(arg, file_index, ret): - assert utils.parse_stream_spec(arg, file_index) == ret - - -def test_stream_spec(): - assert utils.stream_spec() == "" - assert utils.stream_spec(0) == "0" - assert utils.stream_spec(media_type="a") == "a" - assert utils.stream_spec(1, media_type="v") == "v:1" - assert utils.stream_spec(program_id=1) == "p:1" - assert utils.stream_spec(1, media_type="v", program_id=1) == "v:p:1:1" - assert utils.stream_spec(stream_id=342) == "#342" - assert utils.stream_spec(tag="creation_time") == "m:creation_time" - assert ( - utils.stream_spec(tag=("creation_time", "2018-05-26T19:36:24.000000Z")) - == "m:creation_time:2018-05-26T19:36:24.000000Z" - ) - assert utils.stream_spec(usable=True) == "u" - - def test_get_pixel_config(): with pytest.raises(Exception): utils.get_pixel_config("yuv") # unknown format @@ -87,29 +42,17 @@ def test_get_pixel_config(): assert cfg[0] == "rgb24" and cfg[1] == 3 and cfg[2] == "|u1" -def test_alpha_change(): - - cases = (("rgb24", "rgba", 1), ("rgb24", "rgb24", 0), ("ya8", "gray", -1)) - - for input_pix_fmt, output_pix_fmt, dir in cases: - dout = utils.alpha_change(input_pix_fmt, output_pix_fmt) - assert dir == dout - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, dir) is True - if dir: - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, -dir) is False - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, 0) is False - else: - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, 1) is False - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, -1) is False +def test_get_pixel_format(): + with pytest.raises(KeyError): + utils.get_pixel_format("yuv") # unknown format + cfg = utils.get_pixel_format("rgb24") # unknown format + assert cfg[1] == 3 and cfg[0] == "|u1" -def test_get_rotated_shape(): - w = 1000 - h = 400 - print(utils.get_rotated_shape(w, h, 30)) - print(utils.get_rotated_shape(w, h, 45)) - print(utils.get_rotated_shape(w, h, 60)) - assert utils.get_rotated_shape(w, h, 90) == (h, w, math.pi / 2.0) + with pytest.raises(ValueError): + utils.get_pixel_format("yuv420p") + cfg = utils.get_pixel_format("yuv444p") # unknown format + assert cfg[0] == "|u1" and cfg[1] == 3 def test_get_audio_codec(): @@ -122,5 +65,14 @@ def test_get_audio_format(): assert cfg[0] == "