From 16b7e7077a6ef5a0746069de2dd28494f2250bc1 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 8 Jul 2024 20:29:06 -0500 Subject: [PATCH 01/57] code formatting --- src/ffmpegio/audio.py | 2 +- src/ffmpegio/configure.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 2846d80b..cb5ae369 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -192,7 +192,7 @@ def read(url, progress=None, show_log=None, sp_kwargs=None, **options): 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) + ar_in, sample_fmt, ac_in = _probe_audio_info(url, "a:0", sp_kwargs) except: sample_fmt = "s16" diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 88d59c30..445be441 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -167,9 +167,11 @@ def add_url(args, type, url, opts=None, update=False): elif opts is not None: filelist[id] = ( url, - opts and {**opts} - if filelist[id][1] is None - else {**filelist[id][1], **opts}, + ( + opts and {**opts} + if filelist[id][1] is None + else {**filelist[id][1], **opts} + ), ) return id, filelist[id] From ad8d548040850e118682395d401f64d77c7d6d3f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 8 Jul 2024 20:48:31 -0500 Subject: [PATCH 02/57] allow writers' `extra_inputs` arguments to be `str` or `tuple[str, dict|None]` - refactored `extra_inputs` argument handling to `configure.add_urls()` --- CHANGELOG.md | 7 ++++ src/ffmpegio/audio.py | 6 +--- src/ffmpegio/configure.py | 50 +++++++++++++++++++++++++++ src/ffmpegio/image.py | 6 +--- src/ffmpegio/streams/SimpleStreams.py | 6 +--- src/ffmpegio/video.py | 6 +--- tests/test_configure.py | 15 ++++++++ 7 files changed, 76 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b893289d..f4416ff9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ 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]` + +### Added +- `configure.add_urls()` to handle `extra_inputs` argument processing + ## [0.10.0] - 2024-07-03 ### Changed diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index cb5ae369..240edf7d 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -271,11 +271,7 @@ def write( # add extra input arguments if given if extra_inputs is not None: - for input in extra_inputs: - if isinstance(input, str): - configure.add_url(ffmpeg_args, "input", input) - else: - configure.add_url(ffmpeg_args, "input", *input) + configure.add_urls(ffmpeg_args, "input", extra_inputs) configure.add_url(ffmpeg_args, "output", url, options) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 445be441..4200542d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,3 +1,8 @@ +from __future__ import annotations + +from typing import Literal +from collections.abc import Sequence + import re, logging logger = logging.getLogger("ffmpegio") @@ -6,6 +11,8 @@ from .filtergraph import Graph, Filter, Chain from .errors import FFmpegioError +UrlType = Literal["input", "output"] + def array_to_video_input(rate, data, stream_id=None, **opts): """create an stdin input with video stream @@ -700,3 +707,46 @@ def config_input_fg(expr, args, kwargs): dopt = None # infinite 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 + + :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))) + ) + else None + ) + ) + + ret = process_one(urls) + return [process_one(url) for url in urls] if ret is None else [ret] diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index d3d02a3c..cb8155c8 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -225,11 +225,7 @@ def write( # add extra input arguments if given if extra_inputs is not None: - for input in extra_inputs: - if isinstance(input, str): - configure.add_url(ffmpeg_args, "input", input) - else: - configure.add_url(ffmpeg_args, "input", *input) + configure.add_urls(ffmpeg_args, "input", extra_inputs) outopts = configure.add_url(ffmpeg_args, "output", url, options)[1][1] outopts["frames:v"] = 1 diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 4a914f21..abc89272 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -361,11 +361,7 @@ def __init__( # add extra input arguments if given if extra_inputs is not None: - for input in extra_inputs: - if isinstance(input, str): - configure.add_url(ffmpeg_args, "input", input) - else: - configure.add_url(ffmpeg_args, "input", *input) + 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) diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 869796f3..23ac43a5 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -254,11 +254,7 @@ def write( # add extra input arguments if given if extra_inputs is not None: - for input in extra_inputs: - if isinstance(input, str): - configure.add_url(ffmpeg_args, "input", input) - else: - configure.add_url(ffmpeg_args, "input", *input) + configure.add_urls(ffmpeg_args, "input", extra_inputs) configure.add_url(ffmpeg_args, "output", url, options) diff --git a/tests/test_configure.py b/tests/test_configure.py index 99e12df0..13562ca9 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -66,6 +66,21 @@ def test_add_url(): assert idx == 1 and entry == args_expected["inputs"][1] and args == args_expected +def test_add_urls(): + + url = ["test.mp4", "test1.mp4", "test2.mp4", "test3.mp4", "test4.mp4"] + args = {} + + # urls: str | tuple[str, dict | None] | Sequence[str | tuple[str, dict | None]], + assert configure.add_urls(args, "input", url[0]) == [(0, (url[0], None))] + assert configure.add_urls(args, "input", (url[1], None)) == [(1, (url[1], None))] + assert configure.add_urls(args, "input", (url[2], {})) == [(2, (url[2], {}))] + assert configure.add_urls(args, "input", [url[3], url[4]]) == [ + (3, (url[3], None)), + (4, (url[4], None)), + ] + + def test_get_option(): assert configure.get_option(None, "input", "c") is None From 8e99f3979c4745663d607592457fc2ce5620d87d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 8 Jul 2024 21:01:44 -0500 Subject: [PATCH 03/57] fixed `probe._exec()` error reporting (utf-8 decoding the `stderr`) --- CHANGELOG.md | 5 +++++ src/ffmpegio/probe.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4416ff9..e3e9bd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - allow writers' `extra_inputs` arguments to be `str` or `tuple[str, dict|None]` ### Added + - `configure.add_urls()` to handle `extra_inputs` argument processing +### Fixed + +- `probe._exec()` to decode the error message sent from ffprobe + ## [0.10.0] - 2024-07-03 ### Changed diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 94ffc84a..6de562ef 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -214,7 +214,7 @@ def _exec( # run ffprobe ret = ffprobe(args, **sp_opts) if ret.returncode != 0: - raise Exception(f"ffprobe execution failed\n\n{ret.stderr}\n") + raise Exception(f"ffprobe execution failed\n\n{ret.stderr.decode('utf8')}\n") # decode output JSON string return json.loads(ret.stdout) From 6f324d77bdd37df952e5a3b7b7a3448c3eaa2deb Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 8 Jul 2024 21:14:13 -0500 Subject: [PATCH 04/57] updated github action --- .github/workflows/test_n_pub.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test_n_pub.yml b/.github/workflows/test_n_pub.yml index 83b6fb39..53441d42 100644 --- a/.github/workflows/test_n_pub.yml +++ b/.github/workflows/test_n_pub.yml @@ -40,7 +40,7 @@ jobs: steps: - run: echo ${{github.ref}} - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up FFmpeg uses: FedericoCarboni/setup-ffmpeg@v3 @@ -49,7 +49,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.arch }} @@ -75,10 +75,10 @@ jobs: steps: - run: echo ${{github.ref}} - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} architecture: ${{ matrix.arch }} @@ -99,10 +99,10 @@ jobs: needs: [tests, test_no_ffmpeg] if: startsWith(github.ref, 'refs/tags') steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.x" # Version range or exact version of a Python version to use, using SemVer's version range syntax From ebd00748b49ec493237eb4e725a170a9c540a3bf Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 9 Jul 2024 09:30:32 -0500 Subject: [PATCH 05/57] `filtergraph.Filter.add_labels()`: fixed a syntax error --- CHANGELOG.md | 1 + src/ffmpegio/filtergraph.py | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e9bd40..4279b7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ### Fixed - `probe._exec()` to decode the error message sent from ffprobe +- `filtergraph.Filter.add_labels()`: fixed a syntax error ## [0.10.0] - 2024-07-03 diff --git a/src/ffmpegio/filtergraph.py b/src/ffmpegio/filtergraph.py index ea71e61a..1dbfb1fc 100644 --- a/src/ffmpegio/filtergraph.py +++ b/src/ffmpegio/filtergraph.py @@ -2590,9 +2590,11 @@ def add_labels(self, pad_type, labels): else: pads = list( itertools.islice( - fg.iter_input_pads(exclude_named=True) - if pad_type == "dst" - else fg.iter_output_pads(exclude_named=True), + ( + fg.iter_input_pads(exclude_named=True) + if pad_type == "dst" + else fg.iter_output_pads(exclude_named=True) + ), len(labels), ) ) From 8d0f6d21e6c28ade4a3f18e0929794506dbb31a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Jul 2024 01:13:04 +0000 Subject: [PATCH 06/57] build(deps): bump pillow from 9.5.0 to 10.3.0 in /docsrc Bumps [pillow](https://github.com/python-pillow/Pillow) from 9.5.0 to 10.3.0. - [Release notes](https://github.com/python-pillow/Pillow/releases) - [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst) - [Commits](https://github.com/python-pillow/Pillow/compare/9.5.0...10.3.0) --- updated-dependencies: - dependency-name: pillow dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- docsrc/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsrc/requirements.txt b/docsrc/requirements.txt index 196ab876..cffb795f 100644 --- a/docsrc/requirements.txt +++ b/docsrc/requirements.txt @@ -1,4 +1,4 @@ -Pillow==9.5.0 +Pillow==10.3.0 sphinx sphinx-rtd-theme sphinx-autopackagesummary From 2748d131993e56091d472131d32e298d9c859bbc Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 9 Jul 2024 22:58:58 -0500 Subject: [PATCH 07/57] use forked blockdiag for Pillow10 compatibility --- docsrc/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docsrc/requirements.txt b/docsrc/requirements.txt index cffb795f..c7bf27eb 100644 --- a/docsrc/requirements.txt +++ b/docsrc/requirements.txt @@ -3,6 +3,7 @@ sphinx sphinx-rtd-theme sphinx-autopackagesummary sphinxcontrib-blockdiag +blockdiag @ git+https://github.com/yuzutech/blockdiag.git sphinxcontrib-repl sphinx-exec-directive sphinx-autobuild From 30d780e4239f1de87c2c39e19dc576cb1e834e30 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 9 Jul 2024 22:59:05 -0500 Subject: [PATCH 08/57] update github action --- .github/workflows/test_n_pub.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_n_pub.yml b/.github/workflows/test_n_pub.yml index 53441d42..3e5283bc 100644 --- a/.github/workflows/test_n_pub.yml +++ b/.github/workflows/test_n_pub.yml @@ -96,6 +96,7 @@ jobs: distribute: name: Distribution runs-on: ubuntu-latest + permissions: write-all needs: [tests, test_no_ffmpeg] if: startsWith(github.ref, 'refs/tags') steps: @@ -130,5 +131,5 @@ jobs: - name: Publish distribution 📦 to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} + # with: + # password: ${{ secrets.PYPI_API_TOKEN }} From ff274d647b8a64719f60302f53cb021d2d88c1f1 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 17 Jul 2024 22:31:24 -0500 Subject: [PATCH 09/57] fixed typo --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 86f11404..b9b3aca0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" name = "ffmpegio-core" description = "Media I/O with FFmpeg" readme = "README.rst" -keywords = ["multimedia, ffmpeg"] +keywords = ["multimedia", "ffmpeg"] license = { text = "GPL-2.0 License" } classifiers = [ "Development Status :: 4 - Beta", From 50a637627cfd3b15aa58d961242af5e5c7e88de8 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 1 Aug 2024 15:11:10 -0500 Subject: [PATCH 10/57] fixed/updated typehints --- src/ffmpegio/probe.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 6de562ef..00bbc888 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -393,7 +393,7 @@ def streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, -) -> dict[str, str | Number | Fraction]: +) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of media streams :param url: URL of the media file/stream @@ -448,7 +448,7 @@ def video_streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, -) -> dict[str, str | Number | Fraction]: +) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of video streams :param url: URL of the media file/stream @@ -565,7 +565,7 @@ def audio_streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, -) -> dict[str, str | Number | Fraction]: +) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of audio streams :param url: URL of the media file/stream @@ -671,7 +671,11 @@ def query( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, -) -> dict[str, Any] | Sequence[dict[str, Any]]: +) -> ( + dict[str, Any] + | Sequence[dict[str, Any]] + | list[dict[str, Any] | Sequence[dict[str, Any]]] +): """Query specific fields of media format or stream :param url: URL of the media file/stream @@ -783,7 +787,7 @@ def frames( intervals: IntervalSpec | Sequence[IntervalSpec] | None = None, accurate_time: bool | None = False, sp_kwargs: dict[str, Any] | None = None, -): +) -> list[dict] | list[str | int | float]: """get frame information :param url: URL of the media file/stream From 987c71151885948e88d9061440d548bc1131b2cb Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 8 Aug 2024 15:09:40 -0500 Subject: [PATCH 11/57] fixed github workflow status badge link --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index fd2828c7..a77371f0 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ :alt: PyPI - Python Version .. |github-license| image:: https://img.shields.io/github/license/python-ffmpegio/python-ffmpegio :alt: GitHub License -.. |github-status| image:: https://img.shields.io/github/workflow/status/python-ffmpegio/python-ffmpegio/Run%20Tests +.. |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 Python `ffmpegio` package aims to bring the full capability of `FFmpeg `__ From 817c3d415900720beb166771a4841946f7fa0199 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 11:55:21 -0500 Subject: [PATCH 12/57] added to host custom type hint objects --- src/ffmpegio/utils/typing.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/ffmpegio/utils/typing.py diff --git a/src/ffmpegio/utils/typing.py b/src/ffmpegio/utils/typing.py new file mode 100644 index 00000000..3f4bf871 --- /dev/null +++ b/src/ffmpegio/utils/typing.py @@ -0,0 +1,33 @@ +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] From 3a96f929aff6ae822fd01cb4bedb4096896f983d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 12:00:35 -0500 Subject: [PATCH 13/57] added type hints and removed :type: from docstrings --- src/ffmpegio/utils/__init__.py | 160 +++++++++++++-------------------- 1 file changed, 64 insertions(+), 96 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index dabdc387..2c0e8ee2 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -1,20 +1,25 @@ +from __future__ import annotations + +from collections.abc import Sequence +from numbers import Number + from math import cos, radians, sin import re, fractions from .. import caps from .._utils import * +from .typing import get_args, MediaType, StreamSpec, Any + # TODO: auto-detect endianness # import sys # sys.byteorder -def escape(txt): +def escape(txt: str) -> str: """apply FFmpeg single quote escaping :param txt: Unescaped string - :type txt: any stringifiable object :return: Escaped string - :rtype: str See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping """ @@ -30,13 +35,11 @@ def escape(txt): return re.sub(r"(['\\])", r"\\\1", txt) -def unescape(txt): +def unescape(txt: str) -> str: """undo FFmpeg single quote escaping :param txt: Escaped string - :type txt: str :return: Original string - :rtype: str See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping """ @@ -83,18 +86,17 @@ def unescape(txt): return "".join(blks) -def parse_stream_spec(spec, file_index=False): +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]. - :type spec: str or int or [int,int] :param file_index: True to expect spec to start with a file index, defaults to False - :type file_index: bool, optional :return: stream spec dict - :rtype: dict The reverse of `stream_spec()` """ @@ -148,15 +150,12 @@ def parse_stream_spec(spec, file_index=False): return {"index": int(spec)} -def is_stream_spec(spec, file_index=False): +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 - :type spec: str :param file_index: True if spec starts with a file index, None to allow with or without file_index defaults to False - :type file_index: bool|None, optional :return: True if valid stream specifier - :rtype: bool """ try: parse_stream_spec(spec, True if file_index is None else file_index) @@ -172,15 +171,15 @@ def is_stream_spec(spec, file_index=False): def stream_spec( - index=None, - type=None, - program_id=None, - pid=None, - tag=None, - usable=None, - file_index=None, - no_join=False, -): + index: int | None = None, + type: MediaType | None = None, + program_id: int | None = None, + pid: 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 @@ -189,7 +188,6 @@ def stream_spec( 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 - :type index: int, optional :param 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, @@ -202,30 +200,22 @@ def stream_spec( 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 - :type program_id: int, optional :param pid: stream id given by the container (e.g. PID in MPEG-TS container), defaults to None - :type pid: str, optional :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 - :type tag, str or tuple(key,value), optional :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 - :type usable: bool, optional :param file_index: file index to be prepended if specified, defaults to None - :type file_index: int, optional :param filter_output: True to append "out" to stream type, defaults to False - :type filter_output: bool, optional :param no_join: True to return list of stream specifier elements, defaults to False - :type no_join: bool, optional :return: stream specifier string or empty string if all arguments are None - :rtype: str Note matching by metadata will only work properly for input files. - Note index, pid, tag, and usable are mutually exclusive. Only one of them + Note index, stream_id, tag, and usable are mutually exclusive. Only one of them can be specified. """ @@ -262,16 +252,15 @@ def stream_spec( return spec if no_join else ":".join(spec) -def get_pixel_config(input_pix_fmt, pix_fmt=None): +def get_pixel_config( + input_pix_fmt: str, pix_fmt: str | None = None +) -> tuple[str, int, str, bool]: """get best pixel configuration to read video data in specified pixel format :param input_pix_fmt: input pixel format - :type input_pix_fmt: str :param pix_fmt: desired output pixel format, defaults to None (auto-select) - :type pix_fmt: str, optional :return: output pix_fmt, number of components, data type string, and whether alpha component must be removed - :rtype: tuple(str, int, str, bool) ===== ===== ========= =================================== ncomp dtype pix_fmt Description @@ -328,18 +317,16 @@ def get_pixel_config(input_pix_fmt, pix_fmt=None): ) -def alpha_change(input_pix_fmt, output_pix_fmt, dir=None): +def alpha_change( + input_pix_fmt: str, output_pix_fmt: str | None, dir: int | None = None +) -> bool | int | None: """get best pixel configuration to read video data in specified pixel format :param input_pix_fmt: input pixel format - :type input_pix_fmt: str :param output_pix_fmt: output pixel format - :type output_pix_fmt: str, optional :param dir: specify the change direction for boolean answer, defaults to None - :type dir: int, optional :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 - :rtype: bool, int, None """ if input_pix_fmt is None or output_pix_fmt is None: @@ -350,15 +337,11 @@ def alpha_change(input_pix_fmt, output_pix_fmt, dir=None): 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): +def get_pixel_format(fmt: str) -> tuple[str, int]: """get data format and number of components associated with video pixel format :param fmt: ffmpeg pix_fmt - :type fmt: str - :param return_format: True to return raw audio format name instead of pcm codec name - :type return_format: bool :return: data type string and the number of components associated with the pix_fmt - :rtype: tuple[str, int] """ try: return dict( @@ -379,30 +362,28 @@ def get_pixel_format(fmt): raise ValueError(f"{fmt} is not a valid grayscale/rgb pix_fmt") -def get_video_format(fmt, s): +def get_video_format( + fmt: str, s: tuple[int, int] | str +) -> tuple[str, tuple[int, int, int]]: """get pixel data type and frame array (height,width,ncomp) :param fmt: ffmpeg pix_fmt or data type string - :type fmt: str :param s: frame size (width,height) - :type s: tuple[int, int] :return: data type string and shape tuple - :rtype: tuple[str, tuple[int, int, int]] """ dtype, ncomp = get_pixel_format(fmt) s = parse_video_size(s) return dtype, (*s[::-1], ncomp) -def guess_video_format(shape, dtype): +def guess_video_format( + shape: Sequence[int, int, int], dtype: str +) -> tuple[tuple[int, int], str]: """get video format :param shape: frame data shape - :type shape: Sequence[int,int,int] :param dtype: frame data type - :type dtype: str :return: frame size and pix_fmt - :rtype: tuple[tuple[int,int],str] ``` X = np.ones((100,480,640,3),'|u1') @@ -438,7 +419,14 @@ def guess_video_format(shape, dtype): return size, pix_fmt -def get_rotated_shape(w, h, deg): +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) @@ -447,13 +435,11 @@ def get_rotated_shape(w, h, deg): # return int(round(abs(X[0, 0] - X[0, 2]))), int(round(abs(X[1, 1]))), theta -def get_audio_codec(fmt): +def get_audio_codec(fmt: str) -> tuple[str, str]: """get pcm audio codec & format :param fmt: ffmpeg sample_fmt - :type fmt: str or data type string :return: tuple of pcm codec name and container format - :rtype: tuple """ try: return dict( @@ -468,15 +454,12 @@ def get_audio_codec(fmt): raise ValueError(f"{fmt} is not a valid raw audio sample_fmt") -def get_audio_format(fmt, ac=None): +def get_audio_format(fmt: str, ac: int | None = None) -> str | tuple[str, tuple[int]]: """get audio sample data format :param fmt: ffmpeg sample_fmt or data type string - :type fmt: str or data type string :param ac: number of channels, default to None (to return only dtype) - :type ac: int, optional :return: data type string and array shape tuple - :rtype: tuple[str, tuple[int]] | str """ try: dtype = { @@ -492,15 +475,14 @@ def get_audio_format(fmt, ac=None): raise ValueError(f"Unsupported or unknown sample_fmt ({fmt}) specified.") -def guess_audio_format(dtype, shape=None): +def guess_audio_format( + dtype: str, shape: Sequence[int] | None = None +) -> tuple[int, str]: """get audio format :param dtype: sample data type - :type dtype: str :param shape: sample data shape - :type shape: Sequence[int] :return: tuple of # of channels and sample_fmt - :rtype: tuple(int,str) ``` X = np.ones((1000,2),np.int16) @@ -530,7 +512,7 @@ def guess_audio_format(dtype, shape=None): return sample_fmt, (None if shape is None else shape[-1]) -def parse_video_size(expr): +def parse_video_size(expr: str | tuple[int, int]) -> tuple[int, int]: if isinstance(expr, str): m = re.match(r"(\d+)x(\d+)", expr) @@ -542,14 +524,14 @@ def parse_video_size(expr): return expr -def parse_frame_rate(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): +def parse_color(expr) -> tuple[int, int, int, int | None]: m = re.match( r"([^@]+)?(?:@(0x[\da-f]{2}|[0-1]\.[0-9]+))?$", expr, @@ -577,7 +559,7 @@ def parse_color(expr): return int(rgb[:2], 16), int(rgb[2:4], 16), int(rgb[4:], 16), alpha -def compose_color(r, *args): +def compose_color(r: str | Sequence[Number], *args: tuple[Number]) -> str: if isinstance(r, str): colors = caps.colors() @@ -598,7 +580,7 @@ def conv(x): return "".join((conv(x) for x in (r, *args))) -def layout_to_channels(layout): +def layout_to_channels(layout: str) -> int: layouts = caps.layouts()["layouts"] names = caps.layouts()["channels"].keys() if layout in layouts: @@ -623,15 +605,13 @@ def each_ch(expr): return sum([each_ch(ch) for ch in re.split(r"\+|\|", layout)]) -def parse_time_duration(expr): +def parse_time_duration(expr: str | float) -> float: """convert time/duration expression to seconds if expr is not str, the input is returned without any processing - :param expr: time/duration expression - :type expr: str + :param expr: time/duration expression (or in seconds to pass through) :return: time/duration in seconds - :rtype: float """ if isinstance(expr, str): m = re.match(r"(-)?((\d{2})\:)?(\d{2}):(\d{2}(?:\.\d+)?)", expr) @@ -652,30 +632,24 @@ def parse_time_duration(expr): return expr -def find_stream_options(options, name): +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) - :type options: dict :param suffix: matching suffix - :type suffix: str :return: popped options - :rtype: dict """ re_opt = re.compile(rf"{name}(?=\:|$)") return [k for k in options if re_opt.match(k)] -def pop_extra_options(options, suffix): +def pop_extra_options(options: dict[str, Any], suffix: str) -> dict[str, Any]: """pop matching keys from options dict :param options: source option dict (content will be modified) - :type options: dict :param suffix: matching suffix - :type suffix: str :return: popped options - :rtype: dict """ n = len(suffix) return { @@ -684,15 +658,14 @@ def pop_extra_options(options, suffix): } -def pop_extra_options_multi(options, suffix): +def pop_extra_options_multi( + options: dict[str, Any], suffix: str +) -> tuple[str, dict[int, dict[str, Any]]]: """pop regex matching keys from options dict and :param options: source option dict (content will be modified) - :type options: dict :param suffix: matching suffix regex expression with one group, capturing the (int) id - :type suffix: str :return: dict of popped options with int id key - :rtype: str, dict(int, dict) example: @@ -718,18 +691,13 @@ def match(name, v): return popped -def pop_global_options(options): + +def pop_global_options(options: dict[str, Any]) -> dict[str, Any]: """pop global options from options dict :param options: source option dict (content will be modified) - :type options: dict :return: popped options - :rtype: dict """ all_gopts = caps.options("global") - return { - k: options.pop(k) - for k in [k for k in options.keys() if k in all_gopts] - } - + return {k: options.pop(k) for k in [k for k in options.keys() if k in all_gopts]} From c8a2fef546eefbecddc91df1d5fafaf8b02ea4ad Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 12:04:47 -0500 Subject: [PATCH 14/57] updated sptream_spec parser and composer - changed type to media_type - changed pid to stream_id - support group_id and group_index --- src/ffmpegio/utils/__init__.py | 189 ++++++++++++++++++++++----------- tests/test_utils.py | 55 ++++++---- 2 files changed, 157 insertions(+), 87 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 2c0e8ee2..dd022e53 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -102,52 +102,107 @@ def parse_stream_spec( """ if isinstance(spec, str): - out = {} - if file_index: - m = re.match(r"(\d+)(?::|$)", spec) - if m: - out["file_index"] = int(m[1]) - spec = spec[m.end() :] + + 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: - raise ValueError("Missing file index.") - - while len(spec): - if spec.startswith("p:"): - _, v, *r = spec.split(":", 2) - out["program_id"] = int(v) - spec = r[0] if len(r) else "" - elif spec[0] in "vVadt" and (len(spec) == 1 or spec[1] == ":"): - out["type"], *r = spec.split(":", 1) - spec = r[0] if len(r) 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 not spec: - return out - - try: - out["index"] = int(spec) - except: - m = re.match( - r"#(\d+)$|i\:(\d+)$|m\:(.+?)(?:\:(.+?))?$|(u)$|#(0x[\da-f]+)$|i\:(0x[\da-f]+)$", - spec, - ) - if not m: - raise ValueError("Invalid stream specifier.") - - if m[1] or m[2]: - out["pid"] = int(m[1] or m[2]) - elif m[3] is not None: - out["tag"] = m[3] if m[4] is None else (m[3], m[4]) - elif m[5]: - out["usable"] = True - elif m[6] or m[7]: - out["pid"] = m[6] or m[7] + + if i + 1 < nspecs: + raise ValueError(f"Not all specifiers resolved: {':'.join(spec_parts[i:])}") + return out - else: - if file_index: - return {"file_index": int(spec[0]), "index": int(spec[1])} - else: - return {"index": int(spec)} + + 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: @@ -160,21 +215,23 @@ def is_stream_spec(spec, file_index: bool | None = None) -> bool: try: parse_stream_spec(spec, True if file_index is None else file_index) return True - except: + except ValueError: if file_index is None: try: parse_stream_spec(spec, False) return True - except: + except ValueError: pass return False def stream_spec( index: int | None = None, - type: MediaType | None = None, + media_type: MediaType | None = None, + group_index: int | None = None, + group_id: int | None = None, program_id: int | None = None, - pid: int | None = None, + stream_id: int | None = None, tag: str | tuple[str, str] | None = None, usable: bool | None = None, file_index: int | None = None, @@ -188,19 +245,22 @@ def stream_spec( 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 type: One of following: ’v’ or ’V’ for video, ’a’ for audio, ’s’ for + :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 - :type type: str, optional + :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 pid: stream id given by the container (e.g. PID in MPEG-TS + :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 @@ -220,30 +280,31 @@ def stream_spec( """ - # nothing specified - if all( - [k is None for k in (index, type, program_id, pid, tag, usable, file_index)] - ): - return [] if no_join else "" + 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 type is not None: - spec.append( - dict(video="v", audio="a", subtitle="s", data="d", attachment="t").get( - type, type - ) - ) + 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 sum([k is not None for k in (index, pid, tag, usable)]) > 1: - raise Exception("Multiple mutually exclusive specifiers are given.") if index is not None: spec.append(str(index)) - elif pid is not None: - spec.append(f"#{pid}") + 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: diff --git a/tests/test_utils.py b/tests/test_utils.py index 186ff911..19633555 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -35,34 +35,43 @@ def test_string_escaping(): assert utils.unescape(esc) == raw -def test_parse_stream_spec(): - assert utils.parse_stream_spec(1) == {"index": 1} - assert utils.parse_stream_spec("1") == {"index": 1} - assert utils.parse_stream_spec("v") == {"type": "v"} - assert utils.parse_stream_spec("p:1") == {"program_id": 1} - assert utils.parse_stream_spec("p:1:V") == {"program_id": 1, "type": "V"} - assert utils.parse_stream_spec("p:1:a:#6") == { - "program_id": 1, - "type": "a", - "pid": 6, - } - assert utils.parse_stream_spec("d:i:6") == {"type": "d", "pid": 6} - assert utils.parse_stream_spec("t:m:key") == {"type": "t", "tag": "key"} - assert utils.parse_stream_spec("m:key:value") == {"tag": ("key", "value")} - assert utils.parse_stream_spec("u") == {"usable": True} - - assert utils.parse_stream_spec("0:1", True) == {"index": 1, "file_index": 0} - assert utils.parse_stream_spec([0, 1], True) == {"index": 1, "file_index": 0} +@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(type="a") == "a" - assert utils.stream_spec(1, type="v") == "v:1" - assert utils.stream_spec(program_id="1") == "p:1" - assert utils.stream_spec(1, type="v", program_id="1") == "v:p:1:1" - assert utils.stream_spec(pid=342) == "#342" + 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")) From 50ca5b5aeaa6a09cf5d9ae95bab4665cd41cd36d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 12:05:03 -0500 Subject: [PATCH 15/57] removed unused import .utils --- src/ffmpegio/utils/filter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ffmpegio/utils/filter.py b/src/ffmpegio/utils/filter.py index a7f8f441..de46224e 100644 --- a/src/ffmpegio/utils/filter.py +++ b/src/ffmpegio/utils/filter.py @@ -1,7 +1,6 @@ from fractions import Fraction import re, itertools from collections.abc import Sequence -from .. import utils # Filter string parser/composer # For FilterGraph class, see ../filtergraph.py From fc56b45258f2b9f503f79584267d0fdf835f49ff Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 12:05:56 -0500 Subject: [PATCH 16/57] added typing_extensions to dependencies --- pyproject.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b9b3aca0..6108a480 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,11 +22,15 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12" + "Programming Language :: Python :: 3.12", ] dynamic = ["version"] requires-python = ">=3.8" -dependencies = ["pluggy", "packaging"] +dependencies = [ + "pluggy", + "packaging", + "typing_extensions", +] [project.urls] Repository = "https://github.com/python-ffmpegio/python-ffmpegio" From b7d0282d14dae3017907e6b0e85013c8c9dcbb2e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 12:06:48 -0500 Subject: [PATCH 17/57] added an ff5 error message to scan_stderr --- src/ffmpegio/errors.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ffmpegio/errors.py b/src/ffmpegio/errors.py index 60aca628..a20696b1 100644 --- a/src/ffmpegio/errors.py +++ b/src/ffmpegio/errors.py @@ -283,6 +283,8 @@ def scan_stderr(logs: Union[str, Sequence[str], None]): msg = ( f"{logs[-2]}\n {msg0}" if logs[-2].startswith("[lavfi ") else logs[-2] ) # ... + elif msg0.endswith('No such file or directory'): + msg = msg0 return msg From 0b2ee0dff78db592d2cc1535f65dcf0a5516601e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 12:12:40 -0500 Subject: [PATCH 18/57] major revamp of filtergraph submodule --- README.rst | 4 +- src/ffmpegio/analyze.py | 2 +- src/ffmpegio/filtergraph.py | 2701 ----------------- src/ffmpegio/filtergraph/Chain.py | 558 ++++ src/ffmpegio/filtergraph/Filter.py | 873 ++++++ src/ffmpegio/filtergraph/Graph.py | 1225 ++++++++ src/ffmpegio/filtergraph/GraphLinks.py | 975 ++++++ src/ffmpegio/filtergraph/__init__.py | 170 ++ src/ffmpegio/filtergraph/abc.py | 1100 +++++++ src/ffmpegio/filtergraph/build.py | 349 +++ src/ffmpegio/filtergraph/convert.py | 178 ++ src/ffmpegio/filtergraph/exceptions.py | 47 + src/ffmpegio/filtergraph/typing.py | 39 + src/ffmpegio/filtergraph/util.py | 63 + src/ffmpegio/utils/__init__.py | 40 + src/ffmpegio/utils/fglinks.py | 933 ------ tests/test_filtergraph.py | 394 ++- tests/test_filtergraph_abc.py | 241 ++ tests/test_filtergraph_build.py | 78 + tests/test_filtergraph_chain.py | 189 +- ...fglinks.py => test_filtergraph_fglinks.py} | 167 +- tests/test_filtergraph_filter.py | 166 +- 22 files changed, 6571 insertions(+), 3921 deletions(-) delete mode 100644 src/ffmpegio/filtergraph.py create mode 100644 src/ffmpegio/filtergraph/Chain.py create mode 100644 src/ffmpegio/filtergraph/Filter.py create mode 100644 src/ffmpegio/filtergraph/Graph.py create mode 100644 src/ffmpegio/filtergraph/GraphLinks.py create mode 100644 src/ffmpegio/filtergraph/__init__.py create mode 100644 src/ffmpegio/filtergraph/abc.py create mode 100644 src/ffmpegio/filtergraph/build.py create mode 100644 src/ffmpegio/filtergraph/convert.py create mode 100644 src/ffmpegio/filtergraph/exceptions.py create mode 100644 src/ffmpegio/filtergraph/typing.py create mode 100644 src/ffmpegio/filtergraph/util.py delete mode 100644 src/ffmpegio/utils/fglinks.py create mode 100644 tests/test_filtergraph_abc.py create mode 100644 tests/test_filtergraph_build.py rename tests/{test_utils_fglinks.py => test_filtergraph_fglinks.py} (65%) diff --git a/README.rst b/README.rst index a77371f0..b78c1cfa 100644 --- a/README.rst +++ b/README.rst @@ -235,14 +235,14 @@ Filtergraph Builder >>> v2 = (v0 | v1) + fgb.concat(2) >>> v5 = (v2|v3) + fgb.overlay(eof_action='repeat') + fgb.drawbox(50, 50, 120, 120, 'red', t=5) >>> v5 - + FFmpeg expression: "[0]trim=start_frame=10:end_frame=20[L0];[0]trim=start_frame=30:end_frame=40[L1];[L0][L1]concat=2[L2];[1]hflip[L3];[L2][L3]overlay=eof_action=repeat,drawbox=50:50:120:120:red:t=5" Number of chains: 5 chain[0]: [0]trim=start_frame=10:end_frame=20[L0]; chain[1]: [0]trim=start_frame=30:end_frame=40[L1]; chain[2]: [L0][L1]concat=2[L2]; chain[3]: [1]hflip[L3]; - chain[4]: [L2][L3]overlay=eof_action=repeat,drawbox=50:50:120:120:red:t=5 + chain[4]: [L2][L3]overlay=eof_action=repeat,drawbox=50:50:120:120:red:t=5[UNC0] Available input pads (0): Available output pads: (1): (4, 1, 0) diff --git a/src/ffmpegio/analyze.py b/src/ffmpegio/analyze.py index 1a1b63e4..8a946e76 100644 --- a/src/ffmpegio/analyze.py +++ b/src/ffmpegio/analyze.py @@ -239,7 +239,7 @@ def run( fchains[l.media_type] = c = as_filtergraph(c) # assign the logger to get the output of the previous logger - c >>= f + fchains[l.media_type] = c >> f if len(fchains["video"]): fchains["video"] >>= Filter("metadata", "print", file="-") diff --git a/src/ffmpegio/filtergraph.py b/src/ffmpegio/filtergraph.py deleted file mode 100644 index 1dbfb1fc..00000000 --- a/src/ffmpegio/filtergraph.py +++ /dev/null @@ -1,2701 +0,0 @@ -"""ffmpegio.filtergraph module - FFmpeg filtergraph classes - - Arithmetic Filtergraph Construction - =================================== - - .. list-table:: Supported Arithmetic Operators - :widths: 15 10 30 - :header-rows: 1 - - --------------------------------- ------------------------------------------------------------ - Operation Description Related Methods - ------------------------------ ------------------------------------------------------------ - `+` operator Chaining/join operator, supports scalar expansion - `Filter + Filter -> Chain` Create a filterchain from 2 filters - `Chain + Filter -> Chain` Append filter to filterchain - `Filter + Chain -> Chain` Prepend filter to filterchain - `Chain + Chain -> Chain` Concatenate filterchains - `Filter + Graph -> Graph` Prepend filer to first available input of each chain - `Graph + Filter -> Graph` Append filter to first available output of each chain - `Graph + Chain -> Graph` Append filterchain to first available input of each chain - `Chain + Graph -> Graph` Prepend filterchain to first available output of each chain - `Graph + Graph -> Graph` Join 2 graphs by matching their inputs and outputs in order - - `*` operator Multiplicate-n-stacking operator - `Filter * int -> Graph` Stacking the filters (int) times - ` Chain * int -> Graph` Stacking the chain (int) times - ` Graph * int -> Graph` Stacking the input graph (int) times - - `|` operator Stacking operator - `Filter | Filter -> Graph` Stacking the filters - ` Chain | Filter -> Graph` Stacking chain and filter - `Filter | Chain -> Graph` Stacking filter and chain - ` Chain | Chain -> Graph` Stacking the filterchains - `Filter | Graph -> Graph` Prepend filter as a new chain - ` Graph | Filter -> Graph` Appendd filter as a new chain - ` Graph | Chain -> Graph` Stack graph and chain - ` Chain | Graph -> Graph` Stack - ` Graph | Graph -> Graph` Stack filtergraphs - - left `>>` operator Input labeling or attach input filter/chain - ` str >> Filter -> Graph` Label first available input pad* - ` str >> Chain -> Graph` Label first available input pad* - ` str >> Graph -> Graph` Label first available chainable input pad* - ` Filter >> Graph -> Graph` Attach filter output to first available input pad - ` Chain >> Graph -> Graph` Adding Chain to itself int times - `(_,Index) >> Filter -> Graph` Specify input pad - `(_,Index) >> Chain -> Graph` Specify input pad of the first filter - `(_,Index) >> Graph -> Graph` Specify input pad - - right `>>` operator Output labeling or attach output filter/chain - `Filter >> str -> Graph` Label first available output pad* - ` Chain >> str -> Graph` Label first available output pad* - ` Graph >> str -> Graph` Label first available chainable output pad* - ` Graph >> Filter -> Graph` Attach filter to the first - ` Graph >> Chain -> Graph` Adding Chain to itself int times - `Filter >> (Index,_) -> Graph` Specify output pad - ` Chain >> (Index,_) -> Graph` Specify output pad - ` Graph >> (Index,_) -> Graph` Specify output pad - ------------------------------ ------------------------------------------------------------ - -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. - -.. 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: - -.. code-block::python - - fg = '[in]' >> Filter('scale',0.5,-1) + 'setsar=1/1' >> '[out]' - -Filter Pad Indexing -=================== - -Both input and output filter pads can be specified in a number of ways: - - --------------------- ----------------------------------------------------------------------- - Syntax Description - --------------------- ----------------------------------------------------------------------- - int n Specifies the n-th pad of the first available filter - (int m, int n) Specifies the n-th pad of the m-th filter of the first available chain - (int k, int m, int n) Specifies the n-th pad of the m-th filter of the k-th chain - str label Specifies the pad associated with the link label (no bracket necessary) - --------------------- ----------------------------------------------------------------------- - - Except for the label indexing, which is a Graph specific feature, all the indexing syntax may be - used by `Filter`, `Chain`, or `Graph` class instances. An irrelevant field (e.g., chain or filter - indexing for a `Filter` instance) will be ignored. Standard negative-number indexing is supported. - -""" -from collections import UserList, abc -from contextlib import contextmanager -from functools import partial, reduce -from copy import deepcopy -import itertools -from math import floor, log10 -import os -import re -from subprocess import PIPE -from tempfile import NamedTemporaryFile - -from . import path -from .caps import filters as list_filters, filter_info, layouts -from .utils import filter as filter_utils, is_stream_spec -from .utils.fglinks import GraphLinks -from .errors import FFmpegioError - - -class FilterOperatorTypeError(TypeError, FFmpegioError): - def __init__(self, other) -> None: - super().__init__( - f"invalid filtergraph operation with an incompatible object of type {type(other)}" - ) - - -class FiltergraphMismatchError(TypeError, FFmpegioError): - def __init__(self, n, m) -> None: - super().__init__( - f"cannot append mismatched filtergraphs: the first has {n} input " - f"while the second has {m} outputs available." - ) - - -class FiltergraphInvalidIndex(TypeError, FFmpegioError): - pass - - -def _check_joinable(src, dst): - n = src.get_num_outputs() - m = dst.get_num_inputs() - if not (n and m): - raise FiltergraphMismatchError(n, m) - return n == 1 and m == 1 - - -def _is_label(expr): - return isinstance(expr, str) and re.match(r"\[[^\[\]]+\]$", expr) - - -class FiltergraphPadNotFoundError(FFmpegioError): - def __init__(self, type, index) -> None: - target = ( - f"pad {index}" - if isinstance(index, tuple) - else f"label {index}" - if isinstance(index, str) - else f"filter {index}" - ) - super().__init__(f"cannot find {type} pad at {target}") - - -def as_filter(filter_spec): - if isinstance(filter_spec, Graph): - if len(filter_spec) != 1 and len(filter_spec[0]) != 1: - raise FFmpegioError( - "Only a Graph object with a single one-element chain can be downconverted to Filter." - ) - else: - return filter_spec[0, 0] - if isinstance(filter_spec, Chain): - if len(filter_spec) != 1: - raise FFmpegioError( - "Only a Chain object with a single element can be downconverted to Filter." - ) - else: - return filter_spec[0][0] - - return filter_spec if isinstance(filter_spec, Filter) else Filter(filter_spec) - - -def as_filterchain(filter_specs, copy=False): - if isinstance(filter_specs, Graph): - if len(filter_specs) != 1: - raise FFmpegioError( - "Only a Graph object with a single chain can be downconverted to Chain." - ) - return Chain(filter_specs[0]) - - return ( - filter_specs - if not copy and isinstance(filter_specs, Chain) - else Chain([filter_specs] if isinstance(filter_specs, Filter) else filter_specs) - ) - - -def as_filtergraph(filter_specs, copy=False): - return ( - filter_specs - if not copy and isinstance(filter_specs, Graph) - else Graph(filter_specs) - ) - - -def as_filtergraph_object(filter_specs): - if isinstance(filter_specs, (Filter, Chain, Graph)): - return filter_specs - - try: - assert isinstance(filter_specs, str) - specs, links, sws_flags = filter_utils.parse_graph(filter_specs) - n = len(specs) - if links or sws_flags or n > 1: - return Graph(specs, links, sws_flags) - specs = specs[0] - return Filter(specs[0]) if len(specs) == 1 else Chain(specs) - except: - try: - return as_filter(filter_specs) - except: - try: - return as_filterchain(filter_specs) - except: - return as_filtergraph(filter_specs) - - -def _shift_labels(obj, label_type, args): - if _is_label(args): - return obj.add_labels(label_type, args) - - if all(_is_label(arg) for arg in args): - return obj.add_labels(label_type, args) - - is_dst = label_type == "dst" - assert len(args) == 2 and _is_label(args[0 if is_dst else 1]) - return obj.add_labels( - label_type, {obj._resolve_index(is_dst, args[is_dst]): args[not is_dst]} - ) - - -################################################################################################### - -# FILTER TOOLS -class Filter(tuple): - """FFmpeg filter definition immutable class - - :param filter_spec: _description_ - :type filter_spec: _type_ - :param filter_id: _description_, defaults to None - :type filter_id: _type_, optional - :param \\*opts: filter option values assigned in the order options are - declared - :type \\*opts: dict, optional - :param \\**kwopts: filter options in key=value pairs - :type \\**kwopts: dict, optional - - """ - - class Error(FFmpegioError): - pass - - class InvalidName(Error): - def __init__(self, name): - super().__init__( - f"Filter {name} is not defined in FFmpeg (v{path.FFMPEG_VER}).\n" - ) - - class InvalidOption(Error): - pass - - class Unsupported(Error): - def __init__(self, name, feature) -> None: - super().__init__(f"{feature} not yet supported feature for {name} filter.") - - def __new__(self, filter_spec, *args, filter_id=None, **kwargs): - """_summary_""" - 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)) - proto.extend(filter_spec[1:]) - else: - proto.extend(filter_spec) - else: - # parse if str given - if isinstance(filter_spec, str): - filter_spec = filter_utils.parse_filter(filter_spec) - - if not (isinstance(filter_spec, abc.Sequence) and len(filter_spec)): - raise ValueError("filter_spec must be a non-empty sequence.") - name, *opts = filter_spec - if isinstance(name, str): - proto.append((name, id) if isinstance(id, str) else name) - elif not ( - isinstance(name, abc.Sequence) - and len(name) != 2 - and all((isinstance(i, str) for i in name)) - ): - raise ValueError( - "filter_spec[0] must be a str or 2-element str sequence." - ) - else: - # name + id: re-id if id arg given - proto.append(tuple(name) if filter_id is None else (name[0], filter_id)) - - proto.extend(opts) - - # create named options dict - proto_dict = proto.pop() if isinstance(proto[-1], dict) else {} - - # change ordered options if non-None value is given - nord = len(proto) - 1 # # of ordered options - for i, o in enumerate(args[:nord]): - if o is not None: - proto[i] = o - - # add additional ordered options if present - proto.extend(args[nord:]) - - # update named options - if len(kwargs): - proto_dict.update(kwargs) - - # validate named option keys to be str - for k in proto_dict: - if not isinstance(k, str): - raise ValueError( - "All keys of the named option dict must be of type str." - ) - - # add the named option dict to the prototype list - if len(proto_dict): - proto.append(proto_dict) - - # create the final tuple - return tuple.__new__(Filter, proto) - - def _resolve_index(self, is_input, index): - try: - if isinstance(index, tuple): - assert len(index) in (1, 2, 3) - i = index[-1] - elif isinstance(index, int): - i = index - elif index is None: - i = -1 # pick the last input (chainable) - else: - assert False - n = self.get_num_inputs() if is_input else self.get_num_outputs() - if i < 0: - i = n + i - assert i >= 0 and i < n - return i - except: - raise FiltergraphPadNotFoundError("input" if is_input else "output", index) - - def __getitem__(self, key): - value = super().__getitem__(key) - - if isinstance(value, dict): - value = {**value} - if isinstance(value, tuple): - if isinstance(value[-1], dict): - value = tuple((*value[:-1], {**value[-1]})) - elif isinstance(value[0], dict): - value = tuple(({**value[-1]}, *value[1:])) - return value - - def __str__(self): - return filter_utils.compose_filter(*self) - - def __repr__(self): - type_ = type(self) - return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(self))}> - FFmpeg expression: \"{str(self)}\" - Number of inputs: {self.get_num_inputs()} - Number of outputs: {self.get_num_outputs()} -""" - - @property - def name(self): - name = self[0] - return name if isinstance(name, str) else name[0] - - @property - def fullname(self): - name = self[0] - return name if isinstance(name, str) else f"{name[0]}@{name[1]}" - - @property - def id(self): - name = self[0] - return None if isinstance(name, str) else name[1] - - @property - def ordered_options(self): - opts = self[1:] - return opts[:-1] if isinstance(opts[-1], dict) else opts - - @property - def named_options(self): - opts = self[-1] - return opts if isinstance(opts, dict) else {} - - @property - def info(self): - try: - return filter_info(self.name) - except: - raise Filter.InvalidName(self.name) - - def get_pad_media_type(self, port, pad_id): - try: - port = ( - "inputs" - if "inputs".startswith(port) - else "outputs" - if "outputs".startswith(port) - else None - ) - assert port is not None - except: - raise ValueError( - f"{port} is an invalid filter port type. Must be either 'input' or 'output'." - ) - - port_info = getattr(self.info, port) - - if port_info is None: - # filters with homogeneous multiple in/out - # fmt:off - pure_video = { - "inputs": [ - "bm3d", "decimate", "fieldmatch", "hstack", "interleave", "mergeplanes", - "mix", "premultiply", "signature", "streamselect", "unpremultiply", - "vstack", "xmedian", "xstack", - ], - "outputs": [ - "alphaextract", "extractplanes", "select", "split", "streamselect", - ], - } - pure_audio = { - "inputs": [ - "afir", "ainterleave", "amerge", "amix", "astreamselect", "headphone", "join", "ladspa", - ], - "outputs": [ - "acrossover", "aselect", "asplit", "astreamselect", "channelsplit", - ], - } - # fmt:on - - if self.name in pure_video[port]: - return "video" - if self.name in pure_audio[port]: - return "audio" - - if self.name == "concat": - n = self.get_option_value("n") - v = self.get_option_value("v") - a = self.get_option_value("a") - return ( - ("video" if pad_id % n < v else "audio") - if port != "outputs" - else ("video" if pad_id < v else "audio") - ) - - # multiple pads possible if streams option set - if self.name in ("movie", "amovie"): - if self.get_option_value("streams") is None: - return "video" if self.name == "movie" else "audio" - - # 2nd pad for audio visualization stream - vis_mode = ["afir", "aiir", "anequalizer", "ebur128", "aphasemeter"] - if port == "outputs" and self.name in vis_mode: - return "video" if pad_id else "audio" - - raise Filter.Unsupported(self.name, "dynamic media type resolution") - - try: - pad_info = port_info[pad_id] - return pad_info["type"] - except: - raise ValueError( - f"{pad_id} is an invalid pad_id as an {port[:-1]} pad of {self.name} filter." - ) - - def get_option_value(self, option_name): - - # first check the named options as-is - named_opts = self.named_options - try: - return named_opts[option_name] - except: - pass - - # get the option info - i, opt_info = next( - ( - (i, o) - for i, o in enumerate(self.info.options) - if o.name == option_name or option_name in o.aliases - ), - (None, None), - ) - if i is None: - raise Filter.InvalidOption( - f"Invalid option name ({option_name}) for {self.name} filter" - ) - - try: - # try full name first - return named_opts[opt_info.name] - except: - # try alias name next - for a in opt_info.aliases: - try: - return named_opts[a] - except: - pass - - # try from ordered options next - try: - return self.ordered_options[i] - except: - # if nothing fits, use the default value (maybe undefined/None) - return opt_info.default - - def get_num_inputs(self): - """get the number of input pads of the filter - :return: number of input pads - :rtype: int - """ - name = self.name - if not isinstance(name, str): - # name@id - name = name[0] - - try: - nin = list_filters()[name].num_inputs - except: - raise Filter.InvalidName(name) - if nin is not None: # fixed number - return nin - - def _inplace(): - return 1 if self.get_option_value("inplace") else 2 - - def _headphone(): - if self.get_option_value("hrir") == "multich": - return 2 - map = self.get_option_value("map") - return ( - len(re.split(r"\s*\|\s*", map)) + 1 - if isinstance(map, str) - else len(map) + 1 - ) - - def _mergeplanes(): - map = self.get_option_value("mapping") - if not isinstance(map, int): - map = int(map, 16 if map.startswith("0x") else 10) - - return int(max(f"{map:08x}"[::2])) + 1 - - def _concat(): - return self.get_option_value("n") * ( - self.get_option_value("v") + self.get_option_value("a") - ) - - option_name, inc = { - "afir": ("nbirs", 1), - "concat": (None, _concat), - "decimate": ("ppsrc", 1), - "fieldmatch": ("ppsrc", 1), - "headphone": (None, _headphone), - "interleave": ("nb_inputs", 0), - "limitdiff": ("reference", 1), - "mergeplanes": (None, _mergeplanes), - "premultiply": (None, _inplace), - "unpremultiply": (None, _inplace), - "signature": ("nb_inputs", 0), - # "astreamselect": ("inputs", 0), - # "bm3d": ("inputs", 0), - # "hstack": ("inputs", 0), - # "mix": ("inputs", 0), - # "streamselect": ("inputs", 0), - # "vstack": ("inputs", 0), - # "xmedian": ("inputs", 0), - # "xstack": ("inputs", 0), - }.get(name, ("inputs", 0)) - - return ( - int(self.get_option_value(option_name)) + inc - if isinstance(option_name, str) - else inc() - ) - - def get_num_outputs(self): - """get the number of output pads of the filter - :return: number of output pads - :rtype: int - """ - name = self.name - - try: - nout = list_filters()[name].num_outputs - except: - raise Filter.InvalidName(name) - if nout is not None: # arbitrary number allowed - return nout - - def _concat(): - return int(self.get_option_value("a")) + int(self.get_option_value("v")) - - 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)) - ) + inc - - def _channelsplit(): - layout = self.get_option_value("channel_layout") - channels = self.get_option_value("channels") - return len( - re.split( - rf"\s*\+\s*", - layouts()["layouts"][layout] if channels == "all" else channels, - ) - ) - - # fmt:off - option_name, inc = { - "afir": ("response", 1), # +video stream - "aiir": ("response", 1), # +video stream - "anequalizer": ("curves", 1), - "ebur128": ("video", 1), - "aphasemeter": ("video", 1), - "acrossover": ('split',partial( _list_var,"split", " ", 1)), # split option (space-separated) - "asegment": ("timestamps", partial( _list_var,"timestamps", r"\|", 1)), - "segment": ("timestamps", partial( _list_var,"timestamps", r"\|", 1)), - "astreamselect": ("map", partial( _list_var,"map", " ", 0)), # parse map? - "streamselect": ("map", partial( _list_var,"map", " ", 0)), # parse map? - "extractplanes": ("planes", partial( _list_var,"planes", r"\+", 0)), # parse planes - "amovie": ("streams",partial( _list_var,"streams", r"\+", 0)), - "movie": ("streams",partial( _list_var,"streams", r"\+", 0)), - "channelsplit": (('channel_layout', 'channels'),_channelsplit), # parse channel_layout/channels - "concat": (('a', 'v'), _concat), # sum a and v - # "aselect": (("output", "n"), 0), # must resolve alias... - # "asplit": ("outputs", 0), - # "select": (("output", "n"), 0), - # "split": ("outputs", 0), - }.get(name, ("outputs", 0)) - # fmt:on - - return ( - int(self.get_option_value(option_name)) + inc - if isinstance(inc, int) - else inc() - ) - - def add_labels(self, pad_type, labels): - """turn into filtergraph and add labels - - :param pad_type: filter pad type - :type pad_type: 'dst'|'src' - :param labels: pad label(s) and optionally pad id - :type labels: str|seq(str)|dict(int:str), optional - """ - - fg = Graph([[self]]) - if labels is not None: - if isinstance(labels, str): - fg.add_label(labels, **{pad_type: (0, 0, 0)}) - elif isinstance(labels, dict): - for pad, label in labels.items(): - fg.add_label(label, **{pad_type: fg._resolve_index(pad)}) - else: - for pad, label in enumerate(labels): - fg.add_label(label, **{pad_type: (0, 0, pad)}) - return fg - - def apply(self, options, filter_id=None): - """apply new filter options - - :param options: new option key-value pairs. For ordered option, use positional index (0 - corresponds to the first option). Set value as None to drop the option. - Ordered options can only be dropped in contiguous fashion, including the - last ordered option. - :type options: dict - :param filter_id: new filter id, defaults to None - :type filter_id: str, optional - :return: new filter with modified options - :rtype: Filter - - .. note:: - - To add new ordered options, int-keyed options item must be presented in - the increasing key order so the option can be expanded one at a time. - - """ - - try: - assert isinstance(self[-1], dict) - kwopts = dict(self[-1]) - try: - opts = list(self[1:-1]) - except: - opts = [] - except: - kwopts = {} - try: - opts = list(self[1:]) - except: - opts = [] - - nopts = len(opts) - - delopts = set() - for k, v in options.items(): - if type(k) == int: - if k < 0 or k > nopts: - raise Filter.Error(f"invalid positional index [{k}]") - if v is not None: - if k < nopts: - opts[k] = v - else: - opts = [*opts, v] - nopts += 1 - elif k < 0 or k > nopts: - delopts.add(k) - else: - if v is None: - del kwopts[k] - else: - kwopts[k] = v - - if len(delopts): - delopts = sorted(list(delopts)) - o1 = delopts[0] - 1 - on = delopts[-1] - if on != nopts or len(delopts) != on - o1: - raise Filter.Error( - f"cannot drop specified ordered options {delopts}. They must be contiguous and include the last ordered option." - ) - opts = opts[:o1] - - return Filter(self[0], *opts, filter_id=filter_id, **kwopts) - - def __add__(self, other): - # join - try: - other = as_filter(other) - except Exception: - return NotImplemented - if _check_joinable(self, other): - # one-to-one -> chain - return Chain([self, other]) - else: - # one-to-many or many-to-one -> stack and link - return Graph([[self], [other]], {0: ((1, 0, 0), (0, 0, 0))}) - - def __radd__(self, other): - # join - try: - other = as_filter(other) - except Exception: - return NotImplemented - if _check_joinable(other, self): - # one-to-one -> chain - return Chain([other, self]) - else: - # one-to-many or many-to-one -> stack and link - return Graph([[other], [self]], {0: ((1, 0, 0), (0, 0, 0))}) - - def __mul__(self, __n): - return Graph([[self]] * __n) if isinstance(__n, int) else NotImplemented - - def __rmul__(self, __n): - return Graph([[self]] * __n) if isinstance(__n, int) else NotImplemented - - def __or__(self, other): - # stack - - try: - other = as_filter(other) - except: - return NotImplemented - return Graph([[self], [other]]) - - def __ror__(self, other): - # stack - if isinstance(other, int): - return Graph([[self]] * other) - try: - other = as_filter(other) - except: - return NotImplemented - return Graph([[other], [self]]) - - def __rshift__(self, other): - """self >> other | self >> (index, other)""" - - # try labeling first - try: - return _shift_labels(self, "src", other) - except FFmpegioError: - raise - except: - pass - - # resolve the index - if type(other) == tuple: - if len(other) > 2: - index, other_index, other = other - else: - index, other = other - other_index = None - else: - index = None - other_index = None - - index = self._resolve_index(False, index) - - # if other is Filter object, do add operation - try: - other = as_filtergraph_object(other) - except: - return NotImplemented - - # if not Chain or Graph, use other's >> operator - if not isinstance(other, Filter): - return other.__rrshift__((self, index, other_index)) - - if other.get_num_inputs() == 0: - raise FiltergraphMismatchError(self.get_num_outputs(), 0) - - # equivalent to add operation or stack and link - return ( - self.__add__(other) - if index + 1 == self.get_num_outputs() - else Graph([[self], [other]], {0: ((1, 0, 0), (0, 0, index))}) - ) - - def __rrshift__(self, other): - """other >> self, (other, index) >> self : attach input label or filter""" - - # try to label first - try: - return _shift_labels(self, "dst", other) - except FFmpegioError: - raise - except: - pass - - # resolve the index - if type(other) == tuple: - if len(other) > 2: - other, other_index, index = other - else: - other, index = other - other_index = None - else: - index = None - other_index = None - - index = self._resolve_index(True, index) - - # if label - if _is_label(other): - if other_index is None: - return self.add_labels("dst", {index: other}) - else: - raise FiltergraphInvalidIndex("index cannot be assigned to a label") - - # if other is Filter object, do add operation - try: - other = as_filtergraph_object(other) - except: - return NotImplemented - - # if not Chain or Graph, use other's >> operator - if not isinstance(other, Filter): - return other.__rshift__((other_index, index, self)) - - if other.get_num_outputs() == 0: - raise FiltergraphMismatchError(0, self.get_num_inputs()) - - if not index: - # equivalent to chain/add operation - return self.__radd__(other) - else: - # stack and link - return Graph([[other], [self]], {0: ((1, 0, index), (0, 0, 0))}) - - -#################################################################################### - - -class Chain(UserList): - """List of FFmpeg filters, connected in series - - Chain() to instantiate empty Graph object - - Chain(obj) to copy-instantiate Graph object from another - - Chain('...') to parse an FFmpeg filtergraph expression - - :param filter_specs: single-in-single-out filtergraph description without - labels, defaults to None - :type filter_specs: str or seq(Filter), optional - """ - - class Error(FFmpegioError): - pass - - def __init__(self, filter_specs=None): - # convert str to a list of filter_specs - if isinstance(filter_specs, str): - filter_specs, links, sws_flags = filter_utils.parse_graph(filter_specs) - if links: - raise ValueError( - "filter_specs with link labels cannot be represented by the Chain class. Use Graph." - ) - if sws_flags: - raise ValueError( - "filter_specs with sws_flags cannot be represented by the Chain class. Use Graph." - ) - if len(filter_specs) != 1: - raise ValueError( - "filter_specs str must resolve to a single-chain filtergraph. Use the Graph class instead." - ) - filter_specs = filter_specs[0] - elif isinstance(filter_specs, Filter): - filter_specs = [filter_specs] - - super().__init__( - () if filter_specs is None else (as_filter(fspec) for fspec in filter_specs) - ) - - def _resolve_index(self, is_input, index): - try: - if isinstance(index, tuple): - assert len(index) in (1, 2, 3) - i = index[-1] - try: - j = index[-2] - except: - j = None - else: - j = None - i = index if isinstance(index, int) else None - - return next( - (self.iter_input_pads if is_input else self.iter_output_pads)( - filter=j, pad=i - ) - )[:2] - except: - raise FiltergraphPadNotFoundError("input" if is_input else "output", index) - - def __str__(self): - return filter_utils.compose_graph([self.data]) - - def __repr__(self): - type_ = type(self) - return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(self))}> - FFmpeg expression: \"{str(self)}\" - Number of filters: {len(self.data)} - Input pads ({self.get_num_inputs()}): {', '.join((str(id[:-1]) for id in self.iter_input_pads()))} - Output pads: ({self.get_num_outputs()}): {', '.join((str(id[:-1]) for id in self.iter_output_pads()))} -""" - - def __setitem__(self, key, value): - super().__setitem__(key, as_filter(value)) - - def append(self, item): - return super().append(as_filter(item)) - - def extend(self, other): - return super().extend([as_filter(f) for f in other]) - - def insert(self, i, item): - return super().insert(i, as_filter(item)) - - def __contains__(self, item): - item = as_filter(item) - return any((f.name == item for f in self.data)) - - def __mul__(self, __n): - res = super().__mul__(__n) - _check_joinable(self, self) - return res - - def __rmul__(self, __n): - return self.__mul__(__n) - - def __add__(self, other): - # chain - try: - other = as_filterchain(other) - except Exception: - return NotImplemented - n = len(self) - if n and len(other): - if _check_joinable(self, other): - return Chain([*self, *other]) - else: - return Graph([self]).join(other) - return self if n else other - - def __radd__(self, other): - # form a filterchain/filtergraph by appending this to other filter - try: - other = as_filterchain(other) - except Exception: - return NotImplemented - - n = len(self) - if n and len(other): - if _check_joinable(other, self): - return Chain([*other, *self]) - else: - return Graph([other]).join(self) - return self if n else other - - def __mul__(self, __n): - if len(self): - return Graph([self] * __n) if isinstance(__n, int) else NotImplemented - else: - return Chain(self) - - def __rmul__(self, __n): - if len(self): - return Graph([self] * __n) if isinstance(__n, int) else NotImplemented - else: - return Chain(self) - - def __or__(self, other): - # create filtergraph with self and other as parallel chains, self first - - try: - other = as_filterchain(other) - except: - return NotImplemented - - n = len(self) - m = len(other) - return Graph([self, other]) if n and m else self if n else other - - def __ror__(self, other): - # create filtergraph with self and other as parallel chains, self last - - try: - other = as_filterchain(other) - except: - return NotImplemented - - n = len(self) - m = len(other) - return Graph([other, self]) if n and m else self if n else other - - def __rshift__(self, other): - """self >> other | self >> (index, other) | self >> (index, other_index, other)""" - - # try to label first - try: - return _shift_labels(self, "src", other) - except FFmpegioError: - raise - except: - pass - - if type(other) == tuple: - if len(other) > 2: - index, other_index, other = other - else: - index, other = other - other_index = None - else: - index = None - other_index = None - - # resolve the index - if type(other) == tuple: - index, other = other - else: - index = None - - if not len(self): - if index is not None: - raise Chain.Error( - "attempting to specify a pad index of an empty chain." - ) - if _is_label(other): - raise Chain.Error( - "attempting to set a pad label specified to an empty chain." - ) - return as_filterchain(other, True) - - index = self._resolve_index(False, index) - - # if other is Filter object, do add operation - try: - other = as_filtergraph_object(other) - except: - return NotImplemented - - if isinstance(other, Graph): - return other.__rrshift__((self, index, other_index)) - - if other.get_num_inputs() == 0: - raise FiltergraphMismatchError(self.get_num_outputs(), 0) - - # equivalent to add operation or stack and link - return ( - self.__add__(other) - if index[1] + 1 == self.get_num_outputs() - else Graph([self, other], {0: ((1, 0, 0), (0, *index))}) - ) - - def __rrshift__(self, other): - """other >> self, (other, index) >> self : attach input label or filter""" - - # try to label first - try: - return _shift_labels(self, "dst", other) - except FFmpegioError: - raise - except: - pass - - # resolve the index - if type(other) == tuple: - if len(other) > 2: - other, other_index, index = other - else: - other, index = other - other_index = None - else: - index = None - other_index = None - - if not len(self): - if index is not None: - raise Chain.Error( - "attempting to specify a pad index of an empty chain." - ) - if _is_label(other): - raise Chain.Error( - "attempting to set a pad label specified to an empty chain." - ) - return as_filterchain(other, True) - - index = self._resolve_index(True, index) - - # if other is Filter object, do add operation - try: - other = as_filtergraph_object(other) - except: - return NotImplemented - - if isinstance(other, Graph): - return other.__rshift__((other_index, index, self)) - - if other.get_num_outputs() == 0: - raise FiltergraphMismatchError(0, self.get_num_inputs()) - - if not index: - # equivalent to chain/add operation - return self.__radd__(other) - else: - # stack and link - return Graph([other, self], {0: ((1, *index), (0, 0, 0))}) - - def __mul__(self, __n): - if not isinstance(__n, int): - return NotImplemented - if not len(self.data): - return Chain(self) - fg = Graph([self]) - return reduce(fg.stack, [self] * (__n - 1), fg) - - def __rmul__(self, __n): - return self.__mul__(__n) - - def __iadd__(self, other): - fg = self + other - if type(fg) != Chain: - raise Chain.Error( - "cannot assign operation outcome which is not a filterchain" - ) - self.data = fg.data - return self - - def __irshift__(self, other): - fg = self >> other - if type(fg) != Chain: - raise Chain.Error( - "cannot assign operation outcome which is not a filterchain" - ) - self.data = fg.data - return self - - def iter_input_pads(self, filter=None, pad=None): - """Iterate over input pads of the filters - - :param pad: specify if only interested in pid-th pad of each filter, defaults to None - :type pad: int, optional - :yield: filter index, pad index, and filter instance - :rtype: tuple(int, int, Filter) - """ - - def iter_base(i, f): - n = f.get_num_inputs() - if pad is None: - for j in range(n - 1 if i else n): - yield (i, j, f) - elif pad < (n - 1 if i else n): - yield (i, pad, f) - - try: - if filter is None: - for i, f in enumerate(self.data): - for v in iter_base(i, f): - yield v - else: - if filter < 0: - filter += len(self.data) - for v in iter_base(filter, self.data[filter]): - yield v - except: - # invalid index - pass - - def iter_output_pads(self, filter=None, pad=None): - """Iterate over output pads of the filters - - :param pid: specify if only interested in pid-th pad of each filter, defaults to None - :type pid: int, optional - :yield: filter index, pad index, and filter instance - :rtype: tuple(int, int, Filter) - - Filters are scanned from the end to the front - """ - - imax = len(self.data) - 1 - - def iter_base(i, f): - n = f.get_num_outputs() - if pad is None: - for j in range(n if i == imax else n - 1): - yield (i, j, f) - elif pad < (n if i == imax else n - 1): - yield (i, pad, f) - - try: - if filter is None: - for i, f in reversed(tuple(enumerate(self.data))): - for v in iter_base(i, f): - yield v - else: - if filter < 0: - filter += len(self.data) - for v in iter_base(filter, self.data[filter]): - yield v - except: - pass - - def get_chainable_input_pad(self): - """get first filter's input pad, which can be chained - - :return: filter position, input pad poisition, and filter object. - If the head filter is a source filter, returns None. - :rtype: tuple(int, int, Filter) | None - """ - - if not len(self): - return None - f = self[-1] - nin = f.get_num_inputs() - return (0, nin - 1, f) if nin else None - - def get_chainable_output_pad(self): - """get last filter's output pad, which can be chained - - :return: filter position, output pad poisition, and filter object. - If the tail filter is a sink filter, returns None. - :rtype: tuple(int, int, Filter) | None - """ - - if not len(self): - return None - f = self[-1] - nout = f.get_num_outputs() - return (len(self) - 1, nout - 1, f) if nout else None - - def get_num_inputs(self): - return len(list(self.iter_input_pads())) - - def get_num_outputs(self): - return len(list(self.iter_output_pads())) - - def validate_input_index(self, pos, pad_pos): - - if pos < 0 or pos >= len(self): - raise Chain.Error(f"invliad filter position #{pos}.") - - # if chained to the previous filter, not avail - n = self[pos].get_num_inputs() - if pad_pos < 0 or pad_pos >= (n - 1 if pos else n): - raise Chain.Error(f"invliad input pad position #{pos} for {self[pos]}.") - - def validate_output_index(self, pos, pad_pos): - - if pos < 0 or pos >= len(self): - raise Chain.Error(f"invliad filter position #{pos}.") - - # if chained to the next filter, not avail - n = self[pos].get_num_outputs() - if pad_pos < 0 or pad_pos >= (n - 1 if pos < len(self.data) - 1 else n): - raise Chain.Error(f"invliad output pad position #{pos} for {self[pos]}.") - - def add_labels(self, pad_type, labels): - """turn into filtergraph and add labels - - :param input_labels: input pad labels keyed by pad index, defaults to None - :type input_labels: dict(int:str), optional - :param output_labels: output pad labels keyed by pad index, defaults to None - :type output_labels: dict(int:str), optional - """ - - fg = Graph([self]) - is_input = pad_type == "dst" - if isinstance(labels, str): - pad = fg._resolve_index(is_input, None) - fg.add_label(labels, **{pad_type: pad}) - elif isinstance(labels, dict): - for pad, label in labels.items(): - pad = fg._resolve_index(is_input, pad) - fg.add_label(label, **{pad_type: pad}) - else: - for pad, label in enumerate(labels): - pad = fg._resolve_index(is_input, pad) - fg.add_label(label, **{pad_type: pad}) - return fg - - -#################################################################################### - - -class Graph(UserList): - """List of FFmpeg filterchains in parallel with interchain link specifications - - Graph() to instantiate empty Graph object - - Graph(obj) to copy-instantiate Graph object from another - - Graph('...') to parse an FFmpeg filtergraph expression - - Graph(filter_specs, links, sws_flags) - to specify the compose_graph(...) arguments - - :param filter_specs: either an existing Graph instance to copy, an FFmpeg - filtergraph expression, or a nested sequence of argument - sequences to compose_filter() to define a filtergraph. - For the latter option, The last element of each filter argument - sequence may be a dict, defining its keyword arguments, - defaults to None - :type filter_specs: Graph, str, or seq(seq(filter_args)) - :param links: specifies filter links - :type links: dict, optional - :param sws_flags: specify swscale flags for those automatically inserted - scalers, defaults to None - :type sws_flags: seq of stringifyable elements with optional dict as the last - element for the keyword flags, optional - - """ - - class Error(FFmpegioError): - pass - - class FilterPadMediaTypeMismatch(Error): - def __init__(self, in_name, in_pad, in_type, out_name, out_pad, out_type): - super().__init__( - f"mismatched pad types: {in_name}:{in_pad}[{in_type}] => {out_name}:{out_pad}[{out_type}]" - ) - - class InvalidFilterPadId(Error): - def __init__(self, type, index): - super().__init__(f"invalid {type} filter pad index: {index}") - - def __init__( - self, filter_specs=None, links=None, sws_flags=None, autosplit_output=True - ): - - # convert str to a list of filter_specs - if isinstance(filter_specs, str): - filter_specs, links, sws_flags = filter_utils.parse_graph(filter_specs) - elif isinstance(filter_specs, Graph): - links = filter_specs._links - sws_flags = filter_specs.sws_flags and filter_specs.sws_flags[1:] - autosplit_output = filter_specs.autosplit_output - elif isinstance(filter_specs, Chain): - filter_specs = [filter_specs] if len(filter_specs) else () - elif isinstance(filter_specs, Filter): - filter_specs = [[filter_specs]] - - super().__init__( - () - if filter_specs is None or not len(filter_specs) - else (Chain(fspec) for fspec in filter_specs) - ) - - self._links = GraphLinks(links) - """utils.fglinks.GraphLinks: filtergraph link specifications - """ - - self.sws_flags = None if sws_flags is None else Filter(["scale", *sws_flags]) - """Filter|None: swscale flags for automatically inserted scalers - """ - - self.autosplit_output = autosplit_output - """bool: True to insert a split filter when an output pad is linked multiple times. default: True """ - - def _resolve_index(self, is_input, index): - # call if index needs to be autocompleted - try: - if isinstance(index, str): - # return the pad index associated with the label - label = ( - index[1:-1] - if len(index) > 2 and index[0] == "[" and index[-1] == "]" - else index - ) - dsts, src = self._links[label] - if is_input: # src=None, dst=not None - assert self._links.is_input(label) - return next(self._links.iter_dst_ids(dsts)) - else: # dst=None, src=not None - assert self._links.is_output(label) - return src - - if isinstance(index, tuple): - assert len(index) in (1, 2, 3) - i = index[-1] - try: - j = index[-2] - except: - j = None - try: - k = index[-3] - except: - k = None - else: - k = None - j = None - if isinstance(index, int): - i = index - elif index is None: - i = None - else: - assert False - - # if any index is None, pick the first available - return next( - (self.iter_input_pads if is_input else self.iter_output_pads)( - chain=k, filter=j, pad=i - ) - )[0] - except: - raise FiltergraphPadNotFoundError("input" if is_input else "output", index) - - def __str__(self) -> str: - # insert split filters if autosplit_output is True - fg = self.split_sources() if self.autosplit_output else self - return filter_utils.compose_graph( - fg, fg._links, fg.sws_flags and fg.sws_flags[1:] - ) - - def __repr__(self): - type_ = type(self) - expr = str(self) - nchains = len(self.data) - pos = [0] * nchains - i = n = 0 - for j, chain in enumerate(self): - for k, filter in enumerate(chain): - fstr = str(filter) - i += n - i = expr[i:].find(fstr) + i - n = len(fstr) - pos[j] = i - - pos = [expr.rfind(";", 0, i) + 1 for i in pos] - pos.append(len(expr)) - - prefix = " chain" - nzeros = floor(log10(nchains)) + 1 - 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] - if len(chain_list) > 12: - chain_list = [ - chain_list[:-4], - f"{[' ']*(len(prefix)+3+nzeros)}{expr[:pos[0]]}", - chain_list[-3:], - ] - chain_list = "\n".join(chain_list) - - return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(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()))} -""" - - def __setitem__(self, key, value): - super().__setitem__(key, as_filterchain(value, copy=True)) - # TODO purge invalid links - - def __getitem__(self, key): - """get filterchains/filter - - :param key: filterchain or filter indices - :type key: int, slice, tuple(int|slice,int|slice) - :return: selected filterchain(s) or filter - :rtype: Graph|Chain|Filter - """ - try: - return super().__getitem__(key) - except (IndexError, StopIteration) as e: - raise e - except Exception as e: - try: - assert len(key) == 2 and all((isinstance(k, int) for k in key)) - return super().__getitem__(key[0])[1] - except: - raise TypeError( - "Graph indies must be integers, slices, or 2-element tuple of int" - ) - - def append(self, item): - self.data.append(as_filterchain(item, copy=True)) - - def extend(self, other, auto_link=False, force_link=False): - other = as_filtergraph(other) - self._links.update( - other._links, len(self), auto_link=auto_link, force=force_link - ) - self.data.extend(other) - - def insert(self, i, item): - self.data.insert(i, as_filterchain(item)) - self._links.adjust_chains(i, 1) - - def __delitem__(self, i): - # identify which indices are to be deleted - - indices = range(len(self.data))[i] - if isinstance(indices, int): - (k for k, v in self._links.items() if v[1] is not None and v[1][0] == i) - self._links.iter_dsts() - self._links.adjust_chains(i, -1) - else: # slice - - indices = sorted(indices) - - if i.step is not None and i.step == 1: - # contiguous - if i.start is not None: - pos = i.start - len = len(self.data) - n - - super().__delitem__(i) - - def __mul__(self, __n): - # create a filtergraph with __n filterchains in parallel - return ( - reduce(self.stack, [self] * (__n - 1), self) - if isinstance(__n, int) - else NotImplemented - ) - - def __rmul__(self, __n): - # create a filtergraph with __n filterchains in parallel - return ( - reduce(self.stack, [self] * (__n - 1), self) - if isinstance(__n, int) - else NotImplemented - ) - - def __add__(self, other): - # join - try: - other = as_filtergraph_object(other) - except Exception: - return NotImplemented - return self.join(other, "auto") - - def __radd__(self, other): - # join - try: - other = as_filtergraph(other) - except Exception: - return NotImplemented - return other.join(self, "auto") - - def __or__(self, other): - # create filtergraph with self and other as parallel chains, self first - - try: - other = as_filtergraph_object(other) - except: - return NotImplemented - return self.stack(other) - - def __ror__(self, other): - # create filtergraph with self and other as parallel chains, self last - try: - other = as_filtergraph(other) - except: - return NotImplemented - return other.stack(self) - - def __rshift__(self, other): - """self >> other | self >> (index, other) | self >> (index, other_index, other)""" - - # try to label first - try: - return _shift_labels(self, "src", other) - except FFmpegioError: - raise - except: - pass - - # resolve the index - if type(other) == tuple: - if len(other) > 2: - index, other_index, other = other - else: - index, other = other - other_index = None - else: - index = None - other_index = None - - if not len(self): - if index is not None: - raise Chain.Error( - "attempting to specify a filter pad index of an empty chain." - ) - if _is_label(other): - raise Chain.Error( - "attempting to set a filter pad label specified to an empty chain." - ) - return as_filtergraph(other, True) - - index = self._resolve_index(False, index) - - # if other is Filter object, do add operation - try: - other = as_filtergraph_object(other) - except: - return NotImplemented - - return self.attach(other, left_on=index, right_on=other_index) - - def __rrshift__(self, other): - """other >> self, (other, index) >> self, (other, other_index, index) >> self : attach input label or filter""" - - # try to label first - try: - return _shift_labels(self, "dst", other) - except FFmpegioError: - raise - except: - pass - - # resolve the index - if type(other) == tuple: - if len(other) > 2: - other, other_index, index = other - else: - other, index = other - other_index = None - else: - index = None - other_index = None - - if not len(self): - if index is not None: - raise Chain.Error( - "attempting to specify a filter pad index of an empty chain." - ) - if _is_label(other): - raise Chain.Error( - "attempting to set a filter pad label specified to an empty chain." - ) - return as_filtergraph(other, True) - - index = self._resolve_index(True, index) - - # if other is Filter object, do add operation - try: - other = as_filtergraph(other) - except: - return NotImplemented - - # equivalent to add operation or stack and link - return other.attach(self, other_index, right_on=index) - - def __iadd__(self, other): - fg = self + other - self.data = fg.data - self._links = fg._links - return self - - def __imul__(self, __n): - fg = self * __n - self.data = fg.data - self._links = fg._links - return self - - def __ior__(self, other): - fg = self | other - self.data = fg.data - self._links = fg._links - return self - - def __irshift__(self, other): - fg = self >> other - self.data = fg.data - self._links = fg._links - return self - - def _screen_input_pads(self, iter_pads, exclude_named, include_connected): - - links = self._links - for index, f in iter_pads(): # for each input pad - label = links.find_dst_label(index) # get link label if exists - if ( - (label is None) - or ( - not exclude_named - and links.is_input(label) - and not is_stream_spec(label, True) - ) - or (include_connected and is_stream_spec(label, True)) - ): - yield (index, label, f) - - def iter_input_pads( - self, - exclude_named=False, - include_connected=False, - chain=None, - filter=None, - pad=None, - ): - """Iterate over filtergraph's filter output pads - - :param exclude_named: True to leave out named inputs, defaults to False to return only all inputs - :type exclude_named: bool, optional - :param include_connected: True to include pads connected to input streams, defaults to False - :type include_connected: bool, optional - :yield: filter pad index, link label, & source filter object - :rtype: tuple(tuple(int,int,int), label, Filter) - """ - - def iter_pads(): - try: - if chain is None: - for cid, obj in enumerate(self.data): - for j, i, f in obj.iter_input_pads(filter=filter, pad=pad): - yield (cid, j, i), f - else: - cid = chain + len(self.data) if chain < 0 else chain - for j, i, f in self.data[cid].iter_input_pads( - filter=filter, pad=pad - ): - yield (cid, j, i), f - except: - pass - - return self._screen_input_pads(iter_pads, exclude_named, include_connected) - - def iter_chainable_input_pads( - self, exclude_named=False, include_connected=False, chain=None - ): - """Iterate over filtergraph's chainable filter output pads - - :param exclude_named: True to leave out named input pads, defaults to False (all avail pads) - :type exclude_named: bool, optional - :param include_connected: True to include input streams, which are already connected to input streams, defaults to False - :type include_connected: bool, optional - :yield: filter pad index, link label, & source filter object - :rtype: tuple(tuple(int,int,int), label, Filter) - """ - - # get all inputs - def iter_pads(): - if chain is None: - for cid, fchain in enumerate(self.data): - info = fchain.get_chainable_input_pad() - if info is not None: - yield (cid, *info[:2]), info[2] - else: - cid = chain + len(self.data) if chain < 0 else chain - try: - info = self.data[chain].get_chainable_input_pad() - if info is not None: - yield (cid, *info[:2]), info[2] - except: - pass - - return self._screen_input_pads(iter_pads, exclude_named, include_connected) - - def _screen_output_pads(self, iter_pads, exclude_named): - links = self._links - for index, f in iter_pads(): # for each output pad - labels = links.find_src_labels(index) # get link label if exists - if labels is None or not len(labels): - # unlabeled output pad - yield (index, None, f) - elif not exclude_named: - # all labeled output pads are by definition named - for label in labels: - # if multiple input link slots are reserved - # return for each slot - for _ in range(links.num_outputs(label)): - yield (index, label, f) - - def iter_output_pads(self, exclude_named=False, chain=None, filter=None, pad=None): - """Iterate over filtergraph's filter output pads - - :param exclude_named: True to leave out named outputs, defaults to False - :type exclude_named: bool, optional - :yield: filter pad index, link label, and source filter object - :rtype: tuple(tuple(int,int,int), str, Filter) - """ - - def iter_pads(): - try: - # iterate over all input pads - if chain is None: - for cid, obj in enumerate(self.data): - for j, i, f in obj.iter_output_pads(filter=filter, pad=pad): - yield (cid, j, i), f - else: - cid = chain + len(self.data) if chain < 0 else chain - for j, i, f in self.data[cid].iter_output_pads( - filter=filter, pad=pad - ): - yield (cid, j, i), f - except: - pass - - return self._screen_output_pads(iter_pads, exclude_named) - - def iter_chainable_output_pads(self, exclude_named=False, chain=None): - """Iterate over filtergraph's chainable filter output pads - - :param exclude_named: True to leave out unnamed outputs, defaults to False - :type exclude_named: bool, optional - :yield: filter pad index, link label (if any), & source filter object - :rtype: tuple(tuple(int,int,int), str, Filter) - """ - - def iter_pads(): - if chain is None: - for cid, fchain in enumerate(self.data): - info = fchain.get_chainable_output_pad() - if info is not None: - yield ((cid, *info[:2]), info[2]) - else: - cid = chain + len(self.data) if chain < 0 else chain - try: - info = self.data[chain].get_chainable_output_pad() - if info is not None: - yield ((cid, *info[:2]), info[2]) - except: - pass - - return self._screen_output_pads(iter_pads, exclude_named) - - def get_num_inputs(self, chainable_only=False): - return len( - list( - self.iter_chainable_input_pads() - if chainable_only - else self.iter_input_pads() - ) - ) - - def get_num_outputs(self, chainable_only=False): - return len( - list( - self.iter_chainable_output_pads - if chainable_only - else self.iter_output_pads() - ) - ) - - def validate_input_index(self, dst): - try: - GraphLinks.validate_pad_id_pair((dst, None)) - for index in GraphLinks.iter_dst_ids(dst): - self[index[0]].validate_input_index(*index[1:]) - except: - raise Graph.InvalidFilterPadId("input", dst) - - def validate_output_index(self, index): - try: - GraphLinks.validate_pad_id_pair((None, index)) - self[index[0]].validate_output_index(*index[1:]) - except: - raise Graph.InvalidFilterPadId("output", index) - - def get_input_pad(self, index): - """resolve (unconnected) input pad from pad index or label - - :param index: pad index or link label - :type index: tuple(int,int,int) or str - :return: filter input pad index and its link label (None if not assigned) - :rtype: tuple(int,int,int), str|None - - Raises error if specified label does not resolve uniquely to an input pad - """ - - if isinstance(index, tuple): - # given pad index - dst = index - label = self._links.find_dst_label(index) - desc = f"input pad {index}" - - if label is not None and self._links[label][1] is not None: - raise Graph.Error(f"{desc} is not an input label.") - - else: - # given label - desc = f"link label [{index}]" - try: - dsts, src = self._links[index] - except: - raise Graph.Error(f"{desc} does not exist.") - - if src is not None: - raise Graph.Error(f"{desc} is not an input label.") - - dsts = [d for d in self._links.iter_dst_ids(dsts)] - n = len(dsts) - - if not n: - raise Graph.Error( - f"no input pad found. specified {desc} is an output label." - ) - - if n > 1: - raise Graph.Error(f"{desc} is associated with multiple input pads.") - - dst = dsts[0] - label = index - - if label is not None and is_stream_spec(label, True): - raise Graph.Error(f"{desc} is already connected to an input stream.") - - # make sure the input pad is valid one on the fg (raises if fails) - self.validate_input_index(dst) - - return dst, label - - def get_output_pad(self, index): - """resolve (unconnected) output filter pad from pad index or labels - - :param index: pad index or link label - :type index: tuple(int,int,int) or str - :return: filter output pad index and its link labels - :rtype: tuple(int,int,int), list(str) - - Raises error if specified index does not resolve uniquely to an output pad - """ - - if isinstance(index, str): - # given label - desc = f"link label [{index}]" - try: - src = self._links[index][1] - assert src is not None - except: - raise Graph.Error(f"{desc} does not exist, or it is an input label.") - label = index - else: - # given pad index - desc = f"output pad {index}" - src = index - label = None - labels = self._links.find_src_labels(src) - - # if labels found, only 1 must be an output - if len(labels): - labels = [label for label in labels if not self._links.is_linked(label)] - if len(labels) != 1: - raise Graph.Error( - f"{desc} is already labeled but associated to no ouput label or multiple output labels" - ) - label = labels[0] - - # make sure the output pad is valid (raises if fails) - self.validate_output_index(src) - - return src, label - - def copy(self): - return Graph(self) - - def are_linked(self, dst, src): - - self._links.are_linked(dst, src) - - def unlink(self, label=None, dst=None, src=None): - """unlink specified links - - :param label: specify all the links with this label, defaults to None - :type label: str|int, optional - :param dst: specify the link with this dst pad, defaults to None - :type dst: tuple(int,int,int), optional - :param src: specify all the links with this src pad, defaults to None - :type src: tuple(int,int,int), optional - """ - self._links.unlink(label, dst, src) - - def link(self, dst, src, label=None, preserve_src_label=False, force=False): - """set a filtergraph link - - :param dst: input pad ids - :type dst: tuple(int,int,int) - :param src: output pad index - :type src: tuple(int,int,int) - :param label: desired label name, defaults to None (=reuse dst/src label or unnamed link) - :type label: str, optional - :param preserve_src_label: True to keep existing output labels of src, defaults to False - to remove one output label of the src - :type preserve_src_label: bool, optional - :param force: True to drop conflicting existing link, defaults to False - :type force: bool, optional - :return: assigned label of the created link. Unnamed links gets a - unique integer value assigned to it. - :rtype: str|int - - ..notes: - - - Unless `force=True`, dst pad must not be already connected - - User-supplied label name is a suggested name, and the function could - modify the name to maintain integrity. - - If dst or src were previously named, their names will be dropped - unless one matches the user-supplied label. - - No guarantee on consistency of the link label (both named and unnamed) - during the life of the object - - """ - - if label is not None: - GraphLinks.validate_label(label, named_only=True, no_stream_spec=True) - if dst is not None: - dst = self._resolve_index(True, dst) - try: - f = self.data[dst[0]][dst[1]] - assert dst[2] >= 0 and dst[2] < f.get_num_inputs() - except: - raise Graph.InvalidFilterPadId("input", dst) - if src is not None: - src = self._resolve_index(False, src) - try: - f = self.data[src[0]][src[1]] - assert src[2] >= 0 and src[2] < f.get_num_outputs() - except: - raise Graph.InvalidFilterPadId("output", src) - - return self._links.link(dst, src, label, preserve_src_label, force) - - def add_label(self, label, dst=None, src=None, force=None): - """label a filter pad - - :param label: name of the new label. Square brackets are optional. - :type label: str - :param dst: input filter pad index or a sequence of pads, defaults to None - :type dst: tuple(int,int,int) | seq(tuple(int,int,int)), optional - :param src: output filter pad index, defaults to None - :type src: tuple(int,int,int), optional - :param force: True to delete existing labels, defaults to None - :type force: bool, optional - :return: actual label name - :rtype: str - - Only one of dst and src argument must be given. - - If given label already exists, no new label will be created. - - If label has a trailing number, the number will be dropped and replaced with an - internally assigned label number. - - """ - - if label[0] == "[" and label[-1] == "]": - label = label[1:-1] - - GraphLinks.validate_label( - label, named_only=True, no_stream_spec=src is not None - ) - if dst is not None: - GraphLinks.validate_pad_id_pair((dst, None)) - for d in GraphLinks.iter_dst_ids(dst): - try: - f = self.data[d[0]][d[1]] - n = f.get_num_inputs() - assert d[2] >= 0 and d[2] < (n - 1 if d[1] > 0 else n) - except: - raise Graph.InvalidFilterPadId("input", d) - elif src is not None: - GraphLinks.validate_pad_id(src) - try: - f = self.data[src[0]][src[1]] - assert src[2] >= 0 and src[2] < f.get_num_outputs() - except: - raise Graph.InvalidFilterPadId("output", src) - else: - raise Graph.Error("filter pad index is not given") - - return self._links.create_label(label, dst, src, force) - - def remove_label(self, label): - """remove an input/output label - - :param label: linkn label - :type label: str - """ - - self._links.remove_label(label) - - def rename_label(self, old_label, new_label): - """rename an existing link label - - :param old_label: existing label named - :type old_label: str - :param new_label: new desired label name or None to make it unnamed label - :type new_label: str|None - :return: actual label name or None if unnamed - :rtype: str|None - - Note: - - - `new_label` is not guaranteed, and actual label depends on existing labels - - """ - - if not (isinstance(old_label, str) and old_label): - raise Graph.Error(f"old_label [{old_label}] must be a string.") - - if new_label is not None and not (isinstance(new_label, str) and new_label): - raise Graph.Error(f"new_label [{new_label}] must be None or a string.") - - # return the actual label or None if unnamed - return new_label or self._links.rename(old_label, new_label) - - def split_sources(self): - """possibly create a new filtergraph with all duplicate sources - separated by split/asplit filter - - :return: _description_ - :rtype: _type_ - """ - - # analyze the links to get a list of srcs which are connected to multiple dst's/labels - srcs_info = self._links.get_repeated_src_info() - if not len(srcs_info): - return self # if none found, good to go as is - - # retrieve all the output pads of the filterchains - chainable_outputs = [v[0] for v in self.iter_chainable_output_pads()] - - # create a clone to modify and output - fg = Graph(self) - - # process each multi-destination src - for src, dsts in srcs_info.items(): - - # resolve stream media type - try: - media_type = fg[src[:2]].get_pad_media_type("o", src[2]) - except Filter.Unsupported as e: - # if source filter pad media type cannot be resolved, try destination pads - for dst in dsts.values(): - if isinstance(dst, tuple): - try: - media_type = fg[dst[:2]].get_pad_media_type("i", dst[2]) - e = None - break - except Filter.Unsupported: - pass - if e is not None: - raise e - - # create the split filter - split_filter = Filter( - {"video": "split", "audio": "asplit"}[media_type], - len(dsts), - ) - - # find `split` filter can be inserted to the src chain - if src in chainable_outputs: - # if it can, extend the chain - fg[src[0]].append(split_filter) - new_src = (src[0], src[1] + 1) - else: - # if not, append a new chain - fg.append([split_filter]) - new_src = (len(fg) - 1, 0) - # create a new link from src to split input - fg._links.link(src, (*new_src, 0), force=True) - - # relink to dst pad and label - for pid, (label, index) in enumerate(dsts.items()): - if isinstance(index, str): # to output label - fg._links.add_label(label, dst=(*new_src, pid), force=True) - else: # to input of a filter - fg._links.link((*new_src, pid), index, label=label, force=True) - - return fg - - def stack( - self, - other, - auto_link=False, - replace_sws_flags=None, - ): - """stack another Graph to this Graph - - :param other: other filtergraph - :type other: Graph - :param auto_link: True to connect matched I/O labels, defaults to None - :type auto_link: bool, optional - :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) - :type replace_sws_flags: bool | None, optional - :return: new filtergraph object - :rtype: Graph - - * extend() and import links - * If `auto-link=False`, common labels may be renamed. - * For more explicit linking rather than the auto-linking, use `connect()` instead. - - TO-CHECK/TO-DO: what happens if common link labels are already linked - """ - - other = as_filtergraph_object(other) - - n = len(self) - m = len(other) - - if not m: # other is empty - return Graph(self) - if not n: # self is empty - 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." - ) - fg._links.update(other._links, len(self), auto_link=auto_link) - fg.data.extend(other) - - else: - # if other is not filtergraph, copy and append the new chain - fg = Graph(self) - fg.append(other) - - return fg - - def connect( - self, - right, - from_left, - to_right, - chain_siso=True, - replace_sws_flags=None, - ): - """stack another Graph and make connection from left to right - - :param right: other filtergraph - :type right: Graph - :param from_left: output pad ids or labels of this fg - :type from_left: seq(tuple(int,int,int)|str) - :param to_right: input pad ids or labels of the `right` fg - :type to_right: seq(tuple(int,int,int)|str) - :param chain_siso: True to chain the single-input single-output connection, default: True - :type chain_siso: bool, optional - :param replace_sws_flags: True to use `right` sws_flags if present, - False to drop `right` sws_flags, - None to throw an exception (default) - :type replace_sws_flags: bool | None, optional - :return: new filtergraph object - :rtype: Graph - - * link labels may be auto-renamed if there is a conflict - - """ - - # make sure right is a Graph object - right = as_filtergraph(right, copy=True) - - # resolve from_left and to_right to pad ids (raises if invalid) - srcs_info = [self._resolve_index(False, index) for index in from_left] - nout = len(srcs_info) - if nout != len(set(srcs_info)): - raise ValueError(f"from_left pad indices are not unique.") - - dsts_info = [right._resolve_index(True, index) for index in to_right] - ndst = len(dsts_info) - if nout != len(set(dsts_info)): - raise ValueError(f"to_right pad indices are not unique.") - - if nout != ndst: - raise ValueError(f"from_left ({ndst}) and to_right ({nout}) do not match.") - - if nout == 0: - raise ValueError( - f"No pads are given in from_left and to_right. Use stack() if no linking is needed" - ) - - # get the labels - srcs_info = [self.get_output_pad(index) for index in srcs_info] - dsts_info = [right.get_input_pad(index) for index in dsts_info] - - # sift through the connections for chainable and unchainables - link_pairs = [] - chain_pairs = [] - rm_chains = set() - n0 = len(self) # chain index offset - - for (dst, dst_label), (src, src_label) in zip(dsts_info, srcs_info): - new_dst = (dst[0] + n0, *dst[1:]) - - do_chain = ( - chain_siso - and self.data[src[0]][src[1]].get_num_outputs() == 1 - and right.data[dst[0]][dst[1]].get_num_inputs() == 1 - ) - - if do_chain: - if dst_label is not None: - right._links.remove_label(dst_label, dst) - chain_pairs.append((new_dst, src, src_label)) - rm_chains.add(new_dst[0]) - else: - # reuse the src or dst label if given - link_pairs.append((new_dst, src, src_label or dst_label)) - - # stack 2 filtergraphs - fg = self.stack(right, False, replace_sws_flags) - - if nout > 0: - # link marked chains - for link_args in link_pairs: - fg._links.link(*link_args) - - # combine chainable chains - for (dst, src, src_label) in reversed( - sorted(chain_pairs, key=lambda v: v[1]) - ): - fc_src = fg[src[0]] - n_src = len(fc_src) - fc_src.extend(fg.pop(dst[0])) - if src_label is not None: - fg._links.remove_label(src_label) - fg._links.merge_chains(dst[0], src[0], n_src) - fg._links.remove_chains(rm_chains) - - return fg - - def _iter_io_pads(self, is_input, how, ignore_labels=False): - """Iterates input/output pads of the filtergraph - - :param is_input: True if input; False if output - :type is_input: bool - :param how: pad selection method - - ----------- ------------------------------------------------------------------- - 'chainable' only chainable pads. - 'per_chain' one pad per chain. Source and sink chains are ignored. - 'all' joins all input pads and output pads - ----------- ------------------------------------------------------------------- - - :type how: "chainable"|"per_chain"|"all" - :param ignore_labels: True to return labaled (but not linked) pads, defaults to False - :type ignore_labels: bool, optional - :yield: pad index, pad label, parent filter - :rtype: tuple(tuple(int,int,int), label, Filter) - """ - if how is None or how in ("per_chain", "all"): - generator = self.iter_input_pads if is_input else self.iter_output_pads - - return ( - generator() - if how == "all" - else ( - info - for info in ( - next(generator(exclude_named=not ignore_labels, chain=c), None) - for c in range(len(self.data)) - ) - if info is not None - ) - ) - elif how == "chainable": - return ( - self.iter_chainable_input_pads - if is_input - else self.iter_chainable_output_pads - )(exclude_named=not ignore_labels) - else: - raise ValueError(f"unknown how argument value: {how}") - - def join( - self, - right, - how="per_chain", - match_scalar=False, - ignore_labels=False, - chain_siso=True, - replace_sws_flags=None, - ): - """append another Graph object and connect all inputs to the outputs of this filtergraph - - :param right: right filtergraph to be appended - :type right: Graph|Chain|Filter - :param how: method on how to mate input and output, defaults to "per_chain". - - =========== =================================================================== - 'chainable' joins only chainable input pads and output pads. - 'per_chain' joins one pair of first available input pad and output pad of each - mating chains. Source and sink chains are ignored. - 'all' joins all input pads and output pads - 'auto' tries 'per_chain' first, if fails, then tries 'all'. - =========== =================================================================== - - :type how: "chainable"|"per_chain"|"all" - :param match_scalar: True to multiply self if SO-MI connection or right if MO-SI connection - to single-ended entity to the other, defaults to False - :type match_scalar: bool - :param ignore_labels: True to pair pads w/out checking pad labels, default: True - :type ignore_labels: bool, optional - :param chain_siso: True to chain the single-input single-output connection, default: True - :type chain_siso: bool, optional - :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) - :type replace_sws_flags: bool | None, optional - :return: Graph with the appended filter chains or None if inplace=True. - :rtype: Graph or None - """ - - # make sure right is a Graph, Chain, or Filter object - right = as_filtergraph(right) - - if not len(right): - return Graph(self) - - if not len(self): - return Graph(right) - - # auto-mode, 1-deep recursion - if how == "auto": - try: - return self.join( - right, - "per_chain", - match_scalar, - ignore_labels, - chain_siso, - replace_sws_flags, - ) - except: - return self.join( - right, - "all", - match_scalar, - ignore_labels, - chain_siso, - replace_sws_flags, - ) - - # list all the unconnected output pads of left fg - # [(index, label, filter)] - src_info = tuple(self._iter_io_pads(False, how, ignore_labels)) - - # list all the unconnected input pads of right fg - dst_info = tuple(right._iter_io_pads(True, how, ignore_labels)) - - # to join, the number of pads must match - nsrc = len(src_info) - ndst = len(dst_info) - - if nsrc != ndst: - - if match_scalar and ndst == 1: - # multiply right to match self - right = right * nsrc - dst_info = right._iter_io_pads(True, how) - elif match_scalar and nsrc == 1: - # multiply self to match right - self = self * ndst - src_info = self._iter_io_pads(False, how) - else: - raise FiltergraphMismatchError(nsrc, ndst) - - return self.connect( - right, - [index for index, *_ in src_info], - [index for index, *_ in dst_info], - chain_siso, - replace_sws_flags, - ) - - def attach(self, right, left_on=None, right_on=None): - """attach an output pad to right's input pad - - :param right: output filterchain to be attached - :type right: Chain or Filter - :param left_on: pad_index, specify the pad on self, default to None (first available) - :type left_on: int or str, optional - :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) - :type right_on: int or str, optional - :return: new filtergraph object - :rtype: Graph - - """ - - right = as_filtergraph_object(right) - right_on = right._resolve_index(True, right_on) - left_on = self._resolve_index(False, left_on) - return self.connect(right, [left_on], [right_on], chain_siso=True) - - def rattach(self, left, right_on=None, left_on=None): - """prepend an input filterchain to an existing filter chain of the filtergraph - - :param left: filterchain to be attached - :type left: Chain or Filter - :param right_on: filterchain to accept the input chain, defaults to None (first available) - :type right_on: int or str, optional - :return: new filtergraph object - :rtype: Graph - - If the attached filter pad has an assigned label, the label will be automatically removed. - - """ - - left = as_filtergraph(left) - left_on = left._resolve_index(False, left_on) - right_on = self._resolve_index(True, right_on) - return left.connect(self, [left_on], [right_on], chain_siso=True) - - def add_labels(self, pad_type, labels): - """turn into filtergraph and add labels - - :param input_labels: input pad labels keyed by pad index, defaults to None - :type input_labels: dict(int:str), optional - :param output_labels: output pad labels keyed by pad index, defaults to None - :type output_labels: dict(int:str), optional - """ - - fg = Graph(self) - is_input = pad_type == "dst" - if isinstance(labels, str): - pad = fg._resolve_index(is_input, None) - fg.add_label(labels, **{pad_type: pad}) - elif isinstance(labels, dict): - for pad, label in labels.items(): - pad = fg._resolve_index(is_input, None) - fg.add_label(label, **{pad_type: pad}) - else: - pads = list( - itertools.islice( - ( - fg.iter_input_pads(exclude_named=True) - if pad_type == "dst" - else fg.iter_output_pads(exclude_named=True) - ), - len(labels), - ) - ) - for label, pad in zip(labels, pads): - fg.add_label(label, **{pad_type: pad[0]}) - return fg - - @contextmanager - def as_script_file(self): - """return script file containing the filtergraph description - - :yield: path of a temporary text file with filtergraph description - :rtype: str - - This method is intended to work with the `filter_script` and - `filter_complex_script` FFmpeg options, by creating a temporary text file - containing the filtergraph description. - - .. note:: - Only use this function when the filtergraph description is too long for - OS to handle it. Presenting the filtergraph with a `filter_complex` or - `filter` option to FFmpeg is always a faster solution. - - Moreover, if `stdin` is available, i.e., not for a write or filter - operation, it is more performant to pass the long filtergraph object - to the subprocess' `input` argument instead of using this method. - - Use this method with a `with` statement. How to incorporate its output - with `ffmpegprocess` depends on the `as_file_obj` argument. - - :Example: - - The following example illustrates a usecase for a video SISO filtergraph: - - .. code-block:: python - - # assume `fg` is a SISO video filter Graph object - - with fg.as_script_file() as script_path: - ffmpegio.ffmpegprocess.run( - { - 'inputs': [('input.mp4', None)] - 'outputs': [('output.mp4', {'filter_script:v': script_path})] - }) - - As noted above, a performant alternative is to use an input pipe and - feed the filtergraph description directly: - - .. code-block:: python - - ffmpegio.ffmpegprocess.run( - { - 'inputs': [('input.mp4', None)] - 'outputs': [('output.mp4', {'filter_script:v': 'pipe:0'})] - }, - input=str(fg)) - - Note that ``pipe:0`` must be used and not the shorthand ``'-'`` unlike - the input url. - - """ - - # populate the file with filtergraph expression - temp_file = NamedTemporaryFile("wt", delete=False) - temp_file.write(str(self)) - temp_file.close() - - try: - # present the file to the caller in the context - yield temp_file.name - - finally: - if temp_file: - os.remove(temp_file.name) - - -# dict: stores filter construction functions -_filters = {} - - -def __getattr__(name): - func = _filters.get(name, None) - if func is None: - try: - notfound = name not in list_filters() - except path.FFmpegNotFound: - notfound = True - - if notfound: - raise AttributeError( - f"{name} is neither a valid ffmpegio.filtergraph module's instance attribute " - "nor a valid FFmpeg filter name." - ) - - def func(*args, filter_id=None, **kwargs): - return Filter(name, *args, filter_id=filter_id, **kwargs) - - func.__name__ = name - func.__doc__ = path.ffmpeg( - f"-hide_banner -h filter={name}", universal_newlines=True, stdout=PIPE - ).stdout - _filters[name] = func - - return func diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py new file mode 100644 index 00000000..e7f847e3 --- /dev/null +++ b/src/ffmpegio/filtergraph/Chain.py @@ -0,0 +1,558 @@ +from __future__ import annotations + +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 .exceptions import * + + +__all__ = ["Chain"] + + +class Chain(fgb.abc.FilterGraphObject, UserList): + """List of FFmpeg filters, connected in series + + Chain() to instantiate empty Graph object + + Chain(obj) to copy-instantiate Graph object from another + + Chain('...') to parse an FFmpeg filtergraph expression + + :param filter_specs: single-in-single-out filtergraph description without + labels, defaults to None + :type filter_specs: str or seq(Filter), optional + """ + + class Error(FFmpegioError): + pass + + def __init__(self, filter_specs=None): + # convert str to a list of filter_specs + + if isinstance(filter_specs, fgb.Filter): + filter_specs = [filter_specs] + elif filter_specs is not None: + if isinstance(filter_specs, str): + filter_specs, links, sws_flags = filter_utils.parse_graph(filter_specs) + if links: + raise ValueError( + "filter_specs with link labels cannot be represented by the Chain class. Use Graph." + ) + if sws_flags: + raise ValueError( + "filter_specs with sws_flags cannot be represented by the Chain class. Use Graph." + ) + if len(filter_specs) != 1: + raise ValueError( + "filter_specs str must resolve to a single-chain filtergraph. Use the Graph class instead." + ) + filter_specs = filter_specs[0] + + filter_specs = (fgb.as_filter(fspec) for fspec in filter_specs) + + UserList.__init__(self, () if filter_specs is None else filter_specs) + + def compose( + self, + show_unconnected_inputs: bool = False, + show_unconnected_outputs: bool = False, + ): + """compose filtergraph + + :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 + """ + + return ( + fgb.Graph(self.data).compose( + show_unconnected_inputs, show_unconnected_outputs + ) + if show_unconnected_inputs or show_unconnected_outputs + else filter_utils.compose_graph([self.data]) + ) + + def __repr__(self): + type_ = type(self) + return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(self))}> + FFmpeg expression: \"{self.compose(True,True)}\" + Number of filters: {len(self.data)} + Input pads ({self.get_num_inputs()}): {', '.join((str(id) for id,*_ in self.iter_input_pads()))} + 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]): + if not isinstance(key, (int, slice)): + i, key = key + if i != 0: + raise IndexError("Invalid chain index") + + return UserList.__getitem__(self, key) + + def __setitem__(self, key, value): + UserList.__setitem__(self, key, fgb.as_filter(value)) + + def get_num_chains(self) -> int: + """get the number of chains""" + return len(self) + + def get_num_filters(self, chain: int) -> int: + """get the number of filters of the specfied chain + + :param chain: id of the chain + """ + + if chain: + raise ValueError(f"{chain=} is invalid. Filter object only has 1 chain.") + return len(self) + + def get_num_inputs(self) -> int: + return len(list(self.iter_input_pads())) + + def get_num_outputs(self) -> int: + return len(list(self.iter_output_pads())) + + 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 add_label( + self, + label: str, + inpad: PAD_INDEX = None, + outpad: PAD_INDEX = None, + force: bool = None, + ) -> fgb.Graph: + """label a filter pad + + :param label: name of the new label. Square brackets are optional. + :param inpad: input filter pad index or a sequence of pads, defaults to None + :param outpad: output filter pad index, defaults to None + :param force: True to delete existing labels, defaults to None + :return: actual label name + + Only one of inpad and outpad argument must be given. + + If given label already exists, no new label will be created. + + If label has a trailing number, the number will be dropped and replaced with an + internally assigned label number. + + """ + + # must convert to FilterGraph as it's the only object with labels + fg = fgb.Graph([self]) + return fg.add_label(label, inpad, outpad, force) + + def append(self, item): + return UserList.append(self, fgb.as_filter(item)) + + def extend(self, other: fgb.Chain | Sequence[fgb.Filter | str]): + return UserList.extend(self, [fgb.as_filter(f) for f in other]) + + def insert(self, i, item): + return UserList.insert(self, i, fgb.as_filter(item)) + + def __contains__(self, item): + item = fgb.as_filter(item) + return any((f == item for f in self.data)) + + def __ior__(self, other): + raise Chain.Error("cannot assign operation outcome which is not a filterchain") + + def __iadd__(self, other): + + if len(other): + fg = self + other if len(self) else Chain(other) + + if isinstance(fg, fgb.Graph): + raise Chain.Error( + "cannot assign operation outcome which is not a filterchain" + ) + self.data = fg.data + return self + + def __irshift__(self, other): + if len(other): + fg = self >> other if len(self) else Chain(other) + + if isinstance(fg, fgb.Graph): + raise Chain.Error( + "cannot assign operation outcome which is not a filterchain" + ) + self.data = fg.data + return self + + def iter_chains( + self, + skip_if_no_input: bool = False, + skip_if_no_output: bool = False, + chainable_only: bool = False, + ) -> Generator[tuple[int, fgb.Chain]]: + """iterate over chains of the filtergraphobject + + :param skip_if_no_input: True to skip chains without available input pads, defaults to False + :param skip_if_no_output: True to skip chains without available output pads, defaults to False + :param chainable_only: True to further restrict ``skip_if_no_input`` and ``skip_if_no_input`` + arguments to require chainable input or output, defaults to False to + allow any input/output + :yield: chain id and chain object + """ + + if not len(self): + return + + if skip_if_no_input and self.next_input_pad() is None: + return + + if skip_if_no_output and self.next_output_pad() is None: + return + + yield (0, self) + + def _iter_pads( + self, + iter_filter_pad: Callable, + i_nochain: int, + pad: int | None, + filter: int | None, + chain: Literal[0] | None, + exclude_chainable: bool, + chainable_first: bool, + include_connected: bool, + chainable_only: bool, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, bool]]: + """Iterate over input pads of the filters on the filterchain + + :param filters: list of filters to iterate + :param iter_filter_pad: Filter class function to iterate on filter pads + :param pad: pad id + :param filter: filter index + :param chain: chain index + :param exclude_chainable: True to leave out the last pads + :param chainable_first: True to yield the last pad first then the rest + :param include_connected: True to include pads connected to input streams, defaults to False + :yield: filter pad index, filter object, and True if no connection + """ + + if len(self) == 0: + return + + if isinstance(chain, int) and chain != 0: + # Filterchain has only one chain. + raise FiltergraphInvalidIndex(f"Invalid {chain=} id") + + if chainable_only: + if filter is not None: + if filter < 0: + filter = len(self) + filter + if filter != i_nochain: + raise FiltergraphInvalidIndex( + f"{filter=} id is not chainable filter." + ) + filters = [self.data[i_nochain]] + i_first = i_nochain + + elif filter is None: + # iterate over all filters + filters = self.data + i_first = 0 + else: + try: + filters = [self.data[filter]] + except IndexError: + raise FiltergraphInvalidIndex(f"Invalid {filter=} id.") + i_first = filter + + # iterate over all filters + for i, f in enumerate(filters): + no_chainables = (not include_connected and i != i_nochain) or ( + exclude_chainable and i == i_nochain + ) + try: + for pidx, f, other_pidx in iter_filter_pad( + f, + pad, + exclude_chainable=no_chainables, + chainable_first=chainable_first, + chainable_only=chainable_only, + ): + yield (i + i_first, *pidx), f, other_pidx + except FiltergraphInvalidIndex: + pass + + def iter_input_pads( + self, + pad: int | None = None, + filter: int | None = None, + chain: Literal[0] | None = None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | None]]: + """Iterate over input pads of the filters on the filterchain + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :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 + :param unlabeled_only: True to leave out named inputs, defaults to False to return all inputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all inputs + :param full_pad_index: True to return 3-element index + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + for index, filter, other_index in self._iter_pads( + fgb.Filter.iter_input_pads, + 0, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + chainable_only, + ): + if other_index is None: + out_index = None + else: + # get the last output pad of the previous filter + out_i = index[1] - 1 + out_index = (0, out_i, self[out_i].get_num_outputs() - 1) + + yield ( + ((0, *index), filter, out_index) + if full_pad_index + else (index, filter, out_index) + ) + + def iter_output_pads( + self, + pad: int | None = None, + filter: int | None = None, + chain: int | None = None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | None]]: + """Iterate over output pads of the filters on the filterchain + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :param exclude_chainable: True to leave out the last output pads, defaults to False (all avail pads) + :param chainable_first: True to yield the last output first then the rest, defaults to False + :param include_connected: True to include pads connected to output streams, defaults to False + :param unlabeled_only: True to leave out named outputs, defaults to False to return all outputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all outputs + :param full_pad_index: True to return 3-element index + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + for index, filter, other_index in self._iter_pads( + fgb.Filter.iter_output_pads, + len(self.data) - 1, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + chainable_only, + ): + if other_index is None: + in_index = None + else: + # get the last input pad of the next filter + in_i = index[1] + 1 + in_index = (0, in_i, self[in_i].get_num_inputs() - 1) + yield ( + ((0, *index), filter, in_index) + if full_pad_index + else (index, filter, in_index) + ) + + def _connect( + self, + right: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """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 to_right: input pad ids or labels of the `right` fg + :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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + if isinstance(right, fgb.Graph): + # right is more complex filtergraph object + return right._rconnect( + self, fwd_links, bwd_links, chain_siso, replace_sws_flags + ) + + right = fgb.as_filterchain(right) + + if chain_siso and self.get_num_outputs() == 1 and right.get_num_inputs() == 1: + return fgb.Chain([*self, *right]) + + # create iterators to organize the links in (input, output) of the combined graph + it_fwd = (((1, *r[1:]), l) for (l, r) in fwd_links) + it_bwd = ((l, (1, *r[1:])) for (r, l) in bwd_links) + + return fgb.Graph( + [[self], [right]], + {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, + ) + + def _rconnect( + self, + left: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """combine another filtergraph object and make upstream connections (worker) + + :param right: other filtergraph + :param fwd_links: a list of tuples, pairing left's output pad and self's ipnut pad + :param bwd_links: a list of tuples, pairing self's output pad and left's ipnut 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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + if isinstance(left, fgb.Graph): + # left is more complex filtergraph object + return left._connect( + self, fwd_links, bwd_links, chain_siso, replace_sws_flags + ) + + left = fgb.as_filterchain(left) + + if chain_siso and left.get_num_outputs() == 1 and self.get_num_inputs() == 1: + return fgb.Chain([*left, *self]) + + # create iterators to organize the links in (input, output) of the combined graph + it_fwd = (((1, *r[1:]), l) for (l, r) in fwd_links) + it_bwd = ((l, (1, *r[1:])) for (r, l) in bwd_links) + + return fgb.Graph( + [[left], [self]], + {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, + ) + + def _stack( + self, + other: fgb.abc.FilterGraphObject, + auto_link: bool = False, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """stack another Graph to this Graph (no var check)""" + + other = fgb.atleast_filterchain(other) + + # if other is not a filter, elevate self to match first + return ( + fgb.Graph([self, other]) + if isinstance(other, fgb.Chain) + else fgb.Graph(self)._stack(other, auto_link, replace_sws_flags) + ) + + return fgb.as_filtergraph(self)._stack(other, auto_link, replace_sws_flags) + + def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: + """returns True if specified input pad index is available""" + + pos = index[1] + if pos < 0 or pos >= len(self): + return False + + # if chained to the previous filter, not avail + pad_pos = index[2] + n = self[pos].get_num_inputs() + return pad_pos >= 0 and pad_pos < (n - 1 if pos else n) + + def _output_pad_is_available(self, index: tuple[int, int, int]) -> bool: + """returns True if specified output pad index is available""" + + pos = index[1] + nchain = len(self) + if pos < 0 or pos >= nchain: + return False + + # if chained to the next filter, not avail + pad_pos = index[2] + n = self[pos].get_num_outputs() + return pad_pos >= 0 and pad_pos < (n - 1 if pos < nchain - 1 else n) + + def _check_partial_pad_index( + self, index: tuple[int | None, int | None, int | None], is_input: bool + ) -> bool: + """True if defined values of the partial pad index are valid""" + + if index[0] is not None and index[0] > 0: + return False + + filter = index[1] + if filter is not None: + if filter < 0 or filter >= len(self): + return False + + return any( + f._check_partial_pad_index((None, None, index[2]), is_input) for f in self + ) + + def _input_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified input pad is chainable""" + if index[0]: + return False + try: + filter = self[index[1]] + except IndexError: + return False + else: + return filter._input_pad_is_chainable((0, 0, index[2])) + + def _output_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified output pad is chainable""" + if index[0]: + return False + try: + filter = self[index[1]] + except IndexError: + return False + else: + return filter._output_pad_is_chainable((0, 0, index[2])) diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py new file mode 100644 index 00000000..7adfd351 --- /dev/null +++ b/src/ffmpegio/filtergraph/Filter.py @@ -0,0 +1,873 @@ +from __future__ import annotations + +from collections.abc import Generator, Sequence +import re +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 .exceptions import * + +__all__ = ["Filter"] + + +class Filter(fgb.abc.FilterGraphObject, tuple): + """FFmpeg filter definition immutable class + + :param filter_spec: _description_ + :type filter_spec: _type_ + :param filter_id: _description_, defaults to None + :type filter_id: _type_, optional + :param \\*opts: filter option values assigned in the order options are + declared + :type \\*opts: dict, optional + :param \\**kwopts: filter options in key=value pairs + :type \\**kwopts: dict, optional + + """ + + class Error(FFmpegioError): + pass + + class InvalidName(Error): + def __init__(self, name): + from .. import path + + super().__init__( + f"Filter {name} is not defined in FFmpeg (v{path.FFMPEG_VER}).\n" + ) + + class InvalidOption(Error): + pass + + class Unsupported(Error): + def __init__(self, name, feature) -> None: + super().__init__(f"{feature} not yet supported feature for {name} filter.") + + _info: dict[str, FilterInfo] = {} + + @staticmethod + def _get_info(name: str) -> FilterInfo: + try: + info = Filter._info[name] + except KeyError: + try: + info = Filter._info[name] = list_filters()[name] + except: + raise Filter.InvalidName(name) + return info + + def __new__(self, filter_spec, *args, filter_id=None, **kwargs): + """_summary_""" + 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)) + proto.extend(filter_spec[1:]) + else: + proto.extend(filter_spec) + else: + # parse if str given + if isinstance(filter_spec, str): + filter_spec = filter_utils.parse_filter(filter_spec) + + if not (isinstance(filter_spec, Sequence) and len(filter_spec)): + raise ValueError("filter_spec must be a non-empty sequence.") + name, *opts = filter_spec + if isinstance(name, str): + self._get_info(name) + proto.append((name, id) if isinstance(id, str) else name) + elif not ( + isinstance(name, Sequence) + and len(name) != 2 + and all((isinstance(i, str) for i in name)) + ): + raise ValueError( + "filter_spec[0] must be a str or 2-element str sequence." + ) + else: + # name + id: re-id if id arg given + self._get_info(name[0]) + proto.append(tuple(name) if filter_id is None else (name[0], filter_id)) + + proto.extend(opts) + + # create named options dict + proto_dict = proto.pop() if isinstance(proto[-1], dict) else {} + + # change ordered options if non-None value is given + nord = len(proto) - 1 # # of ordered options + for i, o in enumerate(args[:nord]): + if o is not None: + proto[i] = o + + # add additional ordered options if present + proto.extend(args[nord:]) + + # update named options + if len(kwargs): + proto_dict.update(kwargs) + + # validate named option keys to be str + for k in proto_dict: + if not isinstance(k, str): + raise ValueError( + "All keys of the named option dict must be of type str." + ) + + # add the named option dict to the prototype list + if len(proto_dict): + proto.append(proto_dict) + + # create the final tuple + return tuple.__new__(Filter, proto) + + def __getitem__(self, key): + value = tuple.__getitem__(self, key) + + if isinstance(value, dict): + value = {**value} + if isinstance(value, tuple): + if isinstance(value[-1], dict): + value = tuple((*value[:-1], {**value[-1]})) + elif isinstance(value[0], dict): + value = tuple(({**value[-1]}, *value[1:])) + return value + + def compose( + self, + show_unconnected_inputs: bool = False, + show_unconnected_outputs: bool = False, + ): + """compose filtergraph + + :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 + """ + + return ( + fgb.Graph(self.data).compose( + show_unconnected_inputs, show_unconnected_outputs + ) + if show_unconnected_inputs or show_unconnected_outputs + else filter_utils.compose_filter(*self) + ) + + def __repr__(self): + type_ = type(self) + return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(self))}> + FFmpeg expression: \"{self.compose(True,True)}\" + Number of inputs: {self.get_num_inputs()} + Number of outputs: {self.get_num_outputs()} +""" + + @property + def name(self): + name = self[0] + return name if isinstance(name, str) else name[0] + + @property + def fullname(self): + name = self[0] + return name if isinstance(name, str) else f"{name[0]}@{name[1]}" + + @property + def id(self): + name = self[0] + return None if isinstance(name, str) else name[1] + + @property + def ordered_options(self): + opts = self[1:] + return opts[:-1] if isinstance(opts[-1], dict) else opts + + @property + def named_options(self): + opts = self[-1] + return opts if isinstance(opts, dict) else {} + + @property + def info(self): + try: + return filter_info(self.name) + except: + raise Filter.InvalidName(self.name) + + def get_pad_media_type(self, port, pad_id): + try: + port = ( + "inputs" + if "inputs".startswith(port) + else "outputs" if "outputs".startswith(port) else None + ) + assert port is not None + except: + raise ValueError( + f"{port} is an invalid filter port type. Must be either 'input' or 'output'." + ) + + port_info = getattr(self.info, port) + + if port_info is None: + # filters with homogeneous multiple in/out + # fmt:off + pure_video = { + "inputs": [ + "bm3d", "decimate", "fieldmatch", "hstack", "interleave", "mergeplanes", + "mix", "premultiply", "signature", "streamselect", "unpremultiply", + "vstack", "xmedian", "xstack", + ], + "outputs": [ + "alphaextract", "extractplanes", "select", "split", "streamselect", + ], + } + pure_audio = { + "inputs": [ + "afir", "ainterleave", "amerge", "amix", "astreamselect", "headphone", "join", "ladspa", + ], + "outputs": [ + "acrossover", "aselect", "asplit", "astreamselect", "channelsplit", + ], + } + # fmt:on + + if self.name in pure_video[port]: + return "video" + if self.name in pure_audio[port]: + return "audio" + + if self.name == "concat": + n = self.get_option_value("n") + v = self.get_option_value("v") + a = self.get_option_value("a") + return ( + ("video" if pad_id % n < v else "audio") + if port != "outputs" + else ("video" if pad_id < v else "audio") + ) + + # multiple pads possible if streams option set + if self.name in ("movie", "amovie"): + if self.get_option_value("streams") is None: + return "video" if self.name == "movie" else "audio" + + # 2nd pad for audio visualization stream + vis_mode = ["afir", "aiir", "anequalizer", "ebur128", "aphasemeter"] + if port == "outputs" and self.name in vis_mode: + return "video" if pad_id else "audio" + + raise Filter.Unsupported(self.name, "dynamic media type resolution") + + try: + pad_info = port_info[pad_id] + return pad_info["type"] + except: + raise ValueError( + f"{pad_id} is an invalid pad_id as an {port[:-1]} pad of {self.name} filter." + ) + + def get_option_value(self, option_name): + + # first check the named options as-is + named_opts = self.named_options + try: + return named_opts[option_name] + except: + pass + + # get the option info + i, opt_info = next( + ( + (i, o) + for i, o in enumerate(self.info.options) + if o.name == option_name or option_name in o.aliases + ), + (None, None), + ) + if i is None: + raise Filter.InvalidOption( + f"Invalid option name ({option_name}) for {self.name} filter" + ) + + try: + # try full name first + return named_opts[opt_info.name] + except: + # try alias name next + for a in opt_info.aliases: + try: + return named_opts[a] + except: + pass + + # try from ordered options next + try: + return self.ordered_options[i] + except: + # if nothing fits, use the default value (maybe undefined/None) + return opt_info.default + + def get_num_inputs(self): + """get the number of input pads of the filter + :return: number of input pads + :rtype: int + """ + name = self.name + if not isinstance(name, str): + # name@id + name = name[0] + + try: + nin = self._info[name].num_inputs + except: + raise Filter.InvalidName(name) + if nin is not None: # fixed number + return nin + + def _inplace(): + return 1 if self.get_option_value("inplace") else 2 + + def _headphone(): + if self.get_option_value("hrir") == "multich": + return 2 + map = self.get_option_value("map") + return ( + len(re.split(r"\s*\|\s*", map)) + 1 + if isinstance(map, str) + else len(map) + 1 + ) + + def _mergeplanes(): + map = self.get_option_value("mapping") + if not isinstance(map, int): + map = int(map, 16 if map.startswith("0x") else 10) + + return int(max(f"{map:08x}"[::2])) + 1 + + def _concat(): + return self.get_option_value("n") * ( + self.get_option_value("v") + self.get_option_value("a") + ) + + option_name, inc = { + "afir": ("nbirs", 1), + "concat": (None, _concat), + "decimate": ("ppsrc", 1), + "fieldmatch": ("ppsrc", 1), + "headphone": (None, _headphone), + "interleave": ("nb_inputs", 0), + "limitdiff": ("reference", 1), + "mergeplanes": (None, _mergeplanes), + "premultiply": (None, _inplace), + "unpremultiply": (None, _inplace), + "signature": ("nb_inputs", 0), + # "astreamselect": ("inputs", 0), + # "bm3d": ("inputs", 0), + # "hstack": ("inputs", 0), + # "mix": ("inputs", 0), + # "streamselect": ("inputs", 0), + # "vstack": ("inputs", 0), + # "xmedian": ("inputs", 0), + # "xstack": ("inputs", 0), + }.get(name, ("inputs", 0)) + + return ( + int(self.get_option_value(option_name)) + inc + if isinstance(option_name, str) + else inc() + ) + + def get_num_outputs(self): + """get the number of output pads of the filter + :return: number of output pads + :rtype: int + """ + name = self.name + + try: + nout = self._info[name].num_outputs + except: + raise Filter.InvalidName(name) + if nout is not None: # arbitrary number allowed + return nout + + def _concat(): + return int(self.get_option_value("a")) + int(self.get_option_value("v")) + + 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)) + ) + inc + + def _channelsplit(): + layout = self.get_option_value("channel_layout") + channels = self.get_option_value("channels") + return len( + re.split( + rf"\s*\+\s*", + layouts()["layouts"][layout] if channels == "all" else channels, + ) + ) + + # fmt:off + option_name, inc = { + "afir": ("response", 1), # +video stream + "aiir": ("response", 1), # +video stream + "anequalizer": ("curves", 1), + "ebur128": ("video", 1), + "aphasemeter": ("video", 1), + "acrossover": ('split',partial( _list_var,"split", " ", 1)), # split option (space-separated) + "asegment": ("timestamps", partial( _list_var,"timestamps", r"\|", 1)), + "segment": ("timestamps", partial( _list_var,"timestamps", r"\|", 1)), + "astreamselect": ("map", partial( _list_var,"map", " ", 0)), # parse map? + "streamselect": ("map", partial( _list_var,"map", " ", 0)), # parse map? + "extractplanes": ("planes", partial( _list_var,"planes", r"\+", 0)), # parse planes + "amovie": ("streams",partial( _list_var,"streams", r"\+", 0)), + "movie": ("streams",partial( _list_var,"streams", r"\+", 0)), + "channelsplit": (('channel_layout', 'channels'),_channelsplit), # parse channel_layout/channels + "concat": (('a', 'v'), _concat), # sum a and v + # "aselect": (("output", "n"), 0), # must resolve alias... + # "asplit": ("outputs", 0), + # "select": (("output", "n"), 0), + # "split": ("outputs", 0), + }.get(name, ("outputs", 0)) + # fmt:on + + return ( + int(self.get_option_value(option_name)) + inc + if isinstance(inc, int) + else inc() + ) + + def get_num_filters(self, chain: int) -> int: + """get the number of filters of the specfied chain + + :param chain: id of the chain + """ + + if chain: + raise ValueError(f"{chain=} is invalid. Filter object only has 1 chain.") + return 1 + + def get_num_chains(self) -> int: + """get the number of chains""" + return 1 + + def add_label( + self, + label: str, + inpad: PAD_INDEX | Sequence[PAD_INDEX] = None, + outpad: PAD_INDEX = None, + force: bool = None, + ) -> fgb.Graph: + """label a filter pad + + :param label: name of the new label. Square brackets are optional. + :param inpad: input filter pad index or a sequence of pads, defaults to None + :param outpad: output filter pad index, defaults to None + :param force: True to delete existing labels, defaults to None + :return: actual label name + + Only one of inpad and outpad argument must be given. + + If given label already exists, no new label will be created. + + If inpad indices are given, the label must be an input stream specifier. + + If label has a trailing number, the number will be dropped and replaced with an + internally assigned label number. + + """ + + # must convert to FilterGraph as it's the only object with labels + fg = fgb.Graph([[self]]) + return fg.add_label(label, inpad, outpad, force) + + def _iter_pads( + self, + n: int, + pad: int | None, + filter: Literal[0] | None, + chain: Literal[0] | None, + exclude_chainable: bool, + chainable_first: bool, + chainable_only: bool, + ) -> Generator[tuple[PAD_INDEX, Filter]]: + """Iterate over input pads of the filter + + :param n: number of pads + :param pad: pad id + :param filter: filter index + :param chain: chain index + :param exclude_chainable: True to leave out the last pads + :param chainable_first: True to yield the last pad first then the rest + :yield: filter pad index, link label, filter object + """ + + if not n: + # takes no input, nothing to iterate + return + + if (isinstance(filter, int) and filter != 0) or ( + isinstance(chain, int) and chain != 0 + ): + # Filter alone can have no connections so yields no pad + raise FiltergraphInvalidIndex(f"Invalid {filter=} or {chain=} id") + + if pad is not None: + if pad < 0: # resolve negative pad index + pad += n + if pad < 0 or pad >= (n - 1 if exclude_chainable else n): + raise FiltergraphInvalidIndex(f"Invalid {pad=} id") + + if chainable_only: + if pad is None: + pad = n - 1 + + if pad != n - 1: + raise FiltergraphInvalidIndex(f"Invalid {pad=} is not chainable pad") + + if not exclude_chainable and pad == n - 1: + yield (pad,), self, None + + elif pad is None: + if chainable_first or exclude_chainable: + n = n - 1 + if chainable_first and not exclude_chainable: + yield (n,), self, None + for j in range(n): + yield (j,), self, None + else: + yield (pad,), self, None + + def iter_input_pads( + self, + pad: int | None = None, + filter: Literal[0] | None = None, + chain: Literal[0] | None = None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, Filter, None]]: + """Iterate over input pads of the filter + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :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 + :param unlabeled_only: True to leave out named inputs, defaults to False to return all inputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all inputs + :param full_pad_index: True to return 3-element index + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + for index, filter, other_index in self._iter_pads( + self.get_num_inputs(), + pad, + filter, + chain, + exclude_chainable, + chainable_first, + chainable_only, + ): + yield ( + ((0, 0, *index), filter, other_index) + if full_pad_index + else (index, filter, other_index) + ) + + def iter_output_pads( + self, + pad: int | None = None, + filter: Literal[0] | None = None, + chain: Literal[0] | None = None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, Filter, PAD_INDEX | None]]: + """Iterate over output pads of the filter + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :param exclude_chainable: True to leave out the last output pads, defaults to False (all avail pads) + :param chainable_first: True to yield the last output first then the rest, defaults to False + :param include_connected: True to include pads connected to output streams, defaults to False + :param unlabeled_only: True to leave out named outputs, defaults to False to return only all outputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all outputs + :param full_pad_index: True to return 3-element index + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + for index, filter, other_index in self._iter_pads( + self.get_num_outputs(), + pad, + filter, + chain, + exclude_chainable, + chainable_first, + chainable_only, + ): + yield ( + ((0, 0, *index), filter, other_index) + if full_pad_index + else (index, filter, other_index) + ) + + def iter_chains( + self, + skip_if_no_input: bool = False, + skip_if_no_output: bool = False, + chainable_only: bool = False, + ) -> Generator[tuple[int, fgb.Chain]]: + """iterate over chains of the filtergraphobject + + :param skip_if_no_input: True to skip chains without available input pads, defaults to False + :param skip_if_no_output: True to skip chains without available output pads, defaults to False + :param chainable_only: True to further restrict ``skip_if_no_input`` and ``skip_if_no_input`` + arguments to require chainable input or output, defaults to False to + allow any input/output + :yield: chain id and chain object + """ + + if (not skip_if_no_input or self.get_num_inputs()) and ( + not skip_if_no_output or self.get_num_outputs() + ): + yield (0, fgb.Chain([self])) + + def _connect( + self, + right: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """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 to_right: input pad ids or labels of the `right` fg + :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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + if not isinstance(right, fgb.Filter): + # right is more complex filtergraph object + return right._rconnect( + self, fwd_links, bwd_links, chain_siso, replace_sws_flags + ) + + if chain_siso and self.get_num_outputs() == 1 and right.get_num_inputs() == 1: + return fgb.Chain([self, right]) + + # create iterators to organize the links in (input, output) of the combined graph + it_fwd = (((1, 0, r[2]), l) for (l, r) in fwd_links) + it_bwd = ((l, (1, 0, r[2])) for (r, l) in bwd_links) + + return fgb.Graph( + [[self], [right]], + {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, + ) + + def _rconnect( + self, + left: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """combine another filtergraph object and make upstream connections (worker) + + :param right: other filtergraph + :param fwd_links: a list of tuples, pairing left's output pad and self's ipnut pad + :param bwd_links: a list of tuples, pairing self's output pad and left's ipnut 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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + if not isinstance(left, fgb.Filter): + # left is more complex filtergraph object + return left._connect( + self, fwd_links, bwd_links, chain_siso, replace_sws_flags + ) + + if chain_siso and left.get_num_outputs() == 1 and self.get_num_inputs() == 1: + return fgb.Chain([left, self]) + + # create iterators to organize the links in (input, output) of the combined graph + it_fwd = (((1, 0, r[2]), l) for (l, r) in fwd_links) + it_bwd = ((l, (1, 0, r[2])) for (r, l) in bwd_links) + + return fgb.Graph( + [[left], [self]], + {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, + ) + + def _stack( + self, + other: fgb.abc.FilterGraphObject, + auto_link: bool = False, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """stack another Graph to this Graph + + :param other: other filtergraph + :param auto_link: True to connect matched I/O labels, defaults to None + :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) + :return: new filtergraph object + + Remarks + ------- + - extend() and import links + - If `auto-link=False`, common labels may be renamed. + - For more explicit linking rather than the auto-linking, use `connect()` instead. + + TO-CHECK/TO-DO: what happens if common link labels are already linked + """ + + other = fgb.as_filtergraph_object(other) + # if other is not a filter, elevate self to match first + return ( + fgb.Graph([[self], [other]]) + if isinstance(other, fgb.Filter) + else fgb.as_filtergraph_object_like(self, other)._stack( + other, auto_link, replace_sws_flags + ) + ) + + def apply(self, options, filter_id=None): + """apply new filter options + + :param options: new option key-value pairs. For ordered option, use positional index (0 + corresponds to the first option). Set value as None to drop the option. + Ordered options can only be dropped in contiguous fashion, including the + last ordered option. + :type options: dict + :param filter_id: new filter id, defaults to None + :type filter_id: str, optional + :return: new filter with modified options + :rtype: Filter + + .. note:: + + To add new ordered options, int-keyed options item must be presented in + the increasing key order so the option can be expanded one at a time. + + """ + + try: + assert isinstance(self[-1], dict) + kwopts = dict(self[-1]) + try: + opts = list(self[1:-1]) + except: + opts = [] + except: + kwopts = {} + try: + opts = list(self[1:]) + except: + opts = [] + + nopts = len(opts) + + delopts = set() + for k, v in options.items(): + if type(k) == int: + if k < 0 or k > nopts: + raise Filter.Error(f"invalid positional index [{k}]") + if v is not None: + if k < nopts: + opts[k] = v + else: + opts = [*opts, v] + nopts += 1 + elif k < 0 or k > nopts: + delopts.add(k) + else: + if v is None: + del kwopts[k] + else: + kwopts[k] = v + + if len(delopts): + delopts = sorted(list(delopts)) + o1 = delopts[0] - 1 + on = delopts[-1] + if on != nopts or len(delopts) != on - o1: + raise Filter.Error( + f"cannot drop specified ordered options {delopts}. They must be contiguous and include the last ordered option." + ) + opts = opts[:o1] + + return Filter(self[0], *opts, filter_id=filter_id, **kwopts) + + def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: + pad_pos = index[2] + return pad_pos >= 0 and pad_pos < self.get_num_inputs() + + def _output_pad_is_available(self, index: tuple[int, int, int]) -> bool: + pad_pos = index[2] + return pad_pos >= 0 and pad_pos < self.get_num_outputs() + + def _check_partial_pad_index( + self, index: tuple[int | None, int | None, int | None], is_input: bool + ) -> bool: + """True if defined values of the partial pad index are valid""" + + if any(i is not None and i > 0 for i in index[:2]): + return False + + pad = index[2] + if pad is None: + pad = 0 # use the smallest pad id + + n = self.get_num_inputs() if is_input else self.get_num_outputs() + return pad >= 0 and pad < n + + def _input_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified input pad is chainable""" + if any(i for i in index[:2]): + return False + return index[2] == self.get_num_inputs() - 1 + + def _output_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified output pad is chainable""" + if any(i for i in index[:2]): + return False + return index[2] == self.get_num_outputs() - 1 diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py new file mode 100644 index 00000000..2cd03545 --- /dev/null +++ b/src/ffmpegio/filtergraph/Graph.py @@ -0,0 +1,1225 @@ +from __future__ import annotations + +from collections import UserList +from collections.abc import Generator, Callable, Sequence + +from contextlib import contextmanager +from itertools import chain +from copy import deepcopy +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 .exceptions import * +from .GraphLinks import GraphLinks + + +__all__ = ["Graph"] + + +class Graph(fgb.abc.FilterGraphObject, UserList): + """List of FFmpeg filterchains in parallel with interchain link specifications + + Graph() to instantiate empty Graph object + + Graph(obj) to copy-instantiate Graph object from another + + Graph('...') to parse an FFmpeg filtergraph expression + + Graph(filter_specs, links, sws_flags) + to specify the compose_graph(...) arguments + + :param filter_specs: either an existing Graph instance to copy, an FFmpeg + filtergraph expression, or a nested sequence of argument + sequences to compose_filter() to define a filtergraph. + For the latter option, The last element of each filter argument + sequence may be a dict, defining its keyword arguments, + defaults to None + :type filter_specs: Graph, str, or seq(seq(filter_args)) + :param links: specifies filter links + :type links: dict, optional + :param sws_flags: specify swscale flags for those automatically inserted + scalers, defaults to None + :type sws_flags: seq of stringifyable elements with optional dict as the last + element for the keyword flags, optional + + """ + + class Error(FFmpegioError): + pass + + class FilterPadMediaTypeMismatch(Error): + def __init__(self, in_name, in_pad, in_type, out_name, out_pad, out_type): + super().__init__( + f"mismatched pad types: {in_name}:{in_pad}[{in_type}] => {out_name}:{out_pad}[{out_type}]" + ) + + class InvalidFilterPadId(Error): + def __init__(self, type, index): + super().__init__(f"invalid {type} filter pad index: {index}") + + _unc_label: str = "UNC" + + def __init__( + self, + filter_specs: ( + Sequence[fgb.Chain | str | Sequence[fgb.Filter]] + | str + | fgb.abc.FilterGraphObject + | None + ) = None, + links: ( + dict[ + str | int, + tuple[ + PAD_INDEX | Sequence[PAD_INDEX] | None, + PAD_INDEX | Sequence[PAD_INDEX] | None, + ], + ] + | GraphLinks + | None + ) = None, + sws_flags: Sequence[str] | None = None, + ): + + # 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:] + 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 isinstance(filter_specs, str): + filter_specs, links, sws_flags = filter_utils.parse_graph(filter_specs) + + if any(not len(fspec) for fspec in filter_specs): + raise ValueError( + "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) + + self._links = GraphLinks(links) + """utils.fglinks.GraphLinks: filtergraph link specifications + """ + + self.sws_flags = ( + None if sws_flags is None else fgb.Filter(["scale", *sws_flags]) + ) + """Filter|None: swscale flags for automatically inserted scalers + """ + + def get_num_chains(self) -> int: + """get the number of hains""" + return len(self) + + def get_num_filters(self, chain: int) -> int: + """get the number of filters of the specfied chain + + :param chain: id of the chain + """ + + if chain < 0 or chain >= len(self): + raise ValueError(f"{chain=} is invalid.") + return len(self[chain]) + + def resolve_pad_index( + self, + index_or_label: PAD_INDEX | str | None, + *, + is_input: bool = True, + chain_id_omittable: bool = False, + filter_id_omittable: bool = False, + pad_id_omittable: bool = False, + resolve_omitted: bool = True, + chain_fill_value: int | None = None, + filter_fill_value: int | None = None, + pad_fill_value: int | None = None, + chainable_first: bool = False, + ) -> PAD_INDEX: + """Resolve unconnected label or pad index to full 3-element pad index + + :param index_or_label: pad index set or pad label or ``None`` to auto-select + :param is_input: True to resolve an input pad, else an output pad, defaults to True + :param chain_id_omittable: True to allow ``None`` chain index, defaults to False + :param filter_id_omittable: True to allow ``None`` filter index, defaults to False + :param pad_id_omittable: True to allow ``None`` pad index, defaults to False + :param resolve_omitted: True to fill each omitted value with the prescribed fill value. + :param chain_fill_value: if ``chain_id_omittable=True`` and chain index is either not + given or ``None``, this value will be returned, defaults to None, + which returns the first available pad. + :param filter_fill_value:if ``filter_id_omittable=True`` and filter index is either not + given or ``None``, this value will be returned, defaults to None, + which returns the first available pad. + :param pad_fill_value: if ``pad_id_omittable=True`` and either ``index`` is None or + pad index is ``None``, this value will be returned, defaults to None, + which returns the first available pad. + :param chainable_first: if True, chainable pad is selected first, defaults to False + + One and only one of ``index`` and ``label`` must be specified. If the given index + or label is invalid, it raises FiltergraphPadNotFoundError. + """ + + # resolve a label string to pad index + if isinstance(index_or_label, str): # label given + label = ( + index_or_label[1:-1] + if index_or_label[0] == "[" and index_or_label[-1] == "]" + else index_or_label + ) + + try: + if is_input: + index_or_label = next( + index + for lbl, index in self.iter_input_labels( + exclude_stream_specs=True + ) + if lbl == label + ) + else: + index_or_label = next( + index + for lbl, index in self.iter_output_labels() + if lbl == label + ) + except StopIteration as exc: + raise FiltergraphPadNotFoundError( + f"{index_or_label=} is not defined on the filtergraph." + ) from exc + + # obtain 3-element tuple index (unvalidated) + return super().resolve_pad_index( + index_or_label, + is_input=is_input, + chain_id_omittable=chain_id_omittable, + filter_id_omittable=filter_id_omittable, + pad_id_omittable=pad_id_omittable, + resolve_omitted=resolve_omitted, + chain_fill_value=chain_fill_value, + filter_fill_value=filter_fill_value, + pad_fill_value=pad_fill_value, + chainable_first=chainable_first, + ) + + def _get_label(self, input: bool, index: PAD_INDEX): + + return getattr( + self._links, "find_inpad_label" if input else "find_outpad_label" + )(index) + + def compose( + self, + show_unconnected_inputs: bool = True, + show_unconnected_outputs: bool = True, + ): + """compose filtergraph + + :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 + """ + + fg = self + + # label unconnected pads + label = self._unc_label + unc_pads = {} + i = j = -1 + if show_unconnected_inputs: + for i, (index, _, _) in enumerate( + self.iter_input_pads(unlabeled_only=True) + ): + unc_pads[f"{label}{i}"] = (index, None) + if show_unconnected_outputs: + for j, (index, _, _) in enumerate( + self.iter_output_pads(unlabeled_only=True) + ): + unc_pads[f"{label}{i+j+1}"] = (None, index) + + links = {**fg._links, **unc_pads} if i >= 0 or j >= 0 else fg._links + + return filter_utils.compose_graph(fg, links, fg.sws_flags and fg.sws_flags[1:]) + + def __repr__(self): + type_ = type(self) + expr = self.compose() + nchains = len(self.data) + pos = [0] * nchains + i = n = 0 + for j, chain in enumerate(self): + for k, filter in enumerate(chain): + fstr = str(filter) + i += n + i = expr[i:].find(fstr) + i + n = len(fstr) + pos[j] = i + + pos = [expr.rfind(";", 0, i) + 1 for i in pos] + pos.append(len(expr)) + + prefix = " chain" + nzeros = floor(log10(nchains)) + 1 + 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] + if len(chain_list) > 12: + chain_list = [ + chain_list[:-4], + f"{[' ']*(len(prefix)+3+nzeros)}{expr[:pos[0]]}", + chain_list[-3:], + ] + chain_list = "\n".join(chain_list) + + return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(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()))} +""" + + def __setitem__(self, key, value): + UserList.__setitem__(self, key, fgb.as_filterchain(value, copy=True)) + # TODO purge invalid links + + def __getitem__(self, key): + """get filterchains/filter + + :param key: filterchain or filter indices + :type key: int, slice, tuple(int|slice,int|slice) + :return: selected filterchain(s) or filter + :rtype: Graph|Chain|Filter + """ + try: + return UserList.__getitem__(self, key) + except (IndexError, StopIteration) as e: + raise e + except Exception as e: + try: + assert len(key) == 2 and all((isinstance(k, int) for k in key)) + return UserList.__getitem__(self, key[0])[key[1]] + except: + raise TypeError( + "Graph indies must be integers, slices, or 2-element tuple of int" + ) + + def append(self, item: fgb.Chain | str): + + fc = fgb.as_filterchain(item, copy=True) + if not len(fc): + raise ValueError("Empty filterchain cannot be appended to filtergraph.") + self.data.append(fc) + + def extend( + self, + other: Sequence[fgb.Chain | str] | fgb.FilterGraph, + auto_link: bool = False, + force_link: bool = False, + ): + other = fgb.as_filtergraph(other) + if any(not len(c) for c in other): + raise ValueError("Empty filterchain cannot be appended to filtergraph.") + self._links.update( + other._links.map_chains(len(self)), auto_link=auto_link, force=force_link + ) + self.data.extend(other.data) + + def insert(self, i: int, item: fgb.Chain | str): + fc = fgb.as_filterchain(item) + if not len(fc): + raise ValueError("Empty filterchain cannot be appended to filtergraph.") + self.data.insert(i, fc) + self._links.adjust_chains(i, 1) + + def __delitem__(self, i: int): + + if i < 0: + i += len(self) + + # delete the chain + UserList.__delitem__(self, i) + + # delete all links with the specified chain + self._links.remove_chains([i]) + + def iter_chains( + self, + skip_if_no_input: bool = False, + skip_if_no_output: bool = False, + chainable_only: bool = False, + ) -> Generator[tuple[int, fgb.Chain]]: + """iterate over chains of the filtergraphobject + + :param skip_if_no_input: True to skip chains without available input pads, defaults to False + :param skip_if_no_output: True to skip chains without available output pads, defaults to False + :param chainable_only: True to further restrict ``skip_if_no_input`` and ``skip_if_no_input`` + arguments to require chainable input or output, defaults to False to + allow any input/output + :yield: chain id and chain object + """ + + for i, c in enumerate(self): + if ( + skip_if_no_output + and self.next_output_pad( + chain=i, filter=-1, chainable_only=chainable_only + ) + is None + ) or ( + skip_if_no_input + and self.next_input_pad( + chain=i, filter=0, chainable_only=chainable_only + ) + is None + ): + continue + + yield i, c + + def _iter_pads( + self, + iter_filter_pad: Callable, + pad_links: dict[PAD_INDEX, PAD_INDEX | str], + pad: int | None, + filter: int | None, + chain: Literal[0] | None, + exclude_chainable: bool, + chainable_first: bool, + include_connected: bool, + unlabeled_only: bool, + chainable_only: bool, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | str | None]]: + """Iterate over input pads of the filters on the filterchain + + :param filters: list of filters to iterate + :param iter_filter_pad: Filter class function to iterate on filter pads + :param pad: pad id + :param filter: filter index + :param chain: chain index + :param exclude_chainable: True to leave out the last pads + :param chainable_first: True to yield the last pad first then the rest + :param include_connected: True to include pads connected to input streams, defaults to False + :yield: filter pad index, filter object, and True if no connection + """ + + if len(self) == 0: + return + + if chain is None: + # iterate over all filters + chains = self.data + ioff = 0 + else: + try: + chains = [self.data[chain]] + except IndexError: + raise FiltergraphInvalidIndex(f"Invalid {chain=} id.") + 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( + c, + pad, + j, + exclude_chainable=exclude_chainable, + chainable_first=chainable_first, + include_connected=include_connected, + chainable_only=chainable_only, + ): + index = (i + ioff, *pidx) + + try: + assert other_pidx is None + # retrieve a connected output pad or a label if just labeled + other_pidx = pad_links[index] + except (AssertionError, KeyError): + # fails if chained or no link defined + yield index, f, other_pidx + continue + + # exclude unlinked label-only pads, including input streams + # return output label or output pad connected to + is_str = isinstance(other_pidx, str) + if (is_str and not unlabeled_only) or ( + not is_str and include_connected + ): + yield index, f, other_pidx + + def iter_input_pads( + self, + pad: int | None = None, + filter: int | None = None, + chain: int | None = None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | str | None]]: + """Iterate over input pads of the filters on the filtergraph + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :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 + :param unlabeled_only: True to leave out named inputs, defaults to False to return all inputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all inputs + :param full_pad_index: True to return 3-element index + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + for index, f, other_pidx in self._iter_pads( + fgb.Chain.iter_input_pads, + self._links.input_dict(), + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + unlabeled_only, + 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) + ): + continue + + yield index, f, other_pidx + + def iter_output_pads( + self, + pad=None, + filter=None, + chain=None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | str | None]]: + """Iterate over output pads of the filter + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :param exclude_chainable: True to leave out the last output pads, defaults to False (all avail pads) + :param chainable_first: True to yield the last output first then the rest, defaults to False + :param include_connected: True to include pads connected to output streams, defaults to False + :param unlabeled_only: True to leave out named outputs, defaults to False to return all outputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all outputs + :param full_pad_index: True to return 3-element index + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + for v in self._iter_pads( + fgb.Chain.iter_output_pads, + self._links.output_dict(), + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + unlabeled_only, + chainable_only, + ): + yield v + + def get_num_inputs(self, chainable_only=False): + return len(list(self.iter_input_pads(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 + ) -> 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 + :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): + yield label_index + + def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: + """iterate over the dangling labeled output pads of the filtergraph object + + :yield: a tuple of 3-tuple pad index and the pad index of the connected input pad if connected + """ + for label_index in self._links.iter_outputs(): + yield label_index + + def copy(self): + return Graph(self) + + def are_linked( + self, + inpad: PAD_INDEX | None, + outpad: PAD_INDEX | None, + check_input_stream: bool | str = False, + ) -> bool: + """True if given pads are linked + + :param inpad: input pad index, default to ``None`` to check if ``outpad`` is connected to any + input pad. + :param outpad: output pad index, defaults to ``None`` to check if ``inpad`` is connected to any + output pad or an input stream. + :param check_input_stream: True to check inpad is connected to an input stream, or a stream + specifier string to check the connection to a specific stream, defaults + to ``False``. + + ``ValueError`` will be raised if both ``inpad`` and ``outpad`` ``None`` or + if ``include_input_stream!=False`` and ``outpad`` is ``None``. + + """ + + try: + return self._links.are_linked(inpad, outpad, check_input_stream) + except ValueError: + raise + + def unlink( + self, + label: str | None = None, + inpad: PAD_INDEX | None = None, + outpad: PAD_INDEX | None = None, + ): + """unlink specified links + + :param label: specify all the links with this label, defaults to None + :type label: str|int, optional + :param inpad: specify the link with this inpad pad, defaults to None + :type inpad: tuple(int,int,int), optional + :param outpad: specify all the links with this outpad pad, defaults to None + :type outpad: tuple(int,int,int), optional + """ + self._links.unlink(label, inpad, outpad) + + def link( + self, + inpad: PAD_INDEX, + outpad: PAD_INDEX, + label: str | None = None, + preserve_label: Literal[False, "input", "output"] = False, + force: bool = False, + ) -> str | int: + """set a filtergraph link + + :param inpad: input pad ids + :param outpad: output pad index + :param label: desired label name, defaults to None (=reuse inpad/outpad label or unnamed link) + :param preserve_label: `False` to remove the labels of the input and output pads (default) or + `'input'` to prefer the input label or `'output'` to prefer the output + label. + :param force: True to drop conflicting existing link, defaults to False + :return: assigned label of the created link. Unnamed links gets a + unique integer value assigned to it. + + ..notes: + + - Unless `force=True`, inpad pad must not be already connected + - User-supplied label name is a suggested name, and the function could + modify the name to maintain integrity. + - If inpad or outpad were previously named, their names will be dropped + unless one matches the user-supplied label. + - No guarantee on consistency of the link label (both named and unnamed) + during the life of the object + + """ + + if label is not None: + GraphLinks.validate_label(label, is_link=False, no_stream_spec=True) + if inpad is not None: + inpad = self.resolve_pad_index(inpad, is_input=True) + try: + f = self.data[inpad[0]][inpad[1]] + assert inpad[2] >= 0 and inpad[2] < f.get_num_inputs() + except: + raise Graph.InvalidFilterPadId("input", inpad) + if outpad is not None: + outpad = self.resolve_pad_index(outpad, is_input=False) + try: + f = self.data[outpad[0]][outpad[1]] + assert outpad[2] >= 0 and outpad[2] < f.get_num_outputs() + except: + raise Graph.InvalidFilterPadId("output", outpad) + + return self._links.link(inpad, outpad, label, preserve_label, force) + + def add_label( + self, + label: str, + inpad: PAD_INDEX | None = None, + outpad: PAD_INDEX | None = None, + force: bool = None, + ) -> fgb.Graph: + """label a filter pad + + :param label: name of the new label. Square brackets are optional. + :type label: str + :param inpad: input filter pad index or a sequence of pads, defaults to None + :type inpad: tuple(int,int,int) | seq(tuple(int,int,int)), optional + :param outpad: output filter pad index, defaults to None + :type outpad: tuple(int,int,int), optional + :param force: True to delete existing labels, defaults to None + :type force: bool, optional + :return: actual label name + :rtype: str + + Only one of inpad and outpad argument must be given. + + If given label already exists, no new label will be created. + + If label has a trailing number, the number will be dropped and replaced with an + internally assigned label number. + + """ + + if label[0] == "[" and label[-1] == "]": + label = label[1:-1] + + GraphLinks.validate_label( + label, is_link=False, no_stream_spec=outpad is not None + ) + if inpad is not None: + GraphLinks.validate_pad_idx_pair((inpad, None)) + for d in GraphLinks.iter_inpad_ids(inpad): + try: + f = self.data[d[0]][d[1]] + n = f.get_num_inputs() + assert d[2] >= 0 and d[2] < (n - 1 if d[1] > 0 else n) + except: + raise Graph.InvalidFilterPadId("input", d) + elif outpad is not None: + GraphLinks.validate_pad_idx(outpad) + try: + f = self.data[outpad[0]][outpad[1]] + assert outpad[2] >= 0 and outpad[2] < f.get_num_outputs() + except: + raise Graph.InvalidFilterPadId("output", outpad) + else: + raise Graph.Error("filter pad index is not given") + + self._links.create_label(label, inpad, outpad, force) + + return self + + def remove_label(self, label: str): + """remove an input/output label + + :param label: linkn label + """ + + self._links.remove_label(label) + + def rename_label(self, old_label: str, new_label: str) -> str | None: + """rename an existing link label + + :param old_label: existing label named + :param new_label: new desired label name or None to make it unnamed label + :return: actual label name or None if unnamed + + Note: + + - `new_label` is not guaranteed, and actual label depends on existing labels + + """ + + if not (isinstance(old_label, str) and old_label): + raise Graph.Error(f"old_label [{old_label}] must be a string.") + + if new_label is not None and not (isinstance(new_label, str) and new_label): + raise Graph.Error(f"new_label [{new_label}] must be None or a string.") + + # return the actual label or None if unnamed + return new_label or self._links.rename(old_label, new_label) + + def is_chain_siso( + self, + chain_id: int, + check_input: bool = True, + check_output: bool = True, + check_link: bool = False, + ) -> bool: + """True if specified filter chain is single-input and single-output + + :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: + chain = self[chain_id] + except IndexError: + raise ValueError(f"{chain_id=} is an invalid chain id.") + + if check_input and chain.get_num_inputs() != 1: + return False + + if check_output and chain.get_num_outputs() != 1: + return False + + return not (check_link and self._links.chain_has_link(chain_id)) + + def _stack( + self, + other: fgb.abc.FilterGraphObject, + auto_link: bool = False, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """stack another Graph to this Graph + + :param other: other filtergraph + :param auto_link: True to connect matched I/O labels, defaults to None + :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) + :return: new filtergraph object + + Remarks + ------- + - extend() and import links + - If `auto-link=False`, common labels may be renamed. + - For more explicit linking rather than the auto-linking, use `connect()` instead. + + TO-CHECK/TO-DO: what happens if common link labels are already linked + """ + + n = len(self) + m = len(other) + + if not m: # other is empty + return Graph(self) + if not n: # self is empty + 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." + ) + + try: + fg._links.update( + other._links.map_chains(len(self), False), auto_link=auto_link + ) + except Exception as e: + if auto_link: + raise + else: + raise Graph.Error(e) from e + + fg.data.extend(other) + + else: + # if other is not filtergraph, copy and append the new chain + fg = Graph(self) + fg.append(other) + + return fg + + def _connect( + self, + right: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """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 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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + 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]) + + 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): + + # sift through the connections for chainable and unchainables + n0 = fg.get_num_chains() # chain index offset + + # 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) + + right_links = right._links.drop_labels(tuple(fg._links.keys())).map_chains( + lut, False + ) + + # 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 replace_sws_flags and right.sws_flags: + fg.sws_flags = right.sws_flags + + return fg + + def _rconnect( + self, + left: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """combine another filtergraph object and make upstream connections (worker) + + :param right: other filtergraph + :param fwd_links: a list of tuples, pairing left's output pad and self's ipnut pad + :param bwd_links: a list of tuples, pairing self's output pad and left's ipnut 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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + # 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 + + def _iter_io_pads(self, is_input, how, ignore_labels=False): + """Iterates input/output pads of the filtergraph + + :param is_input: True if input; False if output + :type is_input: bool + :param how: pad selection method + + ----------- ------------------------------------------------------------------- + 'chainable' only chainable pads. + 'per_chain' one pad per chain. Source and sink chains are ignored. + 'all' joins all input pads and output pads + ----------- ------------------------------------------------------------------- + + :type how: "chainable"|"per_chain"|"all" + :param ignore_labels: True to return labaled (but not linked) pads, defaults to False + :type ignore_labels: bool, optional + :yield: pad index, pad label, parent filter + :rtype: tuple(tuple(int,int,int), label, Filter) + """ + if how is None or how in ("per_chain", "all"): + generator = self.iter_input_pads if is_input else self.iter_output_pads + + return ( + generator() + if how == "all" + else ( + info + for info in ( + next(generator(unlabeled_only=not ignore_labels, chain=c), None) + for c in range(len(self.data)) + ) + if info is not None + ) + ) + elif how == "chainable": + return (self.iter_input_pads if is_input else self.iter_output_pads)( + unlabeled_only=not ignore_labels, chainable_only=True + ) + else: + raise ValueError(f"unknown how argument value: {how}") + + @contextmanager + def as_script_file(self): + """return script file containing the filtergraph description + + :yield: path of a temporary text file with filtergraph description + :rtype: str + + This method is intended to work with the `filter_script` and + `filter_complex_script` FFmpeg options, by creating a temporary text file + containing the filtergraph description. + + .. note:: + Only use this function when the filtergraph description is too long for + OS to handle it. Presenting the filtergraph with a `filter_complex` or + `filter` option to FFmpeg is always a faster solution. + + Moreover, if `stdin` is available, i.e., not for a write or filter + operation, it is more performant to pass the long filtergraph object + to the subprocess' `input` argument instead of using this method. + + Use this method with a `with` statement. How to incorporate its output + with `ffmpegprocess` depends on the `as_file_obj` argument. + + :Example: + + The following example illustrates a usecase for a video SISO filtergraph: + + .. code-block:: python + + # assume `fg` is a SISO video filter Graph object + + with fg.as_script_file() as script_path: + ffmpegio.ffmpegprocess.run( + { + 'inputs': [('input.mp4', None)] + 'outputs': [('output.mp4', {'filter_script:v': script_path})] + }) + + As noted above, a performant alternative is to use an input pipe and + feed the filtergraph description directly: + + .. code-block:: python + + ffmpegio.ffmpegprocess.run( + { + 'inputs': [('input.mp4', None)] + 'outputs': [('output.mp4', {'filter_script:v': 'pipe:0'})] + }, + input=str(fg)) + + Note that ``pipe:0`` must be used and not the shorthand ``'-'`` unlike + the input url. + + """ + + # populate the file with filtergraph expression + temp_file = NamedTemporaryFile("wt", delete=False) + temp_file.write(str(self)) + temp_file.close() + + try: + # present the file to the caller in the context + yield temp_file.name + + finally: + if temp_file: + os.remove(temp_file.name) + + 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) + ): + # already connected + return False + + # check the chain + return self[index[0]]._input_pad_is_available((0, *index[1:])) + + 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()): + # already connected + return False + + return self[index[0]]._output_pad_is_available((0, *index[1:])) + + def _check_partial_pad_index( + self, index: tuple[int | None, int | None, int | None], is_input: bool + ) -> bool: + """True if defined values of the partial pad index are valid""" + + chain = index[0] + if chain is not None and (chain < 0 or chain >= len(self)): + return False + + return any( + c._check_partial_pad_index((None, *index[1:]), is_input) for c in self + ) + + def _input_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified input pad is chainable""" + + if self.are_linked(index, None, True): + return False + + i = index[0] + try: + chain = self[i] + except IndexError: + # invalid chain index + return False + else: + return chain._input_pad_is_chainable((0, *index[1:])) + + def _output_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified output pad is chainable""" + + if self.are_linked(None, index): + return False + + i = index[0] + try: + chain = self[i] + except IndexError: + # invalid chain index + return False + else: + return chain._output_pad_is_chainable((0, *index[1:])) + + def __ior__(self, other): + if len(other): + fg = self | other if len(self) else Graph(other) + self.data = fg.data + self._links = fg._links + return self + + def __iadd__(self, other): + + if len(other): + fg = self + other if len(self) else Graph(other) + self.data = fg.data + self._links = fg._links + return self + + def __irshift__(self, other): + + if len(other): + fg = self >> other if len(self) else Graph(other) + self.data = fg.data + self._links = fg._links + return self diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py new file mode 100644 index 00000000..bfdb7f2f --- /dev/null +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -0,0 +1,975 @@ +from __future__ import annotations + +import re +from collections import UserDict +from collections.abc import Generator, Mapping, Sequence, Callable + + +from ..utils import is_stream_spec +from ..errors import FFmpegioError +from .typing import PAD_INDEX, PAD_PAIR, Literal + +""" + +Filtergraph Link Rules: +- One-to-one connection between an output pad of a filter to an input pad of another filter +- Multiple input pad may be connected to a same input stream +- Output labels must be unique, no duplicates + +GraphLinks class design: +- Organize the links as a ``dict[str, tuple[PAD_INDEX|list[PAD_INDEX]|None,PAD_INDEX|None]]`` +- key: link label, value=(``inpad``, ``output``): a tuple of the linked input and output pad indices +- If label is an input stream and links to input pads, ``inpad`` maybe a list of input pad and, + ``outpad`` is always ``None`` +- To represent a link is not yet connected, ``inpad`` or ``outpad`` may be ``None`` but not both + at the same time + +""" + + +class GraphLinks: ... + + +class GraphLinks(UserDict): + + class Error(FFmpegioError): + pass + + @staticmethod + def iter_inpad_ids( + inpads: PAD_INDEX | list[PAD_INDEX] | None, include_labels: bool = False + ) -> Generator[PAD_INDEX]: + """helper generator to work inpads ids + + :param inpads: inpads pad id or ids + :type inpads: tuple(int,int,int) | seq(tuple(int,int,int)) | None + :param include_labels: True to yield None for each unconnected labels, defaults to False to skip None inpads + :param include_labels: bool, optional + :yield: individual inpad id, immediately exits if None + :rtype: tuple(int,int,int)|None + """ + + if inpads is None: + if include_labels: + yield inpads + elif isinstance(inpads[0], int): + yield inpads + else: + for inpad in inpads: + if not (inpads is None and include_labels): + yield inpad + + @staticmethod + def validate_label( + label: str | int, is_link: bool = False, no_stream_spec: bool = False + ): + if isinstance(label, int): + if not is_link: + raise GraphLinks.Error( + "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: + 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 + + @staticmethod + 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") + + if not ( + isinstance(id, (tuple)) + and len(id) == 3 + and all((isinstance(i, int) and i >= 0 for i in id)) + ): + raise GraphLinks.Error( + f"{id=} is not a valid filter pad ID. Filter pad ID must be a 3-element tuple: (chain id, filter id, pad id)" + ) + + @staticmethod + def validate_pad_idx_pair(ids: PAD_PAIR): + + try: + assert len(ids) == 2 + except: + raise GraphLinks.Error( + f"Link value must be a 2-element tuple with inpad and outpad pad ids" + ) + + (inpad, outpad) = ids + GraphLinks.validate_pad_idx(outpad) + + 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.") + + 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.") + GraphLinks.validate_pad_idx(d) + + @staticmethod + def validate_item(label: str | int, pads: PAD_PAIR): + + GraphLinks.validate_pad_idx_pair(pads) # this fails if None-None pair + + inpad_given = pads[0] is not None + outpad_given = pads[1] is not None + + GraphLinks.validate_label( + label, is_link=inpad_given and outpad_given, no_stream_spec=outpad_given + ) + + @staticmethod + def validate(data: dict[str | int, PAD_PAIR]): + + inpads = set() # inpad cannot be repeated + + # validate each link + for label, pads in data.items(): + + if ( + not is_stream_spec(label) + and pads[0] is not None + and isinstance(pads[0][0], tuple) + ): + raise GraphLinks.Error( + "Only stream specifier labels can have multiple input pads." + ) + + GraphLinks.validate_item(label, pads) + for d in GraphLinks.iter_inpad_ids(pads[0]): + # inpad pad id must be unique + if d in inpads: + raise GraphLinks.Error( + f"Duplicate entries of inpad pad id {d} found (must be unique)" + ) + if d is not None: + inpads.add(d) + + @staticmethod + def format_value(inpads, outpad, modifier=None): + + if modifier: + if outpad is not None: + outpad = modifier(outpad) + modified = tuple( + ( + d if d is None else modifier(d) + for d in GraphLinks.iter_inpad_ids(inpads, True) + ) + ) + n = len(modified) + inpads = None if n < 1 else modified[0] if n < 2 else modified + elif inpads is not None and isinstance(inpads[0], tuple): + # make sure inpads sequence of ids is a tuple + inpads = tuple(inpads) + + return (inpads, outpad) + + # regex pattern to identify a label with a trailing number + AutoLabelPattern = re.compile(r"^L\d+?$") + + def __init__( + self, + links: dict[str | int, PAD_PAIR] | GraphLinks | None = None, + ): + + # calls update() if links set + super().__init__() + + # validate input arg + if isinstance(links, GraphLinks): + self.data = links.data.copy() + elif links is not None: + links = {k: self.format_value(*v) for k, v in links.items()} + self.update(links) + + def link( + self, + inpad: PAD_INDEX, + outpad: PAD_INDEX, + label: str | None = None, + preserve_label: Literal[False, "input", "output"] = False, + force: bool = False, + ) -> str | int: + """set a filtergraph link from outpad to inpad + + :param inpad: input pad ids + :param outpad: output pad id + :param label: desired label name, defaults to None (=reuse inpad/outpad label or unnamed link) + :param preserve_label: `False` to remove the labels of the input and output pads (default) or + `'input'` to prefer the input label or `'output'` to prefer the output + label. + :param force: True to drop conflicting existing link, defaults to False + :return: assigned label of the created link. Unnamed links gets a + unique integer value assigned to it. + + notes: + - Unless `force=True`, inpad pad must not be already connected + - User-supplied label name is a suggested name, and the function could + modify the name to maintain integrity. + - If inpad or outpad were previously named, their names will be dropped + unless one matches the user-supplied label. + - No guarantee on consistency of the link label (both named and unnamed) + during the life of the object + + """ + + self.validate_pad_idx(inpad, none_ok=False) + self.validate_pad_idx(outpad, none_ok=False) + + # check if inpad already exists and resolve conflict if there is one + in_label = self.find_inpad_label(inpad) + if in_label is not None: + if not (force or self.is_input(in_label)): + raise GraphLinks.Error(f"input pad {inpad} already linked.") + if (force and self.is_linked(in_label)) or preserve_label != "input": + # if in_label has multi-inpads, cannot reuse it + self.unlink(inpad=inpad) + in_label = None + + # check if output label already exists. pick the first match + out_label = self.find_outpad_label(outpad) + if out_label is not None: + if not force and self.is_linked(out_label): + raise GraphLinks.Error(f"output pad {outpad} already linked.") + if (force and self.is_linked(out_label)) or preserve_label != "output": + # if in_label has multi-inpads, cannot reuse it + self.unlink(outpad=outpad) + out_label = None + + # finalize the label name + # if not defined by user, select new label to be inpad or outpad label if found + + if label is None and preserve_label is not False: + label = ( + out_label + if preserve_label == "output" or in_label is None + else in_label + ) + + if not (in_label or out_label): + # new label, resolve + label = self._resolve_label(label, force) + + # create the new link (overwrite if forced) + self.data[label] = (inpad, outpad) + + return label + + def unlink(self, label=None, inpad=None, outpad=None): + """unlink specified links + + :param label: specify all the links with this label, defaults to None + :type label: str|int, optional + :param inpad: specify the link with this inpad pad, defaults to None + :type inpad: tuple(int,int,int), optional + :param outpad: specify all the links with this outpad pad, defaults to None + :type outpad: tuple(int,int,int), optional + """ + if label is not None: + del self.data[label] + if outpad is not None: + label = self.find_outpad_label(outpad) + if label is not None: + del self.data[label] + if inpad is not None: + label = self.find_inpad_label(inpad) + inpads, outpad = self.data[label] + if isinstance(inpads[0], int): # unique label + del self.data[label] + else: # multi-inpads label + # depends on how many left + inpads = tuple((d for d in inpads if d != inpad)) + self.data[label] = ( + (inpads, outpad) if len(inpads) > 1 else (inpads[0], outpad) + ) + + if isinstance(label, int): + self._refresh_autolabels() + + def _refresh_autolabels(self): + + new_id = old_id = 0 + for new_id, old_id in enumerate( + i for i, label in enumerate(self) if isinstance(label, int) + ): + self.data[new_id] = self.data[old_id] + + for id in range(new_id + 1, old_id + 1): + del self.data[id] + + def _resolve_label( + self, + label: str | int | None, + force: bool = False, + check_stream_spec: bool = True, + ) -> str | int: + """check the label name for duplicate, adjust as needed + + :param label: suggested new label name. If int or `"L"` or `None`, the given 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 + :return: validated label name/id + """ + + if isinstance(label, (type(None), int)) or self.AutoLabelPattern.match(label): + try: + return max(i for i in self if isinstance(i, int)) + 1 + except ValueError: + return 0 + + if check_stream_spec and is_stream_spec(label): + return label + + if not force and label in self: + raise GraphLinks.Error(f"{label=} is already in use.") + + self.validate_label(label) + + return label + + def __getitem__(self, key: str | int) -> PAD_PAIR: + """get link item by label or by inpad pad id tuple + + :param key: label name or inpad pad id tuple (int,int,int) + :return: link inpads-outpad pair, if input pad is `None`, the key is an + output label or if output pad is `None`, the key is an input label + """ + try: + # try as label first + return super().__getitem__(key) + except Exception as e: + # try as inpad id + label = self.find_inpad_label(key) + if label is None: + raise e + return (label, self.data[label][1]) + + def __setitem__(self, key: str | int, value: PAD_PAIR): + # can only set named key + if value[0] is None: + self.create_label(key, outpad=value[1], force=True) + elif value[1] is None: + self.create_label(key, inpad=value[0], force=True) + else: + self.link(value[0], value[1], label=key, force=True) + + def is_linked(self, label): + """True if label specifies a link + + :param label: link label + :type label: str + :return: True if label is a link + :rtype: bool + + If multi-inpad label, True if any inpad is not None + """ + lnk = self.data.get(label, (None, None)) + return lnk[1] is not None and any(self.iter_inpad_ids(lnk[0])) + + def is_input(self, label): + """True if label specifies an input + + :param label: link label + :type label: str + :return: True if label is an input + :rtype: bool + """ + lnk = self.data.get(label, None) + return lnk and lnk[1] is None + + def is_output(self, label): + """True if label specifies an output + + :param label: link label + :type label: str + :return: True if label is an output + :rtype: bool + + If multi-inpad label, True if any inpad is None + """ + lnk = self.data.get(label, None) + return lnk and any((d is None for d in self.iter_inpad_ids(lnk[0], True))) + + def iter_input_pads( + self, label: str | None = None + ) -> Generator[str, PAD_INDEX, PAD_INDEX | None]: + """Iterate over all link elements, possibly separating inpad ids with + the same label + + :param label: to iterate only on this label, defaults to None (all frames) + :type label: str, optional + :yield: a full link definition (inpad or outpad may be None if input or output label, respectively) + :rtype: tuple of label, inpad id, and outpad id + """ + + def iter(label, inpad, outpad): + for d in self.iter_inpad_ids(inpad, True): + yield (label, d, outpad) + + if label is None: + for label, (inpad, outpad) in self.data.items(): + for v in iter(label, inpad, outpad): + yield v + else: + for v in iter(label, *self.data[label]): + yield v + + def iter_links( + self, label: str | None = None, include_input_stream: bool = False + ) -> Generator[tuple[str, PAD_INDEX, PAD_INDEX | None]]: + """Iterate over only actual links, separating inpad ids with + the same input stream + + :param label: to iterate only on this label, defaults to None (all frames) + :param include_input_stream: True to include input pads connected to input streams. + :yield: label, input pad, and output pad of a link + """ + + def iter(label, inpad, outpad): + if outpad is not None or (include_input_stream and is_stream_spec(label)): + for d in self.iter_inpad_ids(inpad): + yield (label, d, outpad) + + if label is None: + for label, (inpad, outpad) in self.data.items(): + for v in iter(label, inpad, outpad): + yield v + else: + for v in iter(label, *self.data[label]): + yield v + + def iter_inputs( + self, exclude_stream_specs: bool = True + ) -> 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 + :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)): + for d in self.iter_inpad_ids(inpad): + yield (label, d) + + def iter_input_streams(self) -> Generator[tuple[str, PAD_INDEX]]: + """Iterate over input stream labels, possibly repeating the same label if shared among + multiple input pad ids + + :yield: label and pad index + """ + for label, (inpad, outpad) in self.data.items(): + if outpad is None and is_stream_spec(label): + for d in self.iter_inpad_ids(inpad): + yield (label, d) + + def iter_outputs(self) -> Generator[tuple[str, PAD_INDEX]]: + """Iterate over only output labels + + :yield: a full output definition + """ + + # iterate over all labels + for label, (inpad, outpad) in self.data.items(): + if inpad is None: + yield (label, outpad) + + def input_dict(self) -> dict[PAD_INDEX, PAD_INDEX | str]: + """Return the link table sorted by the input pad indices + + The value of the returned dict is either the connected output pad index + if linked or a string if input pad is unconnected. Unconnected output + labels are excluded in the returned dict. + + :see also: + ``Graph.iter_input_pads`` + """ + + return { + d: label if outpad is None else outpad + for label, (inpad, outpad) in self.data.items() + if inpad is not None + for d in self.iter_inpad_ids(inpad) + } + + def output_dict(self) -> dict[PAD_INDEX, PAD_INDEX | str]: + """return the link table sorted by the output pad indices + + The value of the returned dict is either the connected input pad index + if linked or a label string if unconnected labels. Unconnected input + labels are excluded in the returned dict. + """ + + return { + outpad: label if inpad is None else inpad + for label, (inpad, outpad) in self.data.items() + if outpad is not None + } + + def find_inpad_label(self, inpad: PAD_INDEX) -> str | int | None: + """get label of an input pad id + + :param inpad: input filter pad id + :return: found label or None if no match found + """ + try: + return next( + ( + label + for label, dst1, _ in self.iter_input_pads() + if dst1 is not None and inpad == dst1 + ), + None, + ) + except StopIteration: + return None + + def find_outpad_label(self, outpad: PAD_INDEX) -> str | int | None: + """get labels of a source/output pad id + + :param inpad: output filter pad id + :return: found label or None if outpad is None + """ + try: + return next( + label + for label, (_, src1) in self.data.items() + if src1 is not None and outpad == src1 + ) + except StopIteration: + return None + + def are_linked( + self, + inpad: PAD_INDEX | None, + outpad: PAD_INDEX | None, + check_input_stream: bool | str = False, + ) -> bool: + """True if given pads are linked + + :param inpad: input pad index, default to ``None`` to check if ``outpad`` is connected to any + input pad. + :param outpad: output pad index, defaults to ``None`` to check if ``inpad`` is connected to any + output pad or an input stream. + :param check_input_stream: True to check inpad is connected to an input stream, or a stream + specifier string to check the connection to a specific stream, defaults + to ``False``. + + ``ValueError`` will be raised if both ``inpad`` and ``outpad`` ``None`` or + if ``include_input_stream!=False`` and ``outpad`` is ``None``. + + """ + + if isinstance(check_input_stream, str): + # check for a specific input stream + if outpad is not None: + raise ValueError( + f"Both {outpad=} and {check_input_stream=} cannot be specified at the same time." + ) + return any( + inpad == d for _, d, _ in self.iter_input_pads(check_input_stream) + ) + else: + if inpad is None and outpad is None: + raise ValueError(f"At least one of inpad or outpad must be specified.") + + # check internal links first + it_links = self.iter_links() + + # single check for a specific outpad + if outpad is not None: + return any( + (outpad == s for _, _, s in it_links) + if inpad is None + else (outpad == s and inpad == d for _, d, s in it_links) + ) + + # possible 2-step check for an arbitrary ouput + + # first check internal links + res = any(inpad == d for _, d, _ in it_links) + # then check for input stream if no link was found + return ( + any(inpad == d for _, d in self.iter_input_streams()) + if check_input_stream and not res and outpad is None + else res + ) + + 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""" + for inpads, outpad in self.values(): + if check_output and outpad and outpad[0] == chain_id: + return True + if check_input and any( + inpad[0] == chain_id for inpad in self.iter_inpad_ids(inpads) + ): + return True + return False + + def create_label( + self, + label: str, + inpad: PAD_INDEX | Sequence[PAD_INDEX] | None = None, + outpad: PAD_INDEX | None = None, + force: bool = False, + ) -> str: + """label a filter pad + + :param label: name of the new label or input stream specifier (for input label only) + :param inpad: input filter pad id (or a sequence of ids), defaults to None + :param outpad: output filter pad id, defaults to None + :param force: True to delete existing labels, defaults to None + :return: created label name + + Only one of inpad and outpad argument must be given. + + If given label already exists, no new label will be created. + + If label has a trailing number, the number will be dropped and replaced with an + internally assigned label number. + + """ + + if not isinstance(label, str): + raise ValueError(f"{label=} must be a string.") + + if (outpad is None) == (inpad is None): + raise ValueError("outpad or inpad (but not both) must be given.") + + is_stspec = is_stream_spec(label) + if not is_stspec: + label = self._resolve_label(label, force=force, check_stream_spec=False) + + label_in_use = label in self + + # check if inpad already exists and resolve conflict if there is one + if outpad: + if is_stspec: + raise ValueError( + "stream specifier ({label}) cannot be specified as an output label." + ) + + pad_in_use = self.find_outpad_label(outpad) + + if label == pad_in_use: + # already labeled as specified + return label + + else: + pad_in_use = self.find_inpad_label(inpad) + + if is_stspec: + # multiple connections allowable (always use tuple to store even if 1) + + inpad0 = self.data.get(label, (None,))[0] + # just in case + if inpad0 is None: + inpad0 = () + elif isinstance(inpad0[0], int): + inpad0 = (inpad0,) + inpad = (*inpad0, *(inpad if isinstance(inpad[0], tuple) else (inpad,))) + label_in_use = False # OK to overwrite + if pad_in_use == label: + pad_in_use = None + elif label == pad_in_use: + return label + + if force: + if pad_in_use: + del self[pad_in_use] + else: + if label_in_use: + raise GraphLinks.Error(f"{label=} is already in use") + if pad_in_use: + raise GraphLinks.Error( + f"{pad_in_use=} is already using the specified pad: {inpad or outpad}" + ) + + self.data[label] = (inpad, outpad) + return label + + def remove_label(self, label: str, inpad: PAD_INDEX | None = None): + """remove an input/output label + + :param label: unconnected link label + :param inpad: (multi-input label only) specify the input filter pad id + + Removing an input label by default removes all associated filter pad ids + unless `inpad` is specified. + + """ + + if isinstance(label, int): + raise ValueError( + f"{label=} must be str. Use `unlink` to remove auto-numbered links." + ) + + try: + inpads, outpad = self.data[label] + except: + raise GraphLinks.Error(f"{label} is not a valid link label.") + + if inpads is None or (outpad is None and inpad is None): + # simple in/out label + del self.data[label] + else: + # possible for an output label coexisting with link labels + inpads = tuple(self.iter_inpad_ids(inpads, True)) + new_dsts = tuple( + (d for d in inpads if d is not None) + if inpad is None + else (d for d in inpads if d is not None and d != inpad) + ) + n = len(new_dsts) + if n == len(inpads): + raise GraphLinks.Error( + f"no specified input labels found: {label} (inpad={inpad})." + ) + + if n < 1: + del self.data[label] + else: + self.data[label] = ( + (new_dsts[0], outpad) if n < 2 else (new_dsts, outpad) + ) + + def rename(self, old_label: str, new_label: str, force: bool = False) -> str: + """rename a label + + :param old_label: existing label (named or unnamed) + :param new_label: new label name (possibly appended with a number if the label already exists) + :param force: True to overwrite existing link by the same name as the `new_label` + :return: renamed label name + """ + v = self.data[old_label] + label = self._resolve_label(new_label, force) + del self.data[old_label] + self.data[label] = v + return label + + def update( + self, + other: GraphLinks | dict[str | int, PAD_PAIR], + auto_link: bool = False, + force: bool = False, + validate: bool = True, + ): + """Update the links with the label/id-pair pairs from other, overwriting existing keys. Return None. + + :param other: other object to copy existing items from + :param auto_link: `True` to connect matching input-output labels, defaults to False + :param preserve_label: `False` to remove the labels of the input and output pads (default) or + `'input'` to prefer the input label or `'output'` to prefer the output + label. + :param force: True to overwrite existing link inpad id, defaults to False + :param validate: False to skip the validation of the new links if not given as another `GraphLinks`, + defaults to True + :returns: dict of given key to the actual labels assigned + """ + + 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") + self.validate(other) + + # set aside labels + labels = { + l: is_input + for l, (i, o) in other.items() + if ((is_input := o is None) or i is None) + } + + # create a working copy + fglinks = GraphLinks() + fglinks.data = self.data.copy() + + # add all the links + for l, (i, o) in other.items(): + if l not in labels: + fglinks.link(i, o, l, force=force) + + # add all the labels + for l, is_input in labels.items(): + i, o = other[l] + add_label = not auto_link + if auto_link: + if is_input and self.is_output(l): + fglinks.link(i, fglinks[l][1], preserve_label="output") + elif not is_input and self.is_input(l): + fglinks.link(fglinks[l][0], o, preserve_label="input") + else: + add_label = True + if add_label: + fglinks.create_label(l, i, o, force) + + # finalize + self.data = fglinks.data + + def _modify_pad_ids(self, select: Callable, adjust: Callable): + """generic pad id modifier + + :param select: function to select a pad id to modify: select(id)->bool + :param adjust: function to adjust the selected pad id: adjust(id)->new_id + + """ + + def adjust_pair(inpads, outpad): + if outpad is not None and select(outpad): + outpad = adjust(outpad) + if inpads is not None: + if isinstance(inpads[0], int): + if select(inpads): + inpads = adjust(inpads) + else: + inpads = tuple(adjust(d) if select(d) else d for d in inpads) + return (inpads, outpad) + + self.data = {label: adjust_pair(*value) for label, value in self.data.items()} + + def adjust_chains(self, pos: int, len: int): + """insert/delete contiguous chains from fg + + :param pos: position of the first chain + :param len: number of chains to be inserted (if positive) or removed (if negative) + """ + + select = lambda pid: pid[0] >= pos # select all chains at or above pos + adjust = lambda pid: (pid[0] + len, *pid[1:]) + self._modify_pad_ids(select, adjust) + + def adjust_filters(self, chain_id: int, pos: int, len: int): + """insert/delete contiguous filters from specified filter chain + + :param chain_id: id of the filter chain to be adjusted + :param pos: position of the first chain + :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 all chains at or above pos + adjust = lambda pid: (pid[0], pid[1] + len, pid[2]) + self._modify_pad_ids(select, adjust) + + def remove_chains(self, chains: Sequence[int]): + """insert/delete contiguous chains from fg + + :param chains: positions of the chains that are removed + """ + + if not len(chains): + return # nothing to remove + + chains = list(enumerate(sorted(set(chains))))[::-1] + + def adj(pid): + return ( + pid[0] - next((i + 1 for i, v in chains if v < pid[0]), 0), + *pid[1:], + ) + + select = lambda pid: pid[0] >= chains[0][1] # select all chains at or above pos + self._modify_pad_ids(select, adj) + + def map_chains( + self, mapper: int | Mapping[int:int], validate_new: bool = True + ) -> 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 + + """ + + # check for duplicate value + if isinstance(mapper, int): + + class OffsetMapper: + def __init__(self, offset): + self._off = offset + + def __contains__(self, _): + # applies to all + return True + + def __getitem__(self, i): + return i + self._off + + 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) + + fglinks = GraphLinks() + fglinks.data = { + label: pair + for label, value in self.data.items() + if (pair := adjust_pair(*value)) is not None + } + return fglinks + + def drop_labels(self, labels: Sequence[str], keep_links: bool = True) -> GraphLinks: + """create new graph links without specified labels + + :param labels: labels to be dropped + :param keep_links: True to keep all the links as auto-labeled, defaults to True + :return: _description_ + """ + + 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 None + else: + return k + + fglinks = GraphLinks() + fglinks.data = { + knew: v for k, v in self.items() if (knew := keep(k)) is not None + } + return fglinks diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py new file mode 100644 index 00000000..264a77f6 --- /dev/null +++ b/src/ffmpegio/filtergraph/__init__.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +"""ffmpegio.filtergraph module - FFmpeg filtergraph classes + + Arithmetic Filtergraph Construction + =================================== + + .. list-table:: Supported Arithmetic Operators + :widths: 15 10 30 + :header-rows: 1 + + --------------------------------- ------------------------------------------------------------ + Operation Description Related Methods + ------------------------------ ------------------------------------------------------------ + `+` operator Chaining/join operator, supports scalar expansion + `Filter + Filter -> Chain` Create a filterchain from 2 filters + `Chain + Filter -> Chain` Append filter to filterchain + `Filter + Chain -> Chain` Prepend filter to filterchain + `Chain + Chain -> Chain` Concatenate filterchains + `Filter + Graph -> Graph` Prepend filer to first available input of each chain + `Graph + Filter -> Graph` Append filter to first available output of each chain + `Graph + Chain -> Graph` Append filterchain to first available input of each chain + `Chain + Graph -> Graph` Prepend filterchain to first available output of each chain + `Graph + Graph -> Graph` Join 2 graphs by matching their inputs and outputs in order + + `*` operator Multiplicate-n-stacking operator + `Filter * int -> Graph` Stacking the filters (int) times + ` Chain * int -> Graph` Stacking the chain (int) times + ` Graph * int -> Graph` Stacking the input graph (int) times + + `|` operator Stacking operator + `Filter | Filter -> Graph` Stacking the filters + ` Chain | Filter -> Graph` Stacking chain and filter + `Filter | Chain -> Graph` Stacking filter and chain + ` Chain | Chain -> Graph` Stacking the filterchains + `Filter | Graph -> Graph` Prepend filter as a new chain + ` Graph | Filter -> Graph` Appendd filter as a new chain + ` Graph | Chain -> Graph` Stack graph and chain + ` Chain | Graph -> Graph` Stack + ` Graph | Graph -> Graph` Stack filtergraphs + + left `>>` operator Input labeling or attach input filter/chain + ` str >> Filter -> Graph` Label first available input pad* + ` str >> Chain -> Graph` Label first available input pad* + ` str >> Graph -> Graph` Label first available chainable input pad* + ` Filter >> Graph -> Graph` Attach filter output to first available input pad + ` Chain >> Graph -> Graph` Adding Chain to itself int times + `(_,Index) >> Filter -> Graph` Specify input pad + `(_,Index) >> Chain -> Graph` Specify input pad of the first filter + `(_,Index) >> Graph -> Graph` Specify input pad + + right `>>` operator Output labeling or attach output filter/chain + `Filter >> str -> Graph` Label first available output pad* + ` Chain >> str -> Graph` Label first available output pad* + ` Graph >> str -> Graph` Label first available chainable output pad* + ` Graph >> Filter -> Graph` Attach filter to the first + ` Graph >> Chain -> Graph` Adding Chain to itself int times + `Filter >> (Index,_) -> Graph` Specify output pad + ` Chain >> (Index,_) -> Graph` Specify output pad + ` Graph >> (Index,_) -> Graph` Specify output pad + ------------------------------ ------------------------------------------------------------ + +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. + +.. 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: + +.. code-block::python + + fg = '[in]' >> Filter('scale',0.5,-1) + 'setsar=1/1' >> '[out]' + +Filter Pad Indexing +=================== + +Both input and output filter pads can be specified in a number of ways: + + --------------------- ----------------------------------------------------------------------- + Syntax Description + --------------------- ----------------------------------------------------------------------- + int n Specifies the n-th pad of the first available filter + (int m, int n) Specifies the n-th pad of the m-th filter of the first available chain + (int k, int m, int n) Specifies the n-th pad of the m-th filter of the k-th chain + str label Specifies the pad associated with the link label (no bracket necessary) + --------------------- ----------------------------------------------------------------------- + + Except for the label indexing, which is a Graph specific feature, all the indexing syntax may be + used by `Filter`, `Chain`, or `Graph` class instances. An irrelevant field (e.g., chain or filter + indexing for a `Filter` instance) will be ignored. Standard negative-number indexing is supported. + +""" + + +from .. import path +from ..caps import filters as list_filters +from . import abc +from .Filter import Filter +from .Chain import Chain +from .Graph import Graph +from .build import connect, join, attach, stack, concatenate +from .convert import ( + as_filter, + as_filterchain, + as_filtergraph, + as_filtergraph_object, + as_filtergraph_object_like, + atleast_filterchain, +) +from .exceptions import FiltergraphInvalidIndex, FiltergraphPadNotFoundError + +# chain | filter | pad + +__all__ = [ + "abc", + "as_filter", + "as_filterchain", + "as_filtergraph", + "as_filtergraph_object", + "as_filtergraph_object_like", + "atleast_filterchain", + "connect", + "join", + "attach", + "stack", + "concatenate", + "Filter", + "Chain", + "Graph", + "FiltergraphInvalidIndex", + "FiltergraphPadNotFoundError", +] + + +# dict: stores filter construction functions +_filters = {} + + +def __getattr__(name): + """Dynamically implement constructor functions for all available FFmpeg filters""" + func = _filters.get(name, None) + if func is None: + try: + notfound = name not in list_filters() + except path.FFmpegNotFound: + notfound = True + + if notfound: + raise AttributeError( + f"{name} is neither a valid ffmpegio.filtergraph module's instance attribute " + "nor a valid FFmpeg filter name." + ) + + def func(*args, filter_id=None, **kwargs): + return Filter(name, *args, filter_id=filter_id, **kwargs) + + func.__name__ = name + func.__doc__ = path.ffmpeg( + f"-hide_banner -h filter={name}", universal_newlines=True, stdout=path.PIPE + ).stdout + _filters[name] = func + + return func diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py new file mode 100644 index 00000000..f11d4ed0 --- /dev/null +++ b/src/ffmpegio/filtergraph/abc.py @@ -0,0 +1,1100 @@ +from __future__ import annotations + +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 + + +__all__ = ["FilterGraphObject"] + + +class FilterGraphObject(ABC): + + def get_num_pads(self, input: bool) -> int: + """get the number of available pads at input or output + + :param input: True to get the input count, False for the output count. + """ + return self.get_num_inputs() if input else self.get_num_outputs() + + @abstractmethod + def get_num_inputs(self) -> int: + """get the number of input pads of the filter + :return: number of input pads + """ + + @abstractmethod + def get_num_outputs(self) -> int: + """get the number of output pads of the filter + :return: number of output pads + """ + + @abstractmethod + def get_num_chains(self) -> int: + """get the number of chains""" + + @abstractmethod + def get_num_filters(self, chain: int) -> int: + """get the number of filters of the specfied chain + + :param chain: id of the chain + """ + + def next_input_pad( + self, + pad: int | None = None, + filter: int | None = None, + chain: int | None = None, + chainable_first: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + exclude_indices: Sequence[PAD_INDEX] | None = None, + ) -> PAD_INDEX | None: + """get next available input pad + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :param chainable_first: True to retrieve the last pad first, then the rest sequentially, defaults to False + :param unlabeled_only: True to retrieve only unlabeled pad, defaults to False + :param chainable_only: True to only iterate chainable pads, defaults to False to return all inputs + :param full_pad_index: True to return 3-element index, defaults to False + :param exclude_indices: List pad indices to skip, defaults to None to allow all + :returns: The index of the pad or ``None`` if no pad found + """ + + if exclude_indices is None: + exclude_indices = () + + try: + return next( + ( + idx + for idx, *_ in self.iter_input_pads( + pad, + filter, + chain, + chainable_first=chainable_first, + unlabeled_only=unlabeled_only, + chainable_only=chainable_only, + full_pad_index=full_pad_index, + ) + if idx not in exclude_indices + ) + ) + except StopIteration: + return None + + def next_output_pad( + self, + pad: int | None = None, + filter: int | None = None, + chain: int | None = None, + chainable_first: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + exclude_indices: Sequence[PAD_INDEX] | None = None, + ) -> PAD_INDEX | None: + """get next available output pad + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :param chainable_first: True to retrieve the last pad first, then the rest sequentially, defaults to False + :param unlabeled_only: True to retrieve only unlabeled pad, defaults to False + :param chainable_only: True to only iterate chainable pads, defaults to False to return all inputs + :param full_pad_index: True to return 3-element index, defaults to False + :param exclude_indices: List pad indices to skip, defaults to None to allow all + :returns: The index of the pad or ``None`` if no pad found + """ + + if exclude_indices is None: + exclude_indices = () + + try: + return next( + idx + for idx, *_ in self.iter_output_pads( + pad, + filter, + chain, + chainable_first=chainable_first, + unlabeled_only=unlabeled_only, + chainable_only=chainable_only, + full_pad_index=full_pad_index, + ) + if idx not in exclude_indices + ) + except StopIteration: + return None + + @abstractmethod + def iter_chains( + self, + skip_if_no_input: bool = False, + skip_if_no_output: bool = False, + chainable_only: bool = False, + ) -> Generator[tuple[int, fgb.Chain]]: + """iterate over chains of the filtergraphobject + + :param skip_if_no_input: True to skip chains without available input pads, defaults to False + :param skip_if_no_output: True to skip chains without available output pads, defaults to False + :param chainable_only: True to further restrict ``skip_if_no_input`` and ``skip_if_no_input`` + arguments to require chainable input or output, defaults to False to + allow any input/output + :yield: chain id and chain object + """ + + @abstractmethod + def iter_input_pads( + self, + pad: int | None = None, + filter: int | None = None, + chain: int | None = None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | None]]: + """Iterate over input pads of the filter + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :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 + :param unlabeled_only: True to leave out named inputs, defaults to False to return all inputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all inputs + :param full_pad_index: True to return 3-element index, defaults to False + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + @abstractmethod + def iter_output_pads( + self, + pad: int | None = None, + filter: int | None = None, + chain: int | None = None, + *, + exclude_chainable: bool = False, + chainable_first: bool = False, + include_connected: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + full_pad_index: bool = False, + ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | None]]: + """Iterate over output pads of the filter + + :param pad: pad id, defaults to None + :param filter: filter index, defaults to None + :param chain: chain index, defaults to None + :param exclude_chainable: True to leave out the last output pads, defaults to False (all avail pads) + :param chainable_first: True to yield the last output first then the rest, defaults to False + :param include_connected: True to include pads connected to output streams, defaults to False + :param unlabeled_only: True to leave out named outputs, defaults to False to return all outputs + :param chainable_only: True to only iterate chainable pads, defaults to False to return all outputs + :param full_pad_index: True to return 3-element index, defaults to False + :yield: filter pad index, link label, filter object, output pad index of connected filter if connected + """ + + # Label management methods (default operation for non-Graph objects) + + def iter_input_labels( + self, exclude_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 + :yield: a tuple of 3-tuple pad index and the pad index of the connected output pad if connected + """ + + raise StopIteration() + + def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: + """iterate over the dangling labeled output pads of the filtergraph object + + :yield: a tuple of 3-tuple pad index and the pad index of the connected input pad if connected + """ + + raise StopIteration() + + def get_label( + self, + input: bool = True, + index: PAD_INDEX | None = None, + inpad: PAD_INDEX | None = None, + outpad: PAD_INDEX | None = None, + ) -> str | None: + """get the label string of the specified filter input or output pad + + :param input: True to get label of input pad, False to get label of output pad, defaults to True + :param index: 3-element tuple to specify the (chain, filter, pad) indices, defaults to None + :param inpad: alternate argument to specify an input pad index, defaults to None + :param outpad: alternate argument to specify an output pad index, defaults to None + :return: the label of the specified pad or ``None`` if no label is assigned. + + If the pad index is invalid, the method raises ``FiltergraphInvalidIndex``. + + """ + + if index is not None: + return self._get_label(input, index) + if inpad is not None: + return self._get_label(True, inpad) + if (outpad is not None) != 1: + return self._get_label(False, outpad) + raise ValueError( + "One and only one of index, inpad, or outpad must be specified." + ) + + def _get_label(self, input: bool, index: PAD_INDEX): + return None + + def get_input_pad( + self, index_or_label: PAD_INDEX | str + ) -> tuple[PAD_INDEX, str | None]: + """resolve (unconnected) input pad from pad index or label + + :param index: pad index or link label + :return: filter input pad index and its link label (None if not assigned) + + Raises error if specified label does not resolve uniquely to an input pad + """ + + index = self.resolve_pad_index(index_or_label, is_input=True) + return index, self._get_label(True, index) + + def get_output_pad( + self, index_or_label: PAD_INDEX | str + ) -> tuple[PAD_INDEX, str | None]: + """resolve (unconnected) output filter pad from pad index or labels + + :param index: pad index or link label + :type index: tuple(int,int,int) or str + :return: filter output pad index and its link labels + :rtype: tuple(int,int,int), list(str) + + Raises error if specified index does not resolve uniquely to an output pad + """ + + index = self.resolve_pad_index(index_or_label, is_input=False) + return index, self._get_label(False, index) + + @abstractmethod + def add_label( + self, + label: str, + inpad: PAD_INDEX | Sequence[PAD_INDEX] = None, + outpad: PAD_INDEX = None, + force: bool = None, + ) -> fgb.Graph: + """label a filter pad + + :param label: name of the new label. Square brackets are optional. + :param inpad: input filter pad index or a sequence of pads, defaults to None + :param outpad: output filter pad index, defaults to None + :param force: True to delete existing labels, defaults to None + :return: actual label name + + Only one of inpad and outpad argument must be given. + + If given label already exists, no new label will be created. + + If inpad indices are given, the label must be an input stream specifier. + + If label has a trailing number, the number will be dropped and replaced with an + internally assigned label number. + + """ + + @abstractmethod + def _connect( + self, + right: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """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 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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + @abstractmethod + def _rconnect( + self, + left: fgb.abc.FilterGraphObject, + fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """combine another filtergraph object and make upstream connections (worker) + + :param right: other filtergraph + :param fwd_links: a list of tuples, pairing left's output pad and self's ipnut pad + :param bwd_links: a list of tuples, pairing self's output pad and left's ipnut 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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + def connect( + self, + right: fgb.abc.FilterGraphObject | str, + from_left: PAD_INDEX | str | list[PAD_INDEX | str], + to_right: PAD_INDEX | str | list[PAD_INDEX | str], + from_right: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, + to_left: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph | fgb.Chain: + """append another filtergraph object and make downstream connections + + :param right: receiving filtergraph object + :param from_left: output pad ids or labels of `left` fg + :param to_right: input pad ids or labels of the `right` fg + :param from_right: output pad ids or labels of the `right` fg + :param to_left: input pad ids or labels of this `left` fg + :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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + return fgb.connect( + self, + right, + from_left, + to_right, + from_right, + to_left, + chain_siso, + replace_sws_flags, + ) + + def rconnect( + self, + left: fgb.abc.FilterGraphObject | str, + from_left: PAD_INDEX | str | list[PAD_INDEX | str], + to_right: PAD_INDEX | str | list[PAD_INDEX | str], + from_right: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, + to_left: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, + chain_siso: bool = True, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph | fgb.Chain: + """append another filtergraph object and make upstream connections + + :param left: transmitting filtergraph object + :param right: receiving filtergraph object + :param from_left: output pad ids or labels of `left` fg + :param to_right: input pad ids or labels of the `right` fg + :param from_right: output pad ids or labels of the `right` fg + :param to_left: input pad ids or labels of this `left` fg + :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, + None to throw an exception (default) + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + + """ + + return fgb.connect( + left, self, from_left, to_right, chain_siso, replace_sws_flags + ) + + def join( + self, + right: fgb.abc.FilterGraphObject | str, + how: JOIN_HOW = "per_chain", + n_links: int | Literal["all"] = "all", + strict: bool = False, + unlabeled_only: bool = False, + chain_siso: bool = True, + replace_sws_flags: bool = None, + ) -> fgb.Graph | None: + """filtergraph auto-connector + + :param right: receiving filtergraph object + :param how: method on how to mate input and output, defaults to ``"per_chain"``. + + - ``'chainable'``: joins only chainable input pads and output pads. + - ``'per_chain'``: joins one pair of first available input pad and output pad of each + mating chains. Source and sink chains are ignored. + - ``'all'``: joins all input pads and output pads + - ``'auto'``: tries ``'per_chain'`` first, if fails, then tries ``'all'``. + :param n_links: number of left output pads to be connected to the right input pads, default: 0 + (all matching links). If ``how=='per_chain'``, ``n_links`` connections are made + per chain. + :param strict: True to raise exception if numbers of available pads do not match, default: False + :param unlabeled_only: True to ignore labeled unconnected pads, defaults to False + :param chain_siso: True to chain the single-input single-output connection, default: True + :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) + :return: Graph with the appended filter chains or None if inplace=True. + """ + + return fgb.join( + self, + right, + how, + n_links, + strict, + unlabeled_only, + chain_siso, + replace_sws_flags, + ) + + def rjoin( + self, + left: fgb.abc.FilterGraphObject | str, + how: JOIN_HOW = "per_chain", + n_links: int | Literal["all"] = "all", + strict: bool = False, + unlabeled_only: bool = False, + chain_siso: bool = True, + replace_sws_flags: bool = None, + ) -> fgb.Graph | None: + """filtergraph auto-connector + + :param left: transmitting filtergraph object + :param how: method on how to mate input and output, defaults to ``"per_chain"``. + + - ``'chainable'``: joins only chainable input pads and output pads. + - ``'per_chain'``: joins one pair of first available input pad and output pad of each + mating chains. Source and sink chains are ignored. + - ``'all'``: joins all input pads and output pads + - ``'auto'``: tries ``'per_chain'`` first, if fails, then tries ``'all'``. + :param n_links: number of left output pads to be connected to the right input pads, default: 0 + (all matching links). If ``how=='per_chain'``, ``n_links`` connections are made + per chain. + :param strict: True to raise exception if numbers of available pads do not match, default: False + :param unlabeled_only: True to ignore labeled unconnected pads, defaults to False + :param chain_siso: True to chain the single-input single-output connection, default: True + :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) + :return: Graph with the appended filter chains or None if inplace=True. + """ + + return fgb.join( + left, + self, + how, + n_links, + strict, + unlabeled_only, + chain_siso, + replace_sws_flags, + ) + + def attach( + self, + 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, + ) -> fgb.Graph: + """attach filter(s), chain(s), or label(s) to a filtergraph object + + :param right: output filterchain, 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) + :return: new filtergraph object + + One and only one of ``left`` or ``right`` may be a list or a label. + + If pad indices are not specified, only the first available output/input pad is linked. If the + primary filtergraph object is ``Filter`` or ``Chain``, the chainable pad (i.e., the last pad) will be + chosen. + + """ + + return fgb.attach(self, right, left_on, right_on) + + def rattach( + self, + left: 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, + ) -> 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 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) + :return: new filtergraph object + + One and only one of ``left`` or ``right`` may be a list or a label. + + If pad indices are not specified, only the first available output/input pad is linked. If the + primary filtergraph object is ``Filter`` or ``Chain``, the chainable pad (i.e., the last pad) will be + chosen. + + """ + + return fgb.attach(left, self, left_on, right_on) + + def stack( + self, + other: fgb.abc.FilterGraphObject | str, + auto_link: bool = False, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """stack another Graph to this Graph + + :param other: other filtergraph + :param auto_link: True to connect matched I/O labels, defaults to None + :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) + :return: new filtergraph object + + Remarks + ------- + - extend() and import links + - If `auto-link=False`, common labels may be renamed. + - For more explicit linking rather than the auto-linking, use `connect()` instead. + + TO-CHECK/TO-DO: what happens if common link labels are already linked + """ + + other = fgb.as_filtergraph_object(other) + + return self._stack(other, auto_link, replace_sws_flags) + + @abstractmethod + def _stack( + self, + other: fgb.abc.FilterGraphObject, + auto_link: bool = False, + replace_sws_flags: bool | None = None, + ) -> fgb.Graph: + """stack another Graph to this Graph (no var check)""" + + @abstractmethod + def __getitem__(self, key): ... + + @abstractmethod + def compose( + self, + show_unconnected_inputs: bool = True, + show_unconnected_outputs: bool = True, + ): + """compose filtergraph + + :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 __str__(self) -> str: + return self.compose(False, False) + + @abstractmethod + def __repr__(self) -> str: ... + + # Filtergraph math operators + + def __add__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: + return fgb.join(self, other) + + def __radd__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: + return fgb.join(other, self) + + def __mul__(self, __n: int) -> fgb.Graph: + """duplicate-n-stack""" + if not isinstance(__n, int): + return NotImplemented + return fgb.stack(*((self,) * __n)) + + def __rmul__(self, __n: int) -> fgb.Graph: + """duplicate-n-stack""" + if not isinstance(__n, int): + return NotImplemented + return fgb.stack(*((self,) * __n)) + + def __or__(self, other: FilterGraphObject | str) -> fgb.Graph: + """stack""" + return fgb.stack(self, other) + + def __ror__(self, other: FilterGraphObject | str) -> fgb.Graph: + """stack""" + return fgb.stack(other, self) + + def __rshift__( + self, + other: ( + FilterGraphObject + | str + | tuple[FilterGraphObject, PAD_INDEX | str] + | tuple[FilterGraphObject, PAD_INDEX | str, PAD_INDEX | str] + | list[ + FilterGraphObject + | str + | tuple[FilterGraphObject, PAD_INDEX | str] + | tuple[FilterGraphObject, PAD_INDEX | str, PAD_INDEX | str] + ] + ), + ) -> fgb.Graph: + """make one-to-one connections + + self >> other|label + self >> (index, other|label) + self >> (index, other_index, other) + self >> [other0, other1, ...] + + If pad is unspecified (i.e., ``index`` is ``None`` or the last + element of ``index`` is ``None``), chain connection is sought first + unless multiple other connection points are given. + """ + + def parse_other(other): + if not isinstance(other, fgb.Filter) and isinstance(other, tuple): + if len(other) > 2: + index, other_index, other = other + else: + index, other = other + other_index = None + else: + index = other_index = None + + return other, index, other_index + + # if output is a list + if isinstance(other, list): + # match the pad indices first + right, left_on, right_on = [ + [*t] for t in zip(*(parse_other(o) for o in other)) + ] + else: + # 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) + + def __rrshift__( + self, + other: ( + FilterGraphObject + | str + | tuple[PAD_INDEX | str, FilterGraphObject] + | tuple[PAD_INDEX | str, PAD_INDEX | str, FilterGraphObject] + | list[ + FilterGraphObject + | str + | tuple[PAD_INDEX | str, FilterGraphObject] + | tuple[PAD_INDEX | str, PAD_INDEX | str, FilterGraphObject] + ] + ), + ) -> fgb.Graph: + """make one-to-one connections + other|label >> self + (other|label, index) >> self + (other, other_index, index) >> self + [other0, other1, ...] >> self + + If pad is unspecified (i.e., ``index`` is ``None`` or the last + element of ``index`` is ``None``), chain connection is sought first + unless multiple other connection points are given. + """ + + def parse_other(other): + if not isinstance(other, fgb.Filter) and isinstance(other, tuple): + if len(other) > 2: + other, other_index, index = other + else: + other, index = other + other_index = None + else: + index = other_index = None + + return other, index, other_index + + # if output is a list + if isinstance(other, list): + # match the pad indices first + left, right_on, left_on = [ + [*t] for t in zip(*(parse_other(o) for o in other)) + ] + else: + # 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) + + def resolve_pad_index( + self, + index_or_label: PAD_INDEX | str | None, + *, + is_input: bool = True, + chain_id_omittable: bool = False, + filter_id_omittable: bool = False, + pad_id_omittable: bool = False, + resolve_omitted: bool = True, + chain_fill_value: int | None = None, + filter_fill_value: int | None = None, + pad_fill_value: int | None = None, + chainable_first: bool = False, + chainable_only: bool = False, + ) -> PAD_INDEX: + """Resolve unconnected label or pad index to full 3-element pad index + + :param index_or_label: pad index set or pad label or ``None`` to auto-select + :param is_input: True to resolve an input pad, else an output pad, defaults to True + :param chain_id_omittable: True to allow ``None`` chain index, defaults to False + :param filter_id_omittable: True to allow ``None`` filter index, defaults to False + :param pad_id_omittable: True to allow ``None`` pad index, defaults to False + :param resolve_omitted: True to fill each omitted value with the prescribed fill value. + :param chain_fill_value: if ``chain_id_omittable=True`` and chain index is either not + given or ``None``, this value will be returned, defaults to None, + which returns the first available pad. + :param filter_fill_value:if ``filter_id_omittable=True`` and filter index is either not + given or ``None``, this value will be returned, defaults to None, + which returns the first available pad. + :param pad_fill_value: if ``pad_id_omittable=True`` and either ``index`` is None or + pad index is ``None``, this value will be returned, defaults to None, + which returns the first available pad. + :param chainable_first: if True, chainable pad is selected first, defaults to False + :param chainable_only: True to only iterate chainable pads, defaults to False to return all pads + + One and only one of ``index`` and ``label`` must be specified. If the given index + or label is invalid, it raises FiltergraphPadNotFoundError. + """ + + # base implementation - guarantees to return 3-element tuple index WITHOUT converting None fill_values + + # label, if allowed, must be resolved in the subclass + if isinstance(index_or_label, str): + raise FiltergraphPadNotFoundError( + f"{index_or_label=} is not defined on the filtergraph." + ) + + # put missing or pad-only input to a 3-element tuple format + index = ( + (None, None, None) + if index_or_label is None + else ( + (None, None, index_or_label) + if isinstance(index_or_label, int) + else ( + index_or_label + if len(index_or_label) == 3 + else (*((None,) * (3 - len(index_or_label))), *index_or_label) + ) + ) + ) + + allow_partial_index = ( + chain_id_omittable or filter_id_omittable or pad_id_omittable + ) + + index_types = (int, type(None)) if allow_partial_index else int + + pad_type = "input" if is_input else "output" # for error messages + + if not (all(isinstance(i, index_types) for i in index)): + raise FiltergraphPadNotFoundError( + f"{index_or_label=} is an invalid {pad_type} pad index." + ) + + def get_value(id_type, id_value, omittable, fill_value): + if id_value is None and not omittable: + raise FiltergraphPadNotFoundError(f"{id_type} id must be specified.") + return fill_value if id_value is None else id_value + + index = tuple( + get_value(id_type, id, omittable, fill_value) + for id_type, id, omittable, fill_value in zip( + ("chain", "filter", "pad"), + index, + (chain_id_omittable, filter_id_omittable, pad_id_omittable), + (chain_fill_value, filter_fill_value, pad_fill_value), + ) + ) + + if allow_partial_index and any(i is None for i in index): + if resolve_omitted: + iter_pads = self.iter_input_pads if is_input else self.iter_output_pads + try: + index = next( + iter_pads( + chain=index[0], + filter=index[1], + pad=index[2], + chainable_first=chainable_first, + chainable_only=chainable_only, + ) + )[0] + except StopIteration as e: + raise FiltergraphPadNotFoundError( + f"{index_or_label=} could not be resolve to an unused {pad_type} pad index." + ) from e + n = len(index) + if n < 3: + index = (*(0,) * (3 - n), *index) + elif not self._check_partial_pad_index(index, is_input=is_input): + raise FiltergraphPadNotFoundError( + f"{index_or_label=} cannot be resolve to a valid {pad_type} pad index." + ) + return index + + # validate + if ( + self._input_pad_is_available if is_input else self._output_pad_is_available + )(index): + return index + + raise FiltergraphPadNotFoundError( + f"{index_or_label=} is either already connected or invalid {pad_type} pad." + ) + + def resolve_pad_indices( + self, + indices_or_labels: Sequence[PAD_INDEX | str | None], + *, + is_input: bool = True, + resolve_omitted: bool = True, + chainable_first: bool = False, + unlabeled_only: bool = False, + chainable_only: bool = False, + ) -> list[PAD_INDEX]: + """Resolve unconnected labels or pad indices to full 3-element pad indices + + :param indices_or_labels: a list of pad indices or pad labels or ``None`` to auto-select + :param is_input: True to resolve an input pad, else an output pad, defaults to True + :param chainable_first: if True, chainable pad is selected first, defaults to False + :param unlabeled_only: True to retrieve only unlabeled pad, defaults to False + :param chainable_only: True to only iterate chainable pads, defaults to False to return all pads + + One and only one of ``index`` and ``label`` must be specified. If the given index + or label is invalid, it raises FiltergraphPadNotFoundError. + + Omitted pads + + """ + + # resolve all the specified pad indices of the self object + indices = [ + ( + self.resolve_pad_index( + idx, + is_input=is_input, + chain_id_omittable=True, + filter_id_omittable=True, + pad_id_omittable=True, + resolve_omitted=False, + ) + ) + for idx in indices_or_labels + ] + + if resolve_omitted: + + # assign unknown pad indices in the order of the following ranking: + # indices ranking + # - int, int, int = 3*6 = 18 + # - int, int, None = 2*5 = 10 + # - int, None, int = 2*4 = 8 + # - None, int, int = 2*3 = 6 + # - int, None, None = 1*3 = 3 + # - None, int, None = 1*2 = 2 + # - None, None, int = 1*1 = 1 + # - None, None, None = 0*0 = 0 + + index_scores = [ + ( + sum(i is not None for i in index) + * sum((3 - j) for j, i in enumerate(index) if i is not None) + ) + for index in indices + ] + index_assign_order = sorted( + range(len(index_scores)), key=index_scores.__getitem__, reverse=True + ) + + next_base_pad = self.next_input_pad if is_input else self.next_output_pad + known_indices = set() + for i in index_assign_order: + if index_scores[i] < 18: + chain, filter, pad = indices[i] + pad = next_base_pad( + chain=chain, + filter=filter, + pad=pad, + chainable_first=chainable_first, + unlabeled_only=unlabeled_only, + chainable_only=chainable_only, + full_pad_index=True, + exclude_indices=known_indices, + ) + if pad is None: + raise ValueError("No more available filter pad found.") + indices[i] = pad + + known_indices.add(indices[i]) + elif len(indices) != len(set(indices)): + raise FiltergrapDuplicatehPadFoundError() + + return indices + + @abstractmethod + def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: + """index must be 3-element tuple""" + + @abstractmethod + def _output_pad_is_available(self, index: tuple[int, int, int]) -> bool: + """index must be 3-element tuple""" + + @abstractmethod + def _check_partial_pad_index( + self, index: tuple[int | None, int | None, int | None], is_input: bool + ) -> bool: + """True if defined values of the partial pad index are valid""" + + @abstractmethod + def _input_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified input pad is chainable""" + + @abstractmethod + def _output_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + """True if specified output pad is chainable""" + + def _attach( + self, + right: list[fgb.Filter | fgb.Chain | str], + left_on: list[PAD_INDEX], + right_on: list[PAD_INDEX | None], + ) -> fgb.Chain | fgb.Graph: + """helper function attach other filtergraph to this graph + + :param right: list of filter/chain objects or pad label strings + :param left_on: list of output pad indices, matching the size of right + :param right_on: list of input pad indices if object or None if label + :param right_first: True to preserve the chain indices of the right filtergraph object, defaults + to False to preserve the chain order of the self object + :return: resulting filtergraph + """ + + fg = ( + fgb.as_filtergraph(self) + if any(idx is None for idx in right_on) + else fgb.atleast_filterchain(self) + ) + for r, l_idx, r_idx in zip(right, left_on, right_on, strict=True): + if r_idx is None: # label + fg.add_label(r, outpad=l_idx) + else: + out = fg._connect(r, [(l_idx, r_idx)], [], chain_siso=True) + if out == NotImplemented: + raise ValueError("right fg objects include a graph.") + fg = out + return fg + + def _rattach( + self, + left: list[fgb.Filter | fgb.Chain | str], + left_on: list[PAD_INDEX | None], + right_on: list[PAD_INDEX], + ) -> fgb.Chain | fgb.Graph: + """helper function attach other filtergraph to this graph + + :param right: list of filter/chain objects or pad label strings + :param left_on: list of output pad indices if object or None if label, size must match that of left + :param right_on: list of input pad indices, matching the size of left + :param right_first: True to preserve the chain indices of the left filtergraph object, defaults + to False to preserve the chain order of the self object + :return: resulting filtergraph + """ + + # fg = ( + # fgb.as_filtergraph(self) + # if any(idx is None for idx in left_on) + # else fgb.atleast_filterchain(self) + # ) + + if not len(left): + return type(self)(self) + + # find chain offset after stacking + nleft = 0 + n0 = [ + 0, + *( + nleft := nleft + (0 if l_idx is None else (l.get_num_chains())) + for l, l_idx in zip(left, left_on) + ), + ] + + # combine the left filtergraphs first + left_fgs = [l for l in left if not isinstance(l, str)] + fg = ( + fgb.stack(*left_fgs) + if nleft > 1 or isinstance(self, fgb.Graph) + else ( + fgb.as_filtergraph_object(left_fgs[0], copy=True) + if len(left_fgs) + else fgb.Chain() + ) + ) + + # adjust left pad indices + left_on = [idx and (idx[0] + n, *idx[1:]) for idx, n in zip(left_on, n0)] + + if len(fg): + # connect stacked left and right + out = fg._connect( + self, + [ + (lidx, ridx) + for lidx, ridx in zip(left_on, right_on, strict=True) + if lidx is not None + ], + [], + chain_siso=True, + ) + if out == NotImplemented: + raise ValueError("right fg objects include a graph.") + else: + out = fgb.as_filtergraph_object(self, copy=True) + + # add labels to the combined graph + for l, l_idx, r_idx in zip(left, left_on, right_on): + if l_idx is None: # label + out = out.add_label(l, inpad=(r_idx[0] + nleft, *r_idx[1:])) + + return out diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py new file mode 100644 index 00000000..ad19e2c2 --- /dev/null +++ b/src/ffmpegio/filtergraph/build.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +from itertools import islice + +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 + +__all__ = ["connect", "join", "attach", "stack", "concatenate"] + + +def connect( + left: fgb.abc.FilterGraphObject | str, + right: fgb.abc.FilterGraphObject | str, + from_left: PAD_INDEX | str | list[PAD_INDEX | str], + to_right: PAD_INDEX | str | list[PAD_INDEX | str], + from_right: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, + to_left: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, + chain_siso: bool = True, + replace_sws_flags: bool | None = None, +) -> fgb.Graph | fgb.Chain: + """connect two filtergraph objects and make explicit connections + + :param left: transmitting filtergraph object + :param right: receiving filtergraph object + :param from_left: output pad ids or labels of `left` fg + :param to_right: input pad ids or labels of the `right` fg + :param from_right: output pad ids or labels of the `right` fg + :param to_left: input pad ids or labels of this `left` fg + :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, + None to throw an exception (default) + :return: new filtergraph object + + Notes + ----- + + * link labels may be auto-renamed if there is a conflict + + """ + + 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) + + # present as a list of pad indices + if not isinstance(from_left, list): + from_left = [from_left] + if not isinstance(to_right, list): + to_right = [to_right] + if not isinstance(from_right, list): + from_right = [] if from_right is None else [from_right] + if not isinstance(to_left, list): + to_left = [] if to_left is None else [to_left] + + fwd_links, bwd_links = resolve_connect_pad_indices( + left, right, from_left, to_right, from_right, to_left, False + ) + + return left._connect(right, fwd_links, bwd_links, chain_siso, replace_sws_flags) + + +def join( + left: fgb.abc.FilterGraphObject | str, + right: fgb.abc.FilterGraphObject | str, + how: JOIN_HOW | None = None, + n_links: int | Literal["all"] | None = None, + strict: bool = False, + unlabeled_only: bool = False, + chain_siso: bool = True, + replace_sws_flags: bool = None, +) -> fgb.Graph | None: + """filtergraph auto-connector + + :param left: transmitting filtergraph object + :param right: receiving filtergraph object + :param how: method on how to mate input and output, defaults to ``"per_chain"``. + + - ``'chainable'``: joins only chainable input pads and output pads. + - ``'per_chain'``: joins one pair of first available input pad and output pad of each + mating chains. Source and sink chains are ignored. + - ``'all'``: joins all input pads and output pads + - ``'auto'``: tries ``'per_chain'`` first, if fails, then tries ``'all'``. + :param n_links: number of left output pads to be connected to the right input pads, default: 0 + (all matching links). If ``how=='per_chain'``, ``n_links`` connections are made + per chain. + :param strict: True to raise exception if numbers of available pads do not match, default: False + :param unlabeled_only: True to ignore labeled unconnected pads, defaults to False + :param chain_siso: True to chain the single-input single-output connection, default: True + :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) + :return: Graph with the appended filter chains or None if inplace=True. + """ + + if how is None: + how = "auto" + if n_links is None: + n_links = "all" + + if how not in get_args(JOIN_HOW): + 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) + + # handle joining empty graph + if not right.get_num_chains(): + return left + if not left.get_num_chains(): + return right + + iter_kws = {"unlabeled_only": unlabeled_only, "full_pad_index": True} + if how == "chainable": + iter_kws["chainable_only"] = True + + 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) + 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), + ) + ] + 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) + ) + + fg = left._connect( + right, + links, + [], + chain_siso, + replace_sws_flags, + ) + if fg == NotImplemented: + fg = right._rconnect( + left, + links, + chain_siso, + replace_sws_flags, + ) + return fg + + +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_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, + right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, +) -> 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 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 + :return: new filtergraph object + + One and only one of ``left`` or ``right`` may be a list or a label. + + If pad indices are not specified, only the first available output/input pad is linked. If the + primary filtergraph object is ``Filter`` or ``Chain``, the chainable pad (i.e., the last pad) will be + chosen. + + """ + + def check_obj(obj): + try: + obj_label = fgb.as_filtergraph_object(obj) + except FiltergraphInvalidExpression: + try: + obj_label = str(obj) + except: + raise ValueError( + f"{type(obj)} could not be converted to a filtergraph object or a label string." + ) + return obj_label + + 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] + + return obj, attach_obj + + left_objs_labels, attach_left = analyze_fgobj(left) + 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): + return right_objs_labels + + # no list or label given + if isinstance(right_objs_labels, (fgb.Filter, fgb.Chain)): + attach_right = True + right_objs_labels = [right_objs_labels] + if not attach_right and isinstance(left_objs_labels, (fgb.Filter, fgb.Chain)): + attach_left = True + left_objs_labels = [left_objs_labels] + if attach_left == attach_right: + raise ValueError( + "Cannot determine which side is attaching. One of left or right argument must be a Filter or Chain object." + ) + + nlinks = len(left_objs_labels) if attach_left else len(right_objs_labels) + + # put single index arguments as lists of indices + if left_on is None: + left_on = [None] * nlinks + elif not isinstance(left_on, list): + left_on = [left_on] + if right_on is None: + right_on = [None] * nlinks + elif not isinstance(right_on, list): + right_on = [right_on] + + def resolve_indices(base, branches, base_indices, branch_indices, base_is_input): + + # resolve all the specified pad indices of the base object + base_indices = base.resolve_pad_indices(base_indices, is_input=base_is_input) + + # resolve the specified attaching pad indices + branch_indices = [ + ( + idx + if isinstance(robj, str) + else robj.resolve_pad_index( + idx, + is_input=not base_is_input, + chain_id_omittable=True, + filter_id_omittable=True, + pad_id_omittable=True, + resolve_omitted=True, + ) + ) + for robj, idx in zip(branches, branch_indices, strict=True) + ] + + return base_indices, branch_indices + + if attach_right: + left_on, right_on = resolve_indices( + left_objs_labels, right_objs_labels, left_on, right_on, False + ) + else: + right_on, left_on = resolve_indices( + right_objs_labels, left_objs_labels, right_on, left_on, True + ) + + if attach_right: + return left_objs_labels._attach(right_objs_labels, left_on, right_on) + else: + return right_objs_labels._rattach(left_objs_labels, left_on, right_on) + + +def concatenate(*fgs): + # TODO + raise NotImplementedError() + + +def stack( + *fgs: fgb.abc.FilterGraphObject, + auto_link: bool = False, + use_last_sws_flags: bool | None = None, +) -> fgb.Graph: + """stack filtergraph objects + + :param fgs: filtergraph objects + :param auto_link: True to connect matched I/O labels, defaults to None + :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) + :return: new filtergraph object + + Remarks + ------- + - extend() and import links + - If `auto-link=False`, common labels may be renamed. + - For more explicit linking rather than the auto-linking, use `connect()` instead. + + TO-CHECK/TO-DO: what happens if common link labels are already linked + """ + + fgs = [fg for fg in fgs if fg.get_num_chains()] + n = len(fgs) + if not n: + return fgb.Graph() + if n == 1: + return fgs[0] + + 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) + + return fg diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py new file mode 100644 index 00000000..e77d58be --- /dev/null +++ b/src/ffmpegio/filtergraph/convert.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from ..utils import filter as filter_utils + +from .exceptions import FiltergraphConversionError, FiltergraphInvalidExpression +from .. import filtergraph as fgb + + +def as_filter( + filter_spec: str | fgb.abc.FilterGraphObject, copy: bool = False +) -> fgb.Filter: + """convert the input to a filter + + :param filter_spec: filtergraph expression or object. + :param copy: True to copy even if the input is a Filter object. + :return: ``Filter`` object interpretation of ``filter_spec``. No copy is performed if the input is + already a ``Filter`` and ``copy=False``. + + If the input is a ``Chain`` or ``Graph`` object with more than one filter element, this function + will raise a ``FiltergraphConversionError`` exception. + + If the input expression could not be parsed, ``FiltergraphInvalidExpression`` will be raised. + """ + if isinstance(filter_spec, fgb.Graph): + if len(filter_spec) != 1 and len(filter_spec[0]) != 1: + raise FiltergraphConversionError( + "Only a Graph object with a single one-element chain can be downconverted to Filter." + ) + else: + return filter_spec[0, 0] + if isinstance(filter_spec, fgb.Chain): + if len(filter_spec) != 1: + raise FiltergraphConversionError( + "Only a Chain object with a single element can be downconverted to Filter." + ) + else: + return filter_spec[0] + + try: + return ( + filter_spec + if not copy and isinstance(filter_spec, fgb.Filter) + else fgb.Filter(filter_spec) + ) + except Exception as exc: + raise FiltergraphInvalidExpression from exc + + +def as_filterchain( + filter_specs: str | fgb.abc.FilterGraphObject, copy: bool = False +) -> fgb.Chain: + """Convert the input to a filter chain + + :param filter_spec: filtergraph expression or object. + :param copy: True to copy even if the input is a Filter object. + :return: ``Chain`` object interpretation of ``filter_spec``. No copy is performed if the input is + already a ``Chain`` and ``copy=False``. + + If the input is a ``Graph`` object with more than one filter chain, this function + will raise a ``FiltergraphConversionError`` exception. + + If the input expression could not be parsed, ``FiltergraphInvalidExpression`` will be raised. + """ + if isinstance(filter_specs, fgb.Graph): + if len(filter_specs) != 1: + raise FiltergraphConversionError( + "Only a Graph object with a single chain can be downconverted to Chain." + ) + return fgb.Chain(filter_specs[0]) + + try: + return ( + filter_specs + if not copy and isinstance(filter_specs, fgb.Chain) + else fgb.Chain( + [filter_specs] if isinstance(filter_specs, fgb.Filter) else filter_specs + ) + ) + except Exception as exc: + raise FiltergraphInvalidExpression from exc + + +def as_filtergraph( + filter_specs: str | fgb.abc.FilterGraphObject, copy: bool = False +) -> fgb.Graph: + """Convert the input to a filter graph + + :param filter_spec: filtergraph expression or object. + :param copy: True to copy even if the input is a Filter object. + :return: ``Graph`` object interpretation of ``filter_spec``. No copy is performed if the input is + already a ``Graph`` and ``copy=False``. + + If the input expression could not be parsed, ``FiltergraphInvalidExpression`` will be raised. + """ + try: + return ( + filter_specs + if not copy and isinstance(filter_specs, fgb.Graph) + else fgb.Graph(filter_specs) + ) + except Exception as exc: + raise FiltergraphInvalidExpression from exc + + +def as_filtergraph_object( + filter_specs: str | fgb.abc.FilterGraphObject, copy: bool = False +) -> fgb.abc.FilterGraphObject: + """Convert the input to a filter graph object + + :param filter_spec: filtergraph expression or object. + :param copy: True to copy even if the input is a Filter object. + :return: Depending on the complexity of the ``filter_spec``, ``Filter``, + ``Chain``, or ``Graph`` object interpretation of ``filter_spec``. + No copy is performed if the input is already a ``Graph`` and ``copy=False``. + """ + + if isinstance(filter_specs, (fgb.Filter, fgb.Chain, fgb.Graph)): + return type(filter_specs)(filter_specs) if copy else filter_specs + + try: + specs, links, sws_flags = filter_utils.parse_graph(filter_specs) + return ( + fgb.Graph(specs, links, sws_flags) + if links or sws_flags or len(specs) > 1 + else fgb.Filter(specs[0][0]) if len(specs[0]) == 1 else fgb.Chain(specs[0]) + ) + except Exception as exc: + raise FiltergraphInvalidExpression from exc + + +def as_filtergraph_object_like( + filter_specs: str | fgb.abc.FilterGraphObject, + like: fgb.abc.FilterGraphObject, + copy: bool = False, +) -> fgb.abc.FilterGraphObject: + """Try to convert input filtergraph spec to match the type of like object + + :param filter_spec: filtergraph expression or object. + :param like: reference filtergraph object to match the type + :param copy: True to copy even if the input is a Filter object. + :return: Filtergraph object of the same type as ``like`` object. + No copy is performed if the input is already a ``Graph`` and ``copy=False``. + """ + otype = type(like) + return ( + filter_specs + if not copy and isinstance(filter_specs, otype) + else otype(filter_specs) + ) + + +def atleast_filterchain( + filter_specs: str | fgb.abc.FilterGraphObject, copy: bool = False +) -> fgb.Chain | fgb.Graph: + """Convert the input to a filter graph object + + :param filter_spec: filtergraph expression or object. + :param copy: True to copy even if the input is a Filter object. + :return: Depending on the complexity of the ``filter_spec``, ``Filter``, + ``Chain``, or ``Graph`` object interpretation of ``filter_spec``. + No copy is performed if the input is already a ``Graph`` and ``copy=False``. + """ + + if isinstance(filter_specs, (fgb.Chain, fgb.Graph)): + return type(filter_specs)(filter_specs) if copy else filter_specs + + if isinstance(filter_specs, fgb.Filter): + return fgb.Chain([filter_specs]) + + try: + specs, links, sws_flags = filter_utils.parse_graph(filter_specs) + return ( + fgb.Graph(specs, links, sws_flags) + if links or sws_flags or len(specs) > 1 + else fgb.Chain(specs[0]) + ) + except Exception as exc: + raise FiltergraphInvalidExpression from exc diff --git a/src/ffmpegio/filtergraph/exceptions.py b/src/ffmpegio/filtergraph/exceptions.py new file mode 100644 index 00000000..d787bf37 --- /dev/null +++ b/src/ffmpegio/filtergraph/exceptions.py @@ -0,0 +1,47 @@ +from __future__ import annotations as _annotations + +from ..errors import FFmpegioError + + +class FiltergraphConversionError(FFmpegioError): ... + + +class FilterOperatorTypeError(TypeError, FFmpegioError): + def __init__(self, other) -> None: + super().__init__( + f"invalid filtergraph operation with an incompatible object of type {type(other)}" + ) + + +class FiltergraphMismatchError(TypeError, FFmpegioError): + def __init__(self, n, m) -> None: + super().__init__( + f"cannot append mismatched filtergraphs: the first has {n} input " + f"while the second has {m} outputs available." + ) + + +class FiltergraphInvalidIndex(TypeError, FFmpegioError): + pass + + +class FiltergraphInvalidLabel(TypeError, FFmpegioError): + pass + + +class FiltergraphInvalidExpression(TypeError, FFmpegioError): + pass + + +class FiltergraphPadNotFoundError(FFmpegioError): + ... + # def __init__(self, type, index) -> None: + # target = ( + # f"pad {index}" + # if isinstance(index, tuple) + # else f"label {index}" if isinstance(index, str) else f"filter {index}" + # ) + # super().__init__(f"cannot find {type} pad at {target}") + +class FiltergrapDuplicatehPadFoundError(FFmpegioError): + ... diff --git a/src/ffmpegio/filtergraph/typing.py b/src/ffmpegio/filtergraph/typing.py new file mode 100644 index 00000000..301664c7 --- /dev/null +++ b/src/ffmpegio/filtergraph/typing.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import * +from typing_extensions import * + + +PAD_INDEX = Union[ + Tuple[Union[int, None], Union[int, None], int], + Tuple[Union[int, None], Union[int, None]], + Tuple[Union[int, None]], + int, +] +"""Filter pad index. + +- 3-element tuple = (chain, filter, pad)] +- 2-element tuple = (filter, pad) +- 1-element tuple = (pad,) +- int = pad + +A None item indicates not specified and +usually means to assign first available +""" + +PAD_PAIR = Union[ + Tuple[PAD_INDEX, PAD_INDEX], + Tuple[Union[PAD_INDEX, List[PAD_INDEX]], None], + Tuple[None, PAD_INDEX], +] +"""Specifies a filter pad linkage or labeling + +A tuple pair of input pad and output pad. + +- Set input or output pad is ``None`` to define a pad label +- If input label is an input stream specifier (e.g., 0:v or 1:a:0) and connects + to multiple filter inputs, specify with a list the input pad indices. + +""" + +JOIN_HOW = Literal["chainable", "per_chain", "all", "auto"] diff --git a/src/ffmpegio/filtergraph/util.py b/src/ffmpegio/filtergraph/util.py new file mode 100644 index 00000000..8472eb2a --- /dev/null +++ b/src/ffmpegio/filtergraph/util.py @@ -0,0 +1,63 @@ +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/__init__.py b/src/ffmpegio/utils/__init__.py index dd022e53..2f845c32 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -15,6 +15,46 @@ # 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 diff --git a/src/ffmpegio/utils/fglinks.py b/src/ffmpegio/utils/fglinks.py deleted file mode 100644 index 101416bd..00000000 --- a/src/ffmpegio/utils/fglinks.py +++ /dev/null @@ -1,933 +0,0 @@ -import re -from collections import UserDict, abc -from . import is_stream_spec -from ..errors import FFmpegioError - - -class GraphLinks(UserDict): - class Error(FFmpegioError): - pass - - @staticmethod - def iter_dst_ids(dsts, include_labels=False): - """helper generator to work dsts ids - - :param dsts: dsts pad id or ids - :type dsts: tuple(int,int,int) | seq(tuple(int,int,int)) | None - :param include_labels: True to yield None for each unconnected labels, defaults to False to skip None dsts - :param include_labels: bool, optional - :yield: individual dst id, immediately exits if None - :rtype: tuple(int,int,int)|None - """ - - if dsts is None: - if include_labels: - yield dsts - elif isinstance(dsts[0], int): - yield dsts - else: - for dst in dsts: - if not (dsts is None and include_labels): - yield dst - - @staticmethod - def validate_label(label, named_only=False, no_stream_spec=False): - try: - assert re.match(r"[a-zA-Z0-9_]+$", label) or ( - not no_stream_spec and is_stream_spec(label, None) - ) - except: - if named_only or not isinstance(label, int): - msg = f'{label} is not a valid link label. A link label must be a string with only alphanumeric and "_" characters' - raise GraphLinks.Error( - msg + "." - if named_only - else msg + " or an int for unnamed internal link." - ) - - @staticmethod - def validate_pad_id(id): - - if id is None: - return - - if not ( - isinstance(id, (tuple)) - and len(id) == 3 - and all((isinstance(i, int) and i >= 0 for i in id)) - ): - raise GraphLinks.Error( - f"{id} is not a valid filter pad ID. Filter pad ID must be a 3-element tuple: (chain id, filter id, pad id)" - ) - - @staticmethod - def validate_pad_id_pair(ids): - - try: - assert len(ids) == 2 - except: - raise GraphLinks.Error( - f"Link value must be a 2-element tuple with dst and src pad ids" - ) - - (dst, src) = ids - GraphLinks.validate_pad_id(src) - - i = -1 - for i, d in enumerate(GraphLinks.iter_dst_ids(dst, True)): - if d is None and src is None: - raise GraphLinks.Error( - f"multi-id input label item cannot be None." - ) - GraphLinks.validate_pad_id(d) - nel = i + 1 - - if src is None and nel == 0: - raise GraphLinks.Error(f"both src and dst cannot be None.") - - if dst is not None and not isinstance(dst[0], int) and nel < 2: - raise GraphLinks.Error( - f"multi-id dst link item must define more than 1 element." - ) - - @staticmethod - def validate_item(label, pads): - GraphLinks.validate_pad_id_pair(pads) # this fails if None-None pair - - GraphLinks.validate_label(label, no_stream_spec=pads[1] is not None) - - # stream specifier can only be used as input label - if is_stream_spec(label, True) and pads[1] is not None: - raise GraphLinks.Error( - f"Input stream specifier ({label}) can only be used as an input label." - ) - - # unnamed link cannot be a pad label - if isinstance(label, int) and ( - not len(list(GraphLinks.iter_dst_ids(pads[0]))) or pads[1] is None - ): - raise GraphLinks.Error( - f"Unnamed (integer) links must specify both dst and src." - ) - - @staticmethod - def validate(data): - - dsts = set() # dst cannot be repeated - - # validate each link - for label, pads in data.items(): - GraphLinks.validate_item(label, pads) - for d in GraphLinks.iter_dst_ids(pads[0]): - # dst pad id must be unique - if d in dsts: - raise GraphLinks.Error( - f"Duplicate entries of dst pad id {d} found (must be unique)" - ) - if d is not None: - dsts.add(d) - - @staticmethod - def format_value(dsts, src, modifier=None): - - if modifier: - if src is not None: - src = modifier(src) - modified = tuple( - ( - d if d is None else modifier(d) - for d in GraphLinks.iter_dst_ids(dsts, True) - ) - ) - n = len(modified) - dsts = None if n < 1 else modified[0] if n < 2 else modified - elif dsts is not None and isinstance(dsts[0], tuple): - # make sure dsts sequence of ids is a tuple - dsts = tuple(dsts) - - return (dsts, src) - - # regex pattern to identify a label with a trailing number - LabelPattern = re.compile(r"(.+?)(\d+)?$") - - def __init__(self, links=None): - # validate input arg - if links is not None and not isinstance(links, GraphLinks): - try: - self.validate(links) - except GraphLinks.Error as e: - raise e - except: - raise TypeError( - "links argument must be a properly formatted dict type." - ) - - links = {k: self.format_value(*v) for k, v in links.items()} - - # label auto-renaming database - self.label_lookup = {int: -1} - - # calls update() if links set - super().__init__(links or {}) - - def _register_label(self, label): - """check the label name for duplicate, adjust as needed - - :param label: suggested new label name - :type label: str|int - :return: safe new label name - :rtype: str - """ - - lut = self.label_lookup - - if isinstance(label, (type(None), int)): - label = lut[int] = lut[int] + 1 - return label - - if is_stream_spec(label, True): - return label - - # guaranteed to match - name, nnew = self.LabelPattern.match(label).groups() - - if name == "L": - name = "L_" # L is reserved for unnamed links - - if name in lut: # matching label found - n = lut[name] - if nnew is None: # new label is not numbered - if n < 0: # existing label not numbered - # number both existing and new - try: - v = self.data.pop(name) - self.data[f"{name}1"] = v - n = 2 - except: - # the existing label has been deleted - return label - else: # existing label numbered - n += 1 - else: # new label is numbered - if n < 0: # existing unnumbered, must modify it, too - # number both existing and new - n = 0 if nnew == "0" else 1 - try: - v = self.data.pop(name) - self.data[f"{name}{n}"] = v - except: - # the existing label has been deleted - n -= 1 - n += 1 - label = f"{name}{n}" - else: # matching label not found, keep the label as is but log it - if nnew is None: # new label not numbered - n = -1 - else: # new label numbered - n = 0 if nnew == "0" else 1 - label = f"{name}{n}" - lut[name] = n - return label - - def __getitem__(self, key): - """get link item by label or by dst pad id tuple - - :param key: label name or dst pad id tuple (int,int,int) - :type key: str or tuple(int,int,int) - :return: link dsts-src pair - :rtype: tuple(tuple(tuple(int,int,int))|None,tuple(int,int,int)|None) - """ - try: - # try as label first - return super().__getitem__(key) - except Exception as e: - # try as dst id - label = self.find_dst_label(key) - if label is None: - raise e - return (label, self.data[label][1]) - - def __setitem__(self, key, value): - # can only set named key - self.link(value[1], value[0], label=key, force=False, validate=True) - - def is_linked(self, label): - """True if label specifies a link - - :param label: link label - :type label: str - :return: True if label is a link - :rtype: bool - - If multi-dst label, True if any dst is not None - """ - lnk = self.data.get(label, (None, None)) - return lnk[1] is not None and any(self.iter_dst_ids(lnk[0])) - - def is_input(self, label): - """True if label specifies an input - - :param label: link label - :type label: str - :return: True if label is an input - :rtype: bool - """ - lnk = self.data.get(label, None) - return lnk and lnk[1] is None - - def is_output(self, label): - """True if label specifies an output - - :param label: link label - :type label: str - :return: True if label is an output - :rtype: bool - - If multi-dst label, True if any dst is None - """ - lnk = self.data.get(label, None) - return lnk and any((d is None for d in self.iter_dst_ids(lnk[0], True))) - - def num_outputs(self, label): - """Get number of outputs - - :param label: link label - :type label: str - :return: number of output (logical) pads - :rtype: int - - If multi-dst label, True if any dst is None - """ - lnk = self.data.get(label, None) - return int(lnk is not None) and sum( - (d is None for d in self.iter_dst_ids(lnk[0], True)) - ) - - def iter_dsts(self, label=None): - """Iterate over all link elements, possibly separating dst ids with - the same label - - :param label: to iterate only on this label, defaults to None (all frames) - :type label: str, optional - :yield: a full link definition (dst or src may be None if input or output label, repectively) - :rtype: tuple of label, dst id, and src id - """ - - def iter(label, dst, src): - for d in self.iter_dst_ids(dst, True): - yield (label, d, src) - - if label is None: - for label, (dst, src) in self.data.items(): - for v in iter(label, dst, src): - yield v - else: - for v in iter(label, *self.data[label]): - yield v - - def iter_links(self, label=None): - """Iterate over only actual links, possibly separating dst ids with - the same label - - :param label: to iterate only on this label, defaults to None (all frames) - :type label: str, optional - :yield: a full link definition - :rtype: tuple of label, dst id, and src id - """ - - def iter(label, dst, src): - if src is not None: - for d in self.iter_dst_ids(dst): - yield (label, d, src) - - if label is None: - for label, (dst, src) in self.data.items(): - for v in iter(label, dst, src): - yield v - else: - for v in iter(label, *self.data[label]): - yield v - - def iter_inputs(self, ignore_connected=False): - """Iterate over only input labels, possibly repeating the same label if shared among - multiple input pad ids - - :param ignore_connected: True to exclude inputs, which are already connected to input streams, defaults to False - :type ignore_connected: bool, optional - :yield: a full input definition - :rtype: tuple of label and dst id - """ - for label, (dst, src) in self.data.items(): - if src is None and not ( - ignore_connected and isinstance(label, str) and is_stream_spec(label) - ): - for d in self.iter_dst_ids(dst): - yield (label, d) - - def iter_outputs(self, label=None): - """Iterate over only output labels - - :param label: to iterate only on this label, defaults to None (all frames) - :type label: str, optional - :yield: a full output definition (same label may be used multiple times) - :rtype: tuple of label and src id - """ - - def iter(label, dst, src): - for d in self.iter_dst_ids(dst, True): - if d is None: - yield (label, src) - - if label is None: - # iterate over all labels - for label, (dst, src) in self.data.items(): - for v in iter(label, dst, src): - yield v - else: - # only for the given label - for v in iter(label, *self.data[label]): - yield v - - def find_dst_label(self, dst): - """get label of an input pad id - - :param dst: input filter pad id - :type dst: tuple(int,int,int) - :return: found label or None if no match found - :rtype: str or None - """ - if dst is None: - return None - return next((label for label, dst1, _ in self.iter_dsts() if dst == dst1), None) - - def find_src_labels(self, src): - """get labels of a source/output pad id - - :param dst: output filter pad id - :type dst: tuple(int,int,int) - :return: found label or None if src is None - :rtype: list of str or None - """ - if src is None: - return None - return [label for label, (_, src1) in self.data.items() if src == src1] - - def find_input_label(self, dst): - """get labels of an unconnected input pad id - - :param dst: input filter pad id - :type dst: tuple(int,int,int) - :return: found label or None if no match found - :rtype: str or None - """ - if dst is None: - return None - return next((label for label, dst1 in self.iter_inputs() if dst == dst1), None) - - def find_output_labels(self, src): - """get labels of an unconnected source/output pad id - - :param dst: output filter pad id - :type dst: tuple(int,int,int) - :return: found label or None if src is None - :rtype: list of str or None - """ - if src is None: - return None - return [label for label, src1 in self.iter_outputs() if src == src1] - - def find_link_label(self, dst, src): - if src is None or dst is None: - return None - return next((l for l, d, s in self.iter_links() if src == s and dst == d), None) - - def are_linked(self, dst, src): - if src is None or dst is None: - return False - return any((src == s and dst == d for _, d, s in self.iter_links())) - - def unlink(self, label=None, dst=None, src=None): - """unlink specified links - - :param label: specify all the links with this label, defaults to None - :type label: str|int, optional - :param dst: specify the link with this dst pad, defaults to None - :type dst: tuple(int,int,int), optional - :param src: specify all the links with this src pad, defaults to None - :type src: tuple(int,int,int), optional - """ - if label is not None: - del self.data[label] - if src is not None: - for label in self.find_src_labels(src): - del self.data[label] - if dst is not None: - label = self.find_dst_label(dst) - dsts, src = self.data[label] - if isinstance(dsts[0], int): # unique label - del self.data[label] - else: # multi-dsts label - # depends on how many left - dsts = tuple((d for d in dsts if d != dst)) - self.data[label] = (dsts, src) if len(dsts) > 1 else (dsts[0], src) - - def link(self, dst, src, label=None, preserve_src_label=False, force=False): - """set a filtergraph link - - :param dst: input pad ids - :type dst: tuple(int,int,int) - :param src: output pad id - :type src: tuple(int,int,int) - :param label: desired label name, defaults to None (=reuse dst/src label or unnamed link) - :type label: str, optional - :param preserve_src_label: True to keep existing output labels of src, defaults to False - to remove one output label of the src - :type preserve_src_label: bool, optional - :param force: True to drop conflicting existing link, defaults to False - :type force: bool, optional - :return: assigned label of the created link. Unnamed links gets a - unique integer value assigned to it. - :rtype: str|int - - notes: - - Unless `force=True`, dst pad must not be already connected - - User-supplied label name is a suggested name, and the function could - modify the name to maintain integrity. - - If dst or src were previously named, their names will be dropped - unless one matches the user-supplied label. - - No guarantee on consistency of the link label (both named and unnamed) - during the life of the object - - """ - - if src is None or dst is None: - raise GraphLinks.Error(f"both src and dst ids must not be Nones.") - - # check if dst already exists and resolve conflict if there is one - dst_label = self.find_dst_label(dst) - if dst_label is not None: - if not (force or self.is_input(dst_label)): - raise GraphLinks.Error(f"input pad {dst} already linked.") - if force or isinstance(self.data[dst_label][0][0], tuple): - # if dst_label has multi-dsts, cannot reuse it - self.unlink(dst=dst) - dst_label = None - - # check if output label already exists. pick the first match - src_label = ( - None - if preserve_src_label - else next( - (k for k, (d, s) in self.data.items() if d is None and s == src), None - ) - ) - - # finalize the label name - # if not defined by user, select new label to be dst or src label if found - label = ( - label - or (isinstance(dst_label, str) and dst_label) - or (isinstance(src_label, str) and src_label) - or None - ) - - if not (dst_label or src_label): - # new label, register - label = self._register_label(label) - - # if input label was found to be updated, remove it - if dst_label is not None and label != dst_label: - # remove output label - self.unlink(label=dst_label) - - # if output label was found to be updated, remove it - if src_label is not None and label != src_label: - # remove output label - self.unlink(label=src_label) - - # create the new link - self.data[label] = (dst, src) - - return label - - def create_label(self, label, dst=None, src=None, force=None): - """label a filter pad - - :param label: name of the new label or input stream specifier (for input label only) - :type label: str - :param dst: input filter pad id (or a sequence of ids), defaults to None - :type dst: tuple(int,int,int) or seq(tuple(int,int,int)), optional - :param src: output filter pad id, defaults to None - :type src: tuple(int,int,int), optional - :param force: True to delete existing labels, defaults to None - :type force: bool, optional - :return: actual label name - :rtype: str - - Only one of dst and src argument must be given. - - If given label already exists, no new label will be created. - - If label has a trailing number, the number will be dropped and replaced with an - internally assigned label number. - - """ - - if (src is None) == (dst is None): - raise ValueError("src or dst (but not both) must be given.") - - # check if dst already exists and resolve conflict if there is one - if dst: - dsts = [dst] if isinstance(dst, tuple) else dst - - # get labels of src and dst if already exists - dst_labels = set( - (d for d in (self.find_dst_label(d) for d in dsts) if d is not None) - ) - - # already labeled as specified - if len(dst_labels) == 1 and label in dst_labels: - return label - - # if an unmatched labal is already assigned to dst - if len(dst_labels): - if force: - # drop existing if forced - for d in dsts: - self.unlink(dst=d) - else: - # or throw an error - raise GraphLinks.Error( - f"the input pad(s) {dst} already linked." - ) - - if not isinstance(dst, tuple): - dst = tuple(dst) - - else: - if is_stream_spec(label): - raise GraphLinks.Error( - f"the output label [{label}] cannot be a stream specifier." - ) - - if label in self.find_src_labels(src): - # already labeled as specified - return label - - # create the link - label = self._register_label(label) - self.data[label] = (dst, src) - return label - - def remove_label(self, label, dst=None): - """remove an input/output label - - :param label: unconnected link label - :type label: str - :param dst: (multi-input label only) specify the input filter pad id - :type dst: int, optional - - Removing an input label by default removes all associated filter pad ids - unless `dst` is specified. - - """ - try: - dsts, src = self.data[label] - except: - raise GraphLinks.Error(f"{label} is not a valid link label.") - - if dsts is None or (src is None and dst is None): - # simple in/out label - del self.data[label] - else: - # possible for an output label coexisting with link labels - dsts = tuple(self.iter_dst_ids(dsts, True)) - new_dsts = tuple( - (d for d in dsts if d is not None) - if dst is None - else (d for d in dsts if d is not None and d != dst) - ) - n = len(new_dsts) - if n == len(dsts): - raise GraphLinks.Error( - f"no specified input labels found: {label} (dst={dst})." - ) - - if n < 1: - del self.data[label] - else: - self.data[label] = (new_dsts[0], src) if n < 2 else (new_dsts, src) - - def rename(self, old_label, new_label): - """rename a label - - :param old_label: existing label (named or unnamed) - :type old_label: str or int - :param new_label: new label name (possibly appended with a number if the label already exists) - :type new_label: str|None - :return: actual label name - :rtype: str - """ - v = self.data[old_label] - label = self._register_label(new_label) - del self.data[old_label] - self.data[label] = v - return label - - def update( - self, - other, - offset=0, - auto_link=False, - force=False, - ): - """Update the links with the label/id-pair pairs from other, overwriting existing keys. Return None. - - :param other: other object to copy existing items from - :type other: GraphLinks or a dict-like object with valid items - :param offset: channel id offset of the copied items, defaults to 0 - :type offset: int, optional - :param auto_link: True to connect matching input-output labels, defaults to False - :type auto_link: bool, optional - :param force: True to overwrite existing link dst id, defaults to False - :type force: bool, optional - :returns: dict of given key to the actual labels assigned - :rtype: dict - """ - if not isinstance(other, GraphLinks): - try: - assert isinstance(other, abc.Mapping) - except Exception as e: - raise GraphLinks.Error( - f"Other must be a dict-like mapping object" - ) - self.validate(other) - - n = len(other) - if not n: - return {} - - # set chain index adjustment function if offset given - adj_fn = (lambda id: (id[0] + offset, *id[1:])) if offset else None - - # prep other's dsts value (adjust chain id & check for duplicate) - to_unlink = [] # for forcing dst - - def chk_dst(d, do): - if any((d == d0 for _, d0, _ in self.iter_dsts())): - if force: - to_unlink.append(d) - else: - raise GraphLinks.Error( - f"dst id {do} with chain id offset {offset or 0} conflicts with existing dst " - ) - - def prep_ids(dsts, src): - dsts_adj, src = ( - self.format_value(dsts, src, adj_fn) if offset else (dsts, src) - ) - if dsts is not None: - if isinstance(dsts[0], int): - chk_dst(dsts_adj, dsts) - else: - for d, d0 in zip(dsts_adj, dsts): - chk_dst(d, d0) - return dsts_adj, src - - other = [(k, prep_ids(*v)) for k, v in other.items()] - - # delete dsts to be overwritten - for dst in to_unlink: - self.unlink(dst=dst) - - # process each input item - def process_item(label, dsts, src): - new_label = True - if label in self.data: - dsts_self, src_self = self.data[label] - if auto_link: - # try to link matching input & output pads - if dsts is None and src_self is None: - self.data[label] = (dsts_self, src) - return label - elif src is None and dsts_self is None: - self.data[label] = (dsts, src_self) - return label - - if src == src_self: - # if links from the same source, merge - dsts = tuple( - ( - *self.iter_dst_ids(dsts_self, True), - *self.iter_dst_ids(dsts, True), - ) - ) - new_label = False - - if new_label: - label = self._register_label(label) - self.data[label] = (dsts, src) - return label - - return {label: process_item(label, dsts, src) for label, (dsts, src) in other} - - def get_repeated_src_info(self): - """return a nested dict with an item per multi-destination filter output pad id's. - - :return: dict of multi-destination srcs - :rtype: dict - - { filter output pad id: { - output link label : None - filter input pad id: link label - }} - """ - # iterate all the sources with multiple destinations - # dict(key=src id: value=dict(key=dst id|None: value=label|# of output labels)) - - # gather all sources, rename labels to guarantee uniqueness - srcs = {} - for label, (dsts, src) in self.data.items(): - if src is None: - continue - if src in srcs: - item = srcs[src] - else: - item = srcs[src] = {} - - dsts = tuple(self.iter_dst_ids(dsts, True)) - if len(dsts) > 1: - item.update({f"{label}_{i}": dst for i, dst in enumerate(dsts)}) - else: - item[label] = dsts[0] - - # drop all sources with single-destination - return {src: dsts for src, dsts in srcs.items() if len(dsts) > 1} - - def _modify_pad_ids(self, select, adjust): - """generic pad id modifier - - :param select: function to select a pad id to modify - :type select: Callable: select(id)->bool - :param adjust: function to adjust the selected pad id - :type adjust: Callable: adjust(id)->new_id - - """ - - def adjust_pair(dsts, src): - if src is not None and select(src): - src = adjust(src) - if dsts is not None: - if isinstance(dsts[0], int): - if select(dsts): - dsts = adjust(dsts) - else: - dsts = tuple(adjust(d) if select(d) else d for d in dsts) - return (dsts, src) - - self.data = {label: adjust_pair(*value) for label, value in self.data.items()} - - def adjust_chains(self, pos, len): - """insert/delete contiguous chains from fg - - :param pos: position of the first chain - :type pos: int - :param len: number of chains to be inserted (if positive) or removed (if negative) - :type len: int - """ - - select = lambda pid: pid[0] >= pos # select all chains at or above pos - adjust = lambda pid: (pid[0] + len, *pid[1:]) - self._modify_pad_ids(select, adjust) - - def remove_chains(self, chains): - """insert/delete contiguous chains from fg - - :param chains: positions of the chains that are removed - :type chains: seq(int) - """ - - if not len(chains): - return # nothing to remove - - chains = list(enumerate(sorted(set(chains))))[::-1] - - def adj(pid): - return ( - pid[0] - next((i + 1 for i, v in chains if v < pid[0]), 0), - *pid[1:], - ) - - select = lambda pid: pid[0] >= chains[0][1] # select all chains at or above pos - self._modify_pad_ids(select, adj) - - def merge_chains(self, id, to_id, to_len): - """adjust link definitions when 2 internal chains are joined - - :param id: id of the chain to be moved - :type id: int - :param to_id: id of the chain to append to - :type to_id: int - :param to_len: length of the src chain - :type to_len: int - - * all chain_id's >= id are affected - * Graph is responsible to remove the connecting labels before running - this function. - """ - - adjust = lambda pid: (to_id, pid[1] + to_len, pid[2]) - select = lambda pid: pid[0] == id - self._modify_pad_ids(select, adjust) - - def adjust_filter_ids(self, cid, pos, len): - """adjust filter id to insert another filter chain - - :param cid: target chain position in fg - :type cid: int - :param pos: filter position to insert another chain - :type pos: int - :param len: length of the chain to be inserted - :type len: int - """ - select = lambda pid: pid[0] == cid and pid[1] >= pos - adjust = lambda pid: (pid[0], pid[1] + len, pid[2]) - self._modify_pad_ids(select, adjust) - - def del_chain(self, cid): - """delete all links involving specified chain - - :param cid: chain id - :type cid: int - """ - - def inspect(dst, src): - - if src and src[0] == cid: - # delete if source chain is deleted - return False - - if dst is not None: - dsts = tuple((d for d in self.iter_dst_ids(dst, True))) - new_dsts = tuple((d for d in dsts if d is None or d[0] != cid)) - n = len(new_dsts) - return ( - dst # no change - if len(dsts) == n - else False # all dsts with cid - if not n - else new_dsts[0] # only 1 survived (the other was from cid) - if n == 1 - else new_dsts # mutiple survived - ) - - return True # output label, not from cid - - self.data = { - label: (dst, src) - for label, dst, src in ( - (label, inspect(dst, src), src) - for label, (dst, src) in self.data.items() - ) - if dst is not False - } diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index cf00fc97..c595ea3f 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -6,31 +6,183 @@ import pytest -def test_iter_io_pads(): - fg = fgb.Graph( - "color;scale,pad[l1]; crop,pad[l2]; overlay; [l1]overlay; pad,overlay; trim,[l2]overlay" +@pytest.mark.parametrize( + "expr, pad, filter, chain, exclude_chainable, chainable_first, include_connected, unlabeled_only, ret", + [ + # fmt: off + ("[0:v][1:v]vstack", None, None, None, False, False, False, False, []), + ("[0:v][1:v]vstack", None, None, None, False, False, True, False, [(0,0,0),(0,0,1)]), + ("[0:v][in]vstack,split[out];[out]vstack", None, None, None, False, False, False, False, [(0,0,1),(1,0,1)]), + ("[0:v][in]vstack,split[out];[out]vstack", None, None, 0, False, False, False, False, [(0,0,1)]), + ("[0:v][in]vstack,split[out];[out]vstack", None, None, 1, False, False, False, False, [(1,0,1)]), + ("[0:v][in]vstack,split[out];[out]vstack", None, None, 2, False, False, False, False, None), + ("[0:v][in]vstack,split[out];[out]vstack", None, None, None, False, False, False, True, [(1,0,1)]), + # fmt: on + ], +) +def test_iter_input_pads( + expr, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + unlabeled_only, + ret, +): + + fg = fgb.Graph(expr) + + out_links = fg._links.output_dict() + + it = fg.iter_input_pads( + pad, + filter, + chain, + exclude_chainable=exclude_chainable, + chainable_first=chainable_first, + include_connected=include_connected, + unlabeled_only=unlabeled_only, + ) + + if ret is None: + with pytest.raises(fgb.FiltergraphInvalidIndex): + next(it) + else: + for r in ret: + index, f, out_index = next(it) + assert index == r and f == fg[r[0]][r[1]] + if isinstance(out_index, tuple): + assert out_index in out_links + + +@pytest.mark.parametrize( + "expr, pad, filter, chain, exclude_chainable, chainable_first, include_connected, unlabeled_only, ret", + [ + # fmt: off + ("split[out0][out1]", None, None, None, False, False, False, False, [(0,0,0),(0,0,1)]), + ("split[out0][out1]", None, None, None, False, False, False, True, []), + # fmt: on + ], +) +def test_iter_output_pads( + expr, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + unlabeled_only, + ret, +): + + fg = fgb.Graph(expr) + + in_links = fg._links.input_dict() + + it = fg.iter_output_pads( + pad, + filter, + chain, + exclude_chainable=exclude_chainable, + chainable_first=chainable_first, + include_connected=include_connected, + unlabeled_only=unlabeled_only, ) - print(fg) - pprint(tuple(fg._iter_io_pads(True, "all"))) - pprint(tuple(fg._iter_io_pads(True, "chainable"))) - pprint(tuple(fg._iter_io_pads(True, "per_chain"))) + + if ret is None: + with pytest.raises(fgb.FiltergraphInvalidIndex): + next(it) + else: + for r in ret: + index, f, in_index = next(it) + assert index == r and f == fg[r[0]][r[1]] + if isinstance(in_index, tuple): + assert in_index in in_links + + +@pytest.mark.parametrize( + "expr, skip_if_no_input, skip_if_no_output, chainable_only, ret", + [ + ("fps;scale", False, False, False, 2), + ("fps;scale", True, True, True, 2), + ("nullsrc;fps", False, False, False, 2), + ("nullsrc;fps", True, False, False, 1), + ("fps;nullsink", False, False, False, 2), + ("fps;nullsink", False, True, False, 1), + ("split[L1][L2];[L2]fps", True, False, False, 1), + ("split[L1][L2];[L2]fps", False, True, False, 1), + ("split[L1][L2];[L2]fps", False, True, True, 1), + ], +) +def test_iter_chains(expr, skip_if_no_input, skip_if_no_output, chainable_only, ret): + f = fgb.Graph(expr) + chains = [*f.iter_chains(skip_if_no_input, skip_if_no_output, chainable_only)] + assert len(chains) == ret -def test_resolve_index(): +@pytest.mark.parametrize( + "index_or_label, ret, is_input, chain_id_omittable, filter_id_omittable, pad_id_omittable, resolve_omitted, chain_fill_value, filter_fill_value, pad_fill_value, chainable_first", + [ + ("in", (2, 0, 0), True, False, False, False, False, None, None, None, False), + ("[in]", (2, 0, 0), True, False, False, False, False, None, None, None, False), + ], +) +def test_resolve_pad_index( + index_or_label, + ret, + is_input, + chain_id_omittable, + filter_id_omittable, + pad_id_omittable, + resolve_omitted, + chain_fill_value, + filter_fill_value, + pad_fill_value, + chainable_first, +): fg = fgb.Graph( - "color;scale,pad[l1]; crop,pad[l2]; overlay; [l1]overlay; pad,overlay[l3]; trim,[l2]overlay,split=3[l4];[l3][l4]overlay" + "color;scale,pad[l1];[in]crop,pad[l2];overlay;[l1]overlay;pad,overlay[l3];trim,[l2]overlay,split=3[l4];[l3][l4]overlay" ) - fg.add_label("in", dst=(2, 0, 0)) - print(fg) - pprint(tuple(fg._iter_io_pads(True, "all"))) - # pprint(tuple(fg._iter_io_pads(False, "all"))) - assert fg._resolve_index(True, 0) == (1, 0, 0) - - assert fg._resolve_index(True, None) == (1, 0, 0) - assert fg._resolve_index(True, "in") == (2, 0, 0) - assert fg._resolve_index(True, "[in]") == (2, 0, 0) - assert fg._resolve_index(True, 1) == (3, 0, 1) - assert fg._resolve_index(True, (1, None)) == (5, 1, 0) + + if ret is None: + with pytest.raises(fgb.FiltergraphPadNotFoundError): + fg.resolve_pad_index( + index_or_label, + is_input=is_input, + chain_id_omittable=chain_id_omittable, + filter_id_omittable=filter_id_omittable, + pad_id_omittable=pad_id_omittable, + resolve_omitted=resolve_omitted, + chain_fill_value=chain_fill_value, + filter_fill_value=filter_fill_value, + pad_fill_value=pad_fill_value, + chainable_first=chainable_first, + ) + else: + assert ( + fg.resolve_pad_index( + index_or_label, + is_input=is_input, + chain_id_omittable=chain_id_omittable, + filter_id_omittable=filter_id_omittable, + pad_id_omittable=pad_id_omittable, + resolve_omitted=resolve_omitted, + chain_fill_value=chain_fill_value, + filter_fill_value=filter_fill_value, + pad_fill_value=pad_fill_value, + chainable_first=chainable_first, + ) + == ret + ) + + # assert fg.resolve_pad_index(True, None) == (1, 0, 0) + # assert fg.resolve_pad_index(True, "in") == (2, 0, 0) + # assert fg.resolve_pad_index(True, "[in]") == (2, 0, 0) + # assert fg.resolve_pad_index(True, 1) == (3, 0, 1) + # assert fg.resolve_pad_index(True, (1, None)) == (5, 1, 0) # pprint(fg._resolve_index(False, 0)) # pprint(fg._resolve_index(False, 1)) @@ -39,91 +191,105 @@ def test_resolve_index(): @pytest.mark.parametrize( "fg,fc,left_on,right_on,out", [ - ("fps;crop", "trim", None, None, "fps,trim;crop"), - ("fps[out];crop", "trim", None, None, "fps,trim;crop"), - ("fps;crop", "trim", (1, 0, 0), None, "fps;crop,trim"), - ("fps;crop[out]", "trim", "out", None, "fps;crop,trim"), + ("fps;crop", "trim", None, None, "[UNC0]fps,trim[UNC2];[UNC1]crop[UNC3]"), + ("fps[out];crop", "trim", None, None, "[UNC0]fps,trim[UNC2];[UNC1]crop[UNC3]"), + ("fps;crop", "trim", (1, 0, 0), None, "[UNC0]fps[UNC2];[UNC1]crop,trim[UNC3]"), + ("fps;crop[out]", "trim", "out", None, "[UNC0]fps[UNC2];[UNC1]crop,trim[UNC3]"), ( - fgb.Graph(["fps", "crop"], {"out": ((None, (1, 0, 0)), (0, 0, 0))}), + fgb.Graph(["fps", "crop"], {"out": ((1, 0, 0), (0, 0, 0))}), "trim", "out", None, None, ), ( - fgb.Graph(["fps", "crop"], {"out": ((None, None), (0, 0, 0))}), + fgb.Graph(["fps", "crop"], {"out": (None, (0, 0, 0))}), "trim", "out", None, - "fps,trim;crop", + "[UNC0]fps,trim[UNC2];[UNC1]crop[UNC3]", + ), + ("fps[L];[L]crop", "trim", None, None, "[UNC0]fps[L];[L]crop,trim[UNC1]"), + ( + "split=2[C];[C]crop", + "trim", + None, + None, + "[UNC0]split=2[C][L0];[C]crop[UNC1];[L0]trim[UNC2]", ), - ("fps[L];[L]crop", "trim", None, None, "fps[L];[L]crop,trim"), - ("split=2[C];[C]crop", "trim", None, None, "split=2[C][L0];[C]crop;[L0]trim"), ( "split=2[C][out];[C]crop", "trim", "out", None, - "split=2[C][out];[C]crop;[out]trim", + "[UNC0]split=2[C][L0];[C]crop[UNC1];[L0]trim[UNC2]", ), ], ) def test_attach(fg, fc, left_on, right_on, out): fg = fgb.Graph(fg) if out is None: - with pytest.raises(fgb.Graph.Error): + with pytest.raises(fgb.FiltergraphPadNotFoundError): fg = fg.attach(fc, left_on, right_on) else: fg = fg.attach(fc, left_on, right_on) - assert str(fg) == out + assert fg.compose() == out @pytest.mark.parametrize( - "fg,fc,left_on,skip_named,out", + "right,left,right_on,out", [ - ("fps;crop", "trim", None, None, "trim,fps;crop"), - ("[in]fps;crop", "trim", None, None, "trim,fps;crop"), - ("fps;crop", "trim", (1, 0, 0), None, "trim,crop;fps"), - ("fps;[in]crop", "trim", "in", None, "trim,crop;fps"), - ("[L]fps;crop[L]", "trim", None, None, "trim,crop[L];[L]fps"), - ("[C]overlay;crop[C]", "trim", None, None, "trim[L0];[C][L0]overlay;crop[C]"), - ( - "[C][in]overlay;crop[C]", - "trim", - "in", - None, - "trim[in];[C][in]overlay;crop[C]", - ), + # fmt: off + ("fps;crop", "trim", None, "[UNC0]trim,fps[UNC2];[UNC1]crop[UNC3]"), + ("[in]fps;crop", "trim", None, "[UNC0]trim,fps[UNC2];[UNC1]crop[UNC3]"), + ("fps;crop", "trim", (1, 0, 0), "[UNC0]fps[UNC2];[UNC1]trim,crop[UNC3]"), + ("fps;[in]crop", "trim", "in", "[UNC0]fps[UNC2];[UNC1]trim,crop[UNC3]"), + ("[L]fps;crop[L]", "trim", None, "[L]fps[UNC1];[UNC0]trim,crop[L]"), + ("[C]overlay;crop[C]", "trim", None, "[UNC0]trim[L0];[C][L0]overlay[UNC2];[UNC1]crop[C]"), + ("[C][in]overlay;crop[C]", "trim", "in", "[UNC0]trim[L0];[C][L0]overlay[UNC2];[UNC1]crop[C]"), + # fmt: on ], ) -def test_rattach(fg, fc, left_on, skip_named, out): - fg = fgb.Graph(fg) +def test_rattach(right, left, right_on, out): + fg = fgb.Graph(right) if out is None: with pytest.raises(fgb.Graph.Error): - fg = fg.rattach(fc, left_on, skip_named) + fg = fg.rattach(left, right_on=right_on) else: - fg = fg.rattach(fc, left_on, skip_named) - assert str(fg) == out + fg = fg.rattach(left, right_on=right_on) + assert fg.compose() == out @pytest.mark.parametrize( "fg, other, auto_link, replace_sws_flags, out", [ - ("fps;crop", "trim;scale", False, None, "fps;crop;trim;scale"), - ("fps;crop", "trim,scale", False, None, "fps;crop;trim,scale"), + ( + "fps;crop", + "trim;scale", + False, + None, + "[UNC0]fps[UNC4];[UNC1]crop[UNC5];[UNC2]trim[UNC6];[UNC3]scale[UNC7]", + ), + ( + "fps;crop", + "trim,scale", + False, + None, + "[UNC0]fps[UNC3];[UNC1]crop[UNC4];[UNC2]trim,scale[UNC5]", + ), ( "[la]fps;crop[lb]", "[lb]trim;scale[la]", False, None, - "[la1]fps;crop[lb1];[lb2]trim;scale[la2]", + None, ), ( "[la]fps;crop[lb]", "[lb]trim;scale[la]", True, None, - "[la]fps;crop[lb];[lb]trim;scale[la]", + "[la]fps[UNC2];[UNC0]crop[lb];[lb]trim[UNC3];[UNC1]scale[la]", ), ("sws_flags=w=200;fps;crop", "sws_flags=h=400;trim;scale", False, None, None), ( @@ -131,14 +297,14 @@ def test_rattach(fg, fc, left_on, skip_named, out): "sws_flags=h=400;trim;scale", False, False, - "sws_flags=w=200;fps;crop;trim;scale", + "sws_flags=w=200;[UNC0]fps[UNC4];[UNC1]crop[UNC5];[UNC2]trim[UNC6];[UNC3]scale[UNC7]", ), ( "sws_flags=w=200;fps;crop", "sws_flags=h=400;trim;scale", False, True, - "sws_flags=h=400;fps;crop;trim;scale", + "sws_flags=h=400;[UNC0]fps[UNC4];[UNC1]crop[UNC5];[UNC2]trim[UNC6];[UNC3]scale[UNC7]", ), ], ) @@ -150,7 +316,7 @@ def test_stack(fg, other, auto_link, replace_sws_flags, out): fg = fg.stack(other, auto_link, replace_sws_flags) else: fg = fg.stack(other, auto_link, replace_sws_flags) - assert str(fg) == out + assert fg.compose() == out @pytest.mark.parametrize( @@ -163,8 +329,8 @@ def test_stack(fg, other, auto_link, replace_sws_flags, out): ("fps;crop", 'fake', None), ("[la]fps;crop[lb]", 'la', ((0,0,0),'la')), ("[la]fps;crop[lb]", 'lb', None), - ("[a]fps;[a]crop", 'a', None), ("[0:v]fps;[0:v]crop", (0,0,0), None), + ("[0:v]fps;[0:v]crop", '0:v', None), # fmt: on ], ) @@ -172,7 +338,7 @@ def test_get_input_pad(fg, id, out): # other, auto_link=False, replace_sws_flags=None, fg = fgb.Graph(fg) if out is None: - with pytest.raises(fgb.Graph.Error): + with pytest.raises(fgb.FiltergraphPadNotFoundError): fg.get_input_pad(id) else: assert fg.get_input_pad(id) == out @@ -196,7 +362,7 @@ def test_get_output_pad(fg, id, out): # other, auto_link=False, replace_sws_flags=None, fg = fgb.Graph(fg) if out is None: - with pytest.raises(fgb.Graph.Error): + with pytest.raises(fgb.FiltergraphPadNotFoundError): fg.get_output_pad(id) else: assert fg.get_output_pad(id) == out @@ -206,9 +372,9 @@ def test_get_output_pad(fg, id, out): "fg, r, to_l,to_r,chain, out", [ # fmt: off - ("[a]fps;crop[b]", "[c]trim;scale[d]", ['b'], ['c'], None, "[a]fps;crop[b];[b]trim;scale[d]"), - ("[la]fps;crop[lb]", "[lb]trim;scale[la]", ['lb'], ['lb'], None, "[la1]fps;crop[lb];[lb]trim;scale[la2]"), - ("[a]fps;crop[b]", "[c]trim;scale[d]", ['b'], ['c'], True, "[a]fps;crop,trim;scale[d]"), + ("[a1]fps;crop[b]", "[c]trim;scale[d1]", ['b'], ['c'], None, "[a1]fps[UNC2];[UNC0]crop[L0];[L0]trim[UNC3];[UNC1]scale[d1]"), + ("[la]fps;crop[lb]", "[lb]trim;scale[la]", ['lb'], ['lb'], None, "[la]fps[UNC2];[UNC0]crop[L0];[L0]trim[UNC3];[UNC1]scale[UNC4]"), + ("[a1]fps;crop[b]", "[c]trim;scale[d1]", ['b'], ['c'], True, "[a1]fps[UNC2];[UNC0]crop[L0];[L0]trim[UNC3];[UNC1]scale[d1]"), # fmt: on ], ) @@ -219,33 +385,34 @@ def test_connect(fg, r, to_l, to_r, chain, out): with pytest.raises(fgb.Graph.Error): fg = fg.connect(r, to_l, to_r, chain) else: - fg = fg.connect(r, to_l, to_r, chain) - assert str(fg) == out + fg = fg.connect(r, to_l, to_r, chain_siso=chain) + assert fg.compose() == out @pytest.mark.parametrize( - "fg, r, how, match_scalar, ignore_labels, out", + "fg, r, how, unlabeled_only, out", [ # fmt: off - ("fps;crop", "trim;scale", None, False, False, "fps,trim;crop,scale"), - ("[a]fps;crop[b]", "[c]trim;scale[d]", None, False, True, "[a]fps,trim;crop,scale[d]"), - ("fps;crop", "trim", None, True, False, "fps,trim;crop,trim"), - ("fps", "trim;crop", None, True, False, "fps,trim;fps,crop"), - ("fps", "overlay", 'per_chain', False, False, "fps[L0];[L0]overlay"), - ("fps", "overlay", 'all', True, False, "fps[L0];fps[L1];[L0][L1]overlay"), - ("fps", "overlay", 'chainable', True, False, "fps[L0];[L0]overlay"), + ("fps;crop", "trim;scale", None, False, "[UNC0]fps,trim[UNC2];[UNC1]crop,scale[UNC3]"), + ("[in1]fps;crop[ou1]", "[in2]trim;scale[out2]", None, True, "[in1]fps[L0];[UNC0]crop[L1];[L0]trim[UNC1];[L1]scale[out2]"), + ("fps", "overlay", 'per_chain', False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), # fmt: on ], ) -def test_join(fg, r, how, match_scalar, ignore_labels, out): +def test_join(fg, r, how, unlabeled_only, out): # other, auto_link=False, replace_sws_flags=None, fg = fgb.Graph(fg) if out is None: with pytest.raises(fgb.Graph.Error): - fg = fg.join(r, how, match_scalar, ignore_labels) + fg = fg.join(r, how, unlabeled_only=unlabeled_only) else: - fg = fg.join(r, how, match_scalar, ignore_labels) - assert str(fg) == out + fg = fg.join(r, how, unlabeled_only=unlabeled_only) + assert fg.compose() == out + + +def test_iter(): + fg = fgb.Graph("[0:v][1:v]vstack=inputs=2,split=outputs=2") + [*fg.iter_output_pads(pad=1, full_pad_index=True)] # @pytest.mark.parametrize( @@ -265,24 +432,41 @@ def test_join(fg, r, how, match_scalar, ignore_labels, out): def test_filter_arithmetics(): fg1 = fgb.trim() + fgb.crop() assert isinstance(fg1, fgb.Chain) - assert str(fg1) == "trim,crop" + assert fg1.compose() == "trim,crop" fg2 = fgb.fps() | fgb.scale() assert isinstance(fg2, fgb.Graph) - assert str(fg2) == "fps;scale" + assert fg2.compose() == "[UNC0]fps[UNC2];[UNC1]scale[UNC3]" fg3 = fgb.setpts() * 3 assert isinstance(fg3, fgb.Graph) - assert str(fg3) == "setpts;setpts;setpts" - - assert str(("[in]" >> fgb.geq()) >> "[out]") == "[in]geq[out]" - assert str("[in]" >> (fgb.geq() >> "[out]")) == "[in]geq[out]" + assert fg3.compose() == "[UNC0]setpts[UNC3];[UNC1]setpts[UNC4];[UNC2]setpts[UNC5]" + + assert (("[in]" >> fgb.geq()) >> "[out]").compose() == "[in]geq[out]" + assert ("[in]" >> (fgb.geq() >> "[out]")).compose() == "[in]geq[out]" + + assert ( + ["[0:v]", "[1:v]"] >> fgb.vstack(inputs=2) + ).compose() == "[0:v][1:v]vstack=inputs=2[UNC0]" + assert ( + fgb.split(2) >> [(1, "[main]"), "[sub]"] + ).compose() == "[UNC0]split=2[sub][main]" + fc = fgb.vstack(inputs=2) + fgb.split(outputs=2) + assert ( + [("[0:v]", 1), "[1:v]"] >> fc + ).compose() == "[1:v][0:v]vstack=inputs=2,split=outputs=2[UNC0][UNC1]" + assert ( + fc >> ["[main]", "[sub]"] + ).compose() == "[UNC0][UNC1]vstack=inputs=2,split=outputs=2[main][sub]" + assert ( + ["[0:v]", "[1:v]"] >> fgb.Graph(fc) >> [(1, "[main]"), "[sub]"] + ).compose() == "[0:v][1:v]vstack=inputs=2,split=outputs=2[sub][main]" fg1 = fgb.trim() >> fgb.crop() - assert str(fg1) == "trim,crop" + assert fg1.compose() == "trim,crop" fg1 = "trim" >> fgb.crop() - assert str(fg1) == "trim,crop" + assert fg1.compose() == "trim,crop" def test_filter_empty_handling(): @@ -291,14 +475,14 @@ def test_filter_empty_handling(): fg3 = fgb.Chain() fg4 = fgb.Graph() - assert str(fg3 * 2) == "" - assert str(fg4 * 2) == "" + assert (fg3 * 2).compose() == "" + assert (fg4 * 2).compose() == "" - assert str(fg1 + fg3) == "trim,crop" - assert str(fg1 | fg3) == "trim,crop" + assert (fg1 + fg3).compose() == "trim,crop" + assert (fg1 | fg3).compose() == "trim,crop" - assert str(fg2 + fg3) == "fps;scale" - assert str(fg2 | fg3) == "fps;scale" + assert (fg2 + fg3).compose() == "[UNC0]fps[UNC2];[UNC1]scale[UNC3]" + assert (fg2 | fg3).compose() == "[UNC0]fps[UNC2];[UNC1]scale[UNC3]" def test_script(): @@ -316,8 +500,30 @@ def test_script(): def test_ops(): - assert str(Chain("scale") + "overlay") == "scale[L0];[L0]overlay" - assert str("scale" + Chain("overlay")) == "scale[L0];[L0]overlay" + assert ( + Chain("scale") + "overlay" + ).compose() == "[UNC0]scale[L0];[L0][UNC1]overlay[UNC2]" + assert ( + "scale" + Chain("overlay") + ).compose() == "[UNC0]scale[L0];[L0][UNC1]overlay[UNC2]" + + +def test_readme(): + """make sure readme example works as advertized""" + + v0 = "[0]" >> fgb.trim(start_frame=10, end_frame=20) + v1 = "[0]" >> fgb.trim(start_frame=30, end_frame=40) + v3 = "[1]" >> fgb.hflip() + v2 = (v0 | v1) + fgb.concat(2) + v5 = ( + (v2 | v3) + + fgb.overlay(eof_action="repeat") + + fgb.drawbox(50, 50, 120, 120, "red", t=5) + ) + print(v5) + assert v5.get_num_inputs() == 0 + # + # FFmpeg expression: "[0]trim=start_frame=10:end_frame=20[L0];[0]trim=start_frame=30:end_frame=40[L1];[L0][L1]concat=2[L2];[1]hflip[L3];[L2][L3]overlay=eof_action=repeat,drawbox=50:50:120:120:red:t=5" if __name__ == "__main__": diff --git a/tests/test_filtergraph_abc.py b/tests/test_filtergraph_abc.py new file mode 100644 index 00000000..cf488936 --- /dev/null +++ b/tests/test_filtergraph_abc.py @@ -0,0 +1,241 @@ +from ffmpegio import filtergraph as fgb +import pytest + + +# def get_num_pads(self, input: bool) -> int: +# def get_num_inputs(self) -> int: +# def get_num_outputs(self) -> int: +@pytest.mark.parametrize( + "cls,expr,nin,nout", + [ + (fgb.Filter, "split=outputs=4", 1, 4), + (fgb.Filter, "color", 0, 1), + (fgb.Filter, "anullsink", 1, 0), + (fgb.Chain, "split=outputs=4,vstack=inputs=4", 4, 4), + (fgb.Graph, "split=outputs=4[out],[0:v][1:v][2:v]vstack=inputs=4", 1, 4), + (fgb.Graph, "split=outputs=2[L1][L2];[L1]scale[O1];[L2]scale[O2]", 1, 2), + ], +) +def test_get_num_pads(cls, expr, nin, nout): + fg = cls(expr) + assert fg.get_num_pads(True) == nin + assert fg.get_num_pads(False) == nout + + +# def next_input_pad( +# self, pad=None, filter=None, chain=None, chainable_first: bool = False +# ) -> PAD_INDEX: + + +@pytest.mark.parametrize( + "cls, expr, pad, filter, chain, chainable_first, ret", + [ + (fgb.Filter, "vstack", None, None, None, False, (0,)), + (fgb.Filter, "vstack", None, None, None, True, (1,)), + (fgb.Filter, "vstack", 0, None, None, False, (0,)), + (fgb.Filter, "vstack", 1, None, None, False, (1,)), + (fgb.Filter, "vstack", 2, None, None, False, -2), + (fgb.Filter, "vstack", -1, None, None, False, (1,)), + (fgb.Filter, "vstack", None, 0, None, False, (0,)), + (fgb.Filter, "vstack", None, 1, None, False, -2), + (fgb.Filter, "vstack", None, None, 0, False, (0,)), + (fgb.Filter, "vstack", None, None, 1, False, -2), + (fgb.Filter, "vstack", None, None, None, False, (0,)), + (fgb.Filter, "color", None, None, None, False, None), + (fgb.Chain, "split,vstack", None, None, None, False, (0, 0)), + (fgb.Chain, "split,vstack", None, 1, None, False, (1, 0)), + (fgb.Chain, "split,vstack", None, 1, None, True, (1, 1)), + # (fgb.Graph, "split=outputs=4[out],[0:v][1:v][2:v]vstack=inputs=4", 1, 4), + # (fgb.Graph, "split=outputs=2[L1][L2];[L1]scale[O1];[L2]scale[O2]", 1, 2), + ], +) +def test_next_input_pad(cls, expr, pad, filter, chain, chainable_first, ret): + fg = cls(expr) + try: + assert fg.next_input_pad(pad, filter, chain, chainable_first) == ret + except StopIteration: + assert ret == -1 + except fgb.FiltergraphInvalidIndex: + assert ret == -2 + + +@pytest.mark.parametrize( + "index_or_label, ret, is_input, chain_id_omittable, filter_id_omittable, pad_id_omittable, resolve_omitted, chain_fill_value, filter_fill_value, pad_fill_value, chainable_first", + [ + (None, None, True, False, False, False, False, None, None, None, False), + (None, (None, None, None), True, True, True, True, False, None, None, None, False), + (None, (0, 0, 0), True, True, True, True, True, 0, 0, 0, False), + (None, (0, 0, 0), True, True, True, True, True, 0, 0, 0, False), + (None, (0, 0, 0), True, True, True, True, True, None, None, None, False), + (None, (0, 0, 1), True, True, True, True, True, None, None, None, True), + # (None, (0, 0, 0), True, True, True, True, True, 0, 0, 0, False), + (1, (None, None, 1), True, True, True, True, False, None, None, None, False), + (1, (0, 0, 1), True, True, True, True, True, 0, 0, 0, False), + (1, (0, 0, 1), True, True, True, True, True, 0, 0, 0, False), + ], +) +def test_resolve_pad_index( + index_or_label, + ret, + is_input, + chain_id_omittable, + filter_id_omittable, + pad_id_omittable, + resolve_omitted, + chain_fill_value, + filter_fill_value, + pad_fill_value, + chainable_first, +): + fg = fgb.Chain("vstack,split") + + if ret is None: + with pytest.raises(fgb.FiltergraphPadNotFoundError): + fg.resolve_pad_index( + index_or_label, + is_input=is_input, + chain_id_omittable=chain_id_omittable, + filter_id_omittable=filter_id_omittable, + pad_id_omittable=pad_id_omittable, + resolve_omitted=resolve_omitted, + chain_fill_value=chain_fill_value, + filter_fill_value=filter_fill_value, + pad_fill_value=pad_fill_value, + chainable_first=chainable_first, + ) + else: + assert ( + fg.resolve_pad_index( + index_or_label, + is_input=is_input, + chain_id_omittable=chain_id_omittable, + filter_id_omittable=filter_id_omittable, + pad_id_omittable=pad_id_omittable, + resolve_omitted=resolve_omitted, + chain_fill_value=chain_fill_value, + filter_fill_value=filter_fill_value, + pad_fill_value=pad_fill_value, + chainable_first=chainable_first, + ) + == ret + ) + + +# def next_output_pad( +# self, pad=None, filter=None, chain=None, chainable_first: bool = False +# ) -> PAD_INDEX: +# def iter_input_pads( +# self, +# pad: int | None = None, +# filter: int | None = None, +# chain: int | None = None, +# *, +# exclude_chainable: bool = False, +# chainable_first: bool = False, +# include_connected: bool = False, +# exclude_named: bool = False, +# ) -> Generator[tuple[PAD_INDEX, fgb.Filter]]: +# def iter_output_pads( +# self, +# pad: int | None = None, +# filter: int | None = None, +# chain: int | None = None, +# *, +# exclude_chainable: bool = False, +# chainable_first: bool = False, +# include_connected: bool = False, +# exclude_named: bool = False, +# ) -> Generator[tuple[PAD_INDEX, fgb.Filter, PAD_INDEX | None]]: +# def iter_input_labels( +# self, exclude_stream_specs: bool = False +# ) -> Generator[tuple[str, PAD_INDEX]]: +# def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: +# def get_label( +# self, +# input: bool = True, +# index: PAD_INDEX | None = None, +# inpad: PAD_INDEX | None = None, +# outpad: PAD_INDEX | None = None, +# ) -> str | None: +# def add_label( +# self, +# label: str, +# inpad: PAD_INDEX | Sequence[PAD_INDEX] = None, +# outpad: PAD_INDEX = None, +# force: bool = None, +# ) -> fgb.Graph: +# def __getitem__(self, key): ... +# def __str__(self): +# def __repr__(self) -> str: ... +# def __add__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: +# def __radd__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: +# def __mul__(self, __n: int) -> fgb.Graph: +# def __rmul__(self, __n: int) -> fgb.Graph: +# def __or__(self, other: FilterGraphObject | str) -> fgb.Graph: +# def __ror__(self, other: FilterGraphObject | str) -> fgb.Graph: +# def __rshift__( +# self, +# other: ( +# FilterGraphObject +# | str +# | tuple[FilterGraphObject, PAD_INDEX | str] +# | tuple[FilterGraphObject, PAD_INDEX | str, PAD_INDEX | str] +# | list[ +# FilterGraphObject +# | str +# | tuple[FilterGraphObject, PAD_INDEX | str] +# | tuple[FilterGraphObject, PAD_INDEX | str, PAD_INDEX | str] +# ] +# ), +# ) -> fgb.Graph: +# def __rrshift__( +# self, +# other: ( +# FilterGraphObject +# | str +# | tuple[PAD_INDEX | str, FilterGraphObject] +# | tuple[PAD_INDEX | str, PAD_INDEX | str, FilterGraphObject] +# | list[ +# FilterGraphObject +# | str +# | tuple[PAD_INDEX | str, FilterGraphObject] +# | tuple[PAD_INDEX | str, PAD_INDEX | str, FilterGraphObject] +# ] +# ), +# ) -> fgb.Graph: +# def _chain( +# self, +# on_left: bool, +# other: fgb.abc.FilterGraphObject, +# chain_id: int, +# other_chain_id: int, +# ) -> fgb.Chain | fgb.Graph: +# def resolve_pad_index( +# self, +# index_or_label: PAD_INDEX | str | None, +# *, +# is_input: bool = True, +# chain_id_omittable: bool = False, +# filter_id_omittable: bool = False, +# pad_id_omittable: bool = False, +# resolve_omitted: bool = True, +# chain_fill_value: int | None = None, +# filter_fill_value: int | None = None, +# pad_fill_value: int | None = None, +# chainable_first: bool = False, +# ) -> 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 _check_partial_pad_index( +# self, index: tuple[int | None, int | None, int | None], is_input: bool +# ) -> bool: +# def _input_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: +# def _output_pad_is_chainable(self, index: tuple[int, int, int]) -> bool: + +# def _attach( +# self, +# is_input: bool, +# other: fgb.abc.FilterGraphObject, +# index: PAD_INDEX | list[PAD_INDEX], +# other_index: PAD_INDEX | list[PAD_INDEX], +# ) -> fgb.Chain | fgb.Graph: diff --git a/tests/test_filtergraph_build.py b/tests/test_filtergraph_build.py new file mode 100644 index 00000000..62279fca --- /dev/null +++ b/tests/test_filtergraph_build.py @@ -0,0 +1,78 @@ +from os import path +from tempfile import TemporaryDirectory +from ffmpegio import ffmpegprocess, filtergraph as fgb +from ffmpegio.filtergraph import Chain +from pprint import pprint +import pytest + + +@pytest.mark.parametrize( + "left,right, from_left, to_right, chain_siso, ret", + [ + # fmt: off + ("scale", "fps",(0,0,0),(0,0,0),True,'scale,fps'), + ("scale", "fps",(0,0,0),(0,0,0),False,'[UNC0]scale[L0];[L0]fps[UNC1]'), + ("split", "fps",(0,0,1),(0,0,0),True,'[UNC0]split[UNC1][L0];[L0]fps[UNC2]'), + ("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", "[in1][0:v]vstack[out]",(0,0,0),(0,0,0),True,'[UNC0]scale[L0];[L0][0:v]vstack[out]'), + # fmt: on + ], +) +def test_connect(left, right, from_left, to_right, chain_siso, ret): + + fg = fgb.connect(left, right, from_left, to_right, chain_siso=chain_siso) + + assert fg.compose() == ret + + +@pytest.mark.parametrize( + "left,right,how,n_links,strict,unlabeled_only,ret", + [ + # fmt: off + ("scale","fps",'all',0,False,False,'scale,fps'), + ("scale","fps,eq",'all',0,False,False,'scale,fps,eq'), + ("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]'), + ("[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]'), + # fmt: on + ], +) +def test_join(left, right, how, n_links, strict, unlabeled_only, ret): + + if ret is None: + with pytest.raises(ValueError): + fgb.join(left, right, how, n_links, strict, unlabeled_only) + else: + fg = fgb.join(left, right, how, n_links, strict, unlabeled_only) + assert fg.compose() == ret + + +@pytest.mark.parametrize( + "left,right,left_on,right_on,ret", + [ + # fmt: off + ("scale","fps",(0,0,0),(0,0,0),'scale,fps'), + ("scale","fps",None,None,'scale,fps'), + ("scale","[out]",None,None,'[UNC0]scale[out]'), + ("[in]","scale",None,None,'[in]scale[UNC0]'), + ("[in]split",["fps","out"],None,None,'[in]split[L0][out];[L0]fps[UNC0]'), + (["in","fps"],"vstack",None,None,'[UNC0]fps[L0];[in][L0]vstack[UNC1]'), + # fmt: on + ], +) +def test_attach(left, right, left_on, right_on, ret): + + if ret is None: + with pytest.raises(ValueError): + fgb.attach(left, right, left_on, right_on) + else: + fg = fgb.attach(left, right, left_on, right_on) + assert fg.compose() == ret diff --git a/tests/test_filtergraph_chain.py b/tests/test_filtergraph_chain.py index 1058eb00..ecfb18a3 100644 --- a/tests/test_filtergraph_chain.py +++ b/tests/test_filtergraph_chain.py @@ -3,90 +3,147 @@ logging.basicConfig(level=logging.INFO) -from ffmpegio import filtergraph as fg_lib +from ffmpegio import filtergraph as fgb from pprint import pprint import pytest def test_fchain(): - fchain = fg_lib.Chain("fps=30,format=pix_fmt=rgb24,trim=0.5:12.4") + fchain = fgb.Chain("fps=30,format=pix_fmt=rgb24,trim=0.5:12.4") fchain.insert(1, "overlay") fchain.append("split=5") print(type(fchain[1])) print(fchain) -def test_iter_pads(): - fchain = fg_lib.Chain("fps,scale2ref,overlay,split=3,concat=3") - ins = [ - (0, 0, ("fps",)), - (1, 0, ("scale2ref",)), - (2, 0, ("overlay",)), - (4, 0, ("concat", 3)), - (4, 1, ("concat", 3)), - ] - outs = [ - (4, 0, ("concat", 3)), - (3, 0, ("split", 3)), - (3, 1, ("split", 3)), - (1, 0, ("scale2ref",)), - ] - assert [*fchain.iter_input_pads()] == ins - assert [*fchain.iter_output_pads()] == outs - - assert [*fchain.iter_input_pads(filter=4)] == ins[3:5] - assert [*fchain.iter_output_pads(filter=3)] == outs[1:3] - - assert [*fchain.iter_input_pads(pad=1)] == ins[4:5] - assert [*fchain.iter_output_pads(pad=1)] == outs[2:3] - - -# @pytest.mark.parametrize( -# "fg,fc,left_on,right_on,out", -# [ -# ("fps;crop", "trim", None, None, "fps,trim;crop"), -# ("fps[out];crop", "trim", None, None, "fps[out];crop,trim"), -# ], -# ) - -# if __name__ == "__main__": -def test_resolve_index(): - with pytest.raises(fg_lib.FiltergraphPadNotFoundError): - fg_lib.Chain("color")._resolve_index(True, None) - - fchain = fg_lib.Chain("fps,scale2ref,overlay,split=3,concat=3") - - with pytest.raises(fg_lib.FiltergraphPadNotFoundError): - fchain._resolve_index(True, 2) - - assert fchain._resolve_index(True, None) == (0, 0) - assert fchain._resolve_index(False, None) == (4, 0) - assert fchain._resolve_index(True, 1) == (4, 1) - assert fchain._resolve_index(False, 1) == (3, 1) - assert fchain._resolve_index(True, (1, 0)) == (1, 0) - assert fchain._resolve_index(True, (4, None)) == (4, 0) - assert fchain._resolve_index(True, (4, 1)) == (4, 1) +@pytest.mark.parametrize( + "expr, pad, filter, chain, exclude_chainable, chainable_first, include_connected, ret", + [ + # fmt: off + ("color,nullsink", None, None, None, False, False, False, []), + ("vstack,hstack", None, None, None, False, False, False, [(0, 0),(0, 1),(1, 0)]), + ("vstack,hstack", None, None, 0, False, False, False, [(0, 0),(0, 1),(1, 0)]), + ("vstack,hstack", None, None, 1, False, False, False, None), + ("vstack,hstack", None, 0, None, False, False, False, [(0, 0),(0, 1)]), + ("vstack,hstack", None, 1, None, False, False, False, [(1, 0)]), + ("vstack,hstack", None, 2, None, False, False, False, None), + ("vstack,hstack", None, None, None, True, False, False, [(0, 0),(1, 0)]), + ("vstack,hstack", None, None, None, False, True, False, [(0, 1),(0, 0),(1, 0)]), + ("vstack,hstack", None, None, None, False, False, True, [(0, 0),(0, 1),(1, 0),(1,1)]), + # fmt: on + ], +) +def test_iter_input_pads( + expr, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + ret, +): + + fg = fgb.Chain(expr) + + it = fg.iter_input_pads( + pad, + filter, + chain, + exclude_chainable=exclude_chainable, + chainable_first=chainable_first, + include_connected=include_connected, + ) + + if ret is None: + with pytest.raises(fgb.FiltergraphInvalidIndex): + next(it) + else: + for r in ret: + index, f, out_index = next(it) + assert index == r and f == fg[r[0]] + if out_index is not None: + assert out_index[0] == index[0] - 1 + + +@pytest.mark.parametrize( + "expr, pad, filter, chain, exclude_chainable, chainable_first, include_connected, ret", + [ + # fmt: off + ("split,split", None, None, None, False, False, False, [(0, 0),(1, 0),(1, 1)]), + # fmt: on + ], +) +def test_iter_output_pads( + expr, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + include_connected, + ret, +): + + fg = fgb.Chain(expr) + + it = fg.iter_output_pads( + pad, + filter, + chain, + exclude_chainable=exclude_chainable, + chainable_first=chainable_first, + include_connected=include_connected, + ) + + if ret is None: + with pytest.raises(fgb.FiltergraphInvalidIndex): + next(it) + else: + for r in ret: + index, f, out_index = next(it) + assert index == r and f == fg[r[0]] + if out_index is not None: + assert out_index[0] == index[0] - 1 + +@pytest.mark.parametrize( + "expr, skip_if_no_input, skip_if_no_output, chainable_only, ret", + [ + ("fps,scale", False, False, False, 1), + ("fps,scale", True, True, True, 1), + ("nullsrc,fps", False, False, False, 1), + ("nullsrc,fps", True, False, False, 0), + ("fps,nullsink", False, False, False, 1), + ("fps,nullsink", False, True, False, 0), + ], +) +def test_iter_chains(expr, skip_if_no_input, skip_if_no_output, chainable_only, ret): + f = fgb.Chain(expr) + chains = [*f.iter_chains(skip_if_no_input, skip_if_no_output, chainable_only)] + assert len(chains) == ret @pytest.mark.parametrize( "op, lhs,rhs,expected", [ # fmt:off - (operator.__add__, fg_lib.Chain("scale"), "overlay", "scale[L0];[L0]overlay"), - (operator.__add__, "scale", fg_lib.Chain("overlay"), "scale[L0];[L0]overlay"), - (operator.__rshift__, fg_lib.Chain("split"), "hflip", "split[L0];[L0]hflip"), - (operator.__rshift__, fg_lib.Chain("split"), (1, "overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, fg_lib.Chain("split"), (1, "[in]overlay"), "split[in];[in]overlay"), - (operator.__rshift__, fg_lib.Chain("split"), (1, 1, "overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, fg_lib.Chain("split"), (None, '[over]', "[base][over]overlay"), "split[over];[base][over]overlay"), - (operator.__rshift__, "hflip", fg_lib.Chain("overlay"), "hflip[L0];[L0]overlay"), - (operator.__rshift__, ("split",1), fg_lib.Chain("overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, ("split",(0,1)), fg_lib.Chain("overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, ("split[out]",1), fg_lib.Chain("overlay"), "split[out];[out]overlay"), - (operator.__rshift__, ("split[out]", '[out]',None), fg_lib.Chain("overlay"), "split[out];[out]overlay"), - # (operator.__rshift__, fg_lib.Graph("split[out1][out2]"), ('[out1]', '[over]', "[base][over]overlay"), "split[out1][out2];[base][out1]overlay"), + (operator.__add__, fgb.Chain("scale"), "overlay", "[UNC0]scale[L0];[L0][UNC1]overlay[UNC2]"), + (operator.__add__, "scale", fgb.Chain("overlay"), "[UNC0]scale[L0];[L0][UNC1]overlay[UNC2]"), + (operator.__rshift__, fgb.Chain("split"), "hflip", "[UNC0]split[L0][UNC1];[L0]hflip[UNC2]"), + (operator.__rshift__, fgb.Chain("split"), (1, "overlay"), "[UNC0]split[UNC2][L0];[L0][UNC1]overlay[UNC3]"), + (operator.__rshift__, fgb.Chain("split"), (1, "[in]overlay"), "[UNC0]split[UNC2][L0];[L0][UNC1]overlay[UNC3]"), + (operator.__rshift__, fgb.Chain("split"), (1, 1, "overlay"), "[UNC0]split[UNC2][L0];[UNC1][L0]overlay[UNC3]"), + (operator.__rshift__, fgb.Chain("split"), (None, '[over]', "[base][over]overlay"), "[UNC0]split[L0][UNC1];[base][L0]overlay[UNC2]"), + (operator.__rshift__, "hflip", fgb.Chain("overlay"), "[UNC0]hflip[L0];[L0][UNC1]overlay[UNC2]"), + (operator.__rshift__, ("split",1), fgb.Chain("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), + (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__, 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 ], ) def test_ops(op, lhs, rhs, expected): - assert str(op(lhs, rhs)) == expected + assert op(lhs, rhs).compose() == expected diff --git a/tests/test_utils_fglinks.py b/tests/test_filtergraph_fglinks.py similarity index 65% rename from tests/test_utils_fglinks.py rename to tests/test_filtergraph_fglinks.py index 3c1c6c72..6cfa5526 100644 --- a/tests/test_utils_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -1,9 +1,8 @@ import logging -from ffmpegio.caps import filters logging.basicConfig(level=logging.INFO) -from ffmpegio.utils.fglinks import GraphLinks +from ffmpegio.filtergraph.GraphLinks import GraphLinks from pprint import pprint import pytest @@ -16,8 +15,8 @@ (((1, 2, 3), (4, 5, 6)), 2), ], ) -def test_iter_dst_ids(dsts, expects): - assert len(list(GraphLinks.iter_dst_ids(dsts))) == expects +def test_iter_inpad_ids(dsts, expects): + assert len(list(GraphLinks.iter_inpad_ids(dsts))) == expects @pytest.mark.parametrize( @@ -26,9 +25,9 @@ def test_iter_dst_ids(dsts, expects): (("0:v",), True), (("label",), True), (("-label",), False), - ((0,), True), - ((0.0,), False), - ((0, True), False), + ((0, True), True), + ((0.0, True), False), + ((0, False), False), ], ) def test_validate_label(args, ok): @@ -48,12 +47,12 @@ def test_validate_label(args, ok): ((0, 0, "0"), False), ], ) -def test_validate_pad_id(id, ok): +def test_validate_pad_idx(id, ok): if ok: - GraphLinks.validate_pad_id(id) + GraphLinks.validate_pad_idx(id) else: with pytest.raises(GraphLinks.Error): - GraphLinks.validate_pad_id(id) + GraphLinks.validate_pad_idx(id) @pytest.mark.parametrize( @@ -66,12 +65,12 @@ def test_validate_pad_id(id, ok): (((None,), (0, 0, 0)), False), ], ) -def test_validate_pad_id_pair(ids, ok): +def test_validate_pad_idx_pair(ids, ok): if ok: - GraphLinks.validate_pad_id_pair(ids) + GraphLinks.validate_pad_idx_pair(ids) else: with pytest.raises(GraphLinks.Error): - GraphLinks.validate_pad_id_pair(ids) + GraphLinks.validate_pad_idx_pair(ids) @pytest.mark.parametrize( @@ -105,8 +104,8 @@ def test_validate(data, ok): with pytest.raises(GraphLinks.Error): GraphLinks.validate(data) -@pytest.mark.parametrize( +@pytest.mark.parametrize( ("args", "expects"), [ (((0, 0, 0), None), ((0, 0, 0), None)), @@ -134,9 +133,9 @@ def base_links(): "l": ((0, 0, 0), (0, 0, 0)), # regular link 0: ((1, 1, 0), (0, 1, 0)), # unnamed link "in": ((2, 1, 0), None), # named input - "min": ([(3, 0, 0), (3, 1, 0)], None), # named inputs + "0:v": ([(3, 0, 0), (3, 1, 0)], None), # named inputs "out": (None, (1, 1, 0)), # named output - "sout1": (None, (1, 0, 0)), # split output label#1 + "sout1": (None, (6, 0, 0)), # split output label#2 "sout2": ((2, 0, 0), (1, 0, 0)), # split output label#2 } ) @@ -152,19 +151,13 @@ def test_init(base_links): [ ([0, 3, None], [0, 1, 2]), (["a", "b"], ["a", "b"]), - (["a0", "b1", "c2"], ["a0", "b1", "c1"]), - (["a2", "a3"], ["a1", "a2"]), # 2 - (["a", "a"], ["a1", "a2"]), # numbered named links - (["a", "a3"], ["a1", "a2"]), # numbered named links - (["a1", "a"], ["a1", "a2"]), # numbered named links - (["a1", "a3"], ["a1", "a2"]), # numbered named links ], ) -def test_register_label(labels, expects): +def test_resolve_label(labels, expects): links = GraphLinks() def update(label): - links.data[links._register_label(label)] = None + links.data[links._resolve_label(label)] = None for label in labels: update(label) @@ -189,11 +182,11 @@ def test_iter_links(base_links): def test_iter_inputs(base_links): res = { ("in", (2, 1, 0)), # regular link - ("min", (3, 0, 0)), # unnamed link - ("min", (3, 1, 0)), # split output label#2 + ("0:v", (3, 0, 0)), # unnamed link + ("0:v", (3, 1, 0)), # split output label#2 } - for v in base_links.iter_inputs(): + for v in base_links.iter_inputs(exclude_stream_specs=False): assert v in res res.discard(v) @@ -203,7 +196,7 @@ def test_iter_inputs(base_links): def test_iter_outputs(base_links): res = { ("out", (1, 1, 0)), # regular link - ("sout1", (1, 0, 0)), # split output label#2 + ("sout1", (6, 0, 0)), # split output label#2 } for v in base_links.iter_outputs(): @@ -213,19 +206,19 @@ def test_iter_outputs(base_links): assert not len(res) -def test_iter_dsts(base_links): +def test_iter_input_pads(base_links): res = { ("l", (0, 0, 0), (0, 0, 0)), # regular link (0, (1, 1, 0), (0, 1, 0)), # unnamed link ("in", (2, 1, 0), None), # named input - ("min", (3, 0, 0), None), # named inputs - ("min", (3, 1, 0), None), # named inputs + ("0:v", (3, 0, 0), None), # named inputs + ("0:v", (3, 1, 0), None), # named inputs ("out", None, (1, 1, 0)), # named output - ("sout1", None, (1, 0, 0)), # split output label#1 + ("sout1", None, (6, 0, 0)), # split output label#1 ("sout2", (2, 0, 0), (1, 0, 0)), # split output label#2 } - for v in base_links.iter_dsts(): + for v in base_links.iter_input_pads(): assert v in res res.discard(v) @@ -246,7 +239,7 @@ def test__getitem__(key, expects, base_links): @pytest.mark.parametrize( ("key", "expects"), - [("l", 0), (0, 0), ("in", 1), ("min", 1), ("sout1", 2)], + [("l", 0), (0, 0), ("in", 1), ("0:v", 1), ("sout1", 2)], ) def test_label_checks(key, expects, base_links): @@ -266,83 +259,77 @@ def test_label_checks(key, expects, base_links): ((10, 0, 0), None, False), ], ) -def test_find_dst_labels(id, label, input, base_links): - retval = base_links.find_dst_label(id) +def test_find_inpad_labels(id, label, input, base_links): + retval = base_links.find_inpad_label(id) assert retval == label if label else retval is None - retval = base_links.find_input_label(id) - assert retval == label if input else retval is None @pytest.mark.parametrize( ("id", "label", "output"), [ - ((0, 0, 0), ["l"], False), - ((1, 1, 0), ["out"], ["out"]), + ((0, 0, 0), "l", False), + ((1, 1, 0), "out", "out"), (None, None, False), - ((1, 0, 0), ["sout1", "sout2"], ["sout1"]), ], ) -def test_find_src_labels(id, label, output, base_links): +def test_find_outpad_labels(id, label, output, base_links): - retval = base_links.find_src_labels(id) - retval1 = base_links.find_output_labels(id) + retval = base_links.find_outpad_label(id) if label is None: - assert retval is None and retval1 is None + assert retval is None else: assert retval == (label if label else []) - assert retval1 == (output if output else []) @pytest.mark.parametrize( - ("label", "dst", "src"), + ("dst", "src", "res"), [ - (None, None, (0, 0, 0)), - (None, (0, 0, 0), None), - ("sout2", (2, 0, 0), (1, 0, 0)), - (None, (4, 0, 0), (1, 0, 0)), + ((0, 0, 0), (0, 0, 0), True), # regular link + ((1, 1, 0), (0, 1, 0), True), # unnamed link + ((1, 1, 0), (0, 1, 1), False), # unnamed link + ((2, 1, 0), None, False), # named input + ((3, 0, 0), None, False), # named inputs ], ) -def test_links(label, src, dst, base_links): +def test_links(src, dst, res, base_links): - if label is None: - assert not base_links.are_linked(dst, src) - assert base_links.find_link_label(dst, src) is None - else: - assert base_links.are_linked(dst, src) - assert base_links.find_link_label(dst, src) == label + assert base_links.are_linked(dst, src) == res def test_unlink(base_links): base_links.unlink(label="l") assert "l" not in base_links - base_links.unlink(src=(1, 0, 0)) + base_links.unlink(outpad=(6, 0, 0)) assert "sout1" not in base_links - assert "sout2" not in base_links - base_links.unlink(dst=(1, 1, 0)) + base_links.unlink(inpad=(1, 1, 0)) assert 0 not in base_links - base_links.unlink(dst=(3, 0, 0)) - assert "min" in base_links # the other input still present + base_links.unlink(inpad=(3, 0, 0)) + assert "0:v" in base_links # the other input still present @pytest.mark.parametrize( ("args", "ok", "unlinked"), [ + # fmt:off (((4, 0, 0), (4, 0, 0)), 1, None), # no conflict (((4, 0, 0), (4, 0, 0), "test"), "test", None), # no conflict - (((4, 0, 0), (4, 0, 0), "l"), "l2", None), # no conflict, label number mod + (((4, 0, 0), (4, 0, 0), 0), 1, None), # no conflict, label number mod ((None, (4, 0, 0)), False, None), # can't do output label (((4, 0, 0), None), False, None), # can't do input label - (((4, 0, 0), (0, 0, 0)), 1, None), # duplicate src ok + (((4, 0, 0), (0, 0, 0)), False, None), # duplicate src not ok (((0, 0, 0), (4, 0, 0)), False, None), # duplicate dst not ok (((0, 0, 0), (4, 0, 0), None, False, True), 1, "l"), # forced (((0, 0, 0), (4, 0, 0), None, True), False, "1"), # bad src - (((2, 1, 0), (4, 0, 0)), "in", None), # links to inherit 'in' input label - (((4, 0, 0), (1, 1, 0)), "out", None), # links to inherit 'out' output label + (((2, 1, 0), (4, 0, 0)), 1, None), # links to not inherit 'in' input label + (((2, 1, 0), (4, 0, 0), None, 'input'), "in", None), # links to inherit 'in' input label + (((4, 0, 0), (1, 1, 0), None, 'output'), "out", None), # links to inherit 'out' output label (((4, 0, 0), (1, 1, 0), None, True), 1, None), # new label - (((3, 0, 0), (4, 0, 0)), 1, None), # links to inherit 'in' input label + (((3, 0, 0), (4, 0, 0)), 1, None), + # links to inherit 'in' input label + # fmt:on ], ) def test_link(args, ok, unlinked, base_links): @@ -368,8 +355,8 @@ def test_link(args, ok, unlinked, base_links): (("test", (2, 1, 0)), False, None), # existing input (("test", (0, 0, 0)), False, None), # existing link dst (("test", (0, 0, 0), None, True), "test", "l"), # existing link dst - (("in", (2, 1, 0)), "in", None), # existing input - (("in", (4, 1, 0)), "in2", None), # new input, number adjusted + (("in", (2, 1, 0)), False, None), # existing input + (("0:v", (4, 2, 1)), "0:v", None), # new input, number adjusted ], ) def test_create_label(args, ok, unlinked, base_links): @@ -390,36 +377,21 @@ def test_update(base_links): base_links.update({}) # no action`` - assert base_links.update({"test": ((4, 0, 0), (4, 0, 0))})["test"] == "test" + base_links.update({"test": ((4, 0, 0), (4, 0, 0))}) + assert base_links["test"] == ((4, 0, 0), (4, 0, 0)) assert "test" in base_links # existing dst with pytest.raises(GraphLinks.Error): base_links.update({"test": ((4, 0, 0), (4, 0, 0))}) - assert base_links.update({"l": ((4, 0, 0), (4, 0, 0))}, force=True)["l"] == "l2" - - assert base_links.update({"in": (None, (5, 0, 0))}, auto_link=True)["in"] == "in" - assert base_links.is_linked("in") - assert base_links.update({"out": ((6, 0, 0), None)}, auto_link=True)["out"] == "out" - assert base_links.is_linked("out") - - -def test_get_repeated_src_info(base_links): - base_links.update({"link": (((4, 0, 0), (5, 0, 0)), (1, 0, 0))}) - base_links.update({"a": (((6, 0, 0), (7, 0, 0)), (2, 0, 0))}, force=True) - info = base_links.get_repeated_src_info() - assert info == { - (1, 0, 0): { - "link_0": (4, 0, 0), - "link_1": (5, 0, 0), - "sout1": None, - "sout2": (2, 0, 0), - }, - (2, 0, 0): { - "a_0": (6, 0, 0), - "a_1": (7, 0, 0), - }, - } + + base_links.update({"l": ((4, 0, 0), (4, 0, 0))}, force=True) + assert base_links["l"] == ((4, 0, 0), (4, 0, 0)) + + base_links.update({"in": (None, (5, 0, 0))}, auto_link=True) + assert base_links["in"] == ((2, 1, 0), (5, 0, 0)) + base_links.update({"out": ((6, 0, 0), None)}, auto_link=True) + assert base_links["out"] == ((6, 0, 0), (1, 1, 0)) @pytest.mark.parametrize( @@ -428,11 +400,10 @@ def test_get_repeated_src_info(base_links): ((None, (0, 0, 0)), 0, 0), (((0, 0, 0), None), 0, 0), ((((0, 0, 0), (0, 0, 1)), None), 0, 0), - ((((0, 0, 0), None), (0, 0, 0)), 1, 0), ], ) def test_remove_label(links, n, nin): - label = "label" + label = "label" if links[0] is None or len(links[0]) < 2 else "0:v" o = GraphLinks({label: links}) o.remove_label(label) assert len(o) == n @@ -443,7 +414,7 @@ def test_remove_label(links, n, nin): # "l": ((0, 0, 0), (0, 0, 0)), # regular link # 0: ((1, 1, 0), (0, 1, 0)), # unnamed link # "in": ((2, 1, 0), None), # named input -# "min": ([(3, 0, 0), (3, 1, 0)], None), # named inputs +# "0:v": ([(3, 0, 0), (3, 1, 0)], None), # named inputs # "out": (None, (1, 1, 0)), # named output # "sout1": (None, (1, 0, 0)), # split output label#1 # "sout2": ((2, 0, 0), (1, 0, 0)), # split output label#2 diff --git a/tests/test_filtergraph_filter.py b/tests/test_filtergraph_filter.py index 8409b6f5..06ecb217 100644 --- a/tests/test_filtergraph_filter.py +++ b/tests/test_filtergraph_filter.py @@ -2,21 +2,21 @@ logging.basicConfig(level=logging.INFO) -from ffmpegio import filtergraph as fg_lib +from ffmpegio import filtergraph as fgb import pytest import operator def test_Filter(): - f = fg_lib.Filter("concat") + f = fgb.Filter("concat") print(f) assert f[0] == "concat" assert f.name == "concat" assert f.id is None print(f.info) - # fg_lib.Filter('concat',2) - # fg_lib.Filter('concat') - # fg_lib.Filter('concat') + # fgb.Filter('concat',2) + # fgb.Filter('concat') + # fgb.Filter('concat') @pytest.mark.parametrize( @@ -28,10 +28,10 @@ def test_Filter(): ], ) def test_filter_get_option_value(filter_spec, option_name, expected): - f = fg_lib.Filter(filter_spec) + f = fgb.Filter(filter_spec) try: assert f.get_option_value(option_name) == expected - except fg_lib.Filter.InvalidName: + except fgb.Filter.InvalidName: pass # ffmpeg version issue @@ -57,10 +57,10 @@ def test_filter_get_option_value(filter_spec, option_name, expected): ], ) def test_filter_get_num_inputs(filter_spec, expected): - f = fg_lib.Filter(filter_spec) + f = fgb.Filter(filter_spec) try: assert f.get_num_inputs() == expected - except fg_lib.Filter.InvalidName: + except fgb.Filter.InvalidName: logging.warning(f"skipped {filter_spec}: not supported by FFmpeg") @@ -89,15 +89,126 @@ def test_filter_get_num_inputs(filter_spec, expected): ) def test_filter_get_num_outputs(filter_spec, expected): - f = fg_lib.Filter(filter_spec) + f = fgb.Filter(filter_spec) try: assert f.get_num_outputs() == expected - except fg_lib.Filter.InvalidName: + except fgb.Filter.InvalidName: logging.warning(f"skipped {filter_spec}: not supported by FFmpeg") +@pytest.mark.parametrize( + "expr, pad, filter, chain, exclude_chainable, chainable_first, ret", + [ + ("color", None, None, None, False, False, []), + ("vstack", None, None, None, False, False, [0, 1]), + ("vstack", 0, None, None, False, False, [0]), + ("vstack", 1, None, None, False, False, [1]), + ("vstack", 2, None, None, False, False, None), + ("vstack", -1, None, None, False, False, [1]), + ("vstack", None, 0, None, False, False, [0, 1]), + ("vstack", None, 1, None, False, False, None), + ("vstack", None, None, 0, False, False, [0, 1]), + ("vstack", None, None, 1, False, False, None), + ("vstack", None, None, None, True, False, [0]), + ("vstack", None, None, None, False, True, [1, 0]), + ("vstack", None, None, None, True, True, []), + ], +) +def test_iter_input_pads( + expr, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + ret, +): + + fg = fgb.Filter(expr) + + it = fg.iter_input_pads( + pad, + filter, + chain, + exclude_chainable=exclude_chainable, + chainable_first=chainable_first, + ) + + if ret is None: + with pytest.raises(fgb.FiltergraphInvalidIndex): + next(it) + else: + for r in ret: + index, f, out_index = next(it) + assert index == (r,) and f == fg and out_index == None + + +@pytest.mark.parametrize( + "expr, pad, filter, chain, exclude_chainable, chainable_first, ret", + [ + ("nullsink", None, None, None, False, False, []), + ("split", None, None, None, False, False, [0, 1]), + ("split", 0, None, None, False, False, [0]), + ("split", 1, None, None, False, False, [1]), + ("split", 2, None, None, False, False, None), + ("split", -1, None, None, False, False, [1]), + ("split", None, 0, None, False, False, [0, 1]), + ("split", None, 1, None, False, False, None), + ("split", None, None, 0, False, False, [0, 1]), + ("split", None, None, 1, False, False, None), + ("split", None, None, None, True, False, [0]), + ("split", None, None, None, False, True, [1, 0]), + ("split", None, None, None, True, True, []), + ], +) +def test_iter_output_pads( + expr, + pad, + filter, + chain, + exclude_chainable, + chainable_first, + ret, +): + + fg = fgb.Filter(expr) + + it = fg.iter_output_pads( + pad, + filter, + chain, + exclude_chainable=exclude_chainable, + chainable_first=chainable_first, + ) + + if ret is None: + with pytest.raises(fgb.FiltergraphInvalidIndex): + next(it) + else: + for r in ret: + index, f, in_index = next(it) + assert index == (r,) and f == fg and in_index == None + + +@pytest.mark.parametrize( + "expr, skip_if_no_input, skip_if_no_output, chainable_only, ret", + [ + ("fps", False, False, False, 1), + ("fps", True, True, True, 1), + ("nullsrc", False, False, False, 1), + ("nullsrc", True, False, False, 0), + ("nullsink", False, False, False, 1), + ("nullsink", False, True, False, 0), + ], +) +def test_iter_chains(expr, skip_if_no_input, skip_if_no_output, chainable_only, ret): + f = fgb.Filter(expr) + chains = [*f.iter_chains(skip_if_no_input, skip_if_no_output, chainable_only)] + assert len(chains) == ret + + def test_apply(): - f = fg_lib.Filter("fade=in:5:20:color=yellow") + f = fgb.Filter("fade=in:5:20:color=yellow") print(str(f)) f1 = f.apply({1: "in", 2: 4, "color": "red"}) @@ -108,23 +219,26 @@ def test_apply(): @pytest.mark.parametrize( "op, lhs,rhs,expected", [ - (operator.__add__, fg_lib.Filter("scale"), "overlay", "scale[L0];[L0]overlay"), - (operator.__add__, "scale", fg_lib.Filter("overlay"), "scale[L0];[L0]overlay"), - (operator.__rshift__, fg_lib.Filter("split"), "hflip", "split[L0];[L0]hflip"), - (operator.__rshift__, fg_lib.Filter("split"), (1, "overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, fg_lib.Filter("split"), (1, "[in]overlay"), "split[in];[in]overlay"), - (operator.__rshift__, fg_lib.Filter("split"), (1, 1, "overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, fg_lib.Filter("split"), (None, '[over]', "[base][over]overlay"), "split[over];[base][over]overlay"), - (operator.__rshift__, "hflip", fg_lib.Filter("overlay"), "hflip[L0];[L0]overlay"), - (operator.__rshift__, ("split",1), fg_lib.Filter("overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, ("split",(0,1)), fg_lib.Filter("overlay"), "split[L0];[L0]overlay"), - (operator.__rshift__, ("split[out]",1), fg_lib.Filter("overlay"), "split[out];[out]overlay"), - (operator.__rshift__, ("split[out]", '[out]',None), fg_lib.Filter("overlay"), "split[out];[out]overlay"), - # (operator.__rshift__, fg_lib.Graph("split[out1][out2]"), ('[out1]', '[over]', "[base][over]overlay"), "split[out1][out2];[base][out1]overlay"), + # fmt:off + (operator.__add__, fgb.Filter("scale"), "overlay", "[UNC0]scale[L0];[L0][UNC1]overlay[UNC2]"), + (operator.__add__, "scale", fgb.Filter("overlay"), "[UNC0]scale[L0];[L0][UNC1]overlay[UNC2]"), + (operator.__rshift__, fgb.Filter("split"), "hflip", "[UNC0]split[L0][UNC1];[L0]hflip[UNC2]"), + (operator.__rshift__, fgb.Filter("split"), (1, "overlay"), "[UNC0]split[UNC2][L0];[L0][UNC1]overlay[UNC3]"), + (operator.__rshift__, fgb.Filter("split"), (1, "[in]overlay"), "[UNC0]split[UNC2][L0];[L0][UNC1]overlay[UNC3]"), # X + (operator.__rshift__, fgb.Filter("split"), (1, 1, "overlay"), "[UNC0]split[UNC2][L0];[UNC1][L0]overlay[UNC3]"), + (operator.__rshift__, fgb.Filter("split"), (None, "[over]", "[base][over]overlay"), "[UNC0]split[L0][UNC1];[base][L0]overlay[UNC2]"), # X + (operator.__rshift__, "hflip", fgb.Filter("overlay"), "[UNC0]hflip[L0];[L0][UNC1]overlay[UNC2]"), + (operator.__rshift__, ("split", 1), fgb.Filter("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), # X + (operator.__rshift__, ("split", (0, 1)), fgb.Filter("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), # X + (operator.__rshift__, ("split[out]", 1), fgb.Filter("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), # X + (operator.__rshift__, ("split[out]", "[out]", None), fgb.Filter("overlay"), "[UNC0]split[L0][UNC2];[L0][UNC1]overlay[UNC3]"), + # X + # (operator.__rshift__, fgb.Graph("split[out1][out2]"), ('[out1]', '[over]', "[base][over]overlay"), "split[out1][out2];[base][out1]overlay"), + # fmt:on ], ) def test_ops(op, lhs, rhs, expected): - assert str(op(lhs, rhs)) == expected + assert op(lhs, rhs).compose() == expected if __name__ == "__name__": From bf6e5b87370654a4ce27b4dc6d4ffb7f2b90417b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Aug 2024 12:15:01 -0500 Subject: [PATCH 19/57] added type hints to _build_video_basic_filter --- src/ffmpegio/configure.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 4200542d..14c291c6 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -9,6 +9,7 @@ from . import utils, plugins from .filtergraph import Graph, Filter, Chain +from .filtergraph.abc import FilterGraphObject from .errors import FFmpegioError UrlType = Literal["input", "output"] @@ -287,14 +288,16 @@ def check_alpha_change(args, dir=None, ifile=0, ofile=0): def _build_video_basic_filter( - fill_color=None, - remove_alpha=None, - scale=None, - crop=None, - flip=None, - transpose=None, - square_pixels=None, -): + 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 = ( From 59959e23646a81fc2a4d007879e67f7c9980aec0 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 24 Aug 2024 08:40:01 -0500 Subject: [PATCH 20/57] ffmpeg version compatibility tweak --- tests/test_probe.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_probe.py b/tests/test_probe.py index ea90fd60..f28d2426 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -3,6 +3,7 @@ logging.basicConfig(level=logging.DEBUG) import pytest +from ffmpegio import ffmpeg_info import ffmpegio.probe as probe # print( @@ -28,6 +29,9 @@ def test_url_types(): out1 = probe.query(f) f.seek(0) del out1["filename"] + if ffmpeg_info()['version'] < '6': + del out['bit_rate'] + del out['size'] assert out1 == out # piping in byte content of the file yields a few other differences From adb625306f73d076a36b7ca7c575b05b79e8fab8 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 13 Oct 2024 09:31:24 -0500 Subject: [PATCH 21/57] bump python version: added 3.13, dropped 3.8 --- .github/workflows/test_n_pub.yml | 10 +++++----- pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test_n_pub.yml b/.github/workflows/test_n_pub.yml index 3e5283bc..13084c2c 100644 --- a/.github/workflows/test_n_pub.yml +++ b/.github/workflows/test_n_pub.yml @@ -15,15 +15,11 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: [3.9, "3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest, macos-latest, windows-latest] # python-version: [3.7, 3.8, 3.9] # os: [windows-latest] exclude: - - os: macos-latest - python-version: 3.8 - - os: windows-latest - python-version: 3.8 - os: macos-latest python-version: 3.9 - os: windows-latest @@ -36,6 +32,10 @@ jobs: python-version: 3.11 - os: windows-latest python-version: 3.11 + - os: macos-latest + python-version: 3.12 + - os: windows-latest + python-version: 3.12 steps: - run: echo ${{github.ref}} diff --git a/pyproject.toml b/pyproject.toml index 6108a480..62d80a50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dynamic = ["version"] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "pluggy", "packaging", From 83d2a0bc711a83c1fb072c5a24eacc6664c0f8ed Mon Sep 17 00:00:00 2001 From: Kesh Ikuma <79113787+tikuma-lsuhsc@users.noreply.github.com> Date: Fri, 15 Nov 2024 09:27:41 -0600 Subject: [PATCH 22/57] Merge plugins (#53) Merges all the "official" plugins to ffmpegio-core and publish ffmpegio distribution package as the consolidated package. The consolidated plugins: name description ffmpegio Numpy read/write ffmpegio-plugin-mpl Matplotlib figure writer ffmpegio-plugin-downloader Use the binaries downloaded by ffmpeg-downloader/ffdl python-ffmpegio-plugin-static-ffmpeg Use the binaries downloaded by static-ffmpeg Along with these plugin packages, ffmpegio-core will deprecated after v0.11.0 --- .github/workflows/test_n_pub.yml | 2 +- README.rst | 60 +++-- docsrc/finder_ffdl.rst | 52 +++++ docsrc/index.rst | 20 +- docsrc/mpl-writer.rst | 79 +++++++ docsrc/quick.rst | 4 +- docsrc/rawdata_numpy.rst | 308 +++++++++++++++++++++++++ pyproject.toml | 2 +- src/ffmpegio/__init__.py | 6 +- src/ffmpegio/_utils.py | 15 ++ src/ffmpegio/path.py | 3 - src/ffmpegio/plugins/__init__.py | 108 ++++++++- src/ffmpegio/plugins/finder_ffdl.py | 29 +++ src/ffmpegio/plugins/finder_static.py | 26 +++ src/ffmpegio/plugins/finder_syspath.py | 22 ++ src/ffmpegio/plugins/rawdata_mpl.py | 43 ++++ src/ffmpegio/plugins/rawdata_numpy.py | 128 ++++++++++ tests/conftest.py | 18 +- tests/test_plugins.py | 18 ++ 19 files changed, 897 insertions(+), 46 deletions(-) create mode 100644 docsrc/finder_ffdl.rst create mode 100644 docsrc/mpl-writer.rst create mode 100644 docsrc/rawdata_numpy.rst create mode 100644 src/ffmpegio/plugins/finder_ffdl.py create mode 100644 src/ffmpegio/plugins/finder_static.py create mode 100644 src/ffmpegio/plugins/finder_syspath.py create mode 100644 src/ffmpegio/plugins/rawdata_mpl.py create mode 100644 src/ffmpegio/plugins/rawdata_numpy.py diff --git a/.github/workflows/test_n_pub.yml b/.github/workflows/test_n_pub.yml index 13084c2c..ebc2e5ad 100644 --- a/.github/workflows/test_n_pub.yml +++ b/.github/workflows/test_n_pub.yml @@ -60,7 +60,7 @@ jobs: pip install -U build pytest pytest-github-actions-annotate-failures - name: Install ffmpegio package - run: pip install -q . + run: pip install -q . numpy - name: Run tests run: pytest -vv diff --git a/README.rst b/README.rst index b78c1cfa..7fbf4aee 100644 --- a/README.rst +++ b/README.rst @@ -18,40 +18,60 @@ Python `ffmpegio` package aims to bring the full capability of `FFmpeg `__ package aims to bring +the full capability of `FFmpeg `__ to read, write, 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. + +One caveat of FFmpeg is that there is no official program installer for Windows and MacOS (although +`homebrew` could be used for the latter). `ffmpegio-plugin-downloader` adds a capability to download +the latest release build of FFmpeg and enables the `ffmpegio` package to detect the paths of `ffmpeg` +and `ffprobe` automatically. This mechanism is supported by `ffmpeg-downloader `__ +package. Downloading of the release build must be performed interactively from the terminal screen, +outside of Python. + +Use +=== + +Install the package (which also installs `ffmpeg-downloader` package). Then, run `ffmpeg_downloader` to +download and install the latest release: + +.. code-block:: bash + + pip install ffmpegio-core ffmpegio-plugin-downloader + + python -m ffmpeg_downloader # downloads and installs the latest release + +Once the plugin and the FFmpeg executables are installed, `ffmpegio` will automatically +detect the downloaded executables. + +At a later date, the installed FFmpeg can be updated to the latest release + +.. code-block:: bash + + python -m ffmpeg_downloader -U # downloads and updates to the latest release + +.. note:: + `ffmpegio-plugin-downloader` will *not* be activated if `ffmpeg` and `ffprobe` are + already available on the system PATH. diff --git a/docsrc/index.rst b/docsrc/index.rst index e192ef6a..9cb0d4db 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -35,6 +35,7 @@ Features * `ffconcat` scripter to make the use of `-f concat` demuxer easier * I/O device enumeration to eliminate the need to look up device names. (currently supports only: Windows DirectShow) * Advanced users can gain finer controls of FFmpeg I/O with :py:mod:`ffmpegio.ffmpegprocess` submodule +* Supports custom plugins to read/write directly to a desired data type .. * (planned) Multi-stream read/write @@ -49,13 +50,22 @@ Where to start pip install ffmpegio -If `numpy.ndarray` I/O is not needed, use instead +* :py:mod:`ffmpegio` is shipped with 3 plugins, which are enabled if their dependency is satisfied + when the package is loaded in Python. -.. code-block:: bash - - pip install ffmpegio-core + +.. table:: External packages to enable additional features + :class: tight-table + + ======================= ======================================================================== + Package Name (PyPI) Description + ======================= ======================================================================== + ``numpy`` Support Numpy array inputs and outputs intead of bytes + ``matplotlib`` Support generation of images or videos from Matplotlib figures + ``ffmepeg-downloader`` Finding FFmpeg installed by the `ffdl` command + ``static-ffmpeg`` Finding FFmpeg installed by this package + ======================= ======================================================================== -See :ref:`Installation ` for more explanation. Examples -------- diff --git a/docsrc/mpl-writer.rst b/docsrc/mpl-writer.rst new file mode 100644 index 00000000..36bb61e7 --- /dev/null +++ b/docsrc/mpl-writer.rst @@ -0,0 +1,79 @@ +.. highlight:: python +.. _options: + +Creating Videos from Matplotlib figure +====================================== + +While Matplotlib supports video creation via its +`animation module `__, +its interface is a bit cranky because its primary role is to animate the figure on screen +rather than outputting figures to a video file. You must create an animation object first before +saving it as a video. + +:code:`ffmpegio` provides a direct method to write Matplotlib figure to a video write stream with +the same streaming interface as feeding the RGB frame data to FFmpeg. + +Example +------- + +Create an MP4 video of `Matplotlib's animation example `__. + +.. code-block:: python + + import ffmpegio as ff + from matplotlib import pyplot as plt + import numpy as np + + + fig, ax = plt.subplots() + + x = np.arange(0, 2*np.pi, 0.01) + line, = ax.plot(x, np.sin(x)) + + interval=20 # delay in milliseconds + save_count=50 # number of frames + + def animate(i): + line.set_ydata(np.sin(x + i / 50)) # update the data. + return line + + + with ff.open( + "output.mp4", # output file name + "wv", # open file in write-video mode + 1e3/interval, # framerate in frames/second + pix_fmt="yuv420p", # specify the pixel format (default is yuv444p) + # add other ffmpeg options as keywod argument as needed + ) as f: + for n in range(save_count): + animate(n) # update figure + f.write(fig) # write new video frame + +Any video format can be chosen with this interface and any FFmpeg options can be specified here. +For instance, an GIF animation of the above example can be created with optimized color pallette. +To do this, we use `palettegen `__ and +`paletteuse `__` filters and construct a video filtergraph: + +.. code-block:: + + split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse + +This filtergraph string could be provided directly to :code:`ff.open` as a `vf` keyword argument, +but let's use :code:`ffmpegio.filtergraph` submodule to construct it instead: + +.. code-block:: python + + import ffmpegio.filtergraph as fgb + + vf = fgb.split() + fgb.palettegen() + fgb.paletteuse() + + with ff.open( + "output.gif", # output file name + "wv", # open file in write-video mode + 1e3/interval, # framerate in frames/second + vf = vf # optimize the GIF palette + ) as f: + for n in range(save_count): + animate(n) # update figure + f.write(fig) # write new video frame + diff --git a/docsrc/quick.rst b/docsrc/quick.rst index 93254558..7beff41e 100644 --- a/docsrc/quick.rst +++ b/docsrc/quick.rst @@ -31,7 +31,9 @@ Features FFmpeg can read/write virtually any multimedia file out there, and :code:`ffmpegio` uses the FFmpeg's prowess to perform media I/O (and other) operations in Python. It offers two -basic modes of operation: block read/write and stream read/write. Another feature of +basic modes of operation: block read/write and stream read/write. For the read operations, +it can output data either in a Numpy array or in a plain :code:`bytes`. The Numpy mode is +enabled by default if Numpy is available in the system. Another feature of :code:`ffmpegio` is to report the properties of the media files, using FFprobe. Media Probe diff --git a/docsrc/rawdata_numpy.rst b/docsrc/rawdata_numpy.rst new file mode 100644 index 00000000..f25aec61 --- /dev/null +++ b/docsrc/rawdata_numpy.rst @@ -0,0 +1,308 @@ +`ffmpegio`: Media I/O with FFmpeg in Python (with NumPy Array Plugin) +===================================================================== + +|pypi| |pypi-status| |pypi-pyvers| |github-license| |github-status| + +.. |pypi| image:: https://img.shields.io/pypi/v/ffmpegio + :alt: PyPI +.. |pypi-status| image:: https://img.shields.io/pypi/status/ffmpegio + :alt: PyPI - Status +.. |pypi-pyvers| image:: https://img.shields.io/pypi/pyversions/ffmpegio + :alt: PyPI - Python Version +.. |github-license| image:: https://img.shields.io/github/license/python-ffmpegio/python-ffmpegio + :alt: GitHub License +.. |github-status| image:: https://img.shields.io/github/workflow/status/python-ffmpegio/python-ffmpegio/Run%20Tests + :alt: GitHub Workflow Status + +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. + +.. note:: + + Since v0.3.0, `ffmpegio` Python distribution package has been split into `ffmpegio-core` and `ffmpegio` to allow + Numpy-independent installation. + +Install the full `ffmpegio` package via ``pip``: + +.. code-block:: bash + + pip install ffmpegio + +If `numpy.ndarray` data I/O is not needed, instead use + +.. code-block:: bash + + pip install ffmpegio-core + +Main Features +------------- + +* Pure-Python light-weight package interacting with FFmpeg executable found in + the system +* Transcode a media file to another in Python +* Read, write, filter, and create functions for audio, image, and video data +* Context-managing `ffmpegio.open` to perform stream read/write operations of video and audio +* Automatically detect and convert audio & video formats to and from `numpy.ndarray` properties +* Probe media file information +* Accepts all FFmpeg options including filter graphs +* Supports a user callback whenever FFmpeg updates its progress information file + (see `-progress` FFmpeg option) +* `ffconcat` scripter to make the use of `-f concat` demuxer easier +* I/O device enumeration to eliminate the need to look up device names. (currently supports only: Windows DirectShow) +* More features to follow + +Documentation +------------- + +Visit our `GitHub page here `__ + +Examples +-------- + +To import `ffmpegio` + +.. code-block:: python + + >>> import ffmpegio + +- `Transcoding `__ +- `Read Audio Files `__ +- `Read Image Files / Capture Video Frames `__ +- `Read Video Files `__ +- `Read Multiple Files or Streams `__ +- `Write Audio, Image, & Video Files `__ +- `Filter Audio, Image, & Video Data `__ +- `Stream I/O `__ +- `Device I/O Enumeration `__ +- `Progress Callback `__ +- `Run FFmpeg and FFprobe Directly `__ + +.. _ex_trancode: + +Transcoding +^^^^^^^^^^^ + +.. code-block:: python + + >>> # transcode, overwrite output file if exists, showing the FFmpeg log + >>> ffmpegio.transcode('input.avi', 'output.mp4', overwrite=True, show_log=True) + + >>> # 1-pass H.264 transcoding + >>> ffmpegio.transcode('input.avi', 'output.mkv', vcodec='libx264', show_log=True, + >>> preset='slow', crf=22, acodec='copy') + + >>> # 2-pass H.264 transcoding + >>> ffmpegio.transcode('input.avi', 'output.mkv', two_pass=True, show_log=True, + >>> **{'c:v':'libx264', 'b:v':'2600k', 'c:a':'aac', 'b:a':'128k'}) + + >>> # concatenate videos using concat demuxer + >>> files = ['/video/video1.mkv','/video/video2.mkv'] + >>> ffconcat = ffmpegio.FFConcat() + >>> ffconcat.add_files(files) + >>> with ffconcat: # generates temporary ffconcat file + >>> ffmpegio.transcode(ffconcat, 'output.mkv', f_in='concat', codec='copy', safe_in=0) + +.. _ex_read_audio: + +Read Audio Files +^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> # read audio samples in its native sample format and return all channels + >>> fs, x = ffmpegio.audio.read('myaudio.wav') + >>> # fs: sampling rate in samples/second, x: [nsamples x nchannels] numpy array + + >>> # read audio samples from 24.15 seconds to 63.2 seconds, pre-convert to mono in float data type + >>> fs, x = ffmpegio.audio.read('myaudio.flac', ss=24.15, to=63.2, sample_fmt='dbl', ac=1) + + >>> # read filtered audio samples first 10 seconds + >>> # filter: equalizer which attenuate 10 dB at 1 kHz with a bandwidth of 200 Hz + >>> fs, x = ffmpegio.audio.read('myaudio.mp3', t=10.0, af='equalizer=f=1000:t=h:width=200:g=-10') + +.. _ex_read_image: + +Read Image Files / Capture Video Frames +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> # list supported image extensions + >>> ffmpegio.caps.muxer_info('image2')['extensions'] + ['bmp', 'dpx', 'exr', 'jls', 'jpeg', 'jpg', 'ljpg', 'pam', 'pbm', 'pcx', 'pfm', 'pgm', 'pgmyuv', + 'png', 'ppm', 'sgi', 'tga', 'tif', 'tiff', 'jp2', 'j2c', 'j2k', 'xwd', 'sun', 'ras', 'rs', 'im1', + 'im8', 'im24', 'sunras', 'xbm', 'xface', 'pix', 'y'] + + >>> # read BMP image with auto-detected pixel format (rgb24, gray, rgba, or ya8) + >>> I = ffmpegio.image.read('myimage.bmp') # I: [height x width x ncomp] numpy array + + >>> # read JPEG image, then convert to grayscale and proportionally scale so the width is 480 pixels + >>> I = ffmpegio.image.read('myimage.jpg', pix_fmt='grayscale', s='480x-1') + + >>> # read PNG image with transparency, convert it to plain RGB by filling transparent pixels orange + >>> I = ffmpegio.image.read('myimage.png', pix_fmt='rgb24', fill_color='orange') + + >>> # capture video frame at timestamp=4:25.3 and convert non-square pixels to square + >>> I = ffmpegio.image.read('myvideo.mpg', ss='4:25.3', square_pixels='upscale') + + >>> # capture 5 video frames and tile them on 3x2 grid with 7px between them, and 2px of initial margin + >>> I = ffmpegio.image.read('myvideo.mp4', vf='tile=3x2:nb_frames=5:padding=7:margin=2') + + >>> # create spectrogram of the audio input (must specify pix_fmt if input is audio) + >>> I = ffmpegio.image.read('myaudio.mp3', filter_complex='showspectrumpic=s=960x540', pix_fmt='rgb24') + + +.. _ex_read_video: + +Read Video Files +^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> # read 50 video frames at t=00:32:40 then convert to grayscale + >>> fs, F = ffmpegio.video.read('myvideo.mp4', ss='00:32:40', vframes=50, pix_fmt='gray') + >>> # fs: frame rate in frames/second, F: [nframes x height x width x ncomp] numpy array + + >>> # get running spectrogram of audio input (must specify pix_fmt if input is audio) + >>> fs, F = ffmpegio.video.read('myvideo.mp4', pix_fmt='rgb24', filter_complex='showspectrum=s=1280x480') + + +.. _ex_read_media: + +Read Multiple Files or Streams +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> # read both video and audio streams (1 ea) + >>> rates, data = ffmpegio.media.read('mymedia.mp4') + >>> # rates: dict of frame rate and sampling rate: keys="v:0" and "a:0" + >>> # data: dict of video frame array and audio sample array: keys="v:0" and "a:0" + + >>> # combine video and audio files + >>> rates, data = ffmpegio.media.read('myvideo.mp4','myaudio.mp3') + + >>> # get output of complex filtergraph (can take multiple inputs) + >>> expr = "[v:0]split=2[out0][l1];[l1]edgedetect[out1]" + >>> rates, data = ffmpegio.media.read('myvideo.mp4',filter_complex=expr,map=['[out0]','[out1]']) + >>> # rates: dict of frame rates: keys="v:0" and "v:1" + >>> # data: dict of video frame arrays: keys="v:0" and "v:1" + +.. _ex_write: + +Write Audio, Image, & Video Files +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> # create a video file from a numpy array + >>> ffmpegio.video.write('myvideo.mp4', rate, F) + + >>> # create an image file from a numpy array + >>> ffmpegio.image.write('myimage.png', F) + + >>> # create an audio file from a numpy array + >>> ffmpegio.audio.write('myaudio.mp3', rate, x) + +.. _ex_filter: + +Filter Audio, Image, & Video Data +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> # Add fade-in and fade-out effects to audio data + >>> fs_out, y = ffmpegio.audio.filter('afade=t=in:ss=0:d=15,afade=t=out:st=875:d=25', fs_in, x) + + >>> # Apply mirror effect to an image + >>> I_out = ffmpegio.image.filter('crop=iw/2:ih:0:0,split[left][tmp];[tmp]hflip[right];[left][right] hstack', I_in) + + >>> # Add text at the center of the video frame + >>> filter = "drawtext=fontsize=30:fontfile=FreeSerif.ttf:text='hello world':x=(w-text_w)/2:y=(h-text_h)/2" + >>> fs_out, F_out = ffmpegio.video.filter(filter, fs_in, F_in) + +.. _ex_stream: + +Stream I/O +^^^^^^^^^^ + +.. code-block:: python + + >>> # process video 100 frames at a time and save output as a new video + >>> # with the same frame rate + >>> with ffmpegio.open('myvideo.mp4', 'rv', blocksize=100) as fin, + >>> ffmpegio.open('myoutput.mp4', 'wv', rate=fin.frame_rate) as fout: + >>> for frames in fin: + >>> fout.write(myprocess(frames)) + +.. _ex_devices: + +Device I/O Enumeration +^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> # record 5 minutes of audio from Windows microphone + >>> fs, x = ffmpegio.audio.read('a:0', f_in='dshow', sample_fmt='dbl', t=300) + + >>> # capture Windows' webcam frame + >>> with ffmpegio.open('v:0', 'rv', f_in='dshow') as webcam, + >>> for frame in webcam: + >>> process_frame(frame) + +.. _ex_progress: + +Progress Callback +^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> import pprint + + >>> # progress callback + >>> def progress(info, done): + >>> pprint(info) # bunch of stats + >>> if done: + >>> print('video decoding completed') + >>> else: + >>> return check_cancel_command(): # return True to kill immediately + + >>> # can be used in any butch processing + >>> rate, F = ffmpegio.video.read('myvideo.mp4', progress=progress) + + >>> # as well as for stream processing + >>> with ffmpegio.open('myvideo.mp4', 'rv', blocksize=100, progress=progress) as fin: + >>> for frames in fin: + >>> myprocess(frames) + +.. _ex_direct: + +Run FFmpeg and FFprobe Directly +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: python + + >>> from ffmpegio import ffmpeg, FFprobe, ffmpegprocess + >>> from subprocess import PIPE + + >>> # call with options as a long string + >>> ffmpeg('-i input.avi -b:v 64k -bufsize 64k output.avi') + + >>> # or call with list of options + >>> ffmpeg(['-i', 'input.avi' ,'-r', '24', 'output.avi']) + + >>> # the same for ffprobe + >>> ffprobe('ffprobe -show_streams -select_streams a INPUT') + + >>> # specify subprocess arguments to capture stdout + >>> out = ffprobe('ffprobe -of json -show_frames INPUT', + stdout=PIPE, universal_newlines=True).stdout + + >>> # use ffmpegprocess to take advantage of ffmpegio's default behaviors + >>> out = ffmpegprocess.run({"inputs": [("input.avi", None)], + "outputs": [("out1.mp4", None), + ("-", {"f": "rawvideo", "vframes": 1, "pix_fmt": "gray", "an": None}) + }, capture_log=True) + >>> print(out.stderr) # print the captured FFmpeg logs (banner text omitted) + >>> b = out.stdout # width*height bytes of the first frame diff --git a/pyproject.toml b/pyproject.toml index 62d80a50..1952745d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] -name = "ffmpegio-core" +name = "ffmpegio" description = "Media I/O with FFmpeg" readme = "README.rst" keywords = ["multimedia", "ffmpeg"] diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 2f2f6c08..2f27a041 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -43,6 +43,7 @@ except Exception as e: logger.warning(str(e)) +use = plugins.use def __getattr__(name): if name == "ffmpeg_ver": @@ -60,11 +61,14 @@ def __getattr__(name): from . import streams as _streams 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"] + "open", "ffmpegprocess", "FFmpegError", "FilterGraph", "FFConcat", "use"] # fmt:on __version__ = "0.10.0" diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index dc1fb2ab..2651982f 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -6,9 +6,24 @@ prod = lambda seq: reduce(mul, seq, 1) + def dtype_itemsize(dtype): return int(dtype[-1]) def get_samplesize(shape, dtype): return prod(shape) * dtype_itemsize(dtype) + + +def deprecate_core(): + from importlib import metadata + import warnings + + try: + metadata.version("ffmpegio-core") + except metadata.PackageNotFoundError: + return + + warnings.warn( + message="ffmpegio-core distribution package has been deprecated and consolidated to ffmpegio package since v0.11.0. Please uninstall ffmpegio-core and install only the ffmpegio package to receive future updates." + ) diff --git a/src/ffmpegio/path.py b/src/ffmpegio/path.py index abdac606..329f43f1 100644 --- a/src/ffmpegio/path.py +++ b/src/ffmpegio/path.py @@ -129,9 +129,6 @@ def find(ffmpeg_path=None, ffprobe_path=None): ) FFMPEG_BIN = ffmpeg_path FFPROBE_BIN = ffprobe_path - elif which("ffmpeg") and which("ffprobe"): - FFMPEG_BIN = "ffmpeg" - FFPROBE_BIN = "ffprobe" else: res = plugins.get_hook().finder() if res is None: diff --git a/src/ffmpegio/plugins/__init__.py b/src/ffmpegio/plugins/__init__.py index 36438bc1..28f14c07 100644 --- a/src/ffmpegio/plugins/__init__.py +++ b/src/ffmpegio/plugins/__init__.py @@ -1,4 +1,16 @@ -import pluggy, os +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + +from typing import Literal, Any + +from importlib import import_module +import re, os +import pluggy + from . import hookspecs @@ -8,18 +20,102 @@ pm.add_hookspecs(hookspecs) +def _try_register_builtin(plugin_name: str, reregister: bool = False) -> str | None: + module_package, module_name = plugin_name.rsplit(".", 1) + try: + module = import_module(f".{module_name}", module_package) + except ModuleNotFoundError: + logger.info( + f"Skip importing {module_name} builtin-plugin module, likely missing dependency" + ) + else: + registered = pm.has_plugin(plugin_name) + if not reregister and registered: + return + if registered: + pm.unregister(module) + name = pm.register(module) + logger.info(f"registered {name} builtin-plugin module") + return name + + +def register(plugin: object, name: str | None = None) -> str | None: + """Register a plugin and return its name. + + :param plugin: Plugin object + :param name: The name under which to register the plugin. If not specified, + a name is generated using get_canonical_name(). + :returns: The plugin name. If the name is blocked from registering, returns None. + + If the plugin is already registered, raises a ValueError. + """ + return pm.register(plugin, name) + + +def unregister(name: str) -> Any | None: + """Register a plugin and return its name. + + :param name: The name of the plugin to unregister . + :returns: The plugin name. If the name is blocked from registering, returns None. + + If the plugin is already registered, raises a ValueError. + """ + return pm.unregister(name) + +def list_plugins() -> list: + return [pm.get_name(p) for p in pm.get_plugins()] + +def use(name: Literal["read_numpy", "read_bytes"] | str): + """Select the plugin to use (among contentious plugins) + + :param name: The plugin to use. This can either be ``'read_numpy'`` or + ``'read_bytes'`` or a plugin module name: + + - ``"read_numpy"`` - All the media readers to output a Numpy array. + Also, reverts Numpy array input processing by + all the writers to the default. Numpy must be + installed for this plugin to be activated. + - ``"read_bytes"`` - All the media readers to output a dict with keys: + ``"buffer"`` (``bytes``) of the retrieved data, + ``"dtype"`` (``str``) Numpy dtype string of ``'"buffer"'``, + and ``"shape"`` (``tuple`` of ``int``s) the data array shape + of ``'"buffer"'`` + + If a plugin name is given, it must be in the form: + `plugin://my.plugin.name`. + """ + + if name == "read_numpy": + _try_register_builtin("ffmpegio.plugins.rawdata_numpy", True) + elif name == "read_bytes": + _try_register_builtin("ffmpegio.plugins.rawdata_bytes", True) + else: + matched_name = re.match(r"plugin://(.+)$", name) + if matched_name is None: + raise ValueError(f'{name=} must follow "plugin://my.plugin.name" format') + plugin = pm.get_plugin(matched_name) + if plugin is None: + raise ValueError(f"Requested plugin ({name=}) has not been registered") + pm.unregister(plugin) + pm.register(plugin) + + def initialize(): - from . import rawdata_bytes + """initilaize manager and load builtin plugins""" + + _try_register_builtin("ffmpegio.plugins.finder_syspath") - # load bundled base plugins - pm.register(rawdata_bytes) if os.name == "nt": - from . import finder_win32 - pm.register(finder_win32) + for name in ["finder_win32"]: + _try_register_builtin(f"ffmpegio.plugins.{name}") from .devices import dshow + pm.register(dshow) + for name in ["rawdata_bytes", "finder_ffdl", "rawdata_mpl", "rawdata_numpy"]: + _try_register_builtin(f"ffmpegio.plugins.{name}") + # load all ffmpegio plugins found in site-packages pm.load_setuptools_entrypoints("ffmpegio") diff --git a/src/ffmpegio/plugins/finder_ffdl.py b/src/ffmpegio/plugins/finder_ffdl.py new file mode 100644 index 00000000..e754cf38 --- /dev/null +++ b/src/ffmpegio/plugins/finder_ffdl.py @@ -0,0 +1,29 @@ +"""ffmpegio plugin to find ffmpeg and ffprobe installed by ffmpeg-downloader (ffdl) package""" + +import logging +from pluggy import HookimplMarker + +import ffmpeg_downloader as ffdl + +hookimpl = HookimplMarker("ffmpegio") + +__all__ = ["finder"] + + +@hookimpl +def finder(): + """find ffmpeg and ffprobe executables""" + + ffmpeg_path = ffdl.ffmpeg_path + + if ffmpeg_path is None: + logging.warning( + """FFmpeg binaries not found in the ffmpegio-downloader's install directory. To install, run the following in the terminal: + + ffdl install + + This will download and install the ffmpeg and ffprobe executables. Internet connection is required.""" + ) + return None + + return ffmpeg_path, ffdl.ffprobe_path diff --git a/src/ffmpegio/plugins/finder_static.py b/src/ffmpegio/plugins/finder_static.py new file mode 100644 index 00000000..c5b186f1 --- /dev/null +++ b/src/ffmpegio/plugins/finder_static.py @@ -0,0 +1,26 @@ +"""ffmpegio plugin to find ffmpeg and ffprobe installed by static-ffmpeg package""" + +import logging +from pluggy import HookimplMarker +from static_ffmpeg import run + +hookimpl = HookimplMarker("ffmpegio") + +__all__ = ["finder"] + + +@hookimpl +def finder(): + """find ffmpeg and ffprobe executables""" + + try: + paths = run.get_or_fetch_platform_executables_else_raise() + except Exception as e: + logging.warn( + f"""static-ffmpeg binary paths could not be resolved. Error message: + + {e}""" + ) + return None + + return paths diff --git a/src/ffmpegio/plugins/finder_syspath.py b/src/ffmpegio/plugins/finder_syspath.py new file mode 100644 index 00000000..df47bc4a --- /dev/null +++ b/src/ffmpegio/plugins/finder_syspath.py @@ -0,0 +1,22 @@ +"""ffmpegio plugin to find ffmpeg and ffprobe on system path""" + +import logging + +from pluggy import HookimplMarker + +from shutil import which + +hookimpl = HookimplMarker("ffmpegio") + +__all__ = ["finder"] + + +@hookimpl +def finder(): + """find ffmpeg and ffprobe executables""" + + if which("ffmpeg") and which("ffprobe"): + return "ffmpeg", "ffprobe" + + logging.warning("""FFmpeg and FFprobe binaries not found in the system path.""") + return None diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py new file mode 100644 index 00000000..81d6bc8c --- /dev/null +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -0,0 +1,43 @@ +"""ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" + +import matplotlib as Figure +from pluggy import HookimplMarker +from typing import Tuple +import io + +hookimpl = HookimplMarker("ffmpegio") + + +@hookimpl +def video_info(obj: Figure) -> Tuple[Tuple[int, int, int], str]: + """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] + """ + try: + return (int(obj.bbox.bounds[3]), int(obj.bbox.bounds[2]), 4), "|u1" + except: + return None + + +@hookimpl +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: + with io.BytesIO() as io_buf: + obj.savefig(io_buf, format="raw") + io_buf.seek(0) + return io_buf.getvalue() + except: + None + \ No newline at end of file diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py new file mode 100644 index 00000000..add224fb --- /dev/null +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -0,0 +1,128 @@ +"""ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" + +import numpy as np +from pluggy import HookimplMarker +from typing import Tuple + +from numpy.typing import ArrayLike + +hookimpl = HookimplMarker("ffmpegio") + +__all__ = [ + "video_info", + "audio_info", + "video_bytes", + "audio_bytes", + "bytes_to_video", + "bytes_to_audio", +] + + +@hookimpl +def video_info(obj: ArrayLike) -> Tuple[Tuple[int, int, int], str]: + """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] + """ + try: + return obj.shape[-3:] if obj.ndim != 2 else [*obj.shape, 1], obj.dtype.str + except: + return None + + +@hookimpl +def audio_info(obj: ArrayLike) -> Tuple[int, str]: + """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] + """ + try: + return obj.shape[-1:] if obj.ndim > 1 else [1], obj.dtype.str + except: + return None + + +@hookimpl +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 memoryview(np.ascontiguousarray(obj, obj.dtype)) + except: + return None + + +@hookimpl +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 memoryview(np.ascontiguousarray(obj, obj.dtype)) + except: + return None + + +@hookimpl +def bytes_to_video( + b: bytes, dtype: str, shape: Tuple[int, int, int], 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: + """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., ' Date: Mon, 25 Nov 2024 09:25:09 -0600 Subject: [PATCH 23/57] doc: added _future__.annotations line --- src/ffmpegio/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 2f27a041..29a6aec0 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + #!/usr/bin/python # -*- coding: utf-8 -*- """FFmpeg I/O interface From 3bfb1cb27b337271bcaa000cc3ce054baf37c607 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 17 Dec 2024 12:00:55 -0600 Subject: [PATCH 24/57] probe to accept PathLike object as url --- src/ffmpegio/probe.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 00bbc888..23750cc5 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -6,6 +6,7 @@ import json, re from fractions import Fraction from functools import lru_cache +from os import PathLike from .path import ffprobe, PIPE from .utils import parse_stream_spec @@ -196,7 +197,7 @@ def _exec( ["-show_optional_fields", "always" if keep_optional_fields else "never"] ) - pipe = not isinstance(url, str) + pipe = not isinstance(url, (str, PathLike)) args.append("-" if pipe else url) if pipe: From 3f67ea2da28008526041f7e0c0111063fa48430f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 22 Dec 2024 20:24:51 -0600 Subject: [PATCH 25/57] docs update --- README.rst | 51 +++++++++-- docsrc/index.rst | 206 +++++++++++++++++++-------------------------- docsrc/install.rst | 34 +++++--- 3 files changed, 155 insertions(+), 136 deletions(-) diff --git a/README.rst b/README.rst index 7fbf4aee..30f0438f 100644 --- a/README.rst +++ b/README.rst @@ -59,8 +59,8 @@ with them. ========================== ======================================================================== ===================================== :code:`numpy` Support Numpy array inputs and outputs intead of bytes :code:`ffmpegio` :code:`matplotlib` Support generation of images or videos from Matplotlib figure :code:`ffmpegio-plugin-mpl` - :code:`ffmepeg-downloader` Support finding the FFmpeg path installed by the :code:`ffdl` command :code:`ffmpegio-plugin-downloader` - :code:`static-ffmpeg` Support finding the FFmpeg binaries in :code:`site-packages` dir :code:`ffmpegio-plugin-static-ffmpeg` + :code:`ffmepeg-downloader` Support the FFmpeg binaries installed by the :code:`ffdl` command :code:`ffmpegio-plugin-downloader` + :code:`static-ffmpeg` Support the FFmpeg binaries installed by :code:`static-ffmpeg` :code:`ffmpegio-plugin-static-ffmpeg` ========================== ======================================================================== ===================================== These features are automatically enabled if the external packages are installed along along side with `ffmpegio`. @@ -69,8 +69,8 @@ These features are automatically enabled if the external packages are installed .. note:: Prior to v0.11.0, these features were only enabled via installing separate plugin packages (listed in the table above). - After v0.11 :code:`ffmpegio` and :code:`ffmpegio-core` are identical except for the deprecation warning on - :code:`ffmpegio-core`. + :code:`ffmpegio` v0.11 and :code:`ffmpegio-core` v0.11 are identical, and :code:`ffmpegio-core` will no longer receive + the updates. Documentation ------------- @@ -94,6 +94,7 @@ To import `ffmpegio` - `Write Audio, Image, & Video Files `_ - `Filter Audio, Image, & Video Data `_ - `Stream I/O `_ +- `Video from Matplotlib Figure