From 7f5b2d3506d19511ded3e262a5e51382cca1196e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 17:03:29 -0600 Subject: [PATCH 001/333] modified array_to_x_input() - dropped stream_id argument - added pipe_id argument - rate is now optional - doc update (type hints) --- src/ffmpegio/configure.py | 58 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 14c291c6..ae327284 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Literal +from typing import Literal, Any from collections.abc import Sequence import re, logging @@ -15,71 +15,69 @@ UrlType = Literal["input", "output"] -def array_to_video_input(rate, data, stream_id=None, **opts): +def array_to_video_input( + rate: int | float | Fraction | None = None, + data: Any | None = None, + pipe_id: str | None = None, + **opts, +) -> tuple[str, dict]: """create an stdin input with video stream :param rate: input frame rate in frames/second - :type rate: int, float, or `fractions.Fraction` :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) - :type data: object - :param stream_id: video stream id ('v:#'), defaults None to set the options to be file-wide ('v') - :type stream_id: int, optional + :param pipe_id: named pipe path, defaults None to use stdin :param **opts: input options - :type **opts: dict :return: tuple of input url and option dict - :rtype: tuple(str, dict) """ - spec = "" if stream_id is None else ":" + utils.stream_spec(stream_id, "v") + if rate is None and "r" not in opts: + raise ValueError("rate argument must be specified if opts['r'] is not given.") s, pix_fmt = utils.guess_video_format(*plugins.get_hook().video_info(obj=data)) return ( - "-", + pipe_id or "-", { "f": "rawvideo", - f"c{spec or ':v'}": "rawvideo", - f"s{spec}": s, - f"r{spec}": rate, - f"pix_fmt{spec}": pix_fmt, + f"c:v": "rawvideo", + f"s": s, + f"r": rate, + f"pix_fmt": pix_fmt, **opts, }, ) def array_to_audio_input( - rate, - data=None, - stream_id=None, - **opts, + rate: int | None = None, + data: Any | None = None, + pipe_id: str | None = None, + **opts: dict[str, Any], ): """create an stdin input with audio stream :param rate: input sample rate in samples/second - :type rate: int :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) - :type data: object - :param stream_id: audio stream id ('a:#'), defaults to None to set the options to be file-wide ('a') - :type stream_id: int, optional + :param pipe_id: input named pipe id, defaults to None to use the stdin :return: tuple of input url and option dict - :rtype: tuple(str, dict) """ + if rate is None and "ar" not in opts: + raise ValueError("rate argument must be specified if opts['ar'] is not given.") + shape = dtype = None shape, dtype = plugins.get_hook().audio_info(obj=data) sample_fmt, ac = utils.guess_audio_format(dtype, shape) codec, f = utils.get_audio_codec(sample_fmt) - spec = "" if stream_id is None else ":" + utils.stream_spec(stream_id, "a") - return ( - "-", + pipe_id or "-", { "f": f, - f"c{spec or ':a'}": codec, - f"ac{spec}": ac, - f"ar{spec}": rate, - f"sample_fmt{spec}": sample_fmt, + f"c:a": codec, + f"ac": ac, + f"ar": rate, + f"sample_fmt": sample_fmt, **opts, }, ) From 92e06836b1242a738821f007479e780622908448 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 17:04:45 -0600 Subject: [PATCH 002/333] added new types: RawDataBlob, RawStreamDef, FFmpegArgs, & ProgressCallable --- src/ffmpegio/utils/typing.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/ffmpegio/utils/typing.py b/src/ffmpegio/utils/typing.py index 3f4bf871..51d060ea 100644 --- a/src/ffmpegio/utils/typing.py +++ b/src/ffmpegio/utils/typing.py @@ -2,6 +2,7 @@ from typing import * from typing_extensions import * +from fractions import Fraction # from typing_extensions import * @@ -31,3 +32,30 @@ class StreamSpec_Usable(StreamSpec_Options): StreamSpec = Union[StreamSpec_Index, StreamSpec_Tag, StreamSpec_Usable] + +RawDataBlob = Any # depends on raw data reader plugin + +RawStreamDef = ( + tuple[int | float | Fraction, RawDataBlob] | tuple[RawDataBlob, dict[str, Any]] +) + + +class FFmpegArgs(TypedDict): + """FFmpeg arguments + """ + + inputs: list[tuple[str, dict | None]] # list of input definitions (pairs of url and options) + outputs: list[tuple[str, dict | None]] # list of output definitions (pairs of url and options) + global_options: NotRequired[dict | None] # FFmpeg global options + + +ProgressCallable = Callable[[dict[str, Any], bool], bool] +"""FFmpeg progress callback function + + callback(status, done) + + status - dict of encoding status + done - True if the last callback + + The callback may return True to cancel the FFmpeg execution. +""" From 0f6144aec7a80bb5dda8d6a1302391aeb283f665 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:17:02 -0600 Subject: [PATCH 003/333] compose() - removed special handling for '-map' output option --- src/ffmpegio/utils/parser.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/ffmpegio/utils/parser.py b/src/ffmpegio/utils/parser.py index c07487a0..b26f1203 100644 --- a/src/ffmpegio/utils/parser.py +++ b/src/ffmpegio/utils/parser.py @@ -145,12 +145,6 @@ def finalize_global(key, val): def finalize_output(key, val): if re.match(r"s(?:\:|$)", key) and not isinstance(val, str): val = "x".join((str(v) for v in val)) - elif key == "map" and not isinstance(val, str): - # if an entry is a seq, join with ':' - val = [ - v if isinstance(v, str) else ":".join((str(vi) for vi in v)) - for v in val - ] return key, val def finalize_input(key, val): From 302a34ae36415f0664df5d5d9c894c18cf48468a Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:17:35 -0600 Subject: [PATCH 004/333] added add_filtergraph() --- src/ffmpegio/configure.py | 78 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index ae327284..7aa8e972 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,8 +1,10 @@ from __future__ import annotations -from typing import Literal, Any +from .utils.typing import Literal, Any, FFmpegArgs, StreamSpec from collections.abc import Sequence +from fractions import Fraction + import re, logging logger = logging.getLogger("ffmpegio") @@ -751,3 +753,77 @@ def process_one(url): ret = process_one(urls) return [process_one(url) for url in urls] if ret is None else [ret] + + +def add_filtergraph( + args: FFmpegArgs, + filtergraph: Graph, + map: Sequence[StreamSpec] | None = None, + automap: bool = True, + append_filter: bool = True, + append_map: bool = True, + ofile: int = 0, +): + """add a complex filtergraph to FFmpeg arguments + + :param args: FFmpeg argument dict (to be modified in place) + :param filtergraph: Filtergraph to be added to the FFmpeg arguments + :param map: output stream mapping, usually the output pad label, defaults to None + :param automap: True to auto map all the output pads of the filtergraph IF `map` is None, defaults to True. + :param append_filter: True to append `filtergraph` to the `filter_complex` global option if exists, False to replace, defaults to True + :param append_map: True to append `map` to the `map` output option if exists, False to replace, defaults to True + :param ofile: output file id, defaults to 0 + + """ + + if len(args["outputs"]) <= ofile: + raise ValueError( + f"The specified output #{ofile} is not defined in the FFmpegArgs dict." + ) + + if automap and map is None: + map = [f"[{l[0]}]" for l in filtergraph.iter_output_labels()] + + # add the merging filter graph to the filter_complex argument + gopts = args.get("global_options", None) + + if append_filter: + complex_filters = None if gopts is None else gopts.get("filter_complex", None) + if complex_filters is None: + complex_filters = filtergraph + else: + complex_filters = ( + [complex_filters] + if isinstance(complex_filters, Sequence) + and not isinstance(complex_filters, Sequence) + else [*complex_filters] + ) + complex_filters.append(filtergraph) + else: + complex_filters = filtergraph + + if gopts is None: + args["global_options"] = {"filter_complex": complex_filters} + else: + gopts["filter_complex"] = complex_filters + + if not len(map): + # nothing to map + return + + outopts = args["outputs"][ofile][1] + if outopts is None: + args["outputs"][ofile] = (args["outputs"][ofile][0], {"map": map}) + else: + if append_map and "map" in outopts: + + existing_map = outopts["map"] + + # remove merged streams from output map & append the output stream of the filter + map = ( + [existing_map, *map] + if isinstance(existing_map, str) or not isinstance(existing_map, Sequence) + else [*existing_map, *map] + ) + + outopts['map'] = map From 4c98506976feeed3565371773e24f4f02c86b974 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:18:38 -0600 Subject: [PATCH 005/333] fixed a bug in FilterGraphicObject.get_label --- src/ffmpegio/filtergraph/abc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index f11d4ed0..c035a2ba 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -253,7 +253,7 @@ def get_label( return self._get_label(input, index) if inpad is not None: return self._get_label(True, inpad) - if (outpad is not None) != 1: + if outpad is not None: return self._get_label(False, outpad) raise ValueError( "One and only one of index, inpad, or outpad must be specified." From 6bbbe2b70605df57e8ebada3dd1d4b5425529e39 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:20:37 -0600 Subject: [PATCH 006/333] Added FilterGraphObject.normalize_pad_index() and its specializations - used by Graph._get_label --- src/ffmpegio/filtergraph/Chain.py | 22 ++++++++++++++++++++++ src/ffmpegio/filtergraph/Filter.py | 23 +++++++++++++++++++++++ src/ffmpegio/filtergraph/Graph.py | 23 +++++++++++++++++++++++ src/ffmpegio/filtergraph/abc.py | 11 +++++++++++ 4 files changed, 79 insertions(+) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index e7f847e3..b376b3e7 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -121,6 +121,28 @@ def is_last_filter(self, filter_id: int) -> bool: """Returns True if the given id is the last filter of the chain""" return filter_id == len(self) - 1 + def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + + if isinstance(index, int): + index = (0, 0, index) + elif len(index) == 1: + index = (0, 0, *index) + else: + if len(index) == 2: + index = (0, *index) + if index[-2] < 0: + index = (index[-3], len(self) + index[-2], index[-1]) + + return self[index[1]].normalize_pad_index(input, index) + def add_label( self, label: str, diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 7adfd351..4efb9d9a 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -447,6 +447,29 @@ def _channelsplit(): else inc() ) + def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + + if isinstance(index, int): + index = (0, 0, index) + elif len(index) == 1: + index = (0, 0, *index) + elif len(index) == 2: + index = (0, *index) + + if index[-1] < 0: + numpads = self.get_num_inputs() if input else self.get_num_outputs() + index = (*index[-3:-1], numpads + index[-1]) + + return index + def get_num_filters(self, chain: int) -> int: """get the number of filters of the specfied chain diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 2cd03545..42816035 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -210,8 +210,31 @@ def resolve_pad_index( chainable_first=chainable_first, ) + def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + + if isinstance(index, int): + index = (0, 0, index) + elif len(index) == 1: + index = (0, 0, *index) + elif len(index) == 2: + index = (0, *index) + elif index[-3] < 0: + index = (len(self) + index[-3], *index[-2:]) + + return self[index[0]].normalize_pad_index(input, index) + def _get_label(self, input: bool, index: PAD_INDEX): + index = self.normalize_pad_index(input, index) + return getattr( self._links, "find_inpad_label" if input else "find_outpad_label" )(index) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index c035a2ba..64432cdd 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -262,6 +262,17 @@ def get_label( def _get_label(self, input: bool, index: PAD_INDEX): return None + @abstractmethod + def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + """normalize pad index. + + Returns three-element pad index with non-negative indices. + + :param input: True to check the input pad index, False the output. + :param index: pad index to be normalized + :return: normalized pad index + """ + def get_input_pad( self, index_or_label: PAD_INDEX | str ) -> tuple[PAD_INDEX, str | None]: From a4d7c92e0ee173a16a11b8f493f2e1accbe39644 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:24:15 -0600 Subject: [PATCH 007/333] attach() allows Graph object as inputs --- src/ffmpegio/filtergraph/build.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index ad19e2c2..0c10166c 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -183,15 +183,15 @@ def create_links(it_left, it_right): def attach( - left: fgb.Filter | fgb.Chain | str | list[fgb.Filter | fgb.Chain | str], - right: fgb.Filter | fgb.Chain | str | list[fgb.Filter | fgb.Chain | str], + left: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], + right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], left_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, ) -> fgb.Graph: """attach filter(s), chain(s), or label(s) to a filtergraph object :param left: input filtergraph object, filtergraph expression, or label, or list thereof - :param right: output filterchain, filtergraph expression, or label, or list thereof. + :param right: output filtergraph object, filtergraph expression, or label, or list thereof. :param left_on: pad_index, specify the pad on left, default to None (first available) :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) :param right_first: True to preserve the chain indices of the right filtergraph object, defaults @@ -221,10 +221,6 @@ def check_obj(obj): def analyze_fgobj(obj): attach_obj = isinstance(obj, list) obj = [check_obj(o) for o in obj] if attach_obj else check_obj(obj) - if attach_obj and any(isinstance(o, fgb.Graph) for o in obj): - raise ValueError( - "Filtergraph object list cannot include any Graph object. Only Filter and Chain objects are allowed." - ) if isinstance(obj, str): attach_obj = True obj = [obj] From ffbc177a1542ba3cbbb8e779678e5cdd7b614934 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:25:22 -0600 Subject: [PATCH 008/333] added merge_audio() preset filtergraph --- src/ffmpegio/filtergraph/presets.py | 60 +++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src/ffmpegio/filtergraph/presets.py diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py new file mode 100644 index 00000000..7caafad0 --- /dev/null +++ b/src/ffmpegio/filtergraph/presets.py @@ -0,0 +1,60 @@ +"""ffmpegio.filtergraph.presets Module - a collection of preset filtergraph generators +""" + +from __future__ import annotations + +from ..utils.typing import TYPE_CHECKING, Any, StreamSpec + +from collections.abc import Sequence + +if TYPE_CHECKING: + from .Graph import Graph + + +def merge_audio( + streams: dict[StreamSpec, dict[str, Any]], + output_ar: int | None = None, + output_sample_fmt: str | None = None, + output_pad_label: str | None = "aout", +) -> Graph: + """Create a filtergraph to merge input audio streams. + + This preset filtergraph formats the input streams so that their sampling rates and sample formats are first converted + to the same satisfying the requirements of the `amerge` filter. + + :param streams: List of input audio streams to merge. Each stream is keyed by its FFmpeg stream specifier and must provide its input options. + The option must include the sampling rate (`ar`) and sample format (`sample_fmt`). + :param output_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first input stream + :param output_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first input stream + :param output_pad_label: label of the `amerge` filter output, defaults to None to leave the output pad unconnected + + """ + + from .. import filtergraph as fg + + # number of input audio streams to be merged + n_ain = len(streams) + + # if output sampling rate or sample format not given, use the first stream's setting + if output_ar is None or output_sample_fmt is None: + opts = next(iter(streams.values())) + if output_ar is None: + output_ar = opts["ar"] + if output_sample_fmt is None: + output_sample_fmt = opts["sample_fmt"] + + # build complex_filter to merge + + def match_sample(sspec, opts): + fopts = {} + if opts["ar"] != output_ar: + fopts["r"] = output_ar + if opts["sample_fmt"] != output_sample_fmt: + fopts["f"] = output_sample_fmt + + in_label = f"[{sspec}]" + return (in_label >> fg.aformat(**fopts)) if len(fopts) else in_label + + afilt = [match_sample(*st) for st in streams.items()] >> fg.amerge(inputs=n_ain) + + return (afilt >> output_pad_label) if output_pad_label else afilt From cd761b27193c987b58c8ca38d4ea94ad09c5ac3e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:27:56 -0600 Subject: [PATCH 009/333] - Added dependency on `namedpipe` package - Added named pipe support to WriterThread --- pyproject.toml | 1 + src/ffmpegio/threading.py | 53 +++++++++++++++++++++++++-------------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 37d8d9be..0ab582ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "pluggy", "packaging", "typing_extensions", + "namedpipe" ] [project.urls] diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 2514ffe8..02ffb17f 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -2,6 +2,9 @@ """ from __future__ import annotations + +from typing import BinaryIO + from copy import deepcopy import re, os from threading import Thread, Condition, Lock, Event @@ -12,6 +15,8 @@ from math import ceil import logging +from namedpipe import NPopen + logger = logging.getLogger("ffmpegio") from .utils.avi import AviReader @@ -430,17 +435,16 @@ class WriterThread(Thread): """a thread to write byte data to a writable stream :param stdin: stream to write data to - :type stdin: writable stream :param queuesize: depth of a queue for inter-thread data transfer, defaults to None - :type queuesize: int, optional + :param bufsize: maximum number of bytes to write at once, defaults to None (1048576 bytes) """ - def __init__(self, stdin, queuesize=None): + def __init__(self, stdin: BinaryIO | NPopen, queuesize: int | None = None): super().__init__() self.stdin = stdin #:writable stream: data sink self._queue = Queue(queuesize or 0) # inter-thread data I/O - def join(self, timeout=None): + def join(self, timeout: float | None = None): # close the stream if not already closed self.stdin.close() @@ -460,22 +464,37 @@ def __exit__(self, *_): return self def run(self): + + is_namedpipe = isinstance(self.stdin, NPopen) + stream = self.stdin.wait() if is_namedpipe else self.stdin + while True: # get next data block data = self._queue.get() self._queue.task_done() if data is None: + logger.info(f"writer thread: received a sentinel to stop the writer") break - # print(f"writer thread: received {data.shape[0]} samples to write") + else: + logger.info(f"writer thread: received {len(data)} bytes to write") + try: - nbytes = self.stdin.write(data) - # print(f"writer thread: written {nbytes} written") - except: + nwritten = 0 + nwritten = stream.write(data) + logger.info(f"writer thread: written {nwritten} written") + except Exception as e: # stdout stream closed/FFmpeg terminated, end the thread as well + logger.info(f"writer thread exception: {e}") break - if not nbytes and self.stdin.closed: # just in case + if not nwritten and stream.closed: # just in case + logger.info(f"writer thread: somethin' else happened") break + if is_namedpipe: + self.stdin.close() + + logger.info(f"writer thread exiting") + def write(self, data, timeout=None): if not self.is_alive(): raise ThreadNotActive("WriterThread is not running") @@ -564,14 +583,13 @@ def wait(self, timeout: float | None = None) -> bool: ) return flag - def readchunk(self, timeout=None): + def readchunk(self, timeout=None) -> tuple[str, object]: """read the next avi chunk :param timeout: timeout in seconds, defaults to None (waits indefinitely) :type timeout: float, optional :raises TimeoutError: if terminated due to timeout :return: tuple of stream specifier and data array - :rtype: (str, object) """ # wait till matching line is read by the thread @@ -608,7 +626,7 @@ def readchunk(self, timeout=None): return self.reader.streams[id]["spec"], self.reader.from_bytes(id, data) - def find_id(self, ref_stream): + def find_id(self, ref_stream: str) -> object: self.wait() try: return next( @@ -617,22 +635,19 @@ def find_id(self, ref_stream): except: ValueError(f"{ref_stream} is not a valid stream specifier") - def read(self, n=-1, ref_stream=None, timeout=None): + def read( + self, n: int = -1, ref_stream: str | None = None, timeout: float | None = None + ) -> dict[str, bytes]: """read data from all streams :param n: number of samples, negate to non-blocking, defaults to -1 - :type n: int, optional :param ref_stream: stream specifier to count the samples, defaults to None (first stream) - :type ref_stream: str, optional :param timeout: timeout in seconds, defaults to None (waits indefinitely) - :type timeout: float, optional :raises TimeoutError: if terminated due to timeout - :return: tuple of stream specifier and data array :return: dict of data object keyed by stream specifier string, each data object is created by `bytes_to_video` or `bytes_to_image` plugin hook. If all frames have been read, dict items would be all empty - :rtype: dict(spec:str, object) """ # wait till matching line is read by the thread @@ -725,7 +740,7 @@ def combine(id, array, n, nr): return out - def readall(self, timeout=None): + def readall(self, timeout: float | None = None) -> dict[str, bytes]: # wait till matching line is read by the thread if timeout is not None: timeout = time() + timeout From a3f869f6ebacb6643a45100c610cbefdd8a7eea0 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 19:29:18 -0600 Subject: [PATCH 010/333] Added media.write() function --- src/ffmpegio/media.py | 206 +++++++++++++++++++++++++++++++++++++++++- tests/test_media.py | 96 +++++++++++--------- 2 files changed, 256 insertions(+), 46 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 09bb2588..e705de69 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -1,9 +1,23 @@ +from __future__ import annotations + +from typing_extensions import Unpack +from collections.abc import Sequence +from .utils.typing import Literal, Any, RawStreamDef, ProgressCallable + +import contextlib from io import BytesIO +from fractions import Fraction + +from namedpipe import NPopen -from . import ffmpegprocess, utils, configure, FFmpegError +from .threading import WriterThread +from .filtergraph.presets import merge_audio + +from . import ffmpegprocess, utils, configure, FFmpegError, plugins from .utils import avi +from .threading import WriterThread -__all__ = ["read"] +__all__ = ["read", "write"] def read(*urls, progress=None, show_log=None, **options): @@ -91,3 +105,191 @@ def read(*urls, progress=None, show_log=None, **options): } return rates, data + + +def write( + url: str, + stream_types: Sequence[Literal["a", "v"]], + *stream_args: * tuple[RawStreamDef, ...], + merge_audio_streams: bool | Sequence[int] = False, + merge_audio_ar: int | None = None, + merge_audio_sample_fmt: str | None = None, + merge_audio_outpad: str | None = None, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[dict[str, Any]], +): + """write multiple streams to a url/file + + :param url: output url + :stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) + :param stream_args: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob + or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's + (indices of `stream_types`) to combine only specified streams. + :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream + :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream + :param progress: progress callback function, defaults to None + :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + + TIPS + ---- + + * All the input streams will be added to the output file by default, unless `map` option is specified + * If the input streams are of different durations, use `shortest=ffmpegio.FLAG` option to trim all streams to the shortest. + * Using merge_audio_streams: + - adds a `filter_complex` global option + - merged input streams are removed from the `map` option and replaced by the merged stream + + """ + + if not all(t in "av" for t in stream_types): + raise ValueError("Elements of stream_types input must either 'a' or 'v'.") + + # analyze input stream_data + n_in = len(stream_types) + + if len(stream_args) != n_in: + raise ValueError(f"Lengths of `stream_args` and `stream_types` not matching.") + + # separate the input options from the rest of the options + default_in_opts = utils.pop_extra_options(options, "_in") + + url, stdout, _ = configure.check_url(url, True) + + # create FFmpeg argument dict + ffmpeg_args = configure.empty() + + # add input streams + pipes = [] # named pipes and their data blobs (one for each input stream) + + for mtype, arg in zip(stream_types, stream_args): + + try: + a1, a2 = arg + if isinstance(a1, (int, float, Fraction)): + opts, data = a1, a2 + else: + assert isinstance(a2, dict) + data, opts = a1, a2 + except: + raise ValueError( + f"""Invalid raw stream definition: {arg}.\nEach item of `stream_args` must be a two-element tuple: + - a rate (numeric) and a data_blob + - a data_blob and a dict of options + """ + ) + + pipe = NPopen("w", bufsize=0) + + if mtype == "a": # audio + if not isinstance(opts, dict): + opts = {"ar": round(opts)} + elif "ar" not in opts: + raise ValueError( + "audio stream option dict missing the required 'ar' item to set the sampling rate." + ) + in_args = configure.array_to_audio_input( + pipe_id=pipe.path, data=data, **{**default_in_opts, **opts} + ) + byte_data = plugins.get_hook().audio_bytes(obj=data) + + else: # video + if not isinstance(opts, dict): + opts = {"r": opts} + elif "r" not in opts: + raise ValueError( + "video stream option dict missing the required 'r' item to set the frame rate." + ) + in_args = configure.array_to_video_input( + pipe_id=pipe.path, data=data, **{**default_in_opts, **opts} + ) + byte_data = plugins.get_hook().video_bytes(obj=data) + + pipes.append((pipe, byte_data)) + + configure.add_url( + ffmpeg_args, + "input", + *in_args, + ) + + # map all input streams to output unless user specifies the mapping + map = options["map"] if "map" in options else list(range(n_in)) + do_merge = bool(merge_audio_streams) and stream_types.count("a") > 1 + if do_merge: + if merge_audio_streams is True: + # if True, convert to stream indices of audio inputs + merge_audio_streams = [ + i for i, mtype in enumerate(stream_types) if mtype == "a" + ] + else: + try: + assert all(stream_types[i] == "a" for i in merge_audio_streams) + except AssertionError: + raise ValueError( + "merge_audio_streams argument must be bool or a sequence of indices of input audio streams." + ) + + # assign the final map - exclude audio streams if to be merged together + options["map"] = [i for i in map if i not in merge_audio_streams] + + # add output url and options (may also contain possibly global options) + configure.add_url(ffmpeg_args, "output", url, options) + + # add extra input arguments if given + if extra_inputs is not None: + configure.add_urls(ffmpeg_args, "input", extra_inputs) + + if do_merge: + + # get FFmpeg input list + ffinputs = ffmpeg_args["inputs"] + audio_streams = {i: ffinputs[i][1] for i in merge_audio_streams} + afilt = merge_audio( + audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad or "aout", + ) + + # add the merging filter graph to the filter_complex argument + configure.add_filtergraph(ffmpeg_args, afilt) + + kwargs = {**sp_kwargs} if sp_kwargs else {} + kwargs.update( + { + "stdout": stdout, + "progress": progress, + "overwrite": overwrite, + } + ) + kwargs["capture_log"] = None if show_log else False + + with contextlib.ExitStack() as stack: + # run the FFmpeg + proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) + + # connect the pipes and queue the stream data + for p, data in pipes: + stack.enter_context(p) + writer = WriterThread(p) + stack.enter_context(writer) + writer.write(data) # send bytes in out_bytes to the client + writer.write(None) # sentinel message + + # wait for the FFmpeg to finish processing + proc.wait() + + if proc.returncode: + raise FFmpegError(proc.stderr, show_log) diff --git a/tests/test_media.py b/tests/test_media.py index f3119d61..1a1180d0 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,52 +1,60 @@ -from ffmpegio import media +from tempfile import TemporaryDirectory +from os import path +from pprint import pprint + +import ffmpegio as ff def test_media_read(): url = "tests/assets/testmulti-1m.mp4" url1 = "tests/assets/testvideo-1m.mp4" url2 = "tests/assets/testaudio-1m.mp3" - rates, data = media.read(url, t=1) - rates, data = media.read(url, map=("v:0", "v:1", "a:1", "a:0"), t=1) - rates, data = media.read(url1, url2, t=1) - rates, data = media.read(url2, url, map=("1:v:0", (0, "a:0")), t=1) + rates, data = ff.media.read(url, t=1) + rates, data = ff.media.read(url, map=("v:0", "v:1", "a:1", "a:0"), t=1) + rates, data = ff.media.read(url1, url2, t=1) + rates, data = ff.media.read(url2, url, map=("1:v:0", (0, "a:0")), t=1) print(rates) - print([(k, x['shape'], x['dtype']) for k, x in data.items()]) - - -if __name__ == "__main__": - from matplotlib import pyplot as plt - - pass - # out = ffmpegprocess.run( - # { - # "inputs": [(url, None)], - # "outputs": [ - # ( - # "-", - # { - # "ss": 0.1, - # "t": 1, - # "f": "avi", - # "c:v": "rawvideo", - # "pix_fmt": "ya8", - # "c:a": "pcm_f32le", - # "sample_fmt": "flt", - # }, - # ) - # ], - # "global_options": None, - # }, - # capture_log=False, - # ) - # reader = avi.AviReader(BytesIO(out.stdout), True) - # print(reader.streams) - # n = len(reader.streams) - # out = {v["spec"]: [] for v in reader.streams.values()} - # for st, data in reader: - # out[st].append(data) - # out = {k: np.concatenate(v) for k, v in out.items()} - # print({k: (v.shape, v.dtype) for k, v in out.items()}) - - # plt.imshow(out["v:0"][0, ..., 0], alpha=out["v:0"][0, ..., 1]/255) - # plt.show() + print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) + + +def test_media_write(): + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") + + fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) + + outext = ".mp4" + + print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") + + with TemporaryDirectory() as tmpdirname: + outfile = path.join(tmpdirname, f"out{outext}") + ff.media.write( + outfile, "va", (fps, F), (fs, x), show_log=True, shortest=ff.FLAG + ) + + pprint(ff.probe.format_basic(outfile)) + pprint(ff.probe.streams_basic(outfile)) + + +def test_media_write_audio_merge(): + stream1 = ff.audio.read("tests/assets/testaudio-1m.mp3", ar=8000, sample_fmt="s16") + stream2 = ff.audio.read("tests/assets/testaudio-1m.mp3", ar=16000, sample_fmt="flt") + stream3 = ff.audio.read("tests/assets/testaudio-1m.mp3", ar=4000, sample_fmt="dbl") + + outext = ".wav" + + with TemporaryDirectory() as tmpdirname: + outfile = path.join(tmpdirname, f"out{outext}") + ff.media.write( + outfile, + "aaa", + stream1, + stream2, + stream3, + merge_audio_streams=True, + show_log=True, + shortest=ff.FLAG, + ) + pprint(ff.probe.format_basic(outfile)) + pprint(ff.probe.audio_streams_basic(outfile)) From 95e236c6c79bbe93fd17af7147f0b368840743be Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 1 Feb 2025 20:17:57 -0600 Subject: [PATCH 011/333] doc update --- src/ffmpegio/media.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index e705de69..b253f360 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -2,7 +2,14 @@ from typing_extensions import Unpack from collections.abc import Sequence -from .utils.typing import Literal, Any, RawStreamDef, ProgressCallable +from .utils.typing import ( + Literal, + Any, + RawStreamDef, + ProgressCallable, + RawDataBlob, + StreamSpec, +) import contextlib from io import BytesIO @@ -20,39 +27,36 @@ __all__ = ["read", "write"] -def read(*urls, progress=None, show_log=None, **options): +def read( + *urls: * tuple[str], + progress: ProgressCallable | None = None, + show_log: bool | None = None, + **options: Unpack[dict[str, Any]], +) -> tuple[dict[StreamSpec, Fraction | int], dict[StreamSpec, RawDataBlob]]: """Read video and audio frames :param *urls: URLs of the media files to read. - :type *urls: tuple(str) :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param use_ya: True if piped video streams uses `ya8` pix_fmt instead of `gray16le`, default to None - :type use_ya: bool, optional - :param \\**options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific input url or '_in' to be applied to all inputs. The url-specific option gets the preference (see :doc:`options` for custom options) - :type \\**options: dict, optional - - :return: frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: (`fractions.Fraction`, object) + :return: frame/sampling rates and raw data for each requested stream Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run `ffmpegio.video.read()` for each url. + Specify the streams to return by `map` output option: + + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output 'pix_fmt' option is not explicitly set, 'rgb24' is used. For audio streams, if 'sample_fmt' output option is not specified, 's16'. - - - streams = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - """ ninputs = len(urls) @@ -218,11 +222,7 @@ def write( pipes.append((pipe, byte_data)) - configure.add_url( - ffmpeg_args, - "input", - *in_args, - ) + configure.add_url(ffmpeg_args, "input", *in_args) # map all input streams to output unless user specifies the mapping map = options["map"] if "map" in options else list(range(n_in)) From 6d0511587bb220a96113a66f4938be4612f2722a Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 20:20:48 -0600 Subject: [PATCH 012/333] reorganized utility modules --- src/ffmpegio/_utils.py | 39 ++++++++++++ src/ffmpegio/analyze.py | 2 +- src/ffmpegio/filtergraph/Chain.py | 2 +- src/ffmpegio/filtergraph/Filter.py | 8 ++- src/ffmpegio/filtergraph/Graph.py | 6 +- src/ffmpegio/filtergraph/GraphLinks.py | 18 +++--- src/ffmpegio/filtergraph/__init__.py | 21 +++++++ src/ffmpegio/filtergraph/abc.py | 2 +- src/ffmpegio/filtergraph/build.py | 63 +++++++++++++++++-- src/ffmpegio/filtergraph/convert.py | 2 +- src/ffmpegio/filtergraph/presets.py | 4 +- src/ffmpegio/filtergraph/util.py | 63 ------------------- .../{utils/filter.py => filtergraph/utils.py} | 0 src/ffmpegio/utils/__init__.py | 38 ----------- tests/test_image.py | 4 +- tests/test_utils_filter.py | 2 +- 16 files changed, 147 insertions(+), 127 deletions(-) delete mode 100644 src/ffmpegio/filtergraph/util.py rename src/ffmpegio/{utils/filter.py => filtergraph/utils.py} (100%) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 7f81e99b..5821efa2 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -6,6 +6,45 @@ prod = lambda seq: reduce(mul, seq, 1) +from builtins import zip as builtin_zip + + +def zip(*args, strict=False): + + # backwards compatibility for pre-py3.10 + + try: + return builtin_zip(*args, strict=strict) + except TypeError: + if strict is False: + return builtin_zip(*args) + + def strict_zip(): + # strict=True case, excerpted from PEP618: https://peps.python.org/pep-0618/ + iterators = tuple(iter(iterable) for iterable in args) + try: + while True: + items = [] + for iterator in iterators: + items.append(next(iterator)) + yield tuple(items) + except StopIteration: + pass + + if items: + i = len(items) + plural = " " if i == 1 else "s 1-" + msg = f"zip() argument {i+1} is shorter than argument{plural}{i}" + raise ValueError(msg) + sentinel = object() + for i, iterator in enumerate(iterators[1:], 1): + if next(iterator, sentinel) is not sentinel: + plural = " " if i == 1 else "s 1-" + msg = f"zip() argument {i+1} is longer than argument{plural}{i}" + raise ValueError(msg) + + return strict_zip() + def dtype_itemsize(dtype): return int(dtype[-1]) diff --git a/src/ffmpegio/analyze.py b/src/ffmpegio/analyze.py index 8a946e76..8a3dea8d 100644 --- a/src/ffmpegio/analyze.py +++ b/src/ffmpegio/analyze.py @@ -11,7 +11,7 @@ from . import configure from .filtergraph import Graph, Filter, Chain, as_filtergraph -from .utils.filter import compose_filter +from .filtergraph.utils import compose_filter from .errors import FFmpegError from .path import devnull from . import ffmpegprocess as fp diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index b376b3e7..64a3df5c 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -5,7 +5,7 @@ from itertools import chain -from ..utils import filter as filter_utils +from . import utils as filter_utils from .. import filtergraph as fgb from .typing import PAD_INDEX, Literal diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 4efb9d9a..824ce9ed 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -6,11 +6,11 @@ from itertools import chain from ..caps import filters as list_filters, filter_info, layouts, FilterInfo -from ..utils import filter as filter_utils +from . import utils as filter_utils from .. import filtergraph as fgb -from .typing import PAD_INDEX +from .typing import PAD_INDEX, Literal from .exceptions import * __all__ = ["Filter"] @@ -198,7 +198,9 @@ def info(self): except: raise Filter.InvalidName(self.name) - def get_pad_media_type(self, port, pad_id): + def get_pad_media_type( + self, port: Literal["input", "output"], pad_id: int + ) -> Literal["audio", "video"]: try: port = ( "inputs" diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 42816035..4182dfbe 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -10,7 +10,9 @@ import os from tempfile import NamedTemporaryFile -from ..utils import filter as filter_utils, is_stream_spec +from . import utils as filter_utils + +from ..stream_spec import is_map_spec from .. import filtergraph as fgb from .typing import PAD_INDEX @@ -526,7 +528,7 @@ def iter_input_pads( if ( not include_connected and isinstance(other_pidx, str) - and is_stream_spec(other_pidx) + and is_map_spec(other_pidx, allow_missing_file_id=True) ): continue diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index bfdb7f2f..6d9ce259 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -5,7 +5,7 @@ from collections.abc import Generator, Mapping, Sequence, Callable -from ..utils import is_stream_spec +from ..utils import is_map_spec from ..errors import FFmpegioError from .typing import PAD_INDEX, PAD_PAIR, Literal @@ -70,7 +70,7 @@ def validate_label( ) else: try: - if no_stream_spec or not is_stream_spec(label): + if no_stream_spec or not is_map_spec(label, allow_missing_file_id=True): assert re.match(r"[a-zA-Z0-9_]+$", label) except Exception as e: raise GraphLinks.Error( @@ -138,12 +138,12 @@ def validate(data: dict[str | int, PAD_PAIR]): for label, pads in data.items(): if ( - not is_stream_spec(label) + not is_map_spec(label, allow_missing_file_id=True) and pads[0] is not None and isinstance(pads[0][0], tuple) ): raise GraphLinks.Error( - "Only stream specifier labels can have multiple input pads." + "Only map specifier labels can have multiple input pads." ) GraphLinks.validate_item(label, pads) @@ -330,7 +330,7 @@ def _resolve_label( except ValueError: return 0 - if check_stream_spec and is_stream_spec(label): + if check_stream_spec and is_map_spec(label, allow_missing_file_id=True): return label if not force and label in self: @@ -439,7 +439,7 @@ def iter_links( """ def iter(label, inpad, outpad): - if outpad is not None or (include_input_stream and is_stream_spec(label)): + if outpad is not None or (include_input_stream and is_map_spec(label, allow_missing_file_id=True)): for d in self.iter_inpad_ids(inpad): yield (label, d, outpad) @@ -461,7 +461,7 @@ def iter_inputs( :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)): + if outpad is None and not (exclude_stream_specs and is_map_spec(label, allow_missing_file_id=True)): for d in self.iter_inpad_ids(inpad): yield (label, d) @@ -472,7 +472,7 @@ def iter_input_streams(self) -> Generator[tuple[str, PAD_INDEX]]: :yield: label and pad index """ for label, (inpad, outpad) in self.data.items(): - if outpad is None and is_stream_spec(label): + if outpad is None and is_map_spec(label, allow_missing_file_id=True): for d in self.iter_inpad_ids(inpad): yield (label, d) @@ -651,7 +651,7 @@ def create_label( if (outpad is None) == (inpad is None): raise ValueError("outpad or inpad (but not both) must be given.") - is_stspec = is_stream_spec(label) + is_stspec = is_map_spec(label, allow_missing_file_id=True) if not is_stspec: label = self._resolve_label(label, force=force, check_stream_spec=False) diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index 264a77f6..f293cffb 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -168,3 +168,24 @@ def func(*args, filter_id=None, **kwargs): _filters[name] = func return func + +# TODO +# def validate_input_filtergraph(fg): + +# for idx, f, _ in fg.iter_output_pads(): +# label = fg.get_label(outpad=idx) +# if label is None: # '[Out0]' +# if 0 in outlabels: +# raise ValueError( +# "Invalid input filtergraph. Only one unlabeled output allowed." +# ) +# st = 0 +# elif m := re.match(r"out(\d)+$", label): +# st = int(m[1]) +# else: +# raise ValueError( +# 'Input filtergraph must be labelled as "outN" where N is a nonnegative integer, starting at 0.' +# ) +# outlabels[st] = f.get_pad_media_type(port="out", pad_id=idx[-1]) +# if (n := len(outlabels)) != max(outlabels) + 1: +# raise ValueError("Invalid input filtergraph. Missing output label(s).") diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 64432cdd..ccb7d5cf 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -8,7 +8,7 @@ from .. import filtergraph as fgb -from ..utils import zip # pre-py310 compatibility +from .._utils import zip # pre-py310 compatibility __all__ = ["FilterGraphObject"] diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 0c10166c..88822362 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -7,11 +7,68 @@ from .exceptions import FiltergraphInvalidExpression from .. import filtergraph as fgb -from ..utils import zip # pre-py310 compatibility +from .._utils import zip # pre-py310 compatibility __all__ = ["connect", "join", "attach", "stack", "concatenate"] +def resolve_connect_pad_indices( + left: fgb.abc.FilterGraphObject, + right: fgb.abc.FilterGraphObject, + from_left: list[PAD_INDEX | str | None], + to_right: list[PAD_INDEX | str | None], + from_right: list[PAD_INDEX | str | None], + to_left: list[PAD_INDEX | str | None], + resolve_omitted: bool, +) -> tuple[list[tuple[PAD_INDEX, PAD_INDEX]]]: + """resolve and validate pad indices given for a filtergraph connect operation + + :param left: transmitting filtergraph object + :param right: receiving filtergraph object + :param from_left: output pad ids or labels of `left` fg (feedforward link sources) + :param to_right: input pad ids or labels of the `right` fg (feedforward link destinations) + :param from_right: output pad ids or labels of the `right` fg (feedback link sources) + :param to_left: input pad ids or labels of this `left` fg (feedback destinations) + :param resolve_omitted: True to resolve the `None`'s in the pad indices. If False, any + incomplete pad index (those with `None`) will raise FiltergraphPadNotFoundError + :return: tuple pairs of filter pad indices to be paired. Each tuple pair consists of two pad indices: the + first is the source/output pad and the second is the destination/input pad. + """ + + # make sure the pads to be linked are all pairable + try: + fwd_links = [(l, r) for l, r in zip(from_left, to_right, strict=True)] + except: + raise ValueError( + f"the number of pad indices in {from_left=} and {to_right=} must match." + ) + + try: + bwd_links = [(l, r) for l, r in zip(from_right, to_left, strict=True)] + except: + raise ValueError( + f"the number of pad indices in {from_right=} and {to_left=} must match." + ) + + # make sure all the link indices are 3-element tuples + fwd_links = [ + ( + left.resolve_pad_index(l, is_input=False, resolve_omitted=resolve_omitted), + right.resolve_pad_index(r, is_input=True, resolve_omitted=resolve_omitted), + ) + for l, r in fwd_links + ] + bwd_links = [ + ( + right.resolve_pad_index(r, is_input=False, resolve_omitted=resolve_omitted), + left.resolve_pad_index(l, is_input=True, resolve_omitted=resolve_omitted), + ) + for r, l in bwd_links + ] + + return fwd_links, bwd_links + + def connect( left: fgb.abc.FilterGraphObject | str, right: fgb.abc.FilterGraphObject | str, @@ -43,8 +100,6 @@ def connect( """ - from ..filtergraph.util import resolve_connect_pad_indices - # make sure right is a Graph object left = fgb.as_filtergraph_object(left) right = fgb.as_filtergraph_object(right) @@ -159,7 +214,7 @@ def create_links(it_left, it_right): how = "all" else: raise - + if how in ("all", "chanable"): links = create_links( left.iter_output_pads(**iter_kws), right.iter_input_pads(**iter_kws) diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py index e77d58be..67677ea5 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..utils import filter as filter_utils +from . import utils as filter_utils from .exceptions import FiltergraphConversionError, FiltergraphInvalidExpression from .. import filtergraph as fgb diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 7caafad0..7aebc7a9 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -3,7 +3,7 @@ from __future__ import annotations -from ..utils.typing import TYPE_CHECKING, Any, StreamSpec +from ..typing import TYPE_CHECKING, Any, StreamSpecDict from collections.abc import Sequence @@ -12,7 +12,7 @@ def merge_audio( - streams: dict[StreamSpec, dict[str, Any]], + streams: dict[StreamSpecDict, dict[str, Any]], output_ar: int | None = None, output_sample_fmt: str | None = None, output_pad_label: str | None = "aout", diff --git a/src/ffmpegio/filtergraph/util.py b/src/ffmpegio/filtergraph/util.py deleted file mode 100644 index 8472eb2a..00000000 --- a/src/ffmpegio/filtergraph/util.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from .typing import PAD_INDEX - -from .. import filtergraph as fgb -from ..utils import zip # pre-py310 compatibility - - -def resolve_connect_pad_indices( - left: fgb.abc.FilterGraphObject, - right: fgb.abc.FilterGraphObject, - from_left: list[PAD_INDEX | str | None], - to_right: list[PAD_INDEX | str | None], - from_right: list[PAD_INDEX | str | None], - to_left: list[PAD_INDEX | str | None], - resolve_omitted: bool, -) -> tuple[list[tuple[PAD_INDEX, PAD_INDEX]]]: - """resolve and validate pad indices given for a filtergraph connect operation - - :param left: transmitting filtergraph object - :param right: receiving filtergraph object - :param from_left: output pad ids or labels of `left` fg (feedforward link sources) - :param to_right: input pad ids or labels of the `right` fg (feedforward link destinations) - :param from_right: output pad ids or labels of the `right` fg (feedback link sources) - :param to_left: input pad ids or labels of this `left` fg (feedback destinations) - :param resolve_omitted: True to resolve the `None`'s in the pad indices. If False, any - incomplete pad index (those with `None`) will raise FiltergraphPadNotFoundError - :return: tuple pairs of filter pad indices to be paired. Each tuple pair consists of two pad indices: the - first is the source/output pad and the second is the destination/input pad. - """ - - # make sure the pads to be linked are all pairable - try: - fwd_links = [(l, r) for l, r in zip(from_left, to_right, strict=True)] - except: - raise ValueError( - f"the number of pad indices in {from_left=} and {to_right=} must match." - ) - - try: - bwd_links = [(l, r) for l, r in zip(from_right, to_left, strict=True)] - except: - raise ValueError( - f"the number of pad indices in {from_right=} and {to_left=} must match." - ) - - # make sure all the link indices are 3-element tuples - fwd_links = [ - ( - left.resolve_pad_index(l, is_input=False, resolve_omitted=resolve_omitted), - right.resolve_pad_index(r, is_input=True, resolve_omitted=resolve_omitted), - ) - for l, r in fwd_links - ] - bwd_links = [ - ( - right.resolve_pad_index(r, is_input=False, resolve_omitted=resolve_omitted), - left.resolve_pad_index(l, is_input=True, resolve_omitted=resolve_omitted), - ) - for r, l in bwd_links - ] - - return fwd_links, bwd_links diff --git a/src/ffmpegio/utils/filter.py b/src/ffmpegio/filtergraph/utils.py similarity index 100% rename from src/ffmpegio/utils/filter.py rename to src/ffmpegio/filtergraph/utils.py diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 2f845c32..4c4f0da2 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -15,44 +15,6 @@ # 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: diff --git a/tests/test_image.py b/tests/test_image.py index 3171aa6c..98a2bdc8 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -2,6 +2,8 @@ import tempfile, re from os import path +from ffmpegio.filtergraph import utils as filter_utils + outext = ".png" @@ -122,7 +124,7 @@ def test_square_pixels(): from matplotlib import pyplot as plt import logging from ffmpegio import utils, ffmpegprocess - from ffmpegio.utils import filter as filter_utils, log as log_utils + from ffmpegio.utils import log as log_utils # logging.basicConfig(level=logging.DEBUG) diff --git a/tests/test_utils_filter.py b/tests/test_utils_filter.py index e5d23e90..4bf3a4e9 100644 --- a/tests/test_utils_filter.py +++ b/tests/test_utils_filter.py @@ -3,7 +3,7 @@ logging.basicConfig(level=logging.INFO) -from ffmpegio.utils import filter as filter_utils +from ffmpegio.filtergraph import utils as filter_utils from pprint import pprint import pytest From e233851d74a2a45fb80cadd34b871f5371aa97cc Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 20:38:47 -0600 Subject: [PATCH 013/333] refactored stream_spec types and functions to a new module --- src/ffmpegio/stream_spec.py | 239 +++++++++++++++++++++++++++++++++ src/ffmpegio/utils/__init__.py | 230 +------------------------------ src/ffmpegio/utils/typing.py | 23 +--- tests/test_stream_spec.py | 78 +++++++++++ tests/test_utils.py | 45 ------- 5 files changed, 320 insertions(+), 295 deletions(-) create mode 100644 src/ffmpegio/stream_spec.py create mode 100644 tests/test_stream_spec.py diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py new file mode 100644 index 00000000..22be2d7f --- /dev/null +++ b/src/ffmpegio/stream_spec.py @@ -0,0 +1,239 @@ +"""streams and map specifier handling module + +parse & compose FFmpeg stream spec string from/to StreamSpec object + +""" + +from __future__ import annotations + +from typing import get_args, Literal, TypedDict, Union, Tuple +from ._typing import MediaType, NotRequired + +StreamSpecMediaType = Literal["v", "a", "s", "d", "t", "V"] +# libavformat/avformat.c:match_stream_specifier() + + +class StreamSpec_Options(TypedDict): + media_type: NotRequired[MediaType] # py3.11 NotRequired[MediaType] + program_id: NotRequired[int] # py3.11 NotRequired[int] + group_index: NotRequired[int] # py3.11 NotRequired[int] + group_id: NotRequired[int] # py3.11 NotRequired[int] + stream_id: NotRequired[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] + +################################# + + +def parse_stream_spec(spec: str | int) -> StreamSpec: + """Parse stream specifier string + + :param spec: stream specifier string. If int, it specifies the stream index. + :return: stream spec dict + + The reverse of `stream_spec()` + """ + + if isinstance(spec, str): + + out: StreamSpec = {} + spec_parts = spec.split(":") + nspecs = len(spec_parts) + i = 0 # current index + + def get_int(s, name): + try: + v = int( + s, + ( + 10 + if s[0] != "0" and len(s) > 1 + else 16 if s.startswith("0x") or s.startswith("0X") else 8 + ), + ) + assert v >= 0 + except Exception as e: + raise ValueError(f"Invalid {name} ({s})") from e + return v + + def get_id(i, name): + + try: + s = spec_parts[i + 1] + except IndexError as e: + raise ValueError(f"Missing {name}") from e + else: + return get_int(s, name) + + # process the optional parts + while i < nspecs: + spec = spec_parts[i] + # optional specifiers first + if spec in get_args(StreamSpecMediaType): + out["media_type"] = spec + i += 1 + elif spec == "g": + i += 1 + spec = spec_parts[i] + if spec == "i": + out["group_id"] = get_id(i, "group_id") + i += 2 + elif spec.startswith("#"): + out["group_id"] = get_int(spec[1:], "group_id") + i += 1 + else: + out["group_index"] = get_int(spec, "group index") + i += 1 + elif spec == "p": + out["program_id"] = get_id(i, "program_id") + i += 2 + else: + # final primary specifier + if spec.startswith("#"): + out["stream_id"] = get_int(spec[1:], "stream_id") + elif spec == "i": + out["stream_id"] = get_id(i, "stream_id") + i += 1 + elif spec == "u": + out["usable"] = True + elif spec == "m": + try: + key, *value = spec_parts[i + 1 :] + assert len(value) <= 1 + except (IndexError, AssertionError) as e: + raise ValueError( + f"Invalid metadata tag specifier: {':'.join(spec_parts[i:])}" + ) from e + else: + i = nspecs - 1 + out["tag"] = (key, value[0]) if len(value) else key + else: + try: + out["index"] = get_int(spec, "stream_index") + except ValueError as e: + raise ValueError(f"Unknown stream specifier: {spec}") from e + break + + if i + 1 < nspecs: + raise ValueError(f"Not all specifiers resolved: {':'.join(spec_parts[i:])}") + + return out + + if not (isinstance(spec, int) and spec >= 0): + raise ValueError("Invalid stream specifier") + return {"index": int(spec)} + + +def is_stream_spec(spec: str | int) -> bool: + """True if valid stream specifier string + + :param spec: stream specifier string to be tested + :param file_index: True if spec starts with a file index, None to allow with or without file_index defaults to False + :return: True if valid stream specifier + """ + try: + parse_stream_spec(spec) + return True + except ValueError: + return False + + +def stream_spec( + index: int | None = None, + media_type: MediaType | None = None, + group_index: int | None = None, + group_id: int | None = None, + program_id: int | None = None, + stream_id: int | None = None, + tag: str | tuple[str, str] | None = None, + usable: bool | None = None, + file_index: int | None = None, + no_join: bool = False, +) -> str: + """Get stream specifier string + + :param index: Matches the stream with this index. If stream_index is used as + an additional stream specifier, then it selects stream number stream_index + from the matching streams. Stream numbering is based on the order of the + streams as detected by libavformat except when a program ID is also + specified. In this case it is based on the ordering of the streams in the + program., defaults to None + :param media_type: One of following: ’v’ or ’V’ for video, ’a’ for audio, ’s’ for + subtitle, ’d’ for data, and ’t’ for attachments. ’v’ matches all video + streams, ’V’ only matches video streams which are not attached pictures, + video thumbnails or cover arts. If additional stream specifier is used, then + it matches streams which both have this type and match the additional stream + specifier. Otherwise, it matches all streams of the specified type, defaults + to None + :param group_index: Matches streams which are in the group with this group index. + Can be combined with other stream_specifiers, except for `group_index`. + :param group_index: Matches streams which are in the group with this group id. + Can be combined with other stream_specifiers, except for `group_id`. + :param program_id: Selects streams which are in the program with this id. If + additional_stream_specifier is used, then it matches streams which both are + part of the program and match the additional_stream_specifier, defaults to + None + :param stream_id: stream id given by the container (e.g. PID in MPEG-TS + container), defaults to None + :param tag: metadata tag key having the specified value. If value is not + given, matches streams that contain the given tag with any value, defaults + to None + :param usable: streams with usable configuration, the codec must be defined + and the essential information such as video dimension or audio sample rate + must be present, defaults to None + :param file_index: file index to be prepended if specified, defaults to None + :param filter_output: True to append "out" to stream type, defaults to False + :param no_join: True to return list of stream specifier elements, defaults to False + :return: stream specifier string or empty string if all arguments are None + + Note matching by metadata will only work properly for input files. + + Note index, stream_id, tag, and usable are mutually exclusive. Only one of them + can be specified. + + """ + + if sum(v is not None for v in (index, stream_id, tag, usable)) > 1: + raise ValueError('Only one of "index", "tag", or "usable" may be specified.') + + if sum(v is not None for v in (group_index, group_id)) > 1: + raise ValueError('Only one of "group_index" or "group_id" may be specified.') + + spec = [] if file_index is None else [str(file_index)] + + if media_type is not None: + if media_type not in get_args(StreamSpecMediaType): + raise ValueError(f"Unknown {media_type=}.") + spec.append(media_type) + + if group_index is not None: + spec.append(f"g:{group_index}") + elif group_id is not None: + spec.append(f"g:#{group_id}") + + if program_id is not None: + spec.append(f"p:{program_id}") + + if index is not None: + spec.append(str(index)) + elif stream_id is not None: + spec.append(f"#{stream_id}") + elif tag is not None: + spec.append(f"m:{tag}" if isinstance(tag, str) else f"m:{tag[0]}:{tag[1]}") + elif usable is not None and usable: + spec.append("u") + + return spec if no_join else ":".join(spec) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 4c4f0da2..92e960a3 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -7,8 +7,9 @@ import re, fractions from .. import caps from .._utils import * +from ..stream_spec import * -from .typing import get_args, MediaType, StreamSpec, Any +from .typing import Any # TODO: auto-detect endianness # import sys @@ -88,233 +89,6 @@ def unescape(txt: str) -> str: return "".join(blks) -def parse_stream_spec( - spec: str | int | Sequence[int, int], file_index=False -) -> StreamSpec: - """Parse stream specifier string - - :param spec: stream specifier string. If file_index=False and given an int - value, it specifies the stream index. If file_index=True and given - a 2-element sequence, it specifies the file index in spec[0] and - stream index in spec[1]. - :param file_index: True to expect spec to start with a file index, defaults to False - :return: stream spec dict - - The reverse of `stream_spec()` - """ - - if isinstance(spec, str): - - out: StreamSpec = {} - spec_parts = spec.split(":") - nspecs = len(spec_parts) - i = 0 # current index - - def get_int(s, name): - try: - v = int( - s, - ( - 10 - if s[0] != "0" and len(s) > 1 - else 16 if s.startswith("0x") or s.startswith("0X") else 8 - ), - ) - assert v >= 0 - except Exception as e: - raise ValueError(f"Invalid {name} ({s})") from e - return v - - def get_id(i, name): - - try: - s = spec_parts[i + 1] - except IndexError as e: - raise ValueError(f"Missing {name}") from e - else: - return get_int(s, name) - - # add file index only if expected - if file_index: - out["file_index"] = get_int(spec_parts[0], "file index") - i += 1 - - # process the optional parts - while i < nspecs: - spec = spec_parts[i] - # optional specifiers first - if spec in get_args(MediaType): - out["media_type"] = spec - i += 1 - elif spec == "g": - i += 1 - spec = spec_parts[i] - if spec == "i": - out["group_id"] = get_id(i, "group_id") - i += 2 - elif spec.startswith("#"): - out["group_id"] = get_int(spec[1:], "group_id") - i += 1 - else: - out["group_index"] = get_int(spec, "group index") - i += 1 - elif spec == "p": - out["program_id"] = get_id(i, "program_id") - i += 2 - else: - # final primary specifier - if spec.startswith("#"): - out["stream_id"] = get_int(spec[1:], "stream_id") - elif spec == "i": - out["stream_id"] = get_id(i, "stream_id") - i += 1 - elif spec == "u": - out["usable"] = True - elif spec == "m": - try: - key, *value = spec_parts[i + 1 :] - assert len(value) <= 1 - except (IndexError, AssertionError) as e: - raise ValueError( - f"Invalid metadata tag specifier: {':'.join(spec_parts[i:])}" - ) from e - else: - i = nspecs - 1 - out["tag"] = (key, value[0]) if len(value) else key - else: - try: - out["index"] = get_int(spec, "stream_index") - except ValueError as e: - raise ValueError(f"Unknown stream specifier: {spec}") from e - break - - if i + 1 < nspecs: - raise ValueError(f"Not all specifiers resolved: {':'.join(spec_parts[i:])}") - - return out - - if file_index: - if not ( - isinstance(spec, Sequence) - and len(spec) == 2 - and all(isinstance(v, int) and v >= 0 for v in spec) - ): - raise ValueError("Invalid stream specifier") - return {"file_index": int(spec[0]), "index": int(spec[1])} - - if not (isinstance(spec, int) and spec >= 0): - raise ValueError("Invalid stream specifier") - return {"index": int(spec)} - - -def is_stream_spec(spec, file_index: bool | None = None) -> bool: - """True if valid stream specifier string - - :param spec: stream specifier string to be tested - :param file_index: True if spec starts with a file index, None to allow with or without file_index defaults to False - :return: True if valid stream specifier - """ - try: - parse_stream_spec(spec, True if file_index is None else file_index) - return True - except ValueError: - if file_index is None: - try: - parse_stream_spec(spec, False) - return True - except ValueError: - pass - return False - - -def stream_spec( - index: int | None = None, - media_type: MediaType | None = None, - group_index: int | None = None, - group_id: int | None = None, - program_id: int | None = None, - stream_id: int | None = None, - tag: str | tuple[str, str] | None = None, - usable: bool | None = None, - file_index: int | None = None, - no_join: bool = False, -) -> str: - """Get stream specifier string - - :param index: Matches the stream with this index. If stream_index is used as - an additional stream specifier, then it selects stream number stream_index - from the matching streams. Stream numbering is based on the order of the - streams as detected by libavformat except when a program ID is also - specified. In this case it is based on the ordering of the streams in the - program., defaults to None - :param media_type: One of following: ’v’ or ’V’ for video, ’a’ for audio, ’s’ for - subtitle, ’d’ for data, and ’t’ for attachments. ’v’ matches all video - streams, ’V’ only matches video streams which are not attached pictures, - video thumbnails or cover arts. If additional stream specifier is used, then - it matches streams which both have this type and match the additional stream - specifier. Otherwise, it matches all streams of the specified type, defaults - to None - :param group_index: Matches streams which are in the group with this group index. - Can be combined with other stream_specifiers, except for `group_index`. - :param group_index: Matches streams which are in the group with this group id. - Can be combined with other stream_specifiers, except for `group_id`. - :param program_id: Selects streams which are in the program with this id. If - additional_stream_specifier is used, then it matches streams which both are - part of the program and match the additional_stream_specifier, defaults to - None - :param stream_id: stream id given by the container (e.g. PID in MPEG-TS - container), defaults to None - :param tag: metadata tag key having the specified value. If value is not - given, matches streams that contain the given tag with any value, defaults - to None - :param usable: streams with usable configuration, the codec must be defined - and the essential information such as video dimension or audio sample rate - must be present, defaults to None - :param file_index: file index to be prepended if specified, defaults to None - :param filter_output: True to append "out" to stream type, defaults to False - :param no_join: True to return list of stream specifier elements, defaults to False - :return: stream specifier string or empty string if all arguments are None - - Note matching by metadata will only work properly for input files. - - Note index, stream_id, tag, and usable are mutually exclusive. Only one of them - can be specified. - - """ - - if sum(v is not None for v in (index, stream_id, tag, usable)) > 1: - raise ValueError('Only one of "index", "tag", or "usable" may be specified.') - - if sum(v is not None for v in (group_index, group_id)) > 1: - raise ValueError('Only one of "group_index" or "group_id" may be specified.') - - spec = [] if file_index is None else [str(file_index)] - - if media_type is not None: - if media_type not in get_args(MediaType): - raise ValueError(f"Unknown {media_type=}.") - spec.append(media_type) - - if group_index is not None: - spec.append(f"g:{group_index}") - elif group_id is not None: - spec.append(f"g:#{group_id}") - - if program_id is not None: - spec.append(f"p:{program_id}") - - if index is not None: - spec.append(str(index)) - elif stream_id is not None: - spec.append(f"#{stream_id}") - elif tag is not None: - spec.append(f"m:{tag}" if isinstance(tag, str) else f"m:{tag[0]}:{tag[1]}") - elif usable is not None and usable: - spec.append("u") - - return spec if no_join else ":".join(spec) - - def get_pixel_config( input_pix_fmt: str, pix_fmt: str | None = None ) -> tuple[str, int, str, bool]: diff --git a/src/ffmpegio/utils/typing.py b/src/ffmpegio/utils/typing.py index 51d060ea..5bbf3102 100644 --- a/src/ffmpegio/utils/typing.py +++ b/src/ffmpegio/utils/typing.py @@ -10,28 +10,7 @@ # 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 ..stream_spec import StreamSpec RawDataBlob = Any # depends on raw data reader plugin diff --git a/tests/test_stream_spec.py b/tests/test_stream_spec.py new file mode 100644 index 00000000..70a38bf2 --- /dev/null +++ b/tests/test_stream_spec.py @@ -0,0 +1,78 @@ +from ffmpegio import stream_spec as utils +import pytest + + +@pytest.mark.parametrize( + ("arg", "ret"), + [ + (1, {"index": 1}), + ("1", {"index": 1}), + ("v", {"media_type": "v"}), + ("p:1", {"program_id": 1}), + ("p:1:V", {"program_id": 1, "media_type": "V"}), + ( + "p:1:a:#6", + { + "program_id": 1, + "media_type": "a", + "stream_id": 6, + }, + ), + ("d:i:6", {"media_type": "d", "stream_id": 6}), + ("t:m:key", {"media_type": "t", "tag": "key"}), + ("m:key:value", {"tag": ("key", "value")}), + ("u", {"usable": True}), + ], +) +def test_parse_stream_spec(arg, ret): + assert utils.parse_stream_spec(arg) == ret + + +def test_stream_spec(): + assert utils.stream_spec() == "" + assert utils.stream_spec(0) == "0" + assert utils.stream_spec(media_type="a") == "a" + assert utils.stream_spec(1, media_type="v") == "v:1" + assert utils.stream_spec(program_id=1) == "p:1" + assert utils.stream_spec(1, media_type="v", program_id=1) == "v:p:1:1" + assert utils.stream_spec(stream_id=342) == "#342" + assert utils.stream_spec(tag="creation_time") == "m:creation_time" + assert ( + utils.stream_spec(tag=("creation_time", "2018-05-26T19:36:24.000000Z")) + == "m:creation_time:2018-05-26T19:36:24.000000Z" + ) + assert utils.stream_spec(usable=True) == "u" + + # test cases: + + +@pytest.mark.parametrize( + ("map", "input_file_id", "ret"), + [ + ("4", None, {"input_file_id": 4}), + ("0:1", None, {"input_file_id": 0, "stream_specifier": "1"}), + ("0:v:0", None, {"input_file_id": 0, "stream_specifier": "v:0"}), + ( + "-1:v:2:view:back?", + None, + { + "negative": True, + "input_file_id": 1, + "stream_specifier": "v:2", + "view_specifier": "view:back", + "optional": True, + }, + ), + ( + "0:vidx:0", + None, + { + "input_file_id": 0, + "view_specifier": "vidx:0", + }, + ), + ("1:vpos:left", None, {"input_file_id": 1, "view_specifier": "vpos:left"}), + ], +) +def test_parse_map_option(map, input_file_id, ret): + assert ret==utils.parse_map_option(map, input_file_id=input_file_id) diff --git a/tests/test_utils.py b/tests/test_utils.py index 19633555..dd082e3d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -35,51 +35,6 @@ def test_string_escaping(): assert utils.unescape(esc) == raw -@pytest.mark.parametrize( - ("arg", "file_index", "ret"), - [ - (1, False, {"index": 1}), - ("1", False, {"index": 1}), - ("v", False, {"media_type": "v"}), - ("p:1", False, {"program_id": 1}), - ("p:1:V", False, {"program_id": 1, "media_type": "V"}), - ( - "p:1:a:#6", - False, - { - "program_id": 1, - "media_type": "a", - "stream_id": 6, - }, - ), - ("d:i:6", False, {"media_type": "d", "stream_id": 6}), - ("t:m:key", False, {"media_type": "t", "tag": "key"}), - ("m:key:value", False, {"tag": ("key", "value")}), - ("u", False, {"usable": True}), - ("0:1", True, {"index": 1, "file_index": 0}), - ([0, 1], True, {"index": 1, "file_index": 0}), - ], -) -def test_parse_stream_spec(arg, file_index, ret): - assert utils.parse_stream_spec(arg, file_index) == ret - - -def test_stream_spec(): - assert utils.stream_spec() == "" - assert utils.stream_spec(0) == "0" - assert utils.stream_spec(media_type="a") == "a" - assert utils.stream_spec(1, media_type="v") == "v:1" - assert utils.stream_spec(program_id=1) == "p:1" - assert utils.stream_spec(1, media_type="v", program_id=1) == "v:p:1:1" - assert utils.stream_spec(stream_id=342) == "#342" - assert utils.stream_spec(tag="creation_time") == "m:creation_time" - assert ( - utils.stream_spec(tag=("creation_time", "2018-05-26T19:36:24.000000Z")) - == "m:creation_time:2018-05-26T19:36:24.000000Z" - ) - assert utils.stream_spec(usable=True) == "u" - - def test_get_pixel_config(): with pytest.raises(Exception): utils.get_pixel_config("yuv") # unknown format From 1ddd6dc115e66fb48e4d7cdcd1b5d0475addedb6 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 20:47:54 -0600 Subject: [PATCH 014/333] renamed StreamSpec to StreamSpecDict --- src/ffmpegio/configure.py | 4 ++-- src/ffmpegio/media.py | 2 +- src/ffmpegio/stream_spec.py | 12 ++++++------ src/ffmpegio/utils/typing.py | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7aa8e972..5546ac40 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .utils.typing import Literal, Any, FFmpegArgs, StreamSpec +from .utils.typing import Literal, Any, FFmpegArgs, StreamSpecDict from collections.abc import Sequence from fractions import Fraction @@ -758,7 +758,7 @@ def process_one(url): def add_filtergraph( args: FFmpegArgs, filtergraph: Graph, - map: Sequence[StreamSpec] | None = None, + map: Sequence[StreamSpecDict] | None = None, automap: bool = True, append_filter: bool = True, append_map: bool = True, diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index b253f360..f4405490 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -8,7 +8,7 @@ RawStreamDef, ProgressCallable, RawDataBlob, - StreamSpec, + StreamSpecDict, ) import contextlib diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 22be2d7f..f581d1d9 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -9,11 +9,11 @@ from typing import get_args, Literal, TypedDict, Union, Tuple from ._typing import MediaType, NotRequired -StreamSpecMediaType = Literal["v", "a", "s", "d", "t", "V"] +StreamSpecDictMediaType = Literal["v", "a", "s", "d", "t", "V"] # libavformat/avformat.c:match_stream_specifier() -class StreamSpec_Options(TypedDict): +class StreamSpecDict_Options(TypedDict): media_type: NotRequired[MediaType] # py3.11 NotRequired[MediaType] program_id: NotRequired[int] # py3.11 NotRequired[int] group_index: NotRequired[int] # py3.11 NotRequired[int] @@ -21,19 +21,19 @@ class StreamSpec_Options(TypedDict): stream_id: NotRequired[int] # py3.11 NotRequired[int] -class StreamSpec_Index(StreamSpec_Options): +class StreamSpecDict_Index(StreamSpecDict_Options): index: int -class StreamSpec_Tag(StreamSpec_Options): +class StreamSpecDict_Tag(StreamSpecDict_Options): tag: Union[str, Tuple[str, str]] -class StreamSpec_Usable(StreamSpec_Options): +class StreamSpecDict_Usable(StreamSpecDict_Options): usable: bool -StreamSpec = Union[StreamSpec_Index, StreamSpec_Tag, StreamSpec_Usable] +StreamSpecDict = Union[StreamSpecDict_Index, StreamSpecDict_Tag, StreamSpecDict_Usable] ################################# diff --git a/src/ffmpegio/utils/typing.py b/src/ffmpegio/utils/typing.py index 5bbf3102..e27933bc 100644 --- a/src/ffmpegio/utils/typing.py +++ b/src/ffmpegio/utils/typing.py @@ -10,7 +10,7 @@ # libavformat/avformat.c:match_stream_specifier() -from ..stream_spec import StreamSpec +from ..stream_spec import StreamSpecDict RawDataBlob = Any # depends on raw data reader plugin From 7039dc0b2d3d339163976eafb265163d94f52c8d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 20:51:41 -0600 Subject: [PATCH 015/333] reorganized typing modules --- src/ffmpegio/_typing.py | 52 ++++++++++++++++++++++++++++++++++ src/ffmpegio/configure.py | 2 +- src/ffmpegio/media.py | 2 +- src/ffmpegio/typing.py | 28 ++++++++++++++++++ src/ffmpegio/utils/__init__.py | 2 +- 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/ffmpegio/_typing.py create mode 100644 src/ffmpegio/typing.py diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py new file mode 100644 index 00000000..ff7b1081 --- /dev/null +++ b/src/ffmpegio/_typing.py @@ -0,0 +1,52 @@ +"""ffmpegio object independent common type hints""" +from __future__ import annotations + +from typing import * +from typing_extensions import * + +from fractions import Fraction +from pathlib import Path +from urllib.parse import ParseResult + +from namedpipe import NPopen + + +# from typing_extensions import * + + +RawDataBlob = Any # depends on raw data reader plugin + +RawStreamDef = ( + tuple[int | float | Fraction, RawDataBlob] | tuple[RawDataBlob, dict[str, Any]] +) + + +ProgressCallable = Callable[[dict[str, Any], bool], bool] +"""FFmpeg progress callback function + + callback(status, done) + + status - dict of encoding status + done - True if the last callback + + The callback may return True to cancel the FFmpeg execution. +""" + + + + +MediaType = Literal["audio", "video"] + +FFmpegUrlType = Union[str, Path, ParseResult] +FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] +FFmpegOutputType = Literal["url", "fileobj"] + + + +class InputSourceDict(TypedDict): + """input source info""" + + src_type: FFmpegInputType # True if file path/url + bytes: NotRequired[bytes] # index of the source index + fileobj: NotRequired[IO] # file object + pipe: NotRequired[NPopen] # pipe diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 5546ac40..8648b454 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .utils.typing import Literal, Any, FFmpegArgs, StreamSpecDict +from .typing import Literal, Any, FFmpegArgs, StreamSpecDict from collections.abc import Sequence from fractions import Fraction diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index f4405490..b59145d4 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -2,7 +2,7 @@ from typing_extensions import Unpack from collections.abc import Sequence -from .utils.typing import ( +from .typing import ( Literal, Any, RawStreamDef, diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py new file mode 100644 index 00000000..0fd3c989 --- /dev/null +++ b/src/ffmpegio/typing.py @@ -0,0 +1,28 @@ +"""type hint definition for external use""" +from __future__ import annotations + +from typing import * +from typing_extensions import * +from collections.abc import Buffer + +from ._typing import * +from .filtergraph.abc import FilterGraphObject + +from .stream_spec import MediaType, StreamSpecDict, StreamSpecDictMediaType + +# from typing_extensions import * + + +class FFmpegArgs(TypedDict): + """FFmpeg arguments""" + + inputs: list[ + tuple[FFmpegUrlType | FilterGraphObject, dict | None] + ] # list of input definitions (pairs of url and options) + outputs: list[ + tuple[FFmpegUrlType, dict | None] + ] # list of output definitions (pairs of url and options) + global_options: NotRequired[dict | None] # FFmpeg global options + +FFmpegInputUrlComposite = Union[FFmpegUrlType, FilterGraphObject, IO, Buffer] +FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 92e960a3..ca8f2e40 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -9,7 +9,7 @@ from .._utils import * from ..stream_spec import * -from .typing import Any +from ..typing import Any # TODO: auto-detect endianness # import sys From 5a251be7dd5fe9adaf255030c9ad8afc3490feb2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 20:52:27 -0600 Subject: [PATCH 016/333] added is_non_str_sequence() --- src/ffmpegio/_utils.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 5821efa2..644adae9 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -1,3 +1,9 @@ +"""common-across-subpackages utility functions that are not dependent on ffmpegio types and other functions""" + +from __future__ import annotations + +from typing import Any, Sequence + try: from math import prod except: @@ -46,6 +52,13 @@ def strict_zip(): return strict_zip() +def is_non_str_sequence( + value: Any, class_excluded: type | tuple[type, ...] = str +) -> bool: + """Returns true if value is a sequence but not a str object""" + return isinstance(value, Sequence) and not isinstance(value, class_excluded) + + def dtype_itemsize(dtype): return int(dtype[-1]) From 9f8a3712b2f44a0485915d337849acbd1d628ac7 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:07:28 -0600 Subject: [PATCH 017/333] finalize_audio/video_read_opts() to probe input --- src/ffmpegio/audio.py | 26 ++-------- src/ffmpegio/configure.py | 75 +++++++++++++++++++++------ src/ffmpegio/image.py | 44 +++------------- src/ffmpegio/streams/SimpleStreams.py | 46 +++------------- src/ffmpegio/video.py | 55 ++++---------------- tests/test_ffmpegprocess.py | 12 +---- 6 files changed, 91 insertions(+), 167 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 8c3041c7..d071e605 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -11,9 +11,6 @@ def _run_read( *args, - sample_fmt_in=None, - ac_in=None, - ar_in=None, show_log=None, sp_kwargs=None, **kwargs, @@ -49,9 +46,7 @@ def _run_read( :rtype: (int, str) """ - dtype, ac, rate = configure.finalize_audio_read_opts( - args[0], sample_fmt_in, ac_in, ar_in - ) + dtype, ac, rate = configure.finalize_audio_read_opts(args[0], istream="a:0") if sp_kwargs is not None: kwargs = {**sp_kwargs, **kwargs} @@ -144,14 +139,15 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) ) ffmpeg_args = configure.empty() - inopts = configure.add_url( + configure.add_url( ffmpeg_args, "input", url, {**input_options, "f": "lavfi"} )[1][1] - configure.add_url(ffmpeg_args, "output", "-", options)[1][1] + configure.add_url(ffmpeg_args, "output", "-", {"sample_fmt": "dbl", **options})[1][ + 1 + ] return _run_read( ffmpeg_args, - sample_fmt_in=inopts.get("sample_fmt", "dbl"), progress=progress, show_log=show_log, sp_kwargs=sp_kwargs, @@ -187,15 +183,6 @@ def read(url, progress=None, show_log=None, sp_kwargs=None, **options): """ - sample_fmt = options.get("sample_fmt", None) - ac_in = ar_in = None - if sample_fmt is None: - try: - # use the same format as the input - ar_in, sample_fmt, ac_in = _probe_audio_info(url, "a:0", sp_kwargs) - except: - sample_fmt = "s16" - input_options = utils.pop_extra_options(options, "_in") url, stdin, input = configure.check_url( url, False, format=input_options.get("f", None) @@ -212,9 +199,6 @@ def read(url, progress=None, show_log=None, sp_kwargs=None, **options): return _run_read( ffmpeg_args, - sample_fmt_in=sample_fmt, - ac_in=ac_in, - ar_in=ar_in, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs, diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 8648b454..84464659 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -9,8 +9,7 @@ logger = logging.getLogger("ffmpegio") -from . import utils, plugins -from .filtergraph import Graph, Filter, Chain +from . import utils, plugins, probe from .filtergraph.abc import FilterGraphObject from .errors import FFmpegioError @@ -229,11 +228,40 @@ def has_filtergraph(args, type): def finalize_video_read_opts( - args, pix_fmt_in=None, s_in=None, r_in=None, ofile=0, ifile=0 -): - inopts = args["inputs"][ifile][1] or {} + args: FFmpegArgs, ofile: int = 0, ifile: int = 0, istream: str | None = None +) -> tuple[str, tuple[int, int], Fraction]: + + inurl, inopts = args["inputs"][ifile] + if inopts is None: + inopts = {} outopts = args["outputs"][ofile][1] + pix_fmt_in = inopts.get("pix_fmt", None) + w_in, h_in = inopts.get("s", (None, None)) + r_in = inopts.get("r", None) + + if ( + isinstance(inurl, (str, Path)) + and inopts.get("f", None) != "lavfi" + and not (pix_fmt_in and w_in and h_in and r_in) + ): + # TODO: handle lavfi filter processing + try: + # ["pix_fmt", "width", "height", "avg_frame_rate", "r_frame_rate"] + v_pix_fmt, v_width, v_height, vr1, vr2 = probe._video_info( + inurl, istream, None + ) + pix_fmt_in, w_in, h_in, r_in = ( + x or y + for x, y in zip( + (pix_fmt_in, w_in, h_in, r_in), + (v_pix_fmt, v_width, v_height, vr1 or vr2), + ) + ) + except: + pass # not probable, OK... maybe + s_in = (w_in, h_in) if w_in and h_in else None + if outopts is None: outopts = {} args["outputs"][ofile] = (args["outputs"][ofile][0], outopts) @@ -243,7 +271,6 @@ def finalize_video_read_opts( remove_alpha = False if pix_fmt is None: # deduce output pixel format from the input pixel format - pix_fmt_in = inopts.get("pix_fmt", pix_fmt_in) try: outopts["pix_fmt"], ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) except: @@ -267,9 +294,8 @@ def finalize_video_read_opts( # if no filter and video shape and rate are known, all known r = s = None if not has_filtergraph(args, "video") and ncomp is not None: - r = outopts.get("r", inopts.get("r", r_in)) - - s = outopts.get("s", inopts.get("s", s_in)) + r = outopts.get("r", r_in) + s = outopts.get("s", s_in) if s is not None: if isinstance(s, str): m = re.match(r"(\d+)x(\d+)", s) @@ -423,12 +449,31 @@ def build_basic_vf(args, remove_alpha=None, ofile=0): def finalize_audio_read_opts( - args, sample_fmt_in=None, ac_in=None, ar_in=None, ofile=0, ifile=0 -): - inopts = args["inputs"][ifile][1] or {} + args: FFmpegArgs, ofile: int = 0, ifile: int = 0, istream: str | None = None +) -> tuple[str, int, int]: + + inurl, inopts = args["inputs"][ifile] + if inopts is None: + inopts = {} outopts = args["outputs"][ofile][1] has_filter = has_filtergraph(args, "audio") + sample_fmt_in = inopts.get("sample_fmt", None) + ac_in = inopts.get("ac", None) + ar_in = inopts.get("ar", None) + if isinstance(inurl, (str, Path)) and not (sample_fmt_in and ac_in and ar_in): + # TODO: handle lavfi input + try: + ar_in, sample_fmt_in, ac_in = ( + x or y + for x, y in zip( + (ar_in, sample_fmt_in, ac_in), + probe._audio_info(inurl, istream, None), + ) + ) + except: + pass + if outopts is None: outopts = {} args["outputs"][ofile] = (args["outputs"][ofile][0], outopts) @@ -437,7 +482,7 @@ def finalize_audio_read_opts( sample_fmt = outopts.get("sample_fmt", None) if sample_fmt is None: # get pixel format from input - sample_fmt = inopts.get("sample_fmt", sample_fmt_in) + sample_fmt = sample_fmt_in if sample_fmt: if sample_fmt[-1] == "p": # planar format is not supported @@ -449,8 +494,8 @@ def finalize_audio_read_opts( ac = ar = None if not has_filter: - ac = outopts.get("ac", inopts.get("ac", ac_in)) - ar = outopts.get("ar", inopts.get("ar", ar_in)) + ac = outopts.get("ac", ac_in) + ar = outopts.get("ar", ar_in) # sample_fmt must be given dtype, shape = utils.get_audio_format(sample_fmt, ac) diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index f2b9cbef..9e1e3472 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -5,24 +5,12 @@ __all__ = ["create", "read", "write", "filter"] -def _run_read( - *args, - shape=None, - pix_fmt_in=None, - s_in=None, - show_log=None, - sp_kwargs=None, - **kwargs -): +def _run_read(*args, show_log=None, sp_kwargs=None, **kwargs): """run FFmpeg and retrieve audio stream data :param *args ffmpegprocess.run arguments :type *args: tuple :param shape: output frame size if known, defaults to None :type shape: (int, int), optional - :param pix_fmt_in: input pixel format if known but not specified in the ffmpeg arg dict, defaults to None - :type pix_fmt_in: str, optional - :param s_in: input frame size (wxh) if known but not specified in the ffmpeg arg dict, defaults to None - :type s_in: str or (int, int), optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. @@ -37,7 +25,7 @@ def _run_read( :rtype: object """ - dtype, shape, _ = configure.finalize_video_read_opts(args[0], pix_fmt_in, s_in) + dtype, shape, _ = configure.finalize_video_read_opts(args[0], istream="v:0") if sp_kwargs is not None: kwargs = {**sp_kwargs, **kwargs} @@ -112,14 +100,12 @@ def create(expr, *args, show_log=None, sp_kwargs=None, **options): ffmpeg_args = configure.empty() configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"}) - configure.add_url(ffmpeg_args, "output", "-", {**options, "f": "rawvideo"}) - - return _run_read( - ffmpeg_args, - pix_fmt_in=input_options.get("pix_fmt", "rgb24"), - show_log=show_log, - sp_kwargs=sp_kwargs, + configure.add_url( + ffmpeg_args, "output", "-", {"pix_fmt": "rgb24", **options, "f": "rawvideo"} ) + # TODO: filtergraph scanning will remove the default 'pix_fmt' setting + + return _run_read(ffmpeg_args, show_log=show_log, sp_kwargs=sp_kwargs) def read(url, show_log=None, sp_kwargs=None, **options): @@ -144,14 +130,6 @@ def read(url, show_log=None, sp_kwargs=None, **options): option which is an alias of `start` standard option. """ - # get pix_fmt of the input file only if needed - pix_fmt_in = s_in = None - if "pix_fmt" not in options and "pix_fmt_in" not in options: - try: - pix_fmt_in, *s_in, ra_in, rr_in = _probe_video_info(url, "v:0", sp_kwargs) - except: - pix_fmt_in = "rgb24" - input_options = utils.pop_extra_options(options, "_in") # get url/file stream @@ -171,13 +149,7 @@ def read(url, show_log=None, sp_kwargs=None, **options): sp_kwargs["stdin"] = stdin sp_kwargs["input"] = input - return _run_read( - ffmpeg_args, - pix_fmt_in=pix_fmt_in, - s_in=s_in, - show_log=show_log, - sp_kwargs=sp_kwargs, - ) + return _run_read(ffmpeg_args, show_log=show_log, sp_kwargs=sp_kwargs) def write( diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index abc89272..f1f38949 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -4,7 +4,6 @@ logger = logging.getLogger("ffmpegio") from .. import utils, configure, ffmpegprocess, plugins -from ..probe import _audio_info as _probe_audio_info, _video_info as _probe_video_info from ..threading import LoggerThread, ReaderThread, WriterThread # fmt:off @@ -223,31 +222,17 @@ def _finalize(self, ffmpeg_args): outopts = ffmpeg_args.get("outputs", [])[0][1] has_fg = configure.has_filtergraph(ffmpeg_args, "video") - pix_fmt = outopts.get("pix_fmt", None) - pix_fmt_in = s_in = r_in = None - if ( - pix_fmt is None - and not has_fg - and inurl not in ("-", "pipe:", "pipe:0") - and not inopts.get("pix_fmt", None) - ): - try: - # must assign output rgb/grayscale pixel format - pix_fmt_in, *s_in, ra_in, rr_in = _probe_video_info( - inurl, "v:0", self.sp_kwargs - ) - r_in = rr_in if ra_in is None or ra_in == "0/0" else ra_in - except: - pix_fmt_in = "rgb24" - - if pix_fmt_in is None and pix_fmt is None: - raise ValueError("pix_fmt must be specified.") - ( self.dtype, self.shape, self.rate, - ) = configure.finalize_video_read_opts(ffmpeg_args, pix_fmt_in, s_in, r_in) + ) = configure.finalize_video_read_opts(ffmpeg_args, istream="v:0") + + pix_fmt = outopts.get("pix_fmt", None) + pix_fmt_in = inopts.get("pix_fmt", None) + + if pix_fmt_in is None and pix_fmt is None: + raise ValueError("pix_fmt must be specified.") # construct basic video filter if options specified configure.build_basic_vf( @@ -291,26 +276,11 @@ def __init__( def _finalize(self, ffmpeg_args): # finalize FFmpeg arguments and output array - inurl, inopts = ffmpeg_args.get("inputs", [])[0] - has_fg = configure.has_filtergraph(ffmpeg_args, "audio") - - sample_fmt_in = inopts.get("sample_fmt", None) - ac_in = ar_in = None - if not has_fg and sample_fmt_in is None: - # use the same format as the input - try: - # use the same format as the input - ar_in, sample_fmt_in, ac_in = _probe_audio_info( - inurl, "a:0", self.sp_kwargs - ) - except: - sample_fmt_in = "s16" - ( self.dtype, ac, self.rate, - ) = configure.finalize_audio_read_opts(ffmpeg_args, sample_fmt_in, ac_in, ar_in) + ) = configure.finalize_audio_read_opts(ffmpeg_args, istream="a:0") if ac is not None: self.shape = (ac,) diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 23ac43a5..0a0cf428 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -6,25 +6,10 @@ __all__ = ["create", "read", "write", "filter", "detect"] -def _run_read( - *args, - shape=None, - pix_fmt_in=None, - r_in=None, - s_in=None, - show_log=None, - sp_kwargs=None, - **kwargs, -): +def _run_read(*args, show_log=None, sp_kwargs=None, **kwargs): """run FFmpeg and retrieve audio stream data :param *args ffmpegprocess.run arguments :type *args: tuple - :param shape: output frame size if known, defaults to None - :type shape: (int, int), optional - :param pix_fmt_in: input pixel format if known but not specified in the ffmpeg arg dict, defaults to None - :type pix_fmt_in: str, optional - :param s_in: input frame size (wxh) if known but not specified in the ffmpeg arg dict, defaults to None - :type s_in: str or (int, int), optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. @@ -40,9 +25,7 @@ def _run_read( :rtype: object """ - dtype, shape, r = configure.finalize_video_read_opts( - args[0], pix_fmt_in, s_in, r_in - ) + dtype, shape, r = configure.finalize_video_read_opts(args[0], istream="v:0") if sp_kwargs is not None: kwargs = {**sp_kwargs, **kwargs} @@ -124,14 +107,13 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) ffmpeg_args = configure.empty() configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"}) - configure.add_url(ffmpeg_args, "output", "-", {**options, "f": "rawvideo"}) + configure.add_url( + ffmpeg_args, "output", "-", {"pix_fmt": "rgb24", **options, "f": "rawvideo"} + ) + # TODO: filtergraph scanning will remove the default 'pix_fmt' setting return _run_read( - ffmpeg_args, - pix_fmt_in=input_options.get("pix_fmt", "rgb24"), - progress=progress, - show_log=show_log, - sp_kwargs=sp_kwargs, + ffmpeg_args, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs ) @@ -157,17 +139,7 @@ def read(url, progress=None, show_log=None, sp_kwargs=None, **options): :rtype: (fractions.Fraction, object) """ - pix_fmt = options.get("pix_fmt", None) - # get pix_fmt of the input file only if needed - pix_fmt_in = s_in = r_in = None - if pix_fmt is None and "pix_fmt_in" not in options: - try: - pix_fmt_in, *s_in, ra_in, rr_in = _probe_video_info(url, "v:0", sp_kwargs) - r_in = rr_in if ra_in is None or ra_in == "0/0" else ra_in - except: - pix_fmt_in = "rgb24" - input_options = utils.pop_extra_options(options, "_in") # get url/file stream @@ -185,13 +157,7 @@ def read(url, progress=None, show_log=None, sp_kwargs=None, **options): sp_kwargs["input"] = input return _run_read( - ffmpeg_args, - progress=progress, - show_log=show_log, - pix_fmt_in=pix_fmt_in, - s_in=s_in, - r_in=r_in, - sp_kwargs=sp_kwargs, + ffmpeg_args, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs ) @@ -324,10 +290,7 @@ def filter(expr, rate, input, progress=None, show_log=None, sp_kwargs=None, **op sp_kwargs["input"] = plugins.get_hook().video_bytes(obj=input) return _run_read( - ffmpeg_args, - progress=progress, - show_log=show_log, - sp_kwargs=sp_kwargs, + ffmpeg_args, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs ) diff --git a/tests/test_ffmpegprocess.py b/tests/test_ffmpegprocess.py index 8af26aa3..9a728699 100644 --- a/tests/test_ffmpegprocess.py +++ b/tests/test_ffmpegprocess.py @@ -91,14 +91,6 @@ def test_popen(): def test_popen_progress(): url = "tests/assets/testvideo-1m.mp4" - info = probe.video_streams_basic( - url, 0, ["pix_fmt", "width", "height", "frame_rate"] - ) - pix_fmt_in = info["pix_fmt"] - s_in = (info["width"], info["height"]) - r_in = info["frame_rate"] - - i = 0 def progress(*args): global i @@ -110,9 +102,7 @@ def progress(*args): configure.add_url(ffmpeg_args, "input", url) configure.add_url(ffmpeg_args, "output", "-") - dtype, shape, r = configure.finalize_video_read_opts( - ffmpeg_args, pix_fmt_in, s_in, r_in - ) + dtype, shape, r = configure.finalize_video_read_opts(ffmpeg_args, istream="v:0") samplesize = utils.get_samplesize(shape, dtype) From 18de91cb7d146d016073d28eae574126f1898423 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:09:12 -0600 Subject: [PATCH 018/333] query() to return stream list all the time --- src/ffmpegio/probe.py | 10 +++++----- tests/test_probe.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index b6a553af..f7fc1d7b 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -727,9 +727,9 @@ def query( if len(info) == 0: raise ValueError(f"Unknown or invalid stream specifier: {streams}") - if isinstance(streams, (str, int)) and "index" in parse_stream_spec(streams): - # return dict only if a specific stream requested - info = info[0] + # if isinstance(streams, (str, int)) and "index" in parse_stream_spec(streams): + # # return dict only if a specific stream requested + # info = info[0] return info @@ -749,7 +749,7 @@ def _audio_info( False, True, sp_kwargs, - ) + )[0] return tuple(q[f] for f in fields) @@ -775,7 +775,7 @@ def _video_info( False, True, sp_kwargs, - ) + )[0] return tuple(q[f] for f in fields) diff --git a/tests/test_probe.py b/tests/test_probe.py index 64103bf6..24bc10cf 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -57,7 +57,7 @@ def test_query(): list, ) assert isinstance( - probe.query(url, "a:0", fields=("duration", "sample_rate", "sample_fmt")), dict + probe.query(url, "a:0", fields=("duration", "sample_rate", "sample_fmt")), list ) assert all( @@ -70,7 +70,7 @@ def test_query(): assert ( probe.query( url, "v:0", fields=("duration", "max_bit_rate"), keep_optional_fields=True - )["max_bit_rate"] + )[0]["max_bit_rate"] is None ) From 0edab11251106b61048c71bdbb591e4c8e664de1 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:11:30 -0600 Subject: [PATCH 019/333] added missing sp_kwargs arg to filter() --- src/ffmpegio/audio.py | 2 +- src/ffmpegio/image.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index d071e605..ac6fc044 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -327,7 +327,7 @@ def filter( input=plugins.get_hook().audio_bytes(obj=input), progress=progress, show_log=show_log, - sp_kwargs=sp_kwargs + sp_kwargs=sp_kwargs, ) diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 9e1e3472..56d6b92e 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -257,5 +257,5 @@ def filter(expr, input, show_log=None, sp_kwargs=None, **options): ffmpeg_args, input=plugins.get_hook().video_bytes(obj=input), show_log=show_log, - sp_kwargs=sp_kwargs + sp_kwargs=sp_kwargs, ) From 2d7335ab0d252e9cdc01b4b972eb8ee9b2a4bda6 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:11:59 -0600 Subject: [PATCH 020/333] added map option parser and checker --- src/ffmpegio/stream_spec.py | 106 ++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index f581d1d9..1aeba797 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -9,6 +9,8 @@ from typing import get_args, Literal, TypedDict, Union, Tuple from ._typing import MediaType, NotRequired +import re + StreamSpecDictMediaType = Literal["v", "a", "s", "d", "t", "V"] # libavformat/avformat.c:match_stream_specifier() @@ -35,10 +37,32 @@ class StreamSpecDict_Usable(StreamSpecDict_Options): StreamSpecDict = Union[StreamSpecDict_Index, StreamSpecDict_Tag, StreamSpecDict_Usable] + +class InputMapOptionDict(TypedDict): + """Parsed dict of FFmpeg -map option when mapping input stream(s)""" + + negative: NotRequired[ + bool + ] # True to disables matching streams from already created mappings + input_file_id: int # index of the source index + stream_specifier: NotRequired[str | StreamSpecDict] # stream specifier + view_specifier: NotRequired[str] # view specifier + optional: NotRequired[str] # True if optional mapping + + +class GraphMapOptionDict(TypedDict): + """Parsed dict of FFmpeg -map option, when mapping filtergraph output(s)""" + + linklabel: str | None # link label of output of a filtergraph + + +MapOptionDict = Union[InputMapOptionDict, GraphMapOptionDict] +"""Parsed dict of FFmpeg -map option string""" + ################################# -def parse_stream_spec(spec: str | int) -> StreamSpec: +def parse_stream_spec(spec: str | int) -> StreamSpecDict: """Parse stream specifier string :param spec: stream specifier string. If int, it specifies the stream index. @@ -49,7 +73,7 @@ def parse_stream_spec(spec: str | int) -> StreamSpec: if isinstance(spec, str): - out: StreamSpec = {} + out: StreamSpecDict = {} spec_parts = spec.split(":") nspecs = len(spec_parts) i = 0 # current index @@ -82,7 +106,7 @@ def get_id(i, name): while i < nspecs: spec = spec_parts[i] # optional specifiers first - if spec in get_args(StreamSpecMediaType): + if spec in get_args(StreamSpecDictMediaType): out["media_type"] = spec i += 1 elif spec == "g": @@ -215,7 +239,7 @@ def stream_spec( spec = [] if file_index is None else [str(file_index)] if media_type is not None: - if media_type not in get_args(StreamSpecMediaType): + if media_type not in get_args(StreamSpecDictMediaType): raise ValueError(f"Unknown {media_type=}.") spec.append(media_type) @@ -237,3 +261,77 @@ def stream_spec( spec.append("u") return spec if no_join else ":".join(spec) + + +################################# + + +def parse_map_option(map: str, *, input_file_id: int | None = None) -> MapOptionDict: + """parse the FFmpeg -map option str + + :param map: option string value + :param input_file_id: if specified, auto-insert this id if a file id is missing in the given value, + defaults to None to error out if missing. + :param parse_stream_spec: True to also parse stream spec (if given) + :return: dict containing the parsed parts of the option value, possibly containing the items: + - negative: bool + - input_file_id: int + - stream_specifier: str|StreamSpecDictDict + - view_specifier: str + - optional: bool + - linklabel: str + + See the FFmpeg manual for the specification: https://ffmpeg.org/ffmpeg.html#Advanced-options + """ + + map = str(map) + + # -map [-]input_file_id[:stream_specifier][:view_specifier][:?] | [linklabel] + if map[0] == "[" and map[-1] == "]": + return {"linklabel": map} + + if input_file_id is not None: + s1 = map.split(":", 1) + if len(s1) == 1 or not s1[0].isdigit(): + map = f"{input_file_id}:{map}" + + m = re.match(r"(-)?(\d+)(\:[^?]+?)?(\?)?$", map) + + if not m: + raise ValueError(f"Given str ({map}) is not a valid FFmpeg map option.") + + out = {"input_file_id": int(m[2])} + if m[1]: + out["negative"] = True + if m[3]: + s = re.search(r"\:(?:view|vidx|vpos)\:(?:[^:]+)$", m[3]) + if not s: + out["stream_specifier"] = m[3][1:] + elif s.start(0): + out["stream_specifier"] = m[3][1 : s.start(0)] + out["view_specifier"] = m[3][s.start(0) + 1 :] + else: + out["view_specifier"] = m[3][1:] + if m[4]: + out["optional"] = True + + return out + + +def is_map_spec(spec: str, allow_missing_file_id: bool = False) -> bool: + """True if valid stream specifier string + + :param spec: map specifier string to be tested + :param allow_missing_file_id: True to allow missing input file id + :return: True if valid stream specifier + """ + + try: + mspec = parse_map_option( + spec, input_file_id=0 if allow_missing_file_id else None + ) + if "stream_specifier" in mspec: + parse_stream_spec(mspec["stream_specifier"]) + except Exception: + return False + return True From b5a75f867fabebb4aab59360b44207a18df31623 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:17:57 -0600 Subject: [PATCH 021/333] stream_spec arg added to streams_basic() + doc update --- src/ffmpegio/probe.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index f7fc1d7b..82914950 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -392,27 +392,23 @@ def streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + stream_spec: str | None = None, ) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of media streams :param url: URL of the media file/stream - :type url: str or seekable file-like object or bytes-like object :param entries: specify to narrow which stream entries to retrieve. Default to None, returning all entries - :type entries: seq of str, optional :param keep_optional_fields: True to return a missing optional field in the returned dict with None or "N/A" (if keep_str_values is True) as its value - :type keep_optional_fields: bool, optional :param keep_str_values: True to keep all field values as str, defaults to False to convert numeric values - :type keep_str_values: bool, optional :param cache_output: True to cache FFprobe output, defaults to False - :type cache_output: bool, optional :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None - :type sp_kwargs: dict[str, Any], optional + :param stream_spec: Specify stream specification, defaults to None + :type stream_spec: str | None, optional :return: List of media stream information. - :rtype: list of dict Media Stream Information dict Entries @@ -430,7 +426,7 @@ def streams_basic( return query( url, - True, + stream_spec or True, _resolve_entries("basic streams", entries, default_entries), keep_optional_fields, keep_str_values, From 61e99450a787ae3327e1065b44ff2c12d3bbf4a2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:18:46 -0600 Subject: [PATCH 022/333] cleaned up doc & unused import --- src/ffmpegio/probe.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 82914950..edff8b93 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -11,7 +11,6 @@ from functools import lru_cache from .path import ffprobe, PIPE -from .utils import parse_stream_spec # fmt:off __all__ = ['full_details', 'format_basic', 'streams_basic', @@ -447,25 +446,17 @@ def video_streams_basic( """Retrieve basic info of video streams :param url: URL of the media file/stream - :type url: str or seekable file-like object or bytes-like object :param index: video stream index. 0=first video stream. Defaults to None, which returns info of all video streams - :type index: int, optional :param entries: specify to narrow which information entries to retrieve. Default to None, to return all entries - :type entries: seq of str :param keep_optional_fields: True to return a missing optional field in the returned dict with None or "N/A" (if keep_str_values is True) as its value - :type keep_optional_fields: bool, optional :param keep_str_values: True to keep all field values as str, defaults to False to convert numeric values - :type keep_str_values: bool, optional :param cache_output: True to cache FFprobe output, defaults to False - :type cache_output: bool, optional :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None - :type sp_kwargs: dict[str, Any], optional :return: List of video stream information. - :rtype: list of dict Video Stream Information Entries @@ -564,25 +555,17 @@ def audio_streams_basic( """Retrieve basic info of audio streams :param url: URL of the media file/stream - :type url: str or seekable file-like object or bytes-like object :param index: audio stream index. 0=first audio stream. Defaults to None, which returns info of all audio streams - :type index: int, optional :param entries: specify to narrow which information entries to retrieve. Default to None, to return all entries - :type entries: seq of str :param keep_optional_fields: True to return a missing optional field in the returned dict with None or "N/A" (if keep_str_values is True) as its value - :type keep_optional_fields: bool, optional :param keep_str_values: True to keep all field values as str, defaults to False to convert numeric values - :type keep_str_values: bool, optional :param cache_output: True to cache FFprobe output, defaults to False - :type cache_output: bool, optional :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None - :type sp_kwargs: dict[str, Any], optional :return: List of audio stream information. - :rtype: list of dict Audio Stream Information Entries From a2e8138100ba2067cf57b83a644595d6b3859a78 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:46:55 -0600 Subject: [PATCH 023/333] added CopyFileObjThread class --- src/ffmpegio/threading.py | 41 +++++++++++++++++++++++++++++++++++++++ tests/test_threading.py | 19 ++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 02ffb17f..68908930 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -13,6 +13,7 @@ from tempfile import TemporaryDirectory from queue import Empty, Full, Queue from math import ceil +from shutil import copyfileobj import logging from namedpipe import NPopen @@ -785,3 +786,43 @@ def readall(self, timeout: float | None = None) -> dict[str, bytes]: ) return out + + +class CopyFileObjThread(Thread): + """run shutil.copyfileobj in the thread + + :param fsrc: source file object + :param fout: destination file object + :param length: The integer length, if given, is the buffer size. In particular, a negative length + value means to copy the data without looping over the source data in chunks; + defaults to 0; the data is read in chunks to avoid uncontrolled memory consumption. + + Thread terminates when the copy operation is completed. + + Note that if the current file position of the fsrc object is not 0, + only the contents from the current file position to the end of the file will be copied. + """ + + def __init__( + self, fsrc: BinaryIO | NPopen, fdst: BinaryIO | NPopen, length: int = 0 + ): + + super().__init__() + self._fsrc = fsrc + self._fdst = fdst + self.length = length + + def __enter__(self): + self.start() + return self + + def __exit__(self, *_): + self.join() + return self + + def run(self): + src_is_namedpipe = isinstance(self._fsrc, NPopen) + src = self._fsrc.wait() if src_is_namedpipe else self._fsrc + dst_is_namedpipe = isinstance(self._fdst, NPopen) + dst = self._fdst.wait() if dst_is_namedpipe else self._fdst + copyfileobj(src, dst, self.length) diff --git a/tests/test_threading.py b/tests/test_threading.py index 4177e6c7..7e573e3b 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -22,6 +22,25 @@ def test_log_popen(): pprint(logger.output_stream(0, 0)) +def test_copyfileobj(): + url = "tests/assets/testaudio-1m.mp3" + with ( + TemporaryDirectory() as tmpdir, + open(url, "rb") as fsrc, + open(path.join(tmpdir, "out.mp3"), "w+b") as fdst, + threading.CopyFileObjThread(fsrc, fdst) as copier, + ): + + copier.join() + + fsrc.seek(0) + data = fsrc.read() + fdst.seek(0) + data_out = fdst.read() + + assert data == data_out + + if __name__ == "__main__": url = "tests/assets/testmulti-1m.mp4" From 45b55c2f137635222b4a49241de5d1dade4c0e40 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:47:15 -0600 Subject: [PATCH 024/333] removed unused import --- src/ffmpegio/video.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 0a0cf428..3d648bf9 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -1,6 +1,5 @@ import warnings from . import ffmpegprocess as fp, utils, configure, FFmpegError, plugins, analyze -from .probe import _video_info as _probe_video_info from .utils import log as log_utils __all__ = ["create", "read", "write", "filter", "detect"] From 6d2aa153ee8ede0b6bd28e3a513f9d1f2b5256e4 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:47:50 -0600 Subject: [PATCH 025/333] renamed finalize_media_read_opts to finalize_avi_read_opts --- src/ffmpegio/configure.py | 2 +- src/ffmpegio/streams/AviStreams.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 84464659..91b0b392 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -642,7 +642,7 @@ def clear_loglevel(args): pass -def finalize_media_read_opts(args): +def finalize_avi_read_opts(args): """finalize multiple-input media reader setup :param args: FFmpeg dict diff --git a/src/ffmpegio/streams/AviStreams.py b/src/ffmpegio/streams/AviStreams.py index 5c85f5a0..f10b5aed 100644 --- a/src/ffmpegio/streams/AviStreams.py +++ b/src/ffmpegio/streams/AviStreams.py @@ -85,7 +85,7 @@ def __init__( configure.add_url(args, "input", url, opts) # configure output options - use_ya = configure.finalize_media_read_opts(args) + use_ya = configure.finalize_avi_read_opts(args) self._reader = threading.AviReaderThread(queuesize) From 52cceda1761a4110badf3c72bacba0b62118be72 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:48:19 -0600 Subject: [PATCH 026/333] clean up --- tests/test_threading.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/test_threading.py b/tests/test_threading.py index 7e573e3b..eb2f482c 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -1,23 +1,26 @@ from ffmpegio import threading from ffmpegio import ffmpegprocess -from ffmpegio.ffmpegprocess import Popen, run +from ffmpegio.ffmpegprocess import Popen from tempfile import TemporaryDirectory from os import path -import re from pprint import pprint def test_log_popen(): # with exec({"inputs": [(url, None)], "outputs": [("-", None)], "global_options": None},sp_run=sp.Popen,capture_log=True) as f: url = "tests/assets/testmulti-1m.mp4" - with TemporaryDirectory() as tmpdir, Popen( - { - "inputs": [(url, {"t": 0.1})], - "outputs": [(path.join(tmpdir, "test.mp4"), None)], - "global_options": None, - }, - capture_log=True, - ) as proc, threading.LoggerThread(proc.stderr, True) as logger: + with ( + TemporaryDirectory() as tmpdir, + Popen( + { + "inputs": [(url, {"t": 0.1})], + "outputs": [(path.join(tmpdir, "test.mp4"), None)], + "global_options": None, + }, + capture_log=True, + ) as proc, + threading.LoggerThread(proc.stderr, True) as logger, + ): logger.index("Output") pprint(logger.output_stream(0, 0)) From c19db2d9e0fd925335c25eacaa732e6c93ba88bb Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:51:57 -0600 Subject: [PATCH 027/333] added as_multi_option() --- src/ffmpegio/_utils.py | 18 ++++++++++++++++++ src/ffmpegio/configure.py | 9 +++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 644adae9..d0ab72db 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -59,6 +59,24 @@ def is_non_str_sequence( return isinstance(value, Sequence) and not isinstance(value, class_excluded) +def as_multi_option(value: Any, exclude_classes: tuple[type] = None) -> Sequence[Any]: + """Put value in a list if it is not already a sequence + + :param value: value to be put in a list + :param exclude_classes: sequence classes to be treated as an option value, defaults to None + :return: option values in a sequence + """ + + if exclude_classes is None: + exclude_classes = str + + return ( + value + if isinstance(value, Sequence) and not isinstance(value, exclude_classes) + else [value] + ) + + def dtype_itemsize(dtype): return int(dtype[-1]) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 91b0b392..e1765815 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -11,7 +11,7 @@ from . import utils, plugins, probe from .filtergraph.abc import FilterGraphObject -from .errors import FFmpegioError +from ._utils import as_multi_option UrlType = Literal["input", "output"] @@ -837,11 +837,8 @@ def add_filtergraph( if complex_filters is None: complex_filters = filtergraph else: - complex_filters = ( - [complex_filters] - if isinstance(complex_filters, Sequence) - and not isinstance(complex_filters, Sequence) - else [*complex_filters] + complex_filters = as_multi_option( + complex_filters, (str, fgb.Graph, fgb.Chain) ) complex_filters.append(filtergraph) else: From 4301201d990b8a1520152268893ee189b2eef7de Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 21:53:35 -0600 Subject: [PATCH 028/333] use is_non_str_sequence() --- src/ffmpegio/configure.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e1765815..9bfc6700 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -11,7 +11,7 @@ from . import utils, plugins, probe from .filtergraph.abc import FilterGraphObject -from ._utils import as_multi_option +from ._utils import as_multi_option, is_non_str_sequence UrlType = Literal["input", "output"] @@ -863,9 +863,9 @@ def add_filtergraph( # remove merged streams from output map & append the output stream of the filter map = ( - [existing_map, *map] - if isinstance(existing_map, str) or not isinstance(existing_map, Sequence) - else [*existing_map, *map] + [*existing_map, *map] + if is_non_str_sequence(existing_map) + else [existing_map, *map] ) outopts['map'] = map From 9e39e27d5778175445b469ef8e6d975153c36c27 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:00:00 -0600 Subject: [PATCH 029/333] update doc --- src/ffmpegio/configure.py | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 9bfc6700..d898ccf3 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,6 +1,6 @@ from __future__ import annotations -from .typing import Literal, Any, FFmpegArgs, StreamSpecDict +from .typing import Literal, Any, FFmpegArgs, FFmpegUrlType from collections.abc import Sequence from fractions import Fraction @@ -11,6 +11,7 @@ from . import utils, plugins, probe from .filtergraph.abc import FilterGraphObject +from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence UrlType = Literal["input", "output"] @@ -84,28 +85,32 @@ def array_to_audio_input( ) -def empty(global_options=None): +def empty(global_options: dict = None) -> FFmpegArgs: """create empty ffmpeg arg dict :param global_options: global options, defaults to None - :type global_options: dict, optional :return: empty ffmpeg arg dict with 'inputs','outputs',and 'global_options' entries. - :rtype: dict """ return {"inputs": [], "outputs": [], "global_options": global_options} -def check_url(url, nodata=True, nofileobj=False, format=None): +def check_url( + url: FFmpegUrlType | FilterGraphObject | FFConcat | memoryview | IOBase, + nodata: bool = True, + nofileobj: bool = False, + format: str | None = None, + pipe_str: str | None = "-", +) -> tuple[ + FFmpegUrlType | FilterGraphObject | FFConcat, IOBase | None, memoryview | None +]: """Analyze url argument for non-url input :param url: url argument string or data or file or a custom class - :type url: str, bytes-like object, audio or video data object, file-like object, or pipe input custom object :param nodata: True to raise exception if url is a bytes-like object, default to True - :type nodata: bool, optional :param nofileobj: True to raise exception if url is a file-like object, default to False - :type nofileobj: bool, optional + :param format: FFmpeg format option, default to None (unspecified) + :param pipe_str: specify an alternate FFmpeg pipe url or None to leave it blank, default to '-' :return: url string, file object, and data object - :rtype: tuple Custom Pipe Class ----------------- @@ -145,21 +150,21 @@ def hasmethod(o, name): return url, fileobj, data -def add_url(args, type, url, opts=None, update=False): +def add_url( + args: FFmpegArgs, + type: Literal["input", "output"], + url: str, + opts: dict[str, Any] | None = None, + update: bool = False, +) -> tuple[int, tuple[str, dict | None]]: """add new or modify existing url to input or output list :param args: ffmpeg arg dict (modified in place) - :type args: dict :param type: input or output - :type type: 'input' or 'output' :param url: url of the new entry - :type url: str :param opts: FFmpeg options associated with the url, defaults to None - :type opts: dict, optional :param update: True to update existing input of the same url, default to False - :type update: bool, optional :return: file index and its entry - :rtype: tuple(int, tuple(str, dict or None)) """ type = f"{type}s" @@ -183,19 +188,14 @@ def add_url(args, type, url, opts=None, update=False): return id, filelist[id] -def has_filtergraph(args, type): +def has_filtergraph(args: FFmpegArgs, type: Literal["audio", "video"]) -> bool: """True if FFmpeg arguments specify a filter graph :param args: FFmpeg argument dict - :type args: dict :param type: filter type - :type type: 'video' or 'audio' :param file_id: specify output file id (ignored if type=='complex'), defaults to None (or 0) - :type file_id: int, optional :param stream_id: stream, defaults to None - :type stream_id: int, optional :return: True if filter graph is specified - :rtype: bool """ try: if ( @@ -803,7 +803,7 @@ def process_one(url): def add_filtergraph( args: FFmpegArgs, filtergraph: Graph, - map: Sequence[StreamSpecDict] | None = None, + map: Sequence[str] | None = None, automap: bool = True, append_filter: bool = True, append_map: bool = True, From cad8bd4932e847701041a0c02f15bc618545c601 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:02:07 -0600 Subject: [PATCH 030/333] added array_to_video_options() --- src/ffmpegio/configure.py | 11 +---------- src/ffmpegio/utils/__init__.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index d898ccf3..4d6a2f0a 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -35,18 +35,9 @@ def array_to_video_input( if rate is None and "r" not in opts: raise ValueError("rate argument must be specified if opts['r'] is not given.") - s, pix_fmt = utils.guess_video_format(*plugins.get_hook().video_info(obj=data)) - return ( pipe_id or "-", - { - "f": "rawvideo", - f"c:v": "rawvideo", - f"s": s, - f"r": rate, - f"pix_fmt": pix_fmt, - **opts, - }, + {**utils.array_to_video_options(data), f"r": rate, **opts}, ) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index ca8f2e40..754f3ab4 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -5,7 +5,7 @@ from math import cos, radians, sin import re, fractions -from .. import caps +from .. import caps, plugins from .._utils import * from ..stream_spec import * @@ -538,3 +538,14 @@ def pop_global_options(options: dict[str, Any]) -> dict[str, Any]: all_gopts = caps.options("global") return {k: options.pop(k) for k in [k for k in options.keys() if k in all_gopts]} + + +def array_to_video_options(data: Any | None = None) -> dict: + """create an input option dict for the given raw video data blob + + :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) + :return: option dict + """ + + s, pix_fmt = guess_video_format(*plugins.get_hook().video_info(obj=data)) + return {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} From ae60d9be027809eaaabad1ee0a50b8edde2b73c7 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:03:37 -0600 Subject: [PATCH 031/333] refactored array_to_audio_options() --- src/ffmpegio/configure.py | 14 +------------- src/ffmpegio/utils/__init__.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 4d6a2f0a..c9bcc521 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -58,21 +58,9 @@ def array_to_audio_input( if rate is None and "ar" not in opts: raise ValueError("rate argument must be specified if opts['ar'] is not given.") - shape = dtype = None - shape, dtype = plugins.get_hook().audio_info(obj=data) - sample_fmt, ac = utils.guess_audio_format(dtype, shape) - codec, f = utils.get_audio_codec(sample_fmt) - return ( pipe_id or "-", - { - "f": f, - f"c:a": codec, - f"ac": ac, - f"ar": rate, - f"sample_fmt": sample_fmt, - **opts, - }, + {**utils.array_to_audio_options(data), f"ar": rate, **opts}, ) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 754f3ab4..65a2a76e 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -540,6 +540,19 @@ def pop_global_options(options: dict[str, Any]) -> dict[str, Any]: return {k: options.pop(k) for k in [k for k in options.keys() if k in all_gopts]} +def array_to_audio_options(data: Any) -> dict: + """create an input option dict for the given raw audio data blob + :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) + :returns: dict of audio options + """ + + shape = dtype = None + shape, dtype = plugins.get_hook().audio_info(obj=data) + sample_fmt, ac = guess_audio_format(dtype, shape) + codec, f = get_audio_codec(sample_fmt) + return {"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt} + + def array_to_video_options(data: Any | None = None) -> dict: """create an input option dict for the given raw video data blob From 922584c5619731ee96700a760a02f29924811ed2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:05:33 -0600 Subject: [PATCH 032/333] check_url() - use pipe_str arg to use named pipe --- src/ffmpegio/configure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index c9bcc521..c79cdd5c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -110,13 +110,13 @@ def hasmethod(o, name): try: memoryview(url) data = url - url = "-" + url = pipe_str except: if hasmethod(url, "fileno"): if nofileobj: raise ValueError("File-like object cannot be specified as url.") fileobj = url - url = "-" + url = pipe_str elif str(url) in ("-", "pipe:", "pipe:0"): try: data = url.input From 61d680653f3cbf659f6acfd4d268f2d249760e14 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:08:51 -0600 Subject: [PATCH 033/333] add_url() - fix to combine opts and url-specific opts (if given) --- src/ffmpegio/configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index c79cdd5c..10367d71 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -161,7 +161,7 @@ def add_url( ( opts and {**opts} if filelist[id][1] is None - else {**filelist[id][1], **opts} + else filelist[id][1] if opts is None else {**filelist[id][1], **opts} ), ) return id, filelist[id] From 8beb9dbe90535781f7d24b45bd452dc38214cd6e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:10:48 -0600 Subject: [PATCH 034/333] changed how filtergraph subpackage is imported --- src/ffmpegio/configure.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 10367d71..8a30f8d1 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -10,6 +10,7 @@ logger = logging.getLogger("ffmpegio") from . import utils, plugins, probe +from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence @@ -306,9 +307,11 @@ def _build_video_basic_filter( bg_color = fill_color or "white" vfilters = ( - Graph(f"color=c={bg_color}[l1];[l1][in]scale2ref[l2],[l2]overlay=shortest=1") + fgb.Graph( + f"color=c={bg_color}[l1];[l1][in]scale2ref[l2],[l2]overlay=shortest=1" + ) if remove_alpha - else Chain() + else fgb.Chain() ) if square_pixels == "upscale": @@ -325,9 +328,9 @@ def _build_video_basic_filter( if crop: try: assert not isinstance(crop, str) - vfilters += Filter("crop", *crop) + vfilters += fgb.Filter("crop", *crop) except: - vfilters += Filter("crop", crop) + vfilters += fgb.Filter("crop", crop) if flip: try: @@ -342,9 +345,9 @@ def _build_video_basic_filter( if transpose is not None: try: assert not isinstance(transpose, str) - vfilters += Filter("transpose", *transpose) + vfilters += fgb.Filter("transpose", *transpose) except: - vfilters += Filter("transpose", transpose) + vfilters += fgb.Filter("transpose", transpose) if scale: try: @@ -353,9 +356,9 @@ def _build_video_basic_filter( pass try: assert not isinstance(scale, str) - vfilters += Filter("scale", *scale) + vfilters += fgb.Filter("scale", *scale) except: - vfilters += Filter("scale", scale) + vfilters += fgb.Filter("scale", scale) return vfilters @@ -687,7 +690,7 @@ def config_input_fg(expr, args, kwargs): known and finite, and unprocessed kwarg items. :rtype: (str|Filter,float|None,dict) """ - fg = Graph(expr) + fg = fgb.Graph(expr) dopt = None # duration option if len(fg) != 1 or len(fg[0]) != 1: @@ -781,7 +784,7 @@ def process_one(url): def add_filtergraph( args: FFmpegArgs, - filtergraph: Graph, + filtergraph: fgb.Graph, map: Sequence[str] | None = None, automap: bool = True, append_filter: bool = True, From b1600ca54b37d97d47c35b7411506a78d213e0a7 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:15:14 -0600 Subject: [PATCH 035/333] added type hints --- src/ffmpegio/threading.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 68908930..a8b12b20 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -281,7 +281,12 @@ def Exception(self) -> FFmpegError | None: class ReaderThread(Thread): - def __init__(self, stdout, nmin=None, queuesize=None): + def __init__( + self, + stdout: BinaryIO, + nmin: int | None = None, + queuesize: int | None = None, + ): super().__init__() self.stdout = stdout #:readable stream: data source self.nmin = nmin #:positive int: expected minimum number of read()'s n arg (not enforced) @@ -349,15 +354,12 @@ def run(self): self._queue.put(data) # print(f"reader thread: queued samples") - def read(self, n=-1, timeout=None): + def read(self, n: int = -1, timeout: float | None = None) -> bytes: """read n samples :param n: number of samples/frames to read, if non-positive, read all, defaults to -1 - :type n: int, optional :param timeout: timeout in seconds, defaults to None - :type timeout: float, optional :return: n*itemsize bytes - :rtype: bytes """ # wait till matching line is read by the thread @@ -408,7 +410,7 @@ def read(self, n=-1, timeout=None): self._carryover = all_data[nbytes:] return all_data[:nbytes] - def read_all(self, timeout=None): + def read_all(self, timeout: float | None = None) -> bytes: # wait till matching line is read by the thread if timeout is not None: timeout = time() + timeout From 12c559e18f09913802d0921b20962028946c5ff2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:22:59 -0600 Subject: [PATCH 036/333] fixed/enhanced ReaderThread: - assumes unbuffered IO - added NPopen support - added itemsize & retry_delay arguments - fixed thread function's flow - read() to return immediately if no more data in the pipe (tentative) - readall() to return immediately if no more data in the pipe --- src/ffmpegio/threading.py | 46 ++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index a8b12b20..ae297431 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -283,17 +283,20 @@ def Exception(self) -> FFmpegError | None: class ReaderThread(Thread): def __init__( self, - stdout: BinaryIO, + stdout: BinaryIO | NPopen, nmin: int | None = None, queuesize: int | None = None, + itemsize: int | None = None, + retry_delay: float | None = None, ): super().__init__() self.stdout = stdout #:readable stream: data source self.nmin = nmin #:positive int: expected minimum number of read()'s n arg (not enforced) - self.itemsize = None #:int: number of bytes per time sample + self.itemsize = itemsize or 2**20 #:int: number of bytes per time sample self._queue = Queue(queuesize or 0) # inter-thread data I/O self._carryover = None # extra data that was not previously read by user self._collect = True + self._retry_delay = 0.001 if retry_delay is None else retry_delay def start(self): if self.itemsize is None: @@ -334,26 +337,38 @@ def __exit__(self, *_): return self def run(self): + is_npipe = isinstance(self.stdout, NPopen) blocksize = ( self.nmin if self.nmin is not None else 1 if self.itemsize > 1024 else 1024 ) * self.itemsize - while True: + stream = self.stdout.wait() if is_npipe else self.stdout + while self._collect: try: - data = self.stdout.read(blocksize) + data = stream.read(blocksize) except: # stdout stream closed/FFmpeg terminated, end the thread as well - break + data = None + # print(f"reader thread: read {len(data)} bytes") if not data: - if self.stdout.closed: # just in case + if stream.closed: # just in case + logger.info("ReaderThread no data, stream is closed, exiting") break else: - break + # pause a bit then try again + sleep(self._retry_delay) + continue if self._collect: # True until self.cooloff self._queue.put(data) # print(f"reader thread: queued samples") + if self._collect: # True until self.cooloff + logger.info("ReaderThread sending the sentinel") + self._queue.put(None) # sentinel for eos + + logger.info("ReaderThread exiting") + def read(self, n: int = -1, timeout: float | None = None) -> bytes: """read n samples @@ -386,9 +401,10 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: break try: b = self._queue.get(block, tout) + assert b is not None self._queue.task_done() arrays.append(b) - except Empty: + except (Empty, AssertionError): break nr += len(b) // self.itemsize @@ -419,18 +435,22 @@ def read_all(self, timeout: float | None = None) -> bytes: self._carryover = None # loop till enough data are collected - while not self.is_alive() or timeout and timeout > time(): + logger.info("ReaderThread:read_all - start reading") + while True: + # if not self.is_alive() or timeout and timeout > time(): try: data = self._queue.get(self.is_alive(), timeout and timeout - time()) self._queue.task_done() + assert data is not None arrays.append(data) - except Empty: + except (AssertionError, Empty): + logger.info("ReaderThread:read_all - the sentinel received") break + except Exception as e: + logger.info(f"ReaderThread:read_all - exception: {type(e)}") + raise # combine all the data and return requested amount - if not len(arrays): - return b"" - return b"".join(arrays) From aa2847e7c1508233579ee4860946b50dddef6d2b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:42:17 -0600 Subject: [PATCH 037/333] use unbuffered pipes --- src/ffmpegio/streams/SimpleStreams.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index f1f38949..9730e4b0 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -55,7 +55,9 @@ def __init__( self._logger = LoggerThread(None, show_log) kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update({"stdin": stdin, "progress": progress, "capture_log": True}) + kwargs.update( + {"stdin": stdin, "progress": progress, "capture_log": True, "bufsize": 0} + ) # start FFmpeg self._proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) @@ -348,6 +350,7 @@ def __init__( "capture_log": True, "overwrite": overwrite, "stdout": stdout, + "bufsize": 0, } ) @@ -717,6 +720,7 @@ def __init__( "ffmpeg_args": ffmpeg_args, "progress": progress, "capture_log": True, + "bufsize": 0, } ) From dd5f9690b63f58fe397673d9c8f2f75506c9782e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:47:39 -0600 Subject: [PATCH 038/333] added is_url(), is_pipe(), is_namedpipe(), and is_fileobj(), --- pyproject.toml | 2 +- src/ffmpegio/_utils.py | 71 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0ab582ab..dad609ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "pluggy", "packaging", "typing_extensions", - "namedpipe" + "namedpipe >= 0.2" ] [project.urls] diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index d0ab72db..85d791b0 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -4,6 +4,13 @@ from typing import Any, Sequence +from io import IOBase +from pathlib import Path +from namedpipe import NPopen +import urllib.parse + +import re + try: from math import prod except: @@ -97,3 +104,67 @@ def deprecate_core(): warnings.warn( message="!!PACKAGE CONFLICT!! ffmpegio-core distribution package has been deprecated. Please read the following link for the instructions: https://github.com/python-ffmpegio/python-ffmpegio/wiki/Instructions-to-upgrade-to-v0.11.0." ) + + +def is_url(value: Any, *, pipe_ok: bool = False) -> bool: + """True if input/output url string path parsed URL + :param pipe_ok: True to allow FFmpeg pipe protocol string""" + return ( + pipe_ok or not is_pipe(value) + if isinstance(value, str) + else isinstance(value, (Path, urllib.parse.ParseResult)) + ) + + +def is_pipe(value: Any) -> bool: + """True if FFmpeg pipe protocol string""" + return value == "-" or bool(re.match(r"pipe\:\d*", value)) + + +def is_namedpipe( + value: Any, *, readable: bool | None = None, writable: bool | None = None +) -> bool: + """True if named pipe object + + :param readable: True to test for readable pipe, False to test for non-readable pipe, defaults to None (either) + :param writable: True to test for writable pipe, False to test for non-writable pipe, defaults to None (either) + """ + return ( + isinstance(value, NPopen) + and (readable is None or value.readable() is readable) + and (writable is None or value.writable() is writable) + ) + + +def is_fileobj( + value: Any, + *, + seekable: bool | None = None, + readable: bool | None = None, + writable: bool | None = None, +) -> bool: + """True if file object + + :param readable: True to test for readable pipe, False to test for non-readable pipe, defaults to None (either) + :param writable: True to test for writable pipe, False to test for non-writable pipe, defaults to None (either) + """ + + if not isinstance(value, IOBase): + return False + + if seekable is True and not value.seekable(): + raise ValueError("Requested seekable file object but it's not seekable.") + elif seekable is False and value.seekable(): + raise ValueError("Requested non-seekable file object but it is seekable.") + + if readable is True and not value.readable(): + raise ValueError("Requested readable file object but it's not readable.") + elif readable is False and value.readable(): + raise ValueError("Requested non-readable file object but it is readable.") + + if writable is True and not value.writable(): + raise ValueError("Requested writable file object but it's not writable.") + elif writable is False and value.writable(): + raise ValueError("Requested non-writable file object but it is writable.") + + return True From 710fad6a16166a384c15e2e1e5688fbf51e0fd94 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:54:10 -0600 Subject: [PATCH 039/333] renamed read() to read_by_avi() --- src/ffmpegio/media.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index b59145d4..5e62205c 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -27,13 +27,13 @@ __all__ = ["read", "write"] -def read( +def read_by_avi( *urls: * tuple[str], progress: ProgressCallable | None = None, show_log: bool | None = None, **options: Unpack[dict[str, Any]], -) -> tuple[dict[StreamSpec, Fraction | int], dict[StreamSpec, RawDataBlob]]: - """Read video and audio frames +) -> tuple[dict[StreamSpecDict, Fraction | int], dict[StreamSpecDict, RawDataBlob]]: + """Read video and audio frames by AVI reader (old media.read()) :param *urls: URLs of the media files to read. :param progress: progress callback function, defaults to None @@ -79,7 +79,7 @@ def read( configure.add_url(args, "input", url, opts) # configure output options - use_ya = configure.finalize_media_read_opts(args) + use_ya = configure.finalize_avi_read_opts(args) # run FFmpeg out = ffmpegprocess.run( From 8212bd31e97ee11203c863d6a52724ba6e5d5eb6 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 22:55:27 -0600 Subject: [PATCH 040/333] update doc --- src/ffmpegio/configure.py | 2 +- src/ffmpegio/media.py | 4 ++-- src/ffmpegio/streams/SimpleStreams.py | 2 ++ src/ffmpegio/utils/__init__.py | 3 +++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 8a30f8d1..62b28b0b 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -119,7 +119,7 @@ def hasmethod(o, name): fileobj = url url = pipe_str elif str(url) in ("-", "pipe:", "pipe:0"): - try: + try: # for FFConcat data = url.input except: pass diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 5e62205c..b378357e 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -1,6 +1,5 @@ from __future__ import annotations -from typing_extensions import Unpack from collections.abc import Sequence from .typing import ( Literal, @@ -9,6 +8,7 @@ ProgressCallable, RawDataBlob, StreamSpecDict, + Unpack, ) import contextlib @@ -129,7 +129,7 @@ def write( """write multiple streams to a url/file :param url: output url - :stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) + :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) :param stream_args: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 9730e4b0..185b40fd 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from time import time import logging diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 65a2a76e..a4bc90fa 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -1,3 +1,5 @@ +""" utility functions for the main modules""" + from __future__ import annotations from collections.abc import Sequence @@ -5,6 +7,7 @@ from math import cos, radians, sin import re, fractions + from .. import caps, plugins from .._utils import * from ..stream_spec import * From d0c92d5bb7cf4cbc88c947fd43a009392f9c738e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 23:02:59 -0600 Subject: [PATCH 041/333] block read()/read_all() only if thread is still collecting data --- src/ffmpegio/threading.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index ae297431..7c5e8419 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -378,7 +378,7 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: """ # wait till matching line is read by the thread - block = self.is_alive() and n != 0 + block = (self.is_alive() and self._collect) and n != 0 if timeout is not None: timeout = time() + timeout @@ -439,7 +439,7 @@ def read_all(self, timeout: float | None = None) -> bytes: while True: # if not self.is_alive() or timeout and timeout > time(): try: - data = self._queue.get(self.is_alive(), timeout and timeout - time()) + data = self._queue.get(self.is_alive() and self._collect, timeout and timeout - time()) self._queue.task_done() assert data is not None arrays.append(data) From 4f39251f6ea96366c4f7b55e427099128904084d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Feb 2025 23:03:52 -0600 Subject: [PATCH 042/333] SimpleFilterBase - fixed flush/close ops --- src/ffmpegio/streams/SimpleStreams.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 185b40fd..97ae5152 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -800,6 +800,13 @@ def close(self): if self._proc is None: return + try: + # write the sentinel to the writer thread to terminate immediately + self._writer.join() + except: + # possibly close before opening the writer thread + pass + self._proc.stdout.close() self._proc.stderr.close() @@ -821,11 +828,6 @@ def close(self): except: # possibly close before opening the reader thread pass - try: - self._writer.join() - except: - # possibly close before opening the writer thread - pass @property def closed(self): @@ -943,7 +945,12 @@ def flush(self, timeout=None): y = self._reader.read_all(timeout) self._proc.stdin.close() self._proc.wait() - y += self._reader.read_all(None) + self._reader.cool_down() + nbytes = len(y) + while nbytes: + y1 = self._reader.read_all(None) + nbytes = len(y1) + y += y1 self.nout += len(y) // self._bps_out return self._converter(b=y, dtype=self.dtype, shape=self.shape, squeeze=False) From b6aeedd1fae284df9b9309f1fd1ad6c41b9d4ffa Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 11:06:40 -0600 Subject: [PATCH 043/333] moved escape(), unescape() to _utils.py --- src/ffmpegio/_utils.py | 70 ++++++++++++++++++++++++++++++++ src/ffmpegio/utils/__init__.py | 74 ---------------------------------- src/ffmpegio/utils/concat.py | 2 +- 3 files changed, 71 insertions(+), 75 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 85d791b0..cd9c2367 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -168,3 +168,73 @@ def is_fileobj( raise ValueError("Requested non-writable file object but it is writable.") return True + +def escape(txt: str) -> str: + """apply FFmpeg single quote escaping + + :param txt: Unescaped string + :return: Escaped string + + See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping + """ + + txt = str(txt) + + if re.search(r"\s", txt, re.MULTILINE): + # quote if txt has any white space + txt = txt.replace("'", r"'\''") + return f"'{txt}'" + else: + # if not quoted, escape quotes and backslashes + return re.sub(r"(['\\])", r"\\\1", txt) + + +def unescape(txt: str) -> str: + """undo FFmpeg single quote escaping + + :param txt: Escaped string + :return: Original string + + See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping + """ + + n = len(txt) + if not n: + return txt + + re_start = re.compile(r"[^\\](?:\\\\)*'") + re_sub = re.compile(r"\\([\\'])") + + blks = [] + + # look for a first quoted text block + m = re.search(r"(?:^|[^\\])(?:\\\\)*'", txt) + if m: + i0 = m.end() + if i0 > 1: + # unescape the initial unquoted block + blks.append(re_sub.sub(r"\1", txt[0 : i0 - 1])) + else: + # no quoted text block, unescape the whole string + return re_sub.sub(r"\1", txt) + + # always starts with quoted block + in_quote = True + + while i0 < n: + + if in_quote: + # find the end quote + i1 = txt.find("'", i0) + if i1 < 0: + raise ValueError("incorrectly escaped text: missing a closing quote.") + blks.append(txt[i0:i1]) + else: + # find the next starting quote + m = re_start.search(txt, i0 - 1) + i1 = m.end() - 1 if m else n + blks.append(re_sub.sub(r"\1", txt[i0:i1])) + i0 = i1 + 1 + in_quote = not in_quote + + return "".join(blks) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index a4bc90fa..3cbe7fb6 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -18,80 +18,6 @@ # import sys # sys.byteorder - - - -def escape(txt: str) -> str: - """apply FFmpeg single quote escaping - - :param txt: Unescaped string - :return: Escaped string - - See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping - """ - - txt = str(txt) - - if re.search(r"\s", txt, re.MULTILINE): - # quote if txt has any white space - txt = txt.replace("'", r"'\''") - return f"'{txt}'" - else: - # if not quoted, escape quotes and backslashes - return re.sub(r"(['\\])", r"\\\1", txt) - - -def unescape(txt: str) -> str: - """undo FFmpeg single quote escaping - - :param txt: Escaped string - :return: Original string - - See https://ffmpeg.org/ffmpeg-utils.html#Quoting-and-escaping - """ - - n = len(txt) - if not n: - return txt - - re_start = re.compile(r"[^\\](?:\\\\)*'") - re_sub = re.compile(r"\\([\\'])") - - blks = [] - - # look for a first quoted text block - m = re.search(r"(?:^|[^\\])(?:\\\\)*'", txt) - if m: - i0 = m.end() - if i0 > 1: - # unescape the initial unquoted block - blks.append(re_sub.sub(r"\1", txt[0 : i0 - 1])) - else: - # no quoted text block, unescape the whole string - return re_sub.sub(r"\1", txt) - - # always starts with quoted block - in_quote = True - - while i0 < n: - - if in_quote: - # find the end quote - i1 = txt.find("'", i0) - if i1 < 0: - raise ValueError("incorrectly escaped text: missing a closing quote.") - blks.append(txt[i0:i1]) - else: - # find the next starting quote - m = re_start.search(txt, i0 - 1) - i1 = m.end() - 1 if m else n - blks.append(re_sub.sub(r"\1", txt[i0:i1])) - i0 = i1 + 1 - in_quote = not in_quote - - return "".join(blks) - - def get_pixel_config( input_pix_fmt: str, pix_fmt: str | None = None ) -> tuple[str, int, str, bool]: diff --git a/src/ffmpegio/utils/concat.py b/src/ffmpegio/utils/concat.py index 5cd89ebf..3a7b9d79 100644 --- a/src/ffmpegio/utils/concat.py +++ b/src/ffmpegio/utils/concat.py @@ -10,7 +10,7 @@ logger = logging.getLogger("ffmpegio") -from . import escape, unescape +from .._utils import escape, unescape # https://trac.ffmpeg.org/wiki/Concatenate # https://ffmpeg.org/ffmpeg-formats.html#concat From 1613fb5e78e40b7929a143af899c4cf51d074a0a Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 11:08:46 -0600 Subject: [PATCH 044/333] moved type defs around --- src/ffmpegio/_typing.py | 13 ------------ src/ffmpegio/configure.py | 39 ++++++++++++++++++++++++++++++++++- src/ffmpegio/typing.py | 16 ++------------- src/ffmpegio/utils/typing.py | 40 ------------------------------------ 4 files changed, 40 insertions(+), 68 deletions(-) delete mode 100644 src/ffmpegio/utils/typing.py diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index ff7b1081..0a53c0ba 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -33,20 +33,7 @@ """ - - MediaType = Literal["audio", "video"] FFmpegUrlType = Union[str, Path, ParseResult] -FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] -FFmpegOutputType = Literal["url", "fileobj"] - - - -class InputSourceDict(TypedDict): - """input source info""" - src_type: FFmpegInputType # True if file path/url - bytes: NotRequired[bytes] # index of the source index - fileobj: NotRequired[IO] # file object - pipe: NotRequired[NPopen] # pipe diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 62b28b0b..602799c6 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,6 +1,16 @@ from __future__ import annotations -from .typing import Literal, Any, FFmpegArgs, FFmpegUrlType +from ._typing import ( + Literal, + Any, + MediaType, + FFmpegUrlType, + Union, + NotRequired, + TypedDict, + IO, + Buffer, +) from collections.abc import Sequence from fractions import Fraction @@ -16,6 +26,33 @@ from ._utils import as_multi_option, is_non_str_sequence UrlType = Literal["input", "output"] +FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] +FFmpegOutputType = Literal["url", "fileobj"] + +FFmpegInputUrlComposite = Union[FFmpegUrlType, FilterGraphObject, IO, Buffer] +FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] + + +class FFmpegArgs(TypedDict): + """FFmpeg arguments""" + + inputs: list[ + tuple[FFmpegUrlType | FilterGraphObject | FFConcat, dict | None] + ] # list of input definitions (pairs of url and options) + outputs: list[ + tuple[FFmpegUrlType, dict | None] + ] # list of output definitions (pairs of url and options) + global_options: NotRequired[dict | None] # FFmpeg global options + + +class InputSourceDict(TypedDict): + """input source info""" + + src_type: FFmpegInputType # True if file path/url + bytes: NotRequired[bytes] # index of the source index + fileobj: NotRequired[IO] # file object + pipe: NotRequired[NPopen] # pipe + def array_to_video_input( diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py index 0fd3c989..22a9dd91 100644 --- a/src/ffmpegio/typing.py +++ b/src/ffmpegio/typing.py @@ -10,19 +10,7 @@ from .stream_spec import MediaType, StreamSpecDict, StreamSpecDictMediaType -# from typing_extensions import * - +from .configure import FFmpegArgs, FFmpegUrlType -class FFmpegArgs(TypedDict): - """FFmpeg arguments""" - - inputs: list[ - tuple[FFmpegUrlType | FilterGraphObject, dict | None] - ] # list of input definitions (pairs of url and options) - outputs: list[ - tuple[FFmpegUrlType, dict | None] - ] # list of output definitions (pairs of url and options) - global_options: NotRequired[dict | None] # FFmpeg global options +# from typing_extensions import * -FFmpegInputUrlComposite = Union[FFmpegUrlType, FilterGraphObject, IO, Buffer] -FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] diff --git a/src/ffmpegio/utils/typing.py b/src/ffmpegio/utils/typing.py deleted file mode 100644 index e27933bc..00000000 --- a/src/ffmpegio/utils/typing.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -from typing import * -from typing_extensions import * -from fractions import Fraction - -# from typing_extensions import * - -MediaType = Literal["v", "a", "s", "d", "t", "V"] -# libavformat/avformat.c:match_stream_specifier() - - -from ..stream_spec import StreamSpecDict - -RawDataBlob = Any # depends on raw data reader plugin - -RawStreamDef = ( - tuple[int | float | Fraction, RawDataBlob] | tuple[RawDataBlob, dict[str, Any]] -) - - -class FFmpegArgs(TypedDict): - """FFmpeg arguments - """ - - inputs: list[tuple[str, dict | None]] # list of input definitions (pairs of url and options) - outputs: list[tuple[str, dict | None]] # list of output definitions (pairs of url and options) - global_options: NotRequired[dict | None] # FFmpeg global options - - -ProgressCallable = Callable[[dict[str, Any], bool], bool] -"""FFmpeg progress callback function - - callback(status, done) - - status - dict of encoding status - done - True if the last callback - - The callback may return True to cancel the FFmpeg execution. -""" From c1e2bc36fc7aea13a6623d7e380a7afc2ca90a42 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 14:50:28 -0600 Subject: [PATCH 045/333] added `f` keyword argument --- src/ffmpegio/probe.py | 44 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index edff8b93..9b2351c3 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -169,6 +169,8 @@ def _exec( count_frames: bool | None = False, count_packets: bool | None = False, keep_optional_fields: bool | None = None, + *, + f: str | None = None, ) -> dict[str, str]: """execute ffprobe and return stdout as dict""" @@ -179,6 +181,9 @@ def _exec( args = ["-hide_banner", "-of", "json", "-show_entries", entries] + if f is not None: + args.extend(("-f", f)) + if streams is not None: _add_select_streams(args, streams) @@ -230,6 +235,7 @@ def _run( *args, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + f: str | None = None, **kwargs, ) -> dict[str, str]: """execute ffprobe, return stdout as dict, and cache its output""" @@ -238,9 +244,9 @@ def _run( if sp_kwargs is not None: sp_kwargs = tuple(sp_kwargs.items()) return ( - _exec_cached(url, entries, sp_kwargs, *args, **kwargs) + _exec_cached(url, entries, sp_kwargs, *args, **kwargs, f=f) if cache_output - else _exec(url, entries, sp_kwargs, *args, **kwargs) + else _exec(url, entries, sp_kwargs, *args, **kwargs, f=f) ) @@ -254,6 +260,8 @@ def full_details( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> dict[str, str | Number | Fraction]: """Retrieve full details of a media file or stream @@ -277,6 +285,7 @@ def full_details( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: media file information :rtype: dict[str, str|Number|Fraction] @@ -290,7 +299,7 @@ def full_details( ) results = _run( - url, modes, select_streams, cache_output=cache_output, sp_kwargs=sp_kwargs + url, modes, select_streams, cache_output=cache_output, sp_kwargs=sp_kwargs, f=f ) if not modes["stream"]: @@ -328,6 +337,8 @@ def format_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> dict[str, str | Number | Fraction]: """Retrieve basic media format info @@ -347,6 +358,7 @@ def format_basic( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: set of media format information. :rtype: dict @@ -381,6 +393,7 @@ def format_basic( keep_str_values, cache_output, sp_kwargs, + f=f, ) @@ -392,6 +405,8 @@ def streams_basic( cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, stream_spec: str | None = None, + *, + f: str | None = None, ) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of media streams @@ -407,6 +422,7 @@ def streams_basic( default to None :param stream_spec: Specify stream specification, defaults to None :type stream_spec: str | None, optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: List of media stream information. Media Stream Information dict Entries @@ -431,6 +447,7 @@ def streams_basic( keep_str_values, cache_output, sp_kwargs, + f=f, ) @@ -442,6 +459,8 @@ def video_streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of video streams @@ -456,6 +475,7 @@ def video_streams_basic( :param cache_output: True to cache FFprobe output, defaults to False :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None + :param f: Use the specified media container format, defaults to None (auto-detect) :return: List of video stream information. @@ -510,6 +530,7 @@ def video_streams_basic( keep_str_values, cache_output, sp_kwargs, + f=f, ) def adjust(res): @@ -551,6 +572,8 @@ def audio_streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> list[dict[str, str | Number | Fraction]]: """Retrieve basic info of audio streams @@ -565,6 +588,7 @@ def audio_streams_basic( :param cache_output: True to cache FFprobe output, defaults to False :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None + :param f: Use the specified media container format, defaults to None (auto-detect) :return: List of audio stream information. Audio Stream Information Entries @@ -612,6 +636,7 @@ def audio_streams_basic( keep_str_values, cache_output, sp_kwargs, + f=f, ) def adjust(res): @@ -649,6 +674,8 @@ def query( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> ( dict[str, Any] | Sequence[dict[str, Any]] @@ -674,6 +701,7 @@ def query( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: field name-value dict. If streams argument is given but does not specify index, a list of dict is returned instead :rtype: dict or list or dict @@ -695,6 +723,7 @@ def query( sp_kwargs=sp_kwargs, cache_output=cache_output, keep_optional_fields=keep_optional_fields, + f=f, ) if not keep_str_values: @@ -717,6 +746,8 @@ def _audio_info( url: str | BinaryIO | memoryview, stream: str | None, sp_kwargs: dict[str, Any] | None, + *, + f: str | None = None, ) -> tuple[int | None, str | None, int | None]: "returns (sample_rate, sample_fmt, channels) of the specified url/stream" fields = ["sample_rate", "sample_fmt", "channels"] @@ -728,6 +759,7 @@ def _audio_info( False, True, sp_kwargs, + f=f, )[0] return tuple(q[f] for f in fields) @@ -736,6 +768,7 @@ def _video_info( url: str | BinaryIO | memoryview, stream: str | None, sp_kwargs: dict[str, Any] | None, + f: str | None = None, ) -> tuple[ str | None, int | None, @@ -754,6 +787,7 @@ def _video_info( False, True, sp_kwargs, + f=f, )[0] return tuple(q[f] for f in fields) @@ -765,6 +799,8 @@ def frames( intervals: IntervalSpec | Sequence[IntervalSpec] | None = None, accurate_time: bool | None = False, sp_kwargs: dict[str, Any] | None = None, + *, + f: str | None = None, ) -> list[dict] | list[str | int | float]: """get frame information @@ -782,6 +818,7 @@ def frames( :param sp_kwargs: Additional keyword arguments for :py:func:`subprocess.run`, default to None :type sp_kwargs: dict[str, Any], optional + :param f: Use the specified media container format, defaults to None (auto-detect) :return: frame information. list of dictionary if entries is None or a sequence; list of the selected entry if entries is str (i.e., a single entry) :rtype: list[dict] or list[str|int|float] @@ -821,6 +858,7 @@ def frames( sp_kwargs and tuple(sp_kwargs.items()), streams=streams, intervals=intervals, + f=f, ) out = [_items_to_numeric(d) for d in res["frames"]] From 6d70353785273fd42abb499c54d4bc673c3a6651 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 18:51:18 -0600 Subject: [PATCH 046/333] fixed validate_label() - ok to use any string except for closing bracket --- src/ffmpegio/filtergraph/GraphLinks.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 6d9ce259..c5a80c91 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -69,13 +69,10 @@ def validate_label( "A pad label without a link must be a string label." ) else: - try: - if no_stream_spec or not is_map_spec(label, allow_missing_file_id=True): - assert re.match(r"[a-zA-Z0-9_]+$", label) - except Exception as e: + if not (isinstance(label, str) and len(label)): 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 + "Pad label must be a string and has at least one character." + ) @staticmethod def validate_pad_idx(id: PAD_INDEX | None, none_ok: bool = True): @@ -439,7 +436,9 @@ def iter_links( """ def iter(label, inpad, outpad): - if outpad is not None or (include_input_stream and is_map_spec(label, allow_missing_file_id=True)): + if outpad is not None or ( + include_input_stream and is_map_spec(label, allow_missing_file_id=True) + ): for d in self.iter_inpad_ids(inpad): yield (label, d, outpad) @@ -461,7 +460,9 @@ def iter_inputs( :yield: label and pad index """ for label, (inpad, outpad) in self.data.items(): - if outpad is None and not (exclude_stream_specs and is_map_spec(label, allow_missing_file_id=True)): + if outpad is None and not ( + exclude_stream_specs and is_map_spec(label, allow_missing_file_id=True) + ): for d in self.iter_inpad_ids(inpad): yield (label, d) From 7710dc0189298f7a892313592d47de6a459c8fba Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 18:54:22 -0600 Subject: [PATCH 047/333] get_pad_media_type() - (a)movie is specified via `stream` option --- src/ffmpegio/filtergraph/Filter.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 824ce9ed..2573f969 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -9,6 +9,7 @@ from . import utils as filter_utils from .. import filtergraph as fgb +from ..stream_spec import parse_stream_spec from .typing import PAD_INDEX, Literal from .exceptions import * @@ -255,9 +256,23 @@ def get_pad_media_type( # multiple pads possible if streams option set if self.name in ("movie", "amovie"): - if self.get_option_value("streams") is None: + val = self.get_option_value("streams") + if val is None: return "video" if self.name == "movie" else "audio" + spec = val.split("+")[pad_id] + return ( + "video" + if spec == "dv" + else ( + "audio" + if spec == "da" + else {"v": "video", "a": "audio", None: None}[ + parse_stream_spec(spec).get("media_type", None) + ] + ) + ) + # 2nd pad for audio visualization stream vis_mode = ["afir", "aiir", "anequalizer", "ebur128", "aphasemeter"] if port == "outputs" and self.name in vis_mode: From 24153e4d2f43fd38ed96793c256cc52492a46559 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 18:54:33 -0600 Subject: [PATCH 048/333] get_num_outputs() - account for missing option --- src/ffmpegio/filtergraph/Filter.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 2573f969..6d7825c4 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -419,9 +419,13 @@ def _concat(): def _list_var(opt, sep, inc): v = self.get_option_value(opt) return ( - len(v) - if sep == r"\|" and not isinstance(v, str) - else len(re.split(rf"\s*{sep}\s*", v)) + 1 + if v is None + else ( + len(v) + if sep == r"\|" and not isinstance(v, str) + else len(re.split(rf"\s*{sep}\s*", v)) + ) ) + inc def _channelsplit(): From bc2865febab4d1a8f6ae9c43dfc7a437cddda8b7 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 19:08:58 -0600 Subject: [PATCH 049/333] added analyze_input_filtergraph_ids() --- src/ffmpegio/utils/__init__.py | 53 ++++++++++++++++++++++++++++++++++ tests/test_utils.py | 20 +++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 3cbe7fb6..96328595 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -11,6 +11,9 @@ from .. import caps, plugins from .._utils import * from ..stream_spec import * +from ..filtergraph.abc import FilterGraphObject +from ..filtergraph import as_filtergraph_object +from ..errors import FFmpegError from ..typing import Any @@ -18,6 +21,7 @@ # import sys # sys.byteorder + def get_pixel_config( input_pix_fmt: str, pix_fmt: str | None = None ) -> tuple[str, int, str, bool]: @@ -491,3 +495,52 @@ def array_to_video_options(data: Any | None = None) -> dict: s, pix_fmt = guess_video_format(*plugins.get_hook().video_info(obj=data)) return {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} + + +def analyze_input_filtergraph_ids( + fg: FilterGraphObject | str, +) -> list[tuple[int, MediaType]]: + """get labels and media types of input filtergraph output pads + + :param fg: input filtergraph expression/object + :return: list of indices and types of input streams + + FFmpeg Input Filtergraph Output Specification: + ---------------------------------------------- + + Each video open output must be labelled by a unique string of the form `"outN"`, + where `N` is a number starting from `0` corresponding to the mapped input stream + generated by the device. The first unlabelled output is automatically assigned + to the `"out0"` label, but all the others need to be specified explicitly. + + The suffix "+subcc" can be appended to the output label. + + """ + + # parse if str expression is given + fg = as_filtergraph_object(fg) + + outtypes = {} + for idx, filter, _ in fg.iter_output_pads(): + label = fg.get_label(outpad=idx) + if label is None: + if 0 in outtypes: + raise FFmpegError( + "more than one `out0` pad specified in the input filtergraph." + ) + outid = 0 + else: + m = re.match(r"out(\d+)(?:\+subcc)?$", label) + if not m: + raise FFmpegError( + f"Specified input filtergraph contains an invalid output pad label: {label}" + ) + outid = int(m[1]) + if outid in outtypes: + raise FFmpegError( + f"Specified input filtergraph defines more than one `out{outid}` pad label." + ) + + outtypes[outid] = filter.get_pad_media_type("output", idx[-1]) + + return list((i, outtypes[i]) for i in sorted(outtypes)) diff --git a/tests/test_utils.py b/tests/test_utils.py index dd082e3d..fd163b16 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -77,5 +77,25 @@ def test_get_audio_format(): assert cfg[0] == " Date: Sun, 9 Feb 2025 19:11:42 -0600 Subject: [PATCH 050/333] added retrieve_input_stream_ids() --- src/ffmpegio/configure.py | 62 ++++++++++++++++++++++++++++++++++++++- tests/test_configure.py | 33 +++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 602799c6..43482d9d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -887,4 +887,64 @@ def add_filtergraph( else [existing_map, *map] ) - outopts['map'] = map + + +def retrieve_input_stream_ids( + info: InputSourceDict, + url: FFmpegUrlType | FilterGraphObject | None, + opts: dict, + media_type: MediaType | None = None, +) -> list[tuple[int, MediaType]]: + """Retrieve ids and media types of streams in an input source + + :param info: input file source information + :param url: URL or local file path of the input media file/device. None if data is provided via pipe + and data is in the `info` argument + :param opts: FFmpeg input options + :param media_type: Desired stream media type, default to None (=all available streams) + :return: A list of indices and media types of the input streams. + Maybe empty if failed to probe the media (e.g., data inaccessible + or in an ffprobe incompatible format, e.g., ffconcat) + """ + + src_type = info["src_type"] + if src_type == "filtergraph": + return utils.analyze_input_filtergraph_ids(url) + + # file/network input - process only if seekable + sp_kwargs = {} # ffprobe subprocess keywords + if src_type != "url": + url = "pipe:0" + if src_type == "buffer": + sp_kwargs["input"] = info["buffer"] + elif src_type == "fileobj": + f = info["fileobj"] + if not (f.readable() and f.seekable()): + logger.warning("file object must be seekable.") + return [] + pos = f.tell() + sp_kwargs["stdin"] = f + else: + logger.warning("unknown input source type.") + return [] + + media_types = ("audio", "video") if media_type is None else (media_type,) + + # get the stream list if ffprobe can + try: + stream_ids = [ + (i, info["codec_type"]) + for i, info in enumerate( + probe.streams_basic(url, f=opts.get("f", None), sp_kwargs=sp_kwargs) + ) + if info["codec_type"] in media_types + ] + except: + # if failed, return empty + logger.warning("ffprobe failed.") + stream_ids = [] + + if src_type == "fileobj": + # restore the read cursor position + f.seek(pos) + return stream_ids diff --git a/tests/test_configure.py b/tests/test_configure.py index 13562ca9..7f706cd0 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1,8 +1,11 @@ +import pytest + from ffmpegio import configure vid_url = "tests/assets/testvideo-1m.mp4" img_url = "tests/assets/ffmpeg-logo.png" aud_url = "tests/assets/testaudio-1m.wav" +mul_url = "tests/assets/testmulti-1m.mp4" def test_array_to_audio_input(): @@ -120,3 +123,33 @@ def test_video_basic_filter(): # transpose="clock", ) ) + + +mul_streams = [(0, "video"), (1, "audio"), (2, "video"), (3, "audio")] +mul_vid_streams = [mul_streams[0], mul_streams[2]] + + +@pytest.mark.parametrize( + ("info", "url", "opts", "media_type", "ret"), + [ + ({"src_type": "url"}, mul_url, {}, None, mul_streams), + ({"src_type": "url"}, mul_url, {}, "video", mul_vid_streams), + ({"src_type": "fileobj"}, mul_url, {}, "video", mul_vid_streams), + ({"src_type": "buffer"}, mul_url, {}, "video", mul_vid_streams), + ({"src_type": "filtergraph"}, "color=c=pink [out0]", {}, None, [(0, "video")]), + ], +) +def test_retrieve_input_stream_ids(info, url, opts, media_type, ret): + + open_file = info["src_type"] in ("fileobj", "buffer") + try: + if open_file: + info["fileobj"] = open(url, "rb") + if info["src_type"] == "buffer": + info["buffer"] = info["fileobj"].read() + out = configure.retrieve_input_stream_ids(info, url, opts, media_type) + finally: + if open_file: + info["fileobj"].close() + + assert out == ret From 393b6076c8e63287223bed1fdb10eedc74239883 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 21:15:41 -0600 Subject: [PATCH 051/333] updated docs and types --- src/ffmpegio/configure.py | 46 ++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 43482d9d..0f1ea54b 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -19,29 +19,34 @@ logger = logging.getLogger("ffmpegio") -from . import utils, plugins, probe +from io import IOBase +from . import utils from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence +################################# +## module types + UrlType = Literal["input", "output"] FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] FFmpegOutputType = Literal["url", "fileobj"] -FFmpegInputUrlComposite = Union[FFmpegUrlType, FilterGraphObject, IO, Buffer] +FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] +FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, dict | None] +FFmpegOutputOptionTuple = tuple[FFmpegUrlType, dict | None] + class FFmpegArgs(TypedDict): """FFmpeg arguments""" - inputs: list[ - tuple[FFmpegUrlType | FilterGraphObject | FFConcat, dict | None] - ] # list of input definitions (pairs of url and options) - outputs: list[ - tuple[FFmpegUrlType, dict | None] - ] # list of output definitions (pairs of url and options) + inputs: list[FFmpegInputOptionTuple] + # list of input definitions (pairs of url and options) + outputs: list[FFmpegOutputOptionTuple] + # list of output definitions (pairs of url and options) global_options: NotRequired[dict | None] # FFmpeg global options @@ -49,11 +54,14 @@ class InputSourceDict(TypedDict): """input source info""" src_type: FFmpegInputType # True if file path/url - bytes: NotRequired[bytes] # index of the source index + buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object pipe: NotRequired[NPopen] # pipe +################################# +## module functions + def array_to_video_input( rate: int | float | Fraction | None = None, @@ -112,7 +120,7 @@ def empty(global_options: dict = None) -> FFmpegArgs: def check_url( - url: FFmpegUrlType | FilterGraphObject | FFConcat | memoryview | IOBase, + url: FFmpegInputUrlComposite, nodata: bool = True, nofileobj: bool = False, format: str | None = None, @@ -170,10 +178,10 @@ def hasmethod(o, name): def add_url( args: FFmpegArgs, type: Literal["input", "output"], - url: str, + url: FFmpegUrlType, opts: dict[str, Any] | None = None, update: bool = False, -) -> tuple[int, tuple[str, dict | None]]: +) -> tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]: """add new or modify existing url to input or output list :param args: ffmpeg arg dict (modified in place) @@ -205,7 +213,7 @@ def add_url( return id, filelist[id] -def has_filtergraph(args: FFmpegArgs, type: Literal["audio", "video"]) -> bool: +def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: """True if FFmpeg arguments specify a filter graph :param args: FFmpeg argument dict @@ -782,21 +790,15 @@ def add_urls( urls: str | tuple[str, dict | None] | Sequence[str | tuple[str, dict | None]], *, update: bool = False, -) -> list[tuple[int, tuple[str, dict | None]]]: +) -> list[tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]]: """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): @@ -902,8 +904,8 @@ def retrieve_input_stream_ids( and data is in the `info` argument :param opts: FFmpeg input options :param media_type: Desired stream media type, default to None (=all available streams) - :return: A list of indices and media types of the input streams. - Maybe empty if failed to probe the media (e.g., data inaccessible + :return: A list of indices and media types of the input streams. + Maybe empty if failed to probe the media (e.g., data inaccessible or in an ffprobe incompatible format, e.g., ffconcat) """ From 8f41fdb3c75b3d0a6a1791f3c525e134e515dcc9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 21:16:16 -0600 Subject: [PATCH 052/333] added analyze_input_url_arg() --- src/ffmpegio/configure.py | 57 +++++++++++++++++++++++++++++++++++++++ tests/test_configure.py | 35 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 0f1ea54b..f83c4186 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -891,6 +891,63 @@ def add_filtergraph( +def analyze_input_url_arg( + url_opts: FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict], + inopts_default: dict[str, Any], +) -> tuple[tuple[FFmpegUrlType | FilterGraphObject | None, dict], InputSourceDict]: + """analyze and process heterogeneous input url argument + + :param url: composite input url argument + :param inopts_default: default input options + :return: tuple of an FFmpeg inputs entry and input source info. If input is not a url, + the first element of the FFmpeg inputs entry is None. An appropriate pipe url + must be set afterwards. + """ + + # get the option dict + if utils.is_non_str_sequence(url_opts, (str, FilterGraphObject, Buffer)): + if len(url_opts) != 2: + raise ValueError("url-options pair input must be a tuple of the length 2.") + url, opts = url_opts + opts = inopts_default if opts is None else {**inopts_default, **opts} + else: + # only URL given + url, opts = url_opts, inopts_default + + # check url (must be url and not fileobj) + is_fg = isinstance(url, FilterGraphObject) + if is_fg or ("lavfi" == opts.get("f", None) and isinstance(url, str)): + if is_fg: + if "f" not in opts: + opts["f"] = "lavfi" + elif opts["f"] != "lavfi": + raise ValueError( + "input filtergraph must use the `'lavfi'` input format." + ) + + input_info = {"src_type": "filtergraph"} + + elif utils.is_fileobj(url, readable=True): + input_info = {"src_type": "fileobj", "fileobj": url} + url = None + elif utils.is_url(url, pipe_ok=False): + input_info = {"src_type": "url"} + elif isinstance(url, FFConcat): + # convert to buffer + input_info = {"src_type": "buffer", "buffer": FFConcat.input} + url = None + else: + try: + buffer = memoryview(url) + except: + raise ValueError("Given input URL argument is not supported.") + else: + input_info = {"src_type": "buffer", "buffer": buffer} + url = None + + return (url, opts), input_info + + def retrieve_input_stream_ids( info: InputSourceDict, url: FFmpegUrlType | FilterGraphObject | None, diff --git a/tests/test_configure.py b/tests/test_configure.py index 7f706cd0..2b3004d6 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -153,3 +153,38 @@ def test_retrieve_input_stream_ids(info, url, opts, media_type, ret): info["fileobj"].close() assert out == ret + + +@pytest.mark.parametrize( + ("url", "opts", "defopts", "ret"), + [ + (mul_url, None, {}, ((mul_url, {}), {"src_type": "url"})), + (mul_url, None, {}, ((None, {}), {"src_type": "fileobj"})), + (mul_url, None, {}, ((None, {}), {"src_type": "buffer"})), + ( + "color=c=pink [out0]", + None, + {"f": "lavfi"}, + (("color=c=pink [out0]", {"f": "lavfi"}), {"src_type": "filtergraph"}), + ), + ], +) +def test_analyze_input_url_arg(url, opts, defopts, ret): + + info = ret[1] + open_file = info["src_type"] in ("fileobj", "buffer") + try: + if open_file: + fileobj = open(url, "rb") + if info["src_type"] == "buffer": + info["buffer"] = url = fileobj.read() + else: + url = info["fileobj"] = fileobj + out = configure.analyze_input_url_arg( + url if opts is None else (url, opts), defopts + ) + assert out == ret + + finally: + if open_file: + fileobj.close() From 1d2a8d749b9a81fc866d858c4e4e36f6bc6067c0 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 9 Feb 2025 22:33:03 -0600 Subject: [PATCH 053/333] added auto_map() --- src/ffmpegio/configure.py | 104 ++++++++++++++++++++++++++++++++++++++ tests/test_configure.py | 34 ++++++++++++- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index f83c4186..4e487a5a 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -891,6 +891,110 @@ def add_filtergraph( +def auto_map( + args: FFmpegArgs, input_info: Sequence[InputSourceDict] +) -> dict[str, MediaType | None]: + """list all available streams from all FFmpeg input sources + + :param args: FFmpeg argument dict. `filter_complex` argument may be modified. + :param input_info: a list of input data source information + :return: a map of input/filtergraph output labels to their media types. + + Mapping Input Streams vs. Complex Filtergraph Outputs + ----------------------------------------------------- + + If `filter_complex` global option is defined in `args`, `auto_map()` returns the mapping + of all the output pads of the complex filtergraphs'. Otherwise, all the audio and video + streams of the input urls are mapped. + + Possible Complex Filtergraph Modification + ----------------------------------------- + + To enable auto-mapping, all the output pads must be labeled. Thus, if the complex filtergraphs + in the `filter_complex` global option have any unlabeled output, they are automatically + labeled as `outN` where N is a number starting from `0`. If a label has alraedy been assigned + to another output pad, that label will be skipped. + + """ + + gopts = args.get("global_options", None) or {} + map = {} + if "filter_complex" in gopts: + + # make sure it's a list of filtergraphs + filters_complex = utils.as_multi_option( + gopts["filter_complex"], + ( + str, + FilterGraphObject, + ), + ) + + # make sure all are FilterGraphObjects + filters_complex = [fgb.as_filtergraph_object(fg) for fg in filters_complex] + + # check for unlabeled outputs and log existing output labels + out_indices = set() + out_labels = {} + out_unlabeled = False + for fg in filters_complex: + for idx, filter, _ in fg.iter_output_pads(full_pad_index=True): + label = fg.get_label(outpad=idx) + if label is None: + out_unlabeled = True + elif m := re.match(r"out(\d+)$", label): + out_indices.add(int(m[1])) + out_labels[label] = (filter, idx) + + # remove all the output pads connected to an input pad of another filtergraph + if len(filters_complex) > 1: + for fg in filters_complex: + for label, _ in fg.iter_input_labels(): + if label in out_labels: + out_labels.pop(label) + + # if there are unlabeled outputs, label them all + if out_unlabeled: + out_n = next(i for i in range(len(out_labels) + 1) if i not in out_labels) + for i, fg in enumerate(filters_complex): + new_labels = [] + for idx, filter, _ in fg.iter_output_pads( + unlabeled_only=True, full_pad_index=True + ): + label = f"out{out_n}" + out_labels[label] = (filter, idx) + new_labels.append({"label": label, "outpad": idx}) + + # next index + while True: + out_n += 1 + if out_n not in out_labels: + break + + for kwargs in new_labels: + fg = fg.add_label(**kwargs) + filters_complex[i] = fg + + # create the output map + map = { + f"[{label}]": filter.get_pad_media_type("output", pad_id) + for label, (filter, pad_id) in out_labels.items() + } + + # update the filtergraphs + args["global_options"]["filter_complex"] = filters_complex + + else: + # if no filtergraph, get all video & audio streams from all the input urls + map = { + f"{i}:{j}": media_type + for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)) + for j, media_type in retrieve_input_stream_ids(info, url, opts or {}) + } + + return map + + def analyze_input_url_arg( url_opts: FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict], inopts_default: dict[str, Any], diff --git a/tests/test_configure.py b/tests/test_configure.py index 2b3004d6..93d2d4dd 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -4,7 +4,7 @@ vid_url = "tests/assets/testvideo-1m.mp4" img_url = "tests/assets/ffmpeg-logo.png" -aud_url = "tests/assets/testaudio-1m.wav" +aud_url = "tests/assets/testaudio-1m.mp3" mul_url = "tests/assets/testmulti-1m.mp4" @@ -188,3 +188,35 @@ def test_analyze_input_url_arg(url, opts, defopts, ret): finally: if open_file: fileobj.close() + + +@pytest.mark.parametrize( + ("inputs", "input_info", "filters_complex", "ret"), + [ + ( + [(mul_url, None)], + [{"src_type": "url"}], + None, + {f"0:{i}": mtype for i, mtype in mul_streams}, + ), + ( + [(vid_url, None), (aud_url, {})], + [{"src_type": "url"}, {"src_type": "url"}], + None, + {"0:0": "video", "1:0": "audio"}, + ), + ( + [(mul_url, None)], + [{"src_type": "url"}], + ["split=n=2"], + {"[out0]": "video", "[out1]": "video"}, + ), + ], +) +def test_auto_map(inputs, input_info, filters_complex, ret): + args = configure.empty() + args["inputs"].extend(inputs) + if filters_complex is not None: + args["global_options"] = {"filter_complex": filters_complex} + out = configure.auto_map(args, input_info) + assert out == ret From cef0f29f835928f39879498880f3bda4c2f452e5 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 10 Feb 2025 20:21:24 -0600 Subject: [PATCH 054/333] refactored analyze_fg_outputs() --- src/ffmpegio/configure.py | 149 ++++++++++++++++++++------------------ tests/test_configure.py | 10 +++ 2 files changed, 90 insertions(+), 69 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 4e487a5a..93dc04fa 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -907,6 +907,28 @@ def auto_map( of all the output pads of the complex filtergraphs'. Otherwise, all the audio and video streams of the input urls are mapped. + """ + + gopts = args.get("global_options", None) or {} + if "filter_complex" in gopts: + map = analyze_fg_outputs(args) + else: + # if no filtergraph, get all video & audio streams from all the input urls + map = { + f"{i}:{j}": media_type + for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)) + for j, media_type in retrieve_input_stream_ids(info, url, opts or {}) + } + + return map + + +def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: + """list all available output labels of the complex filtergraphs + + :param args: FFmpeg argument dict. `filter_complex` argument may be modified if present. + :return: a map of filtergraph output labels to their media types + Possible Complex Filtergraph Modification ----------------------------------------- @@ -914,83 +936,72 @@ def auto_map( in the `filter_complex` global option have any unlabeled output, they are automatically labeled as `outN` where N is a number starting from `0`. If a label has alraedy been assigned to another output pad, that label will be skipped. - """ gopts = args.get("global_options", None) or {} - map = {} - if "filter_complex" in gopts: - # make sure it's a list of filtergraphs - filters_complex = utils.as_multi_option( - gopts["filter_complex"], - ( - str, - FilterGraphObject, - ), - ) + if "filter_complex" not in gopts: + # no filtergraph + return None - # make sure all are FilterGraphObjects - filters_complex = [fgb.as_filtergraph_object(fg) for fg in filters_complex] + # make sure it's a list of filtergraphs + filters_complex = utils.as_multi_option( + gopts["filter_complex"], (str, FilterGraphObject) + ) - # check for unlabeled outputs and log existing output labels - out_indices = set() - out_labels = {} - out_unlabeled = False + # make sure all are FilterGraphObjects + filters_complex = [fgb.as_filtergraph_object(fg) for fg in filters_complex] + + # check for unlabeled outputs and log existing output labels + out_indices = set() + out_labels = {} + out_unlabeled = False + for fg in filters_complex: + for idx, filter, _ in fg.iter_output_pads(full_pad_index=True): + label = fg.get_label(outpad=idx) + if label is None: + out_unlabeled = True + elif m := re.match(r"out(\d+)$", label): + out_indices.add(int(m[1])) + out_labels[label] = (filter, idx) + + # remove all the output pads connected to an input pad of another filtergraph + if len(filters_complex) > 1: for fg in filters_complex: - for idx, filter, _ in fg.iter_output_pads(full_pad_index=True): - label = fg.get_label(outpad=idx) - if label is None: - out_unlabeled = True - elif m := re.match(r"out(\d+)$", label): - out_indices.add(int(m[1])) - out_labels[label] = (filter, idx) - - # remove all the output pads connected to an input pad of another filtergraph - if len(filters_complex) > 1: - for fg in filters_complex: - for label, _ in fg.iter_input_labels(): - if label in out_labels: - out_labels.pop(label) - - # if there are unlabeled outputs, label them all - if out_unlabeled: - out_n = next(i for i in range(len(out_labels) + 1) if i not in out_labels) - for i, fg in enumerate(filters_complex): - new_labels = [] - for idx, filter, _ in fg.iter_output_pads( - unlabeled_only=True, full_pad_index=True - ): - label = f"out{out_n}" - out_labels[label] = (filter, idx) - new_labels.append({"label": label, "outpad": idx}) - - # next index - while True: - out_n += 1 - if out_n not in out_labels: - break - - for kwargs in new_labels: - fg = fg.add_label(**kwargs) - filters_complex[i] = fg - - # create the output map - map = { - f"[{label}]": filter.get_pad_media_type("output", pad_id) - for label, (filter, pad_id) in out_labels.items() - } - - # update the filtergraphs - args["global_options"]["filter_complex"] = filters_complex + for label, _ in fg.iter_input_labels(): + if label in out_labels: + out_labels.pop(label) + + # if there are unlabeled outputs, label them all + if out_unlabeled: + out_n = next(i for i in range(len(out_labels) + 1) if i not in out_labels) + for i, fg in enumerate(filters_complex): + new_labels = [] + for idx, filter, _ in fg.iter_output_pads( + unlabeled_only=True, full_pad_index=True + ): + label = f"out{out_n}" + out_labels[label] = (filter, idx) + new_labels.append({"label": label, "outpad": idx}) + + # next index + while True: + out_n += 1 + if out_n not in out_labels: + break + + for kwargs in new_labels: + fg = fg.add_label(**kwargs) + filters_complex[i] = fg + + # create the output map + map = { + f"[{label}]": filter.get_pad_media_type("output", pad_id) + for label, (filter, pad_id) in out_labels.items() + } - else: - # if no filtergraph, get all video & audio streams from all the input urls - map = { - f"{i}:{j}": media_type - for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)) - for j, media_type in retrieve_input_stream_ids(info, url, opts or {}) - } + # update the filtergraphs + args["global_options"]["filter_complex"] = filters_complex return map diff --git a/tests/test_configure.py b/tests/test_configure.py index 93d2d4dd..68c58253 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -220,3 +220,13 @@ def test_auto_map(inputs, input_info, filters_complex, ret): args["global_options"] = {"filter_complex": filters_complex} out = configure.auto_map(args, input_info) assert out == ret + + +@pytest.mark.parametrize( + ("filters_complex", "ret"), + [(["split=n=2"], {"[out0]": "video", "[out1]": "video"})], +) +def test_analyze_fg_outputs(filters_complex, ret): + args = configure.empty({"filter_complex": filters_complex}) + out = configure.analyze_fg_outputs(args) + assert out == ret From f58b4a909e81b01f7aeaaf0fa57d23e860313a63 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 10 Feb 2025 20:41:55 -0600 Subject: [PATCH 055/333] retrieve_input_stream_ids() - switched media_type argument to stream_spec --- src/ffmpegio/configure.py | 16 ++++++++-------- tests/test_configure.py | 11 +++++------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 93dc04fa..9d2a45d1 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1067,7 +1067,7 @@ def retrieve_input_stream_ids( info: InputSourceDict, url: FFmpegUrlType | FilterGraphObject | None, opts: dict, - media_type: MediaType | None = None, + stream_spec: str | None = None, ) -> list[tuple[int, MediaType]]: """Retrieve ids and media types of streams in an input source @@ -1075,7 +1075,7 @@ def retrieve_input_stream_ids( :param url: URL or local file path of the input media file/device. None if data is provided via pipe and data is in the `info` argument :param opts: FFmpeg input options - :param media_type: Desired stream media type, default to None (=all available streams) + :param stream_spec: Specify streams to return :return: A list of indices and media types of the input streams. Maybe empty if failed to probe the media (e.g., data inaccessible or in an ffprobe incompatible format, e.g., ffconcat) @@ -1102,16 +1102,16 @@ def retrieve_input_stream_ids( logger.warning("unknown input source type.") return [] - media_types = ("audio", "video") if media_type is None else (media_type,) - # get the stream list if ffprobe can try: stream_ids = [ - (i, info["codec_type"]) - for i, info in enumerate( - probe.streams_basic(url, f=opts.get("f", None), sp_kwargs=sp_kwargs) + (info["index"], info["codec_type"]) + for info in probe.streams_basic( + url, + f=opts.get("f", None), + sp_kwargs=sp_kwargs, + stream_spec=stream_spec, ) - if info["codec_type"] in media_types ] except: # if failed, return empty diff --git a/tests/test_configure.py b/tests/test_configure.py index 68c58253..592dad18 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -130,16 +130,15 @@ def test_video_basic_filter(): @pytest.mark.parametrize( - ("info", "url", "opts", "media_type", "ret"), + ("info", "url", "opts", "stream_spec", "ret"), [ ({"src_type": "url"}, mul_url, {}, None, mul_streams), - ({"src_type": "url"}, mul_url, {}, "video", mul_vid_streams), - ({"src_type": "fileobj"}, mul_url, {}, "video", mul_vid_streams), - ({"src_type": "buffer"}, mul_url, {}, "video", mul_vid_streams), + ({"src_type": "fileobj"}, mul_url, {}, "v", mul_vid_streams), + ({"src_type": "buffer"}, mul_url, {}, "v", mul_vid_streams), ({"src_type": "filtergraph"}, "color=c=pink [out0]", {}, None, [(0, "video")]), ], ) -def test_retrieve_input_stream_ids(info, url, opts, media_type, ret): +def test_retrieve_input_stream_ids(info, url, opts, stream_spec, ret): open_file = info["src_type"] in ("fileobj", "buffer") try: @@ -147,7 +146,7 @@ def test_retrieve_input_stream_ids(info, url, opts, media_type, ret): info["fileobj"] = open(url, "rb") if info["src_type"] == "buffer": info["buffer"] = info["fileobj"].read() - out = configure.retrieve_input_stream_ids(info, url, opts, media_type) + out = configure.retrieve_input_stream_ids(info, url, opts, stream_spec) finally: if open_file: info["fileobj"].close() From 2163143170072439fbfe70d2ed1013076ce34158 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 10 Feb 2025 21:41:27 -0600 Subject: [PATCH 056/333] renamed is_map_spec() to is_map_option() --- src/ffmpegio/filtergraph/Graph.py | 4 ++-- src/ffmpegio/filtergraph/GraphLinks.py | 14 +++++++------- src/ffmpegio/stream_spec.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 4182dfbe..f656be0c 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -12,7 +12,7 @@ from . import utils as filter_utils -from ..stream_spec import is_map_spec +from ..stream_spec import is_map_option from .. import filtergraph as fgb from .typing import PAD_INDEX @@ -528,7 +528,7 @@ def iter_input_pads( if ( not include_connected and isinstance(other_pidx, str) - and is_map_spec(other_pidx, allow_missing_file_id=True) + and is_map_option(other_pidx, allow_missing_file_id=True) ): continue diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index c5a80c91..5752ec5e 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -5,7 +5,7 @@ from collections.abc import Generator, Mapping, Sequence, Callable -from ..utils import is_map_spec +from ..utils import is_map_option from ..errors import FFmpegioError from .typing import PAD_INDEX, PAD_PAIR, Literal @@ -135,7 +135,7 @@ def validate(data: dict[str | int, PAD_PAIR]): for label, pads in data.items(): if ( - not is_map_spec(label, allow_missing_file_id=True) + not is_map_option(label, allow_missing_file_id=True) and pads[0] is not None and isinstance(pads[0][0], tuple) ): @@ -327,7 +327,7 @@ def _resolve_label( except ValueError: return 0 - if check_stream_spec and is_map_spec(label, allow_missing_file_id=True): + if check_stream_spec and is_map_option(label, allow_missing_file_id=True): return label if not force and label in self: @@ -437,7 +437,7 @@ def iter_links( def iter(label, inpad, outpad): if outpad is not None or ( - include_input_stream and is_map_spec(label, allow_missing_file_id=True) + include_input_stream and is_map_option(label, allow_missing_file_id=True) ): for d in self.iter_inpad_ids(inpad): yield (label, d, outpad) @@ -461,7 +461,7 @@ def iter_inputs( """ for label, (inpad, outpad) in self.data.items(): if outpad is None and not ( - exclude_stream_specs and is_map_spec(label, allow_missing_file_id=True) + exclude_stream_specs and is_map_option(label, allow_missing_file_id=True) ): for d in self.iter_inpad_ids(inpad): yield (label, d) @@ -473,7 +473,7 @@ def iter_input_streams(self) -> Generator[tuple[str, PAD_INDEX]]: :yield: label and pad index """ for label, (inpad, outpad) in self.data.items(): - if outpad is None and is_map_spec(label, allow_missing_file_id=True): + if outpad is None and is_map_option(label, allow_missing_file_id=True): for d in self.iter_inpad_ids(inpad): yield (label, d) @@ -652,7 +652,7 @@ def create_label( if (outpad is None) == (inpad is None): raise ValueError("outpad or inpad (but not both) must be given.") - is_stspec = is_map_spec(label, allow_missing_file_id=True) + is_stspec = is_map_option(label, allow_missing_file_id=True) if not is_stspec: label = self._resolve_label(label, force=force, check_stream_spec=False) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 1aeba797..84f5ff57 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -318,7 +318,7 @@ def parse_map_option(map: str, *, input_file_id: int | None = None) -> MapOption return out -def is_map_spec(spec: str, allow_missing_file_id: bool = False) -> bool: +def is_map_option(spec: str, allow_missing_file_id: bool = False) -> bool: """True if valid stream specifier string :param spec: map specifier string to be tested From 0b6a2a1c4ed4443d52307e5f276b96f54e9c42be Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 10 Feb 2025 21:41:46 -0600 Subject: [PATCH 057/333] doc update --- src/ffmpegio/stream_spec.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 84f5ff57..ceabba3e 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -276,7 +276,7 @@ def parse_map_option(map: str, *, input_file_id: int | None = None) -> MapOption :return: dict containing the parsed parts of the option value, possibly containing the items: - negative: bool - input_file_id: int - - stream_specifier: str|StreamSpecDictDict + - stream_specifier: str - view_specifier: str - optional: bool - linklabel: str @@ -319,11 +319,11 @@ def parse_map_option(map: str, *, input_file_id: int | None = None) -> MapOption def is_map_option(spec: str, allow_missing_file_id: bool = False) -> bool: - """True if valid stream specifier string + """True if valid map option string - :param spec: map specifier string to be tested + :param spec: map option string to be tested :param allow_missing_file_id: True to allow missing input file id - :return: True if valid stream specifier + :return: True if valid map option. The validity of stream_specifier is also tested. """ try: From 1f3968079e890146b8db02b042018d9df51c780d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 10 Feb 2025 21:42:35 -0600 Subject: [PATCH 058/333] added map_option() --- src/ffmpegio/stream_spec.py | 42 +++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index ceabba3e..3b931bd6 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -335,3 +335,45 @@ def is_map_option(spec: str, allow_missing_file_id: bool = False) -> bool: except Exception: return False return True + + +def map_option( + input_file_id: int | None = None, + linklabel: str | None = None, + stream_specifier: str | StreamSpecDict | None = None, + negative: bool | None = None, + view_specifier: str | None = None, + optional: bool | None = None, # True if optional mapping +) -> str: + """compose map option str + + :param input_file_id: index of the source index, defaults to None + :param stream_specifier: stream specifier, defaults to None + :param negative: True to disables matching streams from already created mappings, defaults to None + :param view_specifier: view specifier, defaults to None + :param optional: True if optional mapping, defaults to None + :param linklabel: output label of a filtergraph + :return: map option string + + Either input_file_id or linklabel must be non-`None`. + """ + + is_linklabel = input_file_id is None + + if (linklabel is None)==is_linklabel: + raise ValueError('Either linklabel or input_file_id must be non-None') + + if is_linklabel: + return linklabel + + map = str(input_file_id) + if stream_specifier: + map = f'{map}:{stream_specifier}' + if negative: + map = f'-{map}' + if view_specifier: + map = f'{map}:{view_specifier}' + if optional: + map = f'{map}?' + + return map From 5e6ec1c52ba7417c894c83f52982d774b633ad10 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 14:59:06 -0600 Subject: [PATCH 059/333] require the latest typing_extensions version --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dad609ec..e6fcaeec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,8 +29,8 @@ requires-python = ">=3.9" dependencies = [ "pluggy", "packaging", - "typing_extensions", - "namedpipe >= 0.2" + "typing_extensions >= 4.12", + "namedpipe >= 0.2", ] [project.urls] From 4ff1723ed3228b18606874bdbec618e9c323a3c2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 15:11:01 -0600 Subject: [PATCH 060/333] parse_map_option() - added `parse_stream` argument --- src/ffmpegio/stream_spec.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 3b931bd6..688f4cce 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -266,13 +266,15 @@ def stream_spec( ################################# -def parse_map_option(map: str, *, input_file_id: int | None = None) -> MapOptionDict: +def parse_map_option( + map: str, *, input_file_id: int | None = None, parse_stream: bool = False +) -> MapOptionDict: """parse the FFmpeg -map option str :param map: option string value :param input_file_id: if specified, auto-insert this id if a file id is missing in the given value, defaults to None to error out if missing. - :param parse_stream_spec: True to also parse stream spec (if given) + :param parse_stream: True to also parse stream spec (if given) :return: dict containing the parsed parts of the option value, possibly containing the items: - negative: bool - input_file_id: int @@ -315,6 +317,9 @@ def parse_map_option(map: str, *, input_file_id: int | None = None) -> MapOption if m[4]: out["optional"] = True + if parse_stream and "stream_specifier" in out: + out["stream_specifier"] = parse_stream_spec(out["stream_specifier"]) + return out @@ -327,11 +332,9 @@ def is_map_option(spec: str, allow_missing_file_id: bool = False) -> bool: """ try: - mspec = parse_map_option( - spec, input_file_id=0 if allow_missing_file_id else None + parse_map_option( + spec, input_file_id=0 if allow_missing_file_id else None, parse_stream=True ) - if "stream_specifier" in mspec: - parse_stream_spec(mspec["stream_specifier"]) except Exception: return False return True From e8f1488b837ed1654ef105304f2a309b985b494b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 15:12:21 -0600 Subject: [PATCH 061/333] map_option() - support stream_specifier argument in StreamSpecDict --- src/ffmpegio/stream_spec.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 688f4cce..cf55837a 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -371,7 +371,9 @@ def map_option( map = str(input_file_id) if stream_specifier: - map = f'{map}:{stream_specifier}' + if isinstance(stream_specifier, dict): + stream_specifier = stream_spec(**stream_specifier) + map = f"{map}:{stream_specifier}" if negative: map = f'-{map}' if view_specifier: From fdc6d364f494202a9259cc420941ba0ee1e612d3 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 15:13:15 -0600 Subject: [PATCH 062/333] stream_spec() - eliminated `file_index` argument --- src/ffmpegio/stream_spec.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index cf55837a..d1d75018 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -184,7 +184,7 @@ def stream_spec( stream_id: int | None = None, tag: str | tuple[str, str] | None = None, usable: bool | None = None, - file_index: int | None = None, + *, no_join: bool = False, ) -> str: """Get stream specifier string @@ -218,7 +218,6 @@ def stream_spec( :param usable: streams with usable configuration, the codec must be defined and the essential information such as video dimension or audio sample rate must be present, defaults to None - :param file_index: file index to be prepended if specified, defaults to None :param filter_output: True to append "out" to stream type, defaults to False :param no_join: True to return list of stream specifier elements, defaults to False :return: stream specifier string or empty string if all arguments are None @@ -236,7 +235,7 @@ def stream_spec( 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)] + spec = [] if media_type is not None: if media_type not in get_args(StreamSpecDictMediaType): From d4352d273855c08aa181268e7c02d9a3ee138953 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 15:13:34 -0600 Subject: [PATCH 063/333] docs - cleaned up --- src/ffmpegio/stream_spec.py | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index d1d75018..3d426157 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -6,8 +6,7 @@ from __future__ import annotations -from typing import get_args, Literal, TypedDict, Union, Tuple -from ._typing import MediaType, NotRequired +from ._typing import get_args, Literal, TypedDict, Union, Tuple, MediaType, NotRequired import re @@ -16,7 +15,7 @@ class StreamSpecDict_Options(TypedDict): - media_type: NotRequired[MediaType] # py3.11 NotRequired[MediaType] + media_type: NotRequired[StreamSpecDictMediaType] # py3.11 NotRequired[MediaType] program_id: NotRequired[int] # py3.11 NotRequired[int] group_index: NotRequired[int] # py3.11 NotRequired[int] group_id: NotRequired[int] # py3.11 NotRequired[int] @@ -165,7 +164,6 @@ def is_stream_spec(spec: str | int) -> bool: """True if valid stream specifier string :param spec: stream specifier string to be tested - :param file_index: True if spec starts with a file index, None to allow with or without file_index defaults to False :return: True if valid stream specifier """ try: @@ -195,9 +193,9 @@ 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 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, + :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 @@ -362,22 +360,22 @@ def map_option( is_linklabel = input_file_id is None - if (linklabel is None)==is_linklabel: - raise ValueError('Either linklabel or input_file_id must be non-None') - + if (linklabel is None) == is_linklabel: + raise ValueError("Either linklabel or input_file_id must be non-None") + if is_linklabel: return linklabel - + map = str(input_file_id) if stream_specifier: if isinstance(stream_specifier, dict): stream_specifier = stream_spec(**stream_specifier) map = f"{map}:{stream_specifier}" if negative: - map = f'-{map}' + map = f"-{map}" if view_specifier: - map = f'{map}:{view_specifier}' + map = f"{map}:{view_specifier}" if optional: - map = f'{map}?' + map = f"{map}?" return map From 1365c36e46f2ae7069197a87d3f738194d39b28f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 15:14:26 -0600 Subject: [PATCH 064/333] rely `typing_extensions` to load `Buffer` abc --- src/ffmpegio/typing.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py index 22a9dd91..beaf57a4 100644 --- a/src/ffmpegio/typing.py +++ b/src/ffmpegio/typing.py @@ -3,7 +3,6 @@ from typing import * from typing_extensions import * -from collections.abc import Buffer from ._typing import * from .filtergraph.abc import FilterGraphObject @@ -11,6 +10,3 @@ from .stream_spec import MediaType, StreamSpecDict, StreamSpecDictMediaType from .configure import FFmpegArgs, FFmpegUrlType - -# from typing_extensions import * - From 7c99ab7bfea0ca69445f42ea647b21e13b0ba078 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 16:00:33 -0600 Subject: [PATCH 065/333] type name change: StreamSpecDictMediaType -> StreamSpecStreamType --- src/ffmpegio/stream_spec.py | 28 ++++++++++++++-------------- tests/test_stream_spec.py | 16 ++++++++-------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 3d426157..3e523784 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -10,16 +10,16 @@ import re -StreamSpecDictMediaType = Literal["v", "a", "s", "d", "t", "V"] +StreamSpecStreamType = Literal["v", "a", "s", "d", "t", "V"] # libavformat/avformat.c:match_stream_specifier() class StreamSpecDict_Options(TypedDict): - media_type: NotRequired[StreamSpecDictMediaType] # py3.11 NotRequired[MediaType] - program_id: NotRequired[int] # py3.11 NotRequired[int] - group_index: NotRequired[int] # py3.11 NotRequired[int] - group_id: NotRequired[int] # py3.11 NotRequired[int] - stream_id: NotRequired[int] # py3.11 NotRequired[int] + stream_type: NotRequired[StreamSpecStreamType] + program_id: NotRequired[int] + group_index: NotRequired[int] + group_id: NotRequired[int] + stream_id: NotRequired[int] class StreamSpecDict_Index(StreamSpecDict_Options): @@ -105,8 +105,8 @@ def get_id(i, name): while i < nspecs: spec = spec_parts[i] # optional specifiers first - if spec in get_args(StreamSpecDictMediaType): - out["media_type"] = spec + if spec in get_args(StreamSpecStreamType): + out["stream_type"] = spec i += 1 elif spec == "g": i += 1 @@ -175,7 +175,7 @@ def is_stream_spec(spec: str | int) -> bool: def stream_spec( index: int | None = None, - media_type: MediaType | None = None, + stream_type: StreamSpecStreamType | None = None, group_index: int | None = None, group_id: int | None = None, program_id: int | None = None, @@ -193,7 +193,7 @@ 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 media_type: One of following: 'v' or 'V' for video, 'a' for audio, 's' for + :param stream_type: One of following: 'v' or 'V' for video, 'a' for audio, 's' for subtitle, 'd' for data, and 't' for attachments. 'v' matches all video streams, 'V' only matches video streams which are not attached pictures, video thumbnails or cover arts. If additional stream specifier is used, then @@ -235,10 +235,10 @@ def stream_spec( spec = [] - if media_type is not None: - if media_type not in get_args(StreamSpecDictMediaType): - raise ValueError(f"Unknown {media_type=}.") - spec.append(media_type) + if stream_type is not None: + if stream_type not in get_args(StreamSpecStreamType): + raise ValueError(f"Unknown {stream_type=}.") + spec.append(stream_type) if group_index is not None: spec.append(f"g:{group_index}") diff --git a/tests/test_stream_spec.py b/tests/test_stream_spec.py index 70a38bf2..847e488c 100644 --- a/tests/test_stream_spec.py +++ b/tests/test_stream_spec.py @@ -7,19 +7,19 @@ [ (1, {"index": 1}), ("1", {"index": 1}), - ("v", {"media_type": "v"}), + ("v", {"stream_type": "v"}), ("p:1", {"program_id": 1}), - ("p:1:V", {"program_id": 1, "media_type": "V"}), + ("p:1:V", {"program_id": 1, "stream_type": "V"}), ( "p:1:a:#6", { "program_id": 1, - "media_type": "a", + "stream_type": "a", "stream_id": 6, }, ), - ("d:i:6", {"media_type": "d", "stream_id": 6}), - ("t:m:key", {"media_type": "t", "tag": "key"}), + ("d:i:6", {"stream_type": "d", "stream_id": 6}), + ("t:m:key", {"stream_type": "t", "tag": "key"}), ("m:key:value", {"tag": ("key", "value")}), ("u", {"usable": True}), ], @@ -31,10 +31,10 @@ def test_parse_stream_spec(arg, ret): def test_stream_spec(): assert utils.stream_spec() == "" assert utils.stream_spec(0) == "0" - assert utils.stream_spec(media_type="a") == "a" - assert utils.stream_spec(1, media_type="v") == "v:1" + assert utils.stream_spec(stream_type="a") == "a" + assert utils.stream_spec(1, stream_type="v") == "v:1" assert utils.stream_spec(program_id=1) == "p:1" - assert utils.stream_spec(1, media_type="v", program_id=1) == "v:p:1:1" + assert utils.stream_spec(1, stream_type="v", program_id=1) == "v:p:1:1" assert utils.stream_spec(stream_id=342) == "#342" assert utils.stream_spec(tag="creation_time") == "m:creation_time" assert ( From b486fa585916f4769feef910c07483173d5c0a46 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 16:01:36 -0600 Subject: [PATCH 066/333] added FFmpegMediaType --- src/ffmpegio/_typing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 0a53c0ba..01542f60 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -32,8 +32,9 @@ The callback may return True to cancel the FFmpeg execution. """ - MediaType = Literal["audio", "video"] +FFmpegMediaType = Literal["video", "audio", "subtitle", "data", "attachments"] + FFmpegUrlType = Union[str, Path, ParseResult] From b3edc428b1524f5ae7c87ef69b2bc86688913f60 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 16:02:12 -0600 Subject: [PATCH 067/333] added stream_type_to_media_type() --- src/ffmpegio/stream_spec.py | 42 ++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 3e523784..d01dc019 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -6,7 +6,15 @@ from __future__ import annotations -from ._typing import get_args, Literal, TypedDict, Union, Tuple, MediaType, NotRequired +from ._typing import ( + get_args, + Literal, + TypedDict, + Union, + Tuple, + FFmpegMediaType, + NotRequired, +) import re @@ -61,6 +69,38 @@ class GraphMapOptionDict(TypedDict): ################################# +def stream_type_to_media_type(s: StreamSpecStreamType | None) -> FFmpegMediaType | None: + """get media type string from stream type specifier + + :param s: stream type character or `None` + :return: media type string or `None` if input is `None` + + ## Stream-to-Media Type Conversion Table + + | stream | media | + |:-------|:---------------| + | `'v'` | `'video'` | + | `'V'` | `'video'` | + | `'a'` | `'audio'` | + | `'s'` | `'subtitle'` | + | `'d'` | `'data'` | + | `'t'` | `'attachments'`| + + """ + + if s is None: + return s + + return { + "v": "video", + "a": "audio", + "s": "subtitle", + "d": "data", + "t": "attachements", + "V": "video", + }[s] + + def parse_stream_spec(spec: str | int) -> StreamSpecDict: """Parse stream specifier string From b1fcdfc8651bc73dd82e8acb42fbcea84bd866e2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 16:03:14 -0600 Subject: [PATCH 068/333] fixed .stream_spec type imports --- src/ffmpegio/typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py index beaf57a4..03db86e2 100644 --- a/src/ffmpegio/typing.py +++ b/src/ffmpegio/typing.py @@ -7,6 +7,6 @@ from ._typing import * from .filtergraph.abc import FilterGraphObject -from .stream_spec import MediaType, StreamSpecDict, StreamSpecDictMediaType +from .stream_spec import StreamSpecDict, StreamSpecStreamType from .configure import FFmpegArgs, FFmpegUrlType From 0f157b1529db32c46bcf25af5e1f521d22b284b1 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 11 Feb 2025 19:11:26 -0600 Subject: [PATCH 069/333] changed analyze_input_url_arg() to process_url_inputs() --- src/ffmpegio/configure.py | 100 +++++++++++++++++++++----------------- tests/test_configure.py | 9 ++-- 2 files changed, 61 insertions(+), 48 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 9d2a45d1..fb78a5b7 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1006,61 +1006,73 @@ def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: return map -def analyze_input_url_arg( - url_opts: FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict], +def process_url_inputs( + args: FFmpegArgs, + urls: list[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]], inopts_default: dict[str, Any], -) -> tuple[tuple[FFmpegUrlType | FilterGraphObject | None, dict], InputSourceDict]: +) -> list[InputSourceDict]: """analyze and process heterogeneous input url argument - :param url: composite input url argument + :param args: FFmpeg argument dict, `args['inputs']` receives all the new inputs. + If input is a buffer, a fileobj, or an FFconcat, the first element + of the FFmpeg inputs entry is set to 'None', to be replaced by + a pipe expression. + :param urls: list of input urls/data or a pair of input url and its options :param inopts_default: default input options - :return: tuple of an FFmpeg inputs entry and input source info. If input is not a url, - the first element of the FFmpeg inputs entry is None. An appropriate pipe url - must be set afterwards. + :return: list of input information """ - # get the option dict - if utils.is_non_str_sequence(url_opts, (str, FilterGraphObject, Buffer)): - if len(url_opts) != 2: - raise ValueError("url-options pair input must be a tuple of the length 2.") - url, opts = url_opts - opts = inopts_default if opts is None else {**inopts_default, **opts} - else: - # only URL given - url, opts = url_opts, inopts_default - - # check url (must be url and not fileobj) - is_fg = isinstance(url, FilterGraphObject) - if is_fg or ("lavfi" == opts.get("f", None) and isinstance(url, str)): - if is_fg: - if "f" not in opts: - opts["f"] = "lavfi" - elif opts["f"] != "lavfi": + input_info_list = [None] * len(urls) + for i, url in enumerate(urls): # add inputs + # get the option dict + if utils.is_non_str_sequence(url, (str, FilterGraphObject, Buffer)): + if len(url) != 2: raise ValueError( - "input filtergraph must use the `'lavfi'` input format." + "url-options pair input must be a tuple of the length 2." ) - - input_info = {"src_type": "filtergraph"} - - elif utils.is_fileobj(url, readable=True): - input_info = {"src_type": "fileobj", "fileobj": url} - url = None - elif utils.is_url(url, pipe_ok=False): - input_info = {"src_type": "url"} - elif isinstance(url, FFConcat): - # convert to buffer - input_info = {"src_type": "buffer", "buffer": FFConcat.input} - url = None - else: - try: - buffer = memoryview(url) - except: - raise ValueError("Given input URL argument is not supported.") + url, opts = url + opts = inopts_default if opts is None else {**inopts_default, **opts} else: - input_info = {"src_type": "buffer", "buffer": buffer} + # only URL given + url, opts = url, inopts_default + + # check url (must be url and not fileobj) + is_fg = isinstance(url, FilterGraphObject) + if is_fg or ("lavfi" == opts.get("f", None) and isinstance(url, str)): + if is_fg: + if "f" not in opts: + opts["f"] = "lavfi" + elif opts["f"] != "lavfi": + raise ValueError( + "input filtergraph must use the `'lavfi'` input format." + ) + + input_info = {"src_type": "filtergraph"} + + elif utils.is_fileobj(url, readable=True): + input_info = {"src_type": "fileobj", "fileobj": url} url = None + elif utils.is_url(url, pipe_ok=False): + input_info = {"src_type": "url"} + elif isinstance(url, FFConcat): + # convert to buffer + input_info = {"src_type": "buffer", "buffer": FFConcat.input} + url = None + else: + try: + buffer = memoryview(url) + except: + raise ValueError("Given input URL argument is not supported.") + else: + input_info = {"src_type": "buffer", "buffer": buffer} + url = None + + url_opts, input_info_list[i] = (url, opts), input_info + + # leave the URL None if data needs to be piped in + add_url(args, "input", *url_opts) - return (url, opts), input_info + return input_info_list def retrieve_input_stream_ids( diff --git a/tests/test_configure.py b/tests/test_configure.py index 592dad18..4f5bf028 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -168,7 +168,7 @@ def test_retrieve_input_stream_ids(info, url, opts, stream_spec, ret): ), ], ) -def test_analyze_input_url_arg(url, opts, defopts, ret): +def test_process_url_inputs(url, opts, defopts, ret): info = ret[1] open_file = info["src_type"] in ("fileobj", "buffer") @@ -179,10 +179,11 @@ def test_analyze_input_url_arg(url, opts, defopts, ret): info["buffer"] = url = fileobj.read() else: url = info["fileobj"] = fileobj - out = configure.analyze_input_url_arg( - url if opts is None else (url, opts), defopts + args = configure.empty() + out = configure.process_url_inputs( + args, [url if opts is None else (url, opts)], defopts ) - assert out == ret + assert (args["inputs"][0], out[0]) == ret finally: if open_file: From 32e4ffab40ceb889e09f5f0221e2c8db2bbf206e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 10:22:49 -0600 Subject: [PATCH 070/333] retrieve_input_stream_ids() - limit output to supported media streams only --- src/ffmpegio/configure.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index fb78a5b7..d1accd3d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -2,6 +2,7 @@ from ._typing import ( Literal, + get_args, Any, MediaType, FFmpegUrlType, @@ -1124,6 +1125,7 @@ def retrieve_input_stream_ids( sp_kwargs=sp_kwargs, stream_spec=stream_spec, ) + if info["codec_type"] in get_args(MediaType) ] except: # if failed, return empty From e3888ff4dc8350abd520b6267249aa7d8cc08636 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 10:23:12 -0600 Subject: [PATCH 071/333] analyze_fg_outputs() - output empty dict if no filter_complex global option --- src/ffmpegio/configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index d1accd3d..eff5cc71 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -943,7 +943,7 @@ def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: if "filter_complex" not in gopts: # no filtergraph - return None + return {} # make sure it's a list of filtergraphs filters_complex = utils.as_multi_option( From bed67dba2065340b66257f970628c783be472386 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 10:25:57 -0600 Subject: [PATCH 072/333] auto_map() - outputs dict of RawOutputInfoDicts --- src/ffmpegio/configure.py | 42 ++++++++++++++++++++++++++++----------- tests/test_configure.py | 29 +++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index eff5cc71..8dc2e6e1 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -21,7 +21,9 @@ logger = logging.getLogger("ffmpegio") from io import IOBase -from . import utils + +from namedpipe import NPopen + from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject from .utils.concat import FFConcat # for typing @@ -60,6 +62,15 @@ class InputSourceDict(TypedDict): pipe: NotRequired[NPopen] # pipe +class RawOutputInfoDict(TypedDict): + dst_type: FFmpegOutputType # True if file path/url + user_map: str | None # user specified map option + media_type: MediaType | None # + input_file_id: NotRequired[int | None] + input_stream_id: NotRequired[int | None] + pipe: NotRequired[NPopen] + + ################################# ## module functions @@ -893,13 +904,13 @@ def add_filtergraph( def auto_map( - args: FFmpegArgs, input_info: Sequence[InputSourceDict] -) -> dict[str, MediaType | None]: + args: FFmpegArgs, input_info: list[InputSourceDict] +) -> dict[str, RawOutputInfoDict]: """list all available streams from all FFmpeg input sources :param args: FFmpeg argument dict. `filter_complex` argument may be modified. :param input_info: a list of input data source information - :return: a map of input/filtergraph output labels to their media types. + :return: a map of input/filtergraph output labels and their stream information. Mapping Input Streams vs. Complex Filtergraph Outputs ----------------------------------------------------- @@ -912,16 +923,23 @@ def auto_map( gopts = args.get("global_options", None) or {} if "filter_complex" in gopts: - map = analyze_fg_outputs(args) - else: - # if no filtergraph, get all video & audio streams from all the input urls - map = { - f"{i}:{j}": media_type - for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)) - for j, media_type in retrieve_input_stream_ids(info, url, opts or {}) + return { + linklabel: {"dst_type": "pipe", "user_map": None, "media_type": media_type} + for linklabel, media_type in analyze_fg_outputs(args).items() } - return map + # if no filtergraph, get all video & audio streams from all the input urls + return { + f"{i}:{j}": { + "dst_type": "pipe", + "user_map": None, + "media_type": media_type, + "input_file_id": i, + "input_stream_id": j, + } + for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)) + for j, media_type in retrieve_input_stream_ids(info, url, opts or {}) + } def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: diff --git a/tests/test_configure.py b/tests/test_configure.py index 4f5bf028..4160053e 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -197,19 +197,37 @@ def test_process_url_inputs(url, opts, defopts, ret): [(mul_url, None)], [{"src_type": "url"}], None, - {f"0:{i}": mtype for i, mtype in mul_streams}, + { + f"0:{i}": { + "media_type": mtype, + "input_file_id": 0, + "input_stream_id": i, + } + for i, mtype in mul_streams + }, ), ( [(vid_url, None), (aud_url, {})], [{"src_type": "url"}, {"src_type": "url"}], None, - {"0:0": "video", "1:0": "audio"}, + { + "0:0": { + "media_type": "video", + "input_file_id": 0, + "input_stream_id": 0, + }, + "1:0": { + "media_type": "audio", + "input_file_id": 1, + "input_stream_id": 0, + }, + }, ), ( [(mul_url, None)], [{"src_type": "url"}], ["split=n=2"], - {"[out0]": "video", "[out1]": "video"}, + {"[out0]": {"media_type": "video"}, "[out1]": {"media_type": "video"}}, ), ], ) @@ -219,7 +237,10 @@ def test_auto_map(inputs, input_info, filters_complex, ret): if filters_complex is not None: args["global_options"] = {"filter_complex": filters_complex} out = configure.auto_map(args, input_info) - assert out == ret + assert out == { + spec: {"dst_type": "pipe", "user_map": None, **info} + for spec, info in ret.items() + } @pytest.mark.parametrize( From abe75061164b742c0edf6fa18191d4be116ac22c Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 10:29:28 -0600 Subject: [PATCH 073/333] process_url_inputs() - check for a specific exception for a buffer input --- src/ffmpegio/configure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 8dc2e6e1..dc89d8ae 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1080,8 +1080,8 @@ def process_url_inputs( else: try: buffer = memoryview(url) - except: - raise ValueError("Given input URL argument is not supported.") + except TypeError as e: + raise TypeError("Given input URL argument is not supported.") from e else: input_info = {"src_type": "buffer", "buffer": buffer} url = None From 0863a00619e9238278ae9147e6d1242e389d161d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 11:26:07 -0600 Subject: [PATCH 074/333] retrieve_input_stream_ids() - accepts StreamSpecDict for stream_spec argument --- src/ffmpegio/configure.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index dc89d8ae..6070831e 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -28,6 +28,7 @@ from .filtergraph.abc import FilterGraphObject from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence +from .stream_spec import stream_spec as compose_stream_spec, StreamSpecDict ################################# ## module types @@ -1098,7 +1099,7 @@ def retrieve_input_stream_ids( info: InputSourceDict, url: FFmpegUrlType | FilterGraphObject | None, opts: dict, - stream_spec: str | None = None, + stream_spec: str | StreamSpecDict | None = None, ) -> list[tuple[int, MediaType]]: """Retrieve ids and media types of streams in an input source @@ -1141,7 +1142,11 @@ def retrieve_input_stream_ids( url, f=opts.get("f", None), sp_kwargs=sp_kwargs, - stream_spec=stream_spec, + stream_spec=( + compose_stream_spec(**stream_spec) + if isinstance(stream_spec, dict) + else stream_spec + ), ) if info["codec_type"] in get_args(MediaType) ] From 82a04a7407efae8b954e7bf3e6ff852a926f06d7 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 11:30:51 -0600 Subject: [PATCH 075/333] added resolve_raw_output_streams() --- src/ffmpegio/configure.py | 98 ++++++++++++++++++++++++++++++++++++++- tests/test_configure.py | 36 ++++++++++++++ 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 6070831e..128c5c82 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -28,7 +28,11 @@ from .filtergraph.abc import FilterGraphObject from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence -from .stream_spec import stream_spec as compose_stream_spec, StreamSpecDict +from .stream_spec import ( + stream_spec as compose_stream_spec, + StreamSpecDict, + stream_type_to_media_type, +) ################################# ## module types @@ -904,6 +908,98 @@ def add_filtergraph( +def resolve_raw_output_streams( + args: FFmpegArgs, input_info: list[InputSourceDict], streams: Sequence[str] +) -> dict[str:RawOutputInfoDict]: + """resolve the raw output streams from given sequence of map options + + :param args: FFmpeg argument dict + :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` + :param streams: a sequence of map options defining the streams + :return: output information keyed by a unique map option string + """ + + dst_type = "pipe" + + # parse all mapping option values + input_file_id = 0 if len(input_info) == 1 else None + map_options = [ + {"stream_specifier": {}, **opt} + for opt in ( + utils.parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + for spec in streams + ) + ] + + # check if stream specifiers single out mapping one input stream per output + if all( + (opt["stream_specifier"].get("stream_type", None) or "") in "avV" + and "stream_id" in opt["stream_specifier"] + for opt in map_options + ): + # no need to run the stream mapping analysis + return { + spec: { + "dst_type": dst_type, + "user_map": spec, + "media_type": stream_type_to_media_type( + opt["stream_specifier"].get("stream_type", None) + ), + "input_file_id": opt.get("input_file_id", None), + "input_stream_id": None, + } + for spec, opt in zip(streams, map_options) + } + else: + # resolve all the output streams + + # if any linklabel given, analyze the filter_complex global option values + fg_map: dict[str, MediaType] = ( + analyze_fg_outputs(args) + if any("linklabel" in opt for opt in map_options) + else {} + ) + + # given as an FFmpeg map option, convert to the dict format + inputs = args["inputs"] + stream_info = {} # one stream per item, value: map spec & media_type + for spec, map_option in zip(streams, map_options): + + # filtergraph output + media_type = fg_map.get(spec, None) + + if media_type is None: + # input url + file_index = map_option["input_file_id"] + info = input_info[file_index] + stream_spec = map_option["stream_specifier"] + stream_data = retrieve_input_stream_ids( + info, *inputs[file_index], stream_spec=stream_spec + ) + unique_stream = len(stream_data) == 1 + for stream_index, media_type in stream_data: + stream_info[ + (spec if unique_stream else f"{file_index}:{stream_index}") + ] = { + "dst_type": dst_type, + "user_map": spec, + "media_type": media_type, + "input_file_id": file_index, + "input_stream_id": stream_index, + } + else: + # filtergraph output + stream_info[spec] = { + "dst_type": dst_type, + "user_map": spec, + "media_type": media_type, + "input_file_id": None, + "input_stream_id": None, + } + + return stream_info + + def auto_map( args: FFmpegArgs, input_info: list[InputSourceDict] ) -> dict[str, RawOutputInfoDict]: diff --git a/tests/test_configure.py b/tests/test_configure.py index 4160053e..a423b1ad 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1,4 +1,5 @@ import pytest +from pprint import pprint from ffmpegio import configure @@ -251,3 +252,38 @@ def test_analyze_fg_outputs(filters_complex, ret): args = configure.empty({"filter_complex": filters_complex}) out = configure.analyze_fg_outputs(args) assert out == ret + + +# prepare input +@pytest.fixture(scope="module") +def ffmpeg_url_inputs_mul(): + args = configure.empty() + info = configure.process_url_inputs(args, [mul_url], {}) + yield args, info + + +@pytest.fixture(scope="module") +def ffmpeg_url_inputs_vid_aud(): + args = configure.empty() + info = configure.process_url_inputs(args, [vid_url, aud_url], {}) + yield args, info + + +@pytest.mark.parametrize( + ("ffmpeg_url_inputs", "filters_complex", "streams"), + [ + ("ffmpeg_url_inputs_mul", None, ["v"]), + ("ffmpeg_url_inputs_vid_aud", None, ["0:v:0", "1:a:0"]), + ("ffmpeg_url_inputs_mul", ["split=n=2"], ["[out0]", "[out1]", "a:0"]), + ], +) +def test_resolve_raw_output_streams( + ffmpeg_url_inputs, filters_complex, streams, request +): + + args, input_info = request.getfixturevalue(ffmpeg_url_inputs) + + if filters_complex is not None: + args["global_options"] = {"filter_complex": filters_complex} + out = configure.resolve_raw_output_streams(args, input_info, streams) + pprint(out) From 5567e5a7932a485ca0d5a540304c6cb65e3ebe68 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 12:20:53 -0600 Subject: [PATCH 076/333] retrieve_input_stream_ids() - restore read position even if fails --- src/ffmpegio/configure.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 128c5c82..3a4271bf 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1250,8 +1250,8 @@ def retrieve_input_stream_ids( # if failed, return empty logger.warning("ffprobe failed.") stream_ids = [] - - if src_type == "fileobj": - # restore the read cursor position - f.seek(pos) + finally: + if src_type == "fileobj": + # restore the read cursor position + f.seek(pos) return stream_ids From 151bd64ec5e71b9d279a85c4209e1bdeaef8293b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 12 Feb 2025 13:18:06 -0600 Subject: [PATCH 077/333] refactored set_sp_kwargs_stdin() from retrieve_input_stream_ids() --- src/ffmpegio/configure.py | 63 +++++++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 3a4271bf..08e292a3 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -12,7 +12,7 @@ IO, Buffer, ) -from collections.abc import Sequence +from collections.abc import Sequence, Callable from fractions import Fraction @@ -1214,21 +1214,11 @@ def retrieve_input_stream_ids( return utils.analyze_input_filtergraph_ids(url) # file/network input - process only if seekable - sp_kwargs = {} # ffprobe subprocess keywords - if src_type != "url": - url = "pipe:0" - if src_type == "buffer": - sp_kwargs["input"] = info["buffer"] - elif src_type == "fileobj": - f = info["fileobj"] - if not (f.readable() and f.seekable()): - logger.warning("file object must be seekable.") - return [] - pos = f.tell() - sp_kwargs["stdin"] = f - else: - logger.warning("unknown input source type.") - return [] + # get ffprobe subprocess keywords + url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(url, info) + if sp_kwargs is None: + # something failed (warning logged) + return [] # get the stream list if ffprobe can try: @@ -1251,7 +1241,42 @@ def retrieve_input_stream_ids( logger.warning("ffprobe failed.") stream_ids = [] finally: - if src_type == "fileobj": - # restore the read cursor position - f.seek(pos) + # clean-up + exit_fcn() return stream_ids + + + +def set_sp_kwargs_stdin( + url: str | None, info: InputSourceDict, sp_kwargs: dict = {} +) -> tuple[str, dict | None, Callable]: + """configure sp_kwargs for ffprobe/ffmpeg call to pipe-in the data via stdin + + :param url: input URL + :param info: input info + :param sp_kwargs: initial sp_kwargs keyword options + :return: tuple of url (or "pipe:0" if stdin data), updated sp_kwargs, and cleanup function + """ + + # ffprobe subprocess keywords + src_type = info["src_type"] + exit_fcn = lambda: None + + if src_type != "url": + url = "pipe:0" + if src_type == "buffer": + sp_kwargs = {**sp_kwargs, "input": info["buffer"]} + elif src_type == "fileobj": + f = info["fileobj"] + if f.readable() and f.seekable(): + sp_kwargs = {**sp_kwargs, "stdin": f} + pos = f.tell() + exit_fcn = lambda: f.seek(pos) # restore the read cursor position + else: + logger.warning("file object must be seekable.") + sp_kwargs = None + else: + logger.warning("unknown input source type.") + sp_kwargs = None + + return url, sp_kwargs, exit_fcn From ca51c710e1253e4c59458037b611a56a09524813 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 14:05:31 -0600 Subject: [PATCH 078/333] moved InputSourceDict to `configure` to `_typing` --- src/ffmpegio/_typing.py | 11 +++++++++++ src/ffmpegio/configure.py | 12 ++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 01542f60..66102191 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -38,3 +38,14 @@ FFmpegUrlType = Union[str, Path, ParseResult] +FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj", "pipe"] + + +class InputSourceDict(TypedDict): + """input source info""" + + src_type: FFmpegInputType # True if file path/url + buffer: NotRequired[bytes] # index of the source index + fileobj: NotRequired[IO] # file object + pipe: NotRequired[NPopen] # pipe + diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 08e292a3..308b8e53 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -11,6 +11,7 @@ TypedDict, IO, Buffer, + InputSourceDict, ) from collections.abc import Sequence, Callable @@ -38,7 +39,7 @@ ## module types UrlType = Literal["input", "output"] -FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] + FFmpegOutputType = Literal["url", "fileobj"] FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] @@ -58,15 +59,6 @@ class FFmpegArgs(TypedDict): global_options: NotRequired[dict | None] # FFmpeg global options -class InputSourceDict(TypedDict): - """input source info""" - - src_type: FFmpegInputType # True if file path/url - buffer: NotRequired[bytes] # index of the source index - fileobj: NotRequired[IO] # file object - pipe: NotRequired[NPopen] # pipe - - class RawOutputInfoDict(TypedDict): dst_type: FFmpegOutputType # True if file path/url user_map: str | None # user specified map option From 2d5281e222684dd10a31e5bdb0e5b24cf4010788 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 14:12:43 -0600 Subject: [PATCH 079/333] FFmpegArgs['global_options'] changed to be always a dict --- src/ffmpegio/configure.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 308b8e53..13968fbc 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -56,7 +56,7 @@ class FFmpegArgs(TypedDict): # list of input definitions (pairs of url and options) outputs: list[FFmpegOutputOptionTuple] # list of output definitions (pairs of url and options) - global_options: NotRequired[dict | None] # FFmpeg global options + global_options: dict # FFmpeg global options class RawOutputInfoDict(TypedDict): @@ -123,9 +123,9 @@ def empty(global_options: dict = None) -> FFmpegArgs: """create empty ffmpeg arg dict :param global_options: global options, defaults to None - :return: empty ffmpeg arg dict with 'inputs','outputs',and 'global_options' entries. + :return: ffmpeg arg dict with empty 'inputs','outputs',and 'global_options' entries. """ - return {"inputs": [], "outputs": [], "global_options": global_options} + return {"inputs": [], "outputs": [], "global_options": global_options or {}} def check_url( From 43d9a092aa85c47e34fd9ec35f293f34e09793e8 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 20:23:57 -0600 Subject: [PATCH 080/333] fixed the use of is_map_option in label validations --- src/ffmpegio/filtergraph/GraphLinks.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 5752ec5e..47d261c6 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -5,7 +5,7 @@ from collections.abc import Generator, Mapping, Sequence, Callable -from ..utils import is_map_option +from ..stream_spec import is_map_option from ..errors import FFmpegioError from .typing import PAD_INDEX, PAD_PAIR, Literal @@ -73,6 +73,10 @@ def validate_label( raise GraphLinks.Error( "Pad label must be a string and has at least one character." ) + if no_stream_spec and is_map_option(label, allow_missing_file_id=True): + raise GraphLinks.Error( + f"Pad label cannot be an input stream specifier ({label})." + ) @staticmethod def validate_pad_idx(id: PAD_INDEX | None, none_ok: bool = True): @@ -437,7 +441,8 @@ def iter_links( def iter(label, inpad, outpad): if outpad is not None or ( - include_input_stream and is_map_option(label, allow_missing_file_id=True) + include_input_stream + and is_map_option(label, allow_missing_file_id=True) ): for d in self.iter_inpad_ids(inpad): yield (label, d, outpad) @@ -461,7 +466,8 @@ def iter_inputs( """ for label, (inpad, outpad) in self.data.items(): if outpad is None and not ( - exclude_stream_specs and is_map_option(label, allow_missing_file_id=True) + exclude_stream_specs + and is_map_option(label, allow_missing_file_id=True) ): for d in self.iter_inpad_ids(inpad): yield (label, d) From 7b9eec4f01c99fc8c333e21660a510008611960e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:35:10 -0600 Subject: [PATCH 081/333] Graph - fixed xtor to copy the values --- src/ffmpegio/filtergraph/Graph.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index f656be0c..aeeb540a 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -91,7 +91,7 @@ def __init__( # convert str to a list of filter_specs if isinstance(filter_specs, fgb.Graph): links = filter_specs._links - sws_flags = filter_specs.sws_flags and filter_specs.sws_flags[1:] + sws_flags = filter_specs.sws_flags and [*filter_specs.sws_flags[1:]] elif isinstance(filter_specs, fgb.Chain): filter_specs = [filter_specs] if len(filter_specs) else () elif filter_specs is not None: @@ -105,9 +105,10 @@ def __init__( "An empty filterchain found. All chains must be populated." ) - filter_specs = (fgb.Chain(fspec) for fspec in filter_specs) - - UserList.__init__(self, () if filter_specs is None else filter_specs) + UserList.__init__( + self, + () if filter_specs is None else iter(fgb.Chain(c) for c in filter_specs), + ) self._links = GraphLinks(links) """utils.fglinks.GraphLinks: filtergraph link specifications From fc4b7702fb6eb830f7ce59a8d7f088943e22899d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:39:30 -0600 Subject: [PATCH 082/333] fixed/updated module imports --- src/ffmpegio/filtergraph/presets.py | 5 ++--- src/ffmpegio/media.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 7aebc7a9..10230080 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -3,9 +3,8 @@ from __future__ import annotations -from ..typing import TYPE_CHECKING, Any, StreamSpecDict - -from collections.abc import Sequence +from .._typing import TYPE_CHECKING, Any +from ..stream_spec import StreamSpecDict if TYPE_CHECKING: from .Graph import Graph diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index b378357e..6000b515 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -1,15 +1,15 @@ from __future__ import annotations from collections.abc import Sequence -from .typing import ( +from ._typing import ( Literal, Any, RawStreamDef, ProgressCallable, RawDataBlob, - StreamSpecDict, Unpack, ) +from .stream_spec import StreamSpecDict import contextlib from io import BytesIO From 11ed888ad0f52463c0bdb34485c8ed4a3f94902c Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:40:37 -0600 Subject: [PATCH 083/333] added logger warn that cache_output arg is not currently implemented --- src/ffmpegio/probe.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 9b2351c3..12b9dd87 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -10,6 +10,10 @@ from fractions import Fraction from functools import lru_cache +import logging + +logger = logging.getLogger("ffmpegio") + from .path import ffprobe, PIPE # fmt:off @@ -240,6 +244,11 @@ def _run( ) -> dict[str, str]: """execute ffprobe, return stdout as dict, and cache its output""" + # TODO - enable caching + if cache_output: + logger.warning('caching of previous ffprobe outputs is disabled.') + cache_output = False + entries = _compose_entries(entries) if sp_kwargs is not None: sp_kwargs = tuple(sp_kwargs.items()) From 45347c6200960b05ab01cb254256eea82c2321fa Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:41:17 -0600 Subject: [PATCH 084/333] raise FFmpegError if ffprobe exec fails --- 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 12b9dd87..b43cb1a4 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -15,6 +15,7 @@ logger = logging.getLogger("ffmpegio") from .path import ffprobe, PIPE +from .errors import FFmpegError # fmt:off __all__ = ['full_details', 'format_basic', 'streams_basic', @@ -221,7 +222,7 @@ def _exec( # run ffprobe ret = ffprobe(args, **sp_opts) if ret.returncode != 0: - raise Exception(f"ffprobe execution failed\n\n{ret.stderr.decode('utf8')}\n") + raise FFmpegError(f"ffprobe execution failed\n\n{ret.stderr.decode('utf8')}\n") # decode output JSON string return json.loads(ret.stdout) From bddabfda88d5d04d3fee5da1fe9353b46137c242 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:44:18 -0600 Subject: [PATCH 085/333] filter() - removed sample_fmt input argument (still accepted via **options) --- src/ffmpegio/audio.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index ac6fc044..f351c67e 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -279,7 +279,6 @@ def filter( expr, input_rate, input, - sample_fmt=None, progress=None, show_log=None, sp_kwargs=None, @@ -318,7 +317,6 @@ def filter( *configure.array_to_audio_input(input_rate, data=input, **input_options), ) outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - outopts["sample_fmt"] = sample_fmt if expr: outopts["filter:a"] = expr From 23f9ca3221852f4915cffa8c0d231e65a2f2c860 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:44:37 -0600 Subject: [PATCH 086/333] added StreamSpecDict_Options to StreamSpecDict --- src/ffmpegio/stream_spec.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index d01dc019..521f59c1 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -42,7 +42,12 @@ class StreamSpecDict_Usable(StreamSpecDict_Options): usable: bool -StreamSpecDict = Union[StreamSpecDict_Index, StreamSpecDict_Tag, StreamSpecDict_Usable] +StreamSpecDict = Union[ + StreamSpecDict_Index, + StreamSpecDict_Tag, + StreamSpecDict_Usable, + StreamSpecDict_Options, +] class InputMapOptionDict(TypedDict): From 9063b8d1163c2b7fd07e518a5e5839662d2bb97b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:46:56 -0600 Subject: [PATCH 087/333] import whole filtergraph module as fgb --- src/ffmpegio/utils/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 96328595..9fb05476 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -12,7 +12,7 @@ from .._utils import * from ..stream_spec import * from ..filtergraph.abc import FilterGraphObject -from ..filtergraph import as_filtergraph_object +from .. import filtergraph as fgb from ..errors import FFmpegError from ..typing import Any @@ -518,7 +518,7 @@ def analyze_input_filtergraph_ids( """ # parse if str expression is given - fg = as_filtergraph_object(fg) + fg = fgb.as_filtergraph_object(fg) outtypes = {} for idx, filter, _ in fg.iter_output_pads(): From 4881092eba831b60a39ac64cce6e7f928e5599e4 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:49:05 -0600 Subject: [PATCH 088/333] enabled logger --- src/ffmpegio/utils/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 9fb05476..a4813b2e 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -5,6 +5,11 @@ from collections.abc import Sequence from numbers import Number +import logging + +logger = logging.getLogger("ffmpegio") + + from math import cos, radians, sin import re, fractions From eb15be437b121b4f9f8ca5e7c750c6c77fcb2aae Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:50:20 -0600 Subject: [PATCH 089/333] removed redundant code --- src/ffmpegio/configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 13968fbc..bb55a18b 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1142,7 +1142,7 @@ def process_url_inputs( opts = inopts_default if opts is None else {**inopts_default, **opts} else: # only URL given - url, opts = url, inopts_default + opts = inopts_default # check url (must be url and not fileobj) is_fg = isinstance(url, FilterGraphObject) From 9bf1da3e56c5afda1967bb04756d8a0dad334c90 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 21:51:51 -0600 Subject: [PATCH 090/333] import `parse_map_option` from the source module --- src/ffmpegio/configure.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index bb55a18b..b3d65b77 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -33,6 +33,7 @@ stream_spec as compose_stream_spec, StreamSpecDict, stream_type_to_media_type, + parse_map_option, ) ################################# @@ -918,7 +919,7 @@ def resolve_raw_output_streams( map_options = [ {"stream_specifier": {}, **opt} for opt in ( - utils.parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) for spec in streams ) ] From 9041d4be6d852134761168aba4c5a0074e37a3bd Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Feb 2025 22:16:17 -0600 Subject: [PATCH 091/333] revamped `finalize_audio_read_opts()` --- src/ffmpegio/audio.py | 12 ++- src/ffmpegio/configure.py | 139 ++++++++++++++++++-------- src/ffmpegio/probe.py | 22 ---- src/ffmpegio/streams/SimpleStreams.py | 13 ++- tests/test_audio.py | 6 +- 5 files changed, 125 insertions(+), 67 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index f351c67e..d37b4465 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -3,7 +3,6 @@ import warnings from . import ffmpegprocess, utils, configure, FFmpegError, plugins, analyze -from .probe import _audio_info as _probe_audio_info from .utils import log as log_utils __all__ = ["create", "read", "write", "filter", "detect"] @@ -46,12 +45,19 @@ def _run_read( :rtype: (int, str) """ - dtype, ac, rate = configure.finalize_audio_read_opts(args[0], istream="a:0") + outopts = args[0]["outputs"][0][1] + outopts["map"] = "0:a:0" + dtype, ac, rate = configure.finalize_audio_read_opts( + args[0], + input_info=[ + {"src_type": "filtergraph" if outopts.get("f", None) == "lavfi" else "url"} + ], + ) if sp_kwargs is not None: kwargs = {**sp_kwargs, **kwargs} - if dtype is None or ac is None or rate is None: + if ac is None or rate is None: configure.clear_loglevel(args[0]) out = ffmpegprocess.run(*args, capture_log=True, **kwargs) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index b3d65b77..e5b601bc 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -486,56 +486,117 @@ def build_basic_vf(args, remove_alpha=None, ofile=0): def finalize_audio_read_opts( - args: FFmpegArgs, ofile: int = 0, ifile: int = 0, istream: str | None = None -) -> tuple[str, int, int]: + args: FFmpegArgs, + ofile: int = 0, + input_info: list[InputSourceDict] = [], +) -> tuple[str, int | None, int | None]: + """finalize a raw output audio stream - inurl, inopts = args["inputs"][ifile] - if inopts is None: - inopts = {} - outopts = args["outputs"][ofile][1] - has_filter = has_filtergraph(args, "audio") + :param args: FFmpeg arguments. The option dict in args['outputs'][ofile][1] may be modified. + :param ofile: output file index, defaults to 0 + :param input_info: list of input information, defaults to None - sample_fmt_in = inopts.get("sample_fmt", None) - ac_in = inopts.get("ac", None) - ar_in = inopts.get("ar", None) - if isinstance(inurl, (str, Path)) and not (sample_fmt_in and ac_in and ar_in): - # TODO: handle lavfi input - try: - ar_in, sample_fmt_in, ac_in = ( - x or y - for x, y in zip( - (ar_in, sample_fmt_in, ac_in), - probe._audio_info(inurl, istream, None), - ) + * Possible Output Options Modification + - "f" and "c:a" - raw audio format and codec will always be set + - "sample_fmt" - planar format to non-planar equivalent format or 'dbl' if format is unknown + - + + * args['outputs'][ofile]['map'] is a valid mapping str (not a list of str) + * If complex filtergraph(s) is used, args['global_options']['filter_complex'] must be a list of fgb.Graph objects + + """ + options = ["ar", "sample_fmt", "ac"] + fields = ["sample_rate", "sample_fmt", "channels"] + + outopts = args["outputs"][ofile][1] + outmap = outopts["map"] + outmap_fields = parse_map_option(outmap) + + # use the output option by default + opt_vals = [outopts.get(o, None) for o in options] + all_found = all(opt_vals) + + if not all_found: + if "linklabel" in outmap_fields: # mapping filtergraph output + # must be mapped a linklabel of a filter_complex global option + logger.warning( + "Pre-analysis of complex filtergraphs is not currently available." ) - except: - pass + st_vals = [None, None, None] + # combine all the filtergraphs only for the analysis purpose + # fg = fgb.stack(args["global_options"]["filter_complex"]) + else: + ifile = outmap_fields["input_file_id"] + has_simple_filter = "af" in outopts or "filter:a" in outopts + + # check the input option data + inurl, inopts = args["inputs"][ifile] + if not has_simple_filter: + opt_vals = [inopts.get(o, None) for o in options] + all_found = all(opt_vals) + + if not all_found: + # get input options + inopt_vals = [inopts.get(o, None) for o in options] + + # directly from the input url + if not all(inopt_vals): + st_vals = utils.analyze_input_stream( + fields, outmap, "audio", inurl, inopts, input_info[ifile] + ) + inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] - if outopts is None: - outopts = {} - args["outputs"][ofile] = (args["outputs"][ofile][0], outopts) + if has_simple_filter: + # analyze the output of the simple filter - # pixel format must be specified - sample_fmt = outopts.get("sample_fmt", None) + # create a source chain with matching spec and attach it to the af graph + ar, sample_fmt, ac = inopt_vals + af = ( + fgb.aevalsrc("|".join(["0"] * ac)) + + fgb.aformat(sample_fmts=sample_fmt or "dbl", r=ar) + + outopts.get("filter:a", outopts.get("af", None)) + ) + outpad = next(af.iter_output_pads(unlabeled_only=True), None) + if outpad is not None: + af = af >> "[out0]" + inopt_vals = utils.analyze_input_stream( + fields, + "0", + "audio", + af, + {"f": "lavfi"}, + {"src_type": "filtergraph"}, + ) + + + opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] + + # assign the values to individual variables + ar, sample_fmt, ac = opt_vals + + # sample format must be specified if sample_fmt is None: - # get pixel format from input - sample_fmt = sample_fmt_in - if sample_fmt: - if sample_fmt[-1] == "p": - # planar format is not supported - sample_fmt = sample_fmt[:-1] - outopts["sample_fmt"] = sample_fmt # set the format + logger.warning( + 'Sample format of audio stream "%s" could not be retrieved. Uses "dbl".', + outmap, + ) + sample_fmt = outopts["sample_fmt"] = "dbl" + elif sample_fmt[-1] == "p": + # planar format is not supported + logger.warning( + "The audio stream %s uses a planar sample format '%s' which is not supported for audio data IO. Changed to %s.", + outmap, + sample_fmt, + sample_fmt[:-1], + ) + sample_fmt = sample_fmt[:-1] + outopts["sample_fmt"] = sample_fmt # set the format to non-planar # set output format and codec outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) - ac = ar = None - if not has_filter: - ac = outopts.get("ac", ac_in) - ar = outopts.get("ar", ar_in) - # sample_fmt must be given - dtype, shape = utils.get_audio_format(sample_fmt, ac) + dtype, _ = utils.get_audio_format(sample_fmt, ac) return dtype, ac, ar diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index b43cb1a4..cf6fa80e 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -752,28 +752,6 @@ def query( return info -def _audio_info( - url: str | BinaryIO | memoryview, - stream: str | None, - sp_kwargs: dict[str, Any] | None, - *, - f: str | None = None, -) -> tuple[int | None, str | None, int | None]: - "returns (sample_rate, sample_fmt, channels) of the specified url/stream" - fields = ["sample_rate", "sample_fmt", "channels"] - q = query( - url, - "a:0" if stream is None else stream, - fields, - True, - False, - True, - sp_kwargs, - f=f, - )[0] - return tuple(q[f] for f in fields) - - def _video_info( url: str | BinaryIO | memoryview, stream: str | None, diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 97ae5152..85da1be5 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -280,11 +280,22 @@ def __init__( def _finalize(self, ffmpeg_args): # finalize FFmpeg arguments and output array + outopts = ffmpeg_args["outputs"][0][1] + outopts["map"] = "0:a:0" ( self.dtype, ac, self.rate, - ) = configure.finalize_audio_read_opts(ffmpeg_args, istream="a:0") + ) = configure.finalize_audio_read_opts( + ffmpeg_args, + input_info=[ + { + "src_type": ( + "filtergraph" if outopts.get("f", None) == "lavfi" else "url" + ) + } + ], + ) if ac is not None: self.shape = (ac,) diff --git a/tests/test_audio.py b/tests/test_audio.py index 5b7456a6..c3f4c47c 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -104,7 +104,7 @@ def test_filter(): ], ) - output_rate, output = audio.filter(expr, input_rate, input) + output_rate, output = audio.filter(expr, input_rate, input, show_log=True, loglevel ='verbose') assert output_rate == 22050 assert output["shape"] == (22050, 2) assert output["dtype"] == input["dtype"] @@ -125,7 +125,9 @@ def test_filter(): output_rate, output = audio.filter(expr, input_rate, input) assert output_rate == 44100 assert output["shape"] == (44100, 2) - assert output["dtype"] == input["dtype"] + assert output["dtype"] == ' Date: Fri, 14 Feb 2025 08:49:19 -0600 Subject: [PATCH 092/333] `finalize_audio_read_opts()` - cleaned up --- src/ffmpegio/configure.py | 73 ++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e5b601bc..d073dd3a 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -512,11 +512,9 @@ def finalize_audio_read_opts( outmap = outopts["map"] outmap_fields = parse_map_option(outmap) - # use the output option by default + # use the output options by default opt_vals = [outopts.get(o, None) for o in options] - all_found = all(opt_vals) - - if not all_found: + if not all(opt_vals): if "linklabel" in outmap_fields: # mapping filtergraph output # must be mapped a linklabel of a filter_complex global option logger.warning( @@ -527,49 +525,38 @@ def finalize_audio_read_opts( # fg = fgb.stack(args["global_options"]["filter_complex"]) else: ifile = outmap_fields["input_file_id"] - has_simple_filter = "af" in outopts or "filter:a" in outopts - - # check the input option data + + # get input option values inurl, inopts = args["inputs"][ifile] - if not has_simple_filter: - opt_vals = [inopts.get(o, None) for o in options] - all_found = all(opt_vals) - - if not all_found: - # get input options - inopt_vals = [inopts.get(o, None) for o in options] - - # directly from the input url - if not all(inopt_vals): - st_vals = utils.analyze_input_stream( - fields, outmap, "audio", inurl, inopts, input_info[ifile] - ) - inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + inopt_vals = [inopts.get(o, None) for o in options] - if has_simple_filter: - # analyze the output of the simple filter + # fill the still missing values directly from the input url + if not all(inopt_vals): + st_vals = utils.analyze_input_stream( + fields, outmap, "audio", inurl, inopts, input_info[ifile] + ) + inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] - # create a source chain with matching spec and attach it to the af graph - ar, sample_fmt, ac = inopt_vals - af = ( - fgb.aevalsrc("|".join(["0"] * ac)) - + fgb.aformat(sample_fmts=sample_fmt or "dbl", r=ar) - + outopts.get("filter:a", outopts.get("af", None)) - ) - outpad = next(af.iter_output_pads(unlabeled_only=True), None) - if outpad is not None: - af = af >> "[out0]" - inopt_vals = utils.analyze_input_stream( - fields, - "0", - "audio", - af, - {"f": "lavfi"}, - {"src_type": "filtergraph"}, - ) - + # if a simple filter is present, use the stream specs of its output + if "af" in outopts or "filter:a" in outopts: + + # create a source chain with matching specs and attach it to the af graph + ar, sample_fmt, ac = inopt_vals + af = ( + fgb.aevalsrc("|".join(["0"] * ac)) + + fgb.aformat(sample_fmts=sample_fmt or "dbl", r=ar) + + outopts.get("filter:a", outopts.get("af", None)) + ) + inopt_vals = utils.analyze_input_stream( + fields, + "0", + "audio", + af, + {"f": "lavfi"}, + {"src_type": "filtergraph"}, + ) - opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] + opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] # assign the values to individual variables ar, sample_fmt, ac = opt_vals From 9474c344d8ed12820dece659b797843febd4dba4 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 08:59:12 -0600 Subject: [PATCH 093/333] made `fgb` import global --- src/ffmpegio/filtergraph/presets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 10230080..c3602837 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -6,6 +6,8 @@ from .._typing import TYPE_CHECKING, Any from ..stream_spec import StreamSpecDict +from .. import filtergraph as fgb + if TYPE_CHECKING: from .Graph import Graph @@ -29,8 +31,6 @@ def merge_audio( """ - from .. import filtergraph as fg - # number of input audio streams to be merged n_ain = len(streams) @@ -52,8 +52,8 @@ def match_sample(sspec, opts): fopts["f"] = output_sample_fmt in_label = f"[{sspec}]" - return (in_label >> fg.aformat(**fopts)) if len(fopts) else in_label + return (in_label >> fgb.aformat(**fopts)) if len(fopts) else in_label - afilt = [match_sample(*st) for st in streams.items()] >> fg.amerge(inputs=n_ain) + afilt = [match_sample(*st) for st in streams.items()] >> fgb.amerge(inputs=n_ain) return (afilt >> output_pad_label) if output_pad_label else afilt From d20ec58e35aee20ff1b500d38f9b4d6638cd05f5 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 09:07:46 -0600 Subject: [PATCH 094/333] moved `_build_video_basic_filter()` to `filtergraph.presets` --- src/ffmpegio/configure.py | 71 +--------------------------- src/ffmpegio/filtergraph/presets.py | 73 ++++++++++++++++++++++++++++- tests/test_configure.py | 21 --------- tests/test_filtergraph_presets.py | 24 ++++++++++ 4 files changed, 97 insertions(+), 92 deletions(-) create mode 100644 tests/test_filtergraph_presets.py diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index d073dd3a..96132aa2 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -27,6 +27,7 @@ from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject +from .filtergraph.presets import merge_audio, _build_video_basic_filter from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence from .stream_spec import ( @@ -348,76 +349,6 @@ def check_alpha_change(args, dir=None, ifile=0, ofile=0): return utils.alpha_change(inopts.get("pix_fmt", None), outopts.get("pix_fmt", None)) -def _build_video_basic_filter( - fill_color: str | None = None, - remove_alpha: bool = False, - scale: str | Sequence | None = None, - crop: str | Sequence | None = None, - flip: Literal["horizontal", "vertical", "both"] | None = None, - transpose: str | Sequence | None = None, - square_pixels: ( - Literal["upscale", "downscale", "upscale_even", "downscale_even"] | None - ) = None, -) -> FilterGraphObject: - bg_color = fill_color or "white" - - vfilters = ( - fgb.Graph( - f"color=c={bg_color}[l1];[l1][in]scale2ref[l2],[l2]overlay=shortest=1" - ) - if remove_alpha - else fgb.Chain() - ) - - if square_pixels == "upscale": - vfilters += "scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1" - elif square_pixels == "downscale": - vfilters += "scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1" - elif square_pixels == "upscale_even": - vfilters += "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1" - elif square_pixels == "downscale_even": - vfilters += "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1" - elif square_pixels is not None: - raise ValueError(f"unknown `square_pixels` option value given: {square_pixels}") - - if crop: - try: - assert not isinstance(crop, str) - vfilters += fgb.Filter("crop", *crop) - except: - vfilters += fgb.Filter("crop", crop) - - if flip: - try: - ftype = ("", "horizontal", "vertical", "both").index(flip) - except: - raise Exception("Invalid flip filter specified.") - if ftype % 2: - vfilters += "hflip" - if ftype >= 2: - vfilters += "vflip" - - if transpose is not None: - try: - assert not isinstance(transpose, str) - vfilters += fgb.Filter("transpose", *transpose) - except: - vfilters += fgb.Filter("transpose", transpose) - - if scale: - try: - scale = [int(s) for s in scale.split("x")] - except: - pass - try: - assert not isinstance(scale, str) - vfilters += fgb.Filter("scale", *scale) - except: - vfilters += fgb.Filter("scale", scale) - - return vfilters - - def build_basic_vf(args, remove_alpha=None, ofile=0): """convert basic VF options to vf option diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index c3602837..2fec6c8b 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -3,8 +3,9 @@ from __future__ import annotations -from .._typing import TYPE_CHECKING, Any +from .._typing import TYPE_CHECKING, Any, Sequence, Literal from ..stream_spec import StreamSpecDict +from .abc import FilterGraphObject from .. import filtergraph as fgb @@ -12,6 +13,76 @@ from .Graph import Graph +def _build_video_basic_filter( + fill_color: str | None = None, + remove_alpha: bool = False, + scale: str | Sequence | None = None, + crop: str | Sequence | None = None, + flip: Literal["horizontal", "vertical", "both"] | None = None, + transpose: str | Sequence | None = None, + square_pixels: ( + Literal["upscale", "downscale", "upscale_even", "downscale_even"] | None + ) = None, +) -> FilterGraphObject: + bg_color = fill_color or "white" + + vfilters = ( + fgb.Graph( + f"color=c={bg_color}[l1];[l1][in]scale2ref[l2],[l2]overlay=shortest=1" + ) + if remove_alpha + else fgb.Chain() + ) + + if square_pixels == "upscale": + vfilters += "scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1" + elif square_pixels == "downscale": + vfilters += "scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1" + elif square_pixels == "upscale_even": + vfilters += "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1" + elif square_pixels == "downscale_even": + vfilters += "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1" + elif square_pixels is not None: + raise ValueError(f"unknown `square_pixels` option value given: {square_pixels}") + + if crop: + try: + assert not isinstance(crop, str) + vfilters += fgb.Filter("crop", *crop) + except: + vfilters += fgb.Filter("crop", crop) + + if flip: + try: + ftype = ("", "horizontal", "vertical", "both").index(flip) + except: + raise Exception("Invalid flip filter specified.") + if ftype % 2: + vfilters += "hflip" + if ftype >= 2: + vfilters += "vflip" + + if transpose is not None: + try: + assert not isinstance(transpose, str) + vfilters += fgb.Filter("transpose", *transpose) + except: + vfilters += fgb.Filter("transpose", transpose) + + if scale: + try: + scale = [int(s) for s in scale.split("x")] + except: + pass + try: + assert not isinstance(scale, str) + vfilters += fgb.Filter("scale", *scale) + except: + vfilters += fgb.Filter("scale", scale) + + return vfilters + + def merge_audio( streams: dict[StreamSpecDict, dict[str, Any]], output_ar: int | None = None, diff --git a/tests/test_configure.py b/tests/test_configure.py index a423b1ad..a534835b 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -105,27 +105,6 @@ def test_get_option(): assert configure.get_option(args, "output", "ac", file_id=1) == 2 -def test_video_basic_filter(): - print( - configure._build_video_basic_filter( - fill_color=None, - remove_alpha=None, - crop=None, - flip=None, - transpose=None, - ) - ) - print( - configure._build_video_basic_filter( - fill_color="red", - remove_alpha=True, - # crop=(100, 100, 5, 10), - # flip="horizontal", - # transpose="clock", - ) - ) - - mul_streams = [(0, "video"), (1, "audio"), (2, "video"), (3, "audio")] mul_vid_streams = [mul_streams[0], mul_streams[2]] diff --git a/tests/test_filtergraph_presets.py b/tests/test_filtergraph_presets.py new file mode 100644 index 00000000..dc14d312 --- /dev/null +++ b/tests/test_filtergraph_presets.py @@ -0,0 +1,24 @@ +import pytest +from pprint import pprint + +import ffmpegio.filtergraph.presets as presets + +def test_video_basic_filter(): + print( + presets._build_video_basic_filter( + fill_color=None, + remove_alpha=None, + crop=None, + flip=None, + transpose=None, + ) + ) + print( + presets._build_video_basic_filter( + fill_color="red", + remove_alpha=True, + # crop=(100, 100, 5, 10), + # flip="horizontal", + # transpose="clock", + ) + ) \ No newline at end of file From e8439b24d95ac8bf1084580219b2317631effcaf Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 09:25:46 -0600 Subject: [PATCH 095/333] renamed `_build_video_basic_filter()` to `filter_video_basic()` --- src/ffmpegio/configure.py | 4 ++-- src/ffmpegio/filtergraph/presets.py | 2 +- tests/test_filtergraph_presets.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 96132aa2..d82952dc 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -27,7 +27,7 @@ from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject -from .filtergraph.presets import merge_audio, _build_video_basic_filter +from .filtergraph.presets import merge_audio, filter_video_basic from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence from .stream_spec import ( @@ -403,7 +403,7 @@ def build_basic_vf(args, remove_alpha=None, ofile=0): if remove_alpha and "remove_alpha" not in fopts: fopts["remove_alpha"] = True - bvf = _build_video_basic_filter(**fopts) # Graph is remove alpha else Chain + bvf = filter_video_basic(**fopts) # Graph is remove alpha else Chain vf = outopts.get("vf", None) if vf: try: diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 2fec6c8b..fbc78fe3 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -13,7 +13,7 @@ from .Graph import Graph -def _build_video_basic_filter( +def filter_video_basic( fill_color: str | None = None, remove_alpha: bool = False, scale: str | Sequence | None = None, diff --git a/tests/test_filtergraph_presets.py b/tests/test_filtergraph_presets.py index dc14d312..5c582306 100644 --- a/tests/test_filtergraph_presets.py +++ b/tests/test_filtergraph_presets.py @@ -5,7 +5,7 @@ def test_video_basic_filter(): print( - presets._build_video_basic_filter( + presets.filter_video_basic( fill_color=None, remove_alpha=None, crop=None, @@ -14,7 +14,7 @@ def test_video_basic_filter(): ) ) print( - presets._build_video_basic_filter( + presets.filter_video_basic( fill_color="red", remove_alpha=True, # crop=(100, 100, 5, 10), From b8204c15dd96a035b257b3da26d910d8c2db8136 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 10:25:30 -0600 Subject: [PATCH 096/333] `FilterGraphObject.rconnect()` - fixed missing arguments --- src/ffmpegio/filtergraph/abc.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index ccb7d5cf..25094b49 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -445,7 +445,14 @@ def rconnect( """ return fgb.connect( - left, self, from_left, to_right, chain_siso, replace_sws_flags + left, + self, + from_left, + to_right, + from_right, + to_left, + chain_siso, + replace_sws_flags, ) def join( From ee5ab607b3eb069a9a237c96e6dbe5b3f25ec6b9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 10:28:29 -0600 Subject: [PATCH 097/333] `inplace` argument for filtergraph manipulation functions (need to revisit later) --- src/ffmpegio/filtergraph/abc.py | 16 ++++++------- src/ffmpegio/filtergraph/build.py | 38 ++++++++++++++++++++++--------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 25094b49..0d606382 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -649,30 +649,30 @@ def __repr__(self) -> str: ... # Filtergraph math operators def __add__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: - return fgb.join(self, other) + return fgb.join(self, other, inplace=False) def __radd__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: - return fgb.join(other, self) + return fgb.join(other, self, inplace=False) def __mul__(self, __n: int) -> fgb.Graph: """duplicate-n-stack""" if not isinstance(__n, int): return NotImplemented - return fgb.stack(*((self,) * __n)) + return fgb.stack(*((self,) * __n), inplace=False) def __rmul__(self, __n: int) -> fgb.Graph: """duplicate-n-stack""" if not isinstance(__n, int): return NotImplemented - return fgb.stack(*((self,) * __n)) + return fgb.stack(*((self,) * __n), inplace=False) def __or__(self, other: FilterGraphObject | str) -> fgb.Graph: """stack""" - return fgb.stack(self, other) + return fgb.stack(self, other, inplace=False) def __ror__(self, other: FilterGraphObject | str) -> fgb.Graph: """stack""" - return fgb.stack(other, self) + return fgb.stack(other, self, inplace=False) def __rshift__( self, @@ -723,7 +723,7 @@ def parse_other(other): # parse other argument, separate the indices if given right, left_on, right_on = parse_other(other) - return fgb.attach(self, right, left_on, right_on) + return fgb.attach(self, right, left_on, right_on, inplace=False) def __rrshift__( self, @@ -773,7 +773,7 @@ def parse_other(other): # parse other argument, separate the indices if given left, right_on, left_on = parse_other(other) - return fgb.attach(left, self, left_on, right_on) + return fgb.attach(left, self, left_on, right_on, inplace=False) def resolve_pad_index( self, diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 88822362..9c537235 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -78,6 +78,7 @@ def connect( to_left: PAD_INDEX | str | list[PAD_INDEX | str] | None = None, chain_siso: bool = True, replace_sws_flags: bool | None = None, + inplace: bool = False, ) -> fgb.Graph | fgb.Chain: """connect two filtergraph objects and make explicit connections @@ -91,6 +92,8 @@ def connect( :param replace_sws_flags: True to use `right` sws_flags if present, False to drop `right` sws_flags, None to throw an exception (default) + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: new filtergraph object Notes @@ -101,8 +104,8 @@ def connect( """ # make sure right is a Graph object - left = fgb.as_filtergraph_object(left) - right = fgb.as_filtergraph_object(right) + left = fgb.as_filtergraph_object(left, copy=not inplace) + right = fgb.as_filtergraph_object(right, copy=not inplace) # present as a list of pad indices if not isinstance(from_left, list): @@ -118,7 +121,9 @@ def connect( left, right, from_left, to_right, from_right, to_left, False ) - return left._connect(right, fwd_links, bwd_links, chain_siso, replace_sws_flags) + return left._connect( + right, fwd_links, bwd_links, chain_siso, replace_sws_flags + ) def join( @@ -130,6 +135,7 @@ def join( unlabeled_only: bool = False, chain_siso: bool = True, replace_sws_flags: bool = None, + inplace: bool = False, ) -> fgb.Graph | None: """filtergraph auto-connector @@ -151,6 +157,8 @@ def join( :param replace_sws_flags: True to use other's sws_flags if present, False to ignore other's sws_flags, None to throw an exception (default) + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: Graph with the appended filter chains or None if inplace=True. """ @@ -163,8 +171,8 @@ def join( raise ValueError(f"{how=} is an unknown matching method") # make sure right is a Graph, Chain, or Filter object - left = fgb.as_filtergraph_object(left) - right = fgb.as_filtergraph_object(right) + left = fgb.as_filtergraph_object(left, copy=not inplace) + right = fgb.as_filtergraph_object(right, copy=not inplace) # handle joining empty graph if not right.get_num_chains(): @@ -242,6 +250,7 @@ def attach( right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], left_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, + inplace: bool = False, ) -> fgb.Graph: """attach filter(s), chain(s), or label(s) to a filtergraph object @@ -249,8 +258,8 @@ def attach( :param right: output filtergraph object, filtergraph expression, or label, or list thereof. :param left_on: pad_index, specify the pad on left, default to None (first available) :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) - :param right_first: True to preserve the chain indices of the right filtergraph object, defaults - to False to preserve the chain order of the left object + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: new filtergraph object One and only one of ``left`` or ``right`` may be a list or a label. @@ -263,7 +272,7 @@ def attach( def check_obj(obj): try: - obj_label = fgb.as_filtergraph_object(obj) + obj_label = fgb.as_filtergraph_object(obj, copy=not inplace) except FiltergraphInvalidExpression: try: obj_label = str(obj) @@ -350,9 +359,13 @@ def resolve_indices(base, branches, base_indices, branch_indices, base_is_input) ) if attach_right: - return left_objs_labels._attach(right_objs_labels, left_on, right_on) + return fgb.as_filtergraph_object(left_objs_labels, copy=not inplace)._attach( + right_objs_labels, left_on, right_on + ) else: - return right_objs_labels._rattach(left_objs_labels, left_on, right_on) + return fgb.as_filtergraph_object(right_objs_labels, copy=not inplace)._rattach( + left_objs_labels, left_on, right_on + ) def concatenate(*fgs): @@ -364,6 +377,7 @@ def stack( *fgs: fgb.abc.FilterGraphObject, auto_link: bool = False, use_last_sws_flags: bool | None = None, + inplace: bool = False, ) -> fgb.Graph: """stack filtergraph objects @@ -372,6 +386,8 @@ def stack( :param use_last_sws_flags: True to use ``sws_flags`` of the last object with one, False to use ``sws_flags`` of the first object with one ``, None to throw an exception if multiple ``sws_flags`` encountered (default) + :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). + False (default) to make a new filtergraph object :return: new filtergraph object Remarks @@ -390,7 +406,7 @@ def stack( if n == 1: return fgs[0] - fg = fgb.as_filtergraph(fgs[0]) + fg = fgb.as_filtergraph(fgs[0], copy=not inplace) replace_sws_flags = None for other in fgs[1:]: if use_last_sws_flags is not None: From 52688ec2f3d2860c7bd563116cc1cc5b63180ed5 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 10:28:50 -0600 Subject: [PATCH 098/333] Filter.compose() - fixed a bug --- src/ffmpegio/filtergraph/Filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 6d7825c4..768b283b 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -152,7 +152,7 @@ def compose( """ return ( - fgb.Graph(self.data).compose( + fgb.Graph(self).compose( show_unconnected_inputs, show_unconnected_outputs ) if show_unconnected_inputs or show_unconnected_outputs From 7a8c27b58a03b2d2127c51edf1332909a06a6af9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 20:55:00 -0600 Subject: [PATCH 099/333] Chain.compose() - bug fix --- src/ffmpegio/filtergraph/Chain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index 64a3df5c..bd808016 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -70,7 +70,7 @@ def compose( """ return ( - fgb.Graph(self.data).compose( + fgb.Graph([self.data]).compose( show_unconnected_inputs, show_unconnected_outputs ) if show_unconnected_inputs or show_unconnected_outputs From 64a6cc7c827a9121ad76daf9abfd77b874f0344f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 20:58:23 -0600 Subject: [PATCH 100/333] join() - fixed improper behaviors (overhaul) --- src/ffmpegio/filtergraph/build.py | 65 ++++++++++++++----------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 9c537235..625916eb 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -4,7 +4,7 @@ from .typing import PAD_INDEX, JOIN_HOW, Literal, get_args -from .exceptions import FiltergraphInvalidExpression +from .exceptions import FiltergraphInvalidExpression, FFmpegioError from .. import filtergraph as fgb from .._utils import zip # pre-py310 compatibility @@ -175,9 +175,11 @@ def join( right = fgb.as_filtergraph_object(right, copy=not inplace) # handle joining empty graph - if not right.get_num_chains(): + nright = right.get_num_chains() + if not nright: return left - if not left.get_num_chains(): + nleft = left.get_num_chains() + if not nleft: return right iter_kws = {"unlabeled_only": unlabeled_only, "full_pad_index": True} @@ -187,46 +189,37 @@ def join( if n_links == "all" or n_links < 0: n_links = 0 - def create_links(it_left, it_right): - if n_links: - it_left = islice(it_left, n_links) - it_right = islice(it_right, n_links) - - it_left = (v[0] for v in it_left) - it_right = (v[0] for v in it_right) - - try: - return list(zip([*it_left], [*it_right], strict=strict)) - except ValueError: - raise ValueError( - f"Available pads of left and right filtergraph objects do not match ({strict=})" - ) - - if how in ("per_chain", "auto"): - it_left_chain = left.iter_chains(skip_if_no_output=True) - it_right_chain = right.iter_chains(skip_if_no_input=True) + if how in ("per_chain", "auto") and nright == nleft: + # try: - chain_pairs = zip( - [*it_left_chain], [*it_right_chain], strict=strict or how == "auto" - ) - links = [ - ((il, *l[1:]), (ir, *r[1:])) # output -> input - for (il, lchain), (ir, rchain) in chain_pairs - for (l, r) in create_links( - lchain.iter_output_pads(**iter_kws), - rchain.iter_input_pads(**iter_kws), - ) - ] + links = [None] * nleft + for c in range(nleft): + # get the first available pad to join + left_pad, *_ = next(left.iter_output_pads(chain=c, **iter_kws)) + right_pad, *_ = next(right.iter_input_pads(chain=c, **iter_kws)) + links[c] = (left_pad, right_pad) except: if how == "auto": how = "all" else: raise - if how in ("all", "chanable"): - links = create_links( - left.iter_output_pads(**iter_kws), right.iter_input_pads(**iter_kws) - ) + if how in ("all", "chainable") or nright != nleft: + + left_pads = [out[0] for out in left.iter_output_pads(**iter_kws)] + right_pads = [out[0] for out in right.iter_input_pads(**iter_kws)] + + nleft, nright = len(left_pads), len(right_pads) + if strict and nleft != nright: + raise FFmpegioError("`[stict=True] number of unconnected pads must match.") + n_max = min(nleft, nright) + n_links = n_max if n_links <= 0 else min(n_links, n_max) + + links = [None] * n_links + for i, (left_pad, right_pad) in enumerate( + zip(left_pads[:n_links], right_pads[:n_links]) + ): + links[i] = (left_pad, right_pad) fg = left._connect( right, From 97626bf8f1b0ab75815d08b6bc0465b175cfcf0f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 20:59:48 -0600 Subject: [PATCH 101/333] `Graph` - empty string to empty Graph object --- src/ffmpegio/filtergraph/Graph.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index aeeb540a..3887c588 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -97,6 +97,9 @@ def __init__( elif filter_specs is not None: if isinstance(filter_specs, fgb.Filter): filter_specs = [[filter_specs]] + elif not len(filter_specs): + filter_specs = [] + links = sws_flags = None elif isinstance(filter_specs, str): filter_specs, links, sws_flags = filter_utils.parse_graph(filter_specs) From 725a6dfab63c1d20aebced517e06f8d4f1a83e11 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:00:42 -0600 Subject: [PATCH 102/333] as_filtergraph_object() - empty input -> empty chain --- src/ffmpegio/filtergraph/convert.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py index 67677ea5..d2d379e2 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -114,6 +114,9 @@ def as_filtergraph_object( No copy is performed if the input is already a ``Graph`` and ``copy=False``. """ + if not filter_specs: + return fgb.Chain() + if isinstance(filter_specs, (fgb.Filter, fgb.Chain, fgb.Graph)): return type(filter_specs)(filter_specs) if copy else filter_specs From 146ae3d2b99496687b3878f47680ce8d78d73dcc Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:05:05 -0600 Subject: [PATCH 103/333] configure.build_basic_vf() - overhauled presets.filter_video_basic() - refactored presets.remove_video_alpha() --- src/ffmpegio/configure.py | 82 ++++++++++++++--------------- src/ffmpegio/filtergraph/presets.py | 26 +++++---- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index d82952dc..ca29ecea 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -349,71 +349,69 @@ def check_alpha_change(args, dir=None, ifile=0, ofile=0): return utils.alpha_change(inopts.get("pix_fmt", None), outopts.get("pix_fmt", None)) -def build_basic_vf(args, remove_alpha=None, ofile=0): +def build_basic_vf( + args: FFmpegArgs, remove_alpha: bool | None = None, ofile: int = 0 +) -> bool: """convert basic VF options to vf option - :param args: FFmpeg dict - :type args: dict + :param args: FFmpeg dict (may be modified if vf is added/changed) :param remove_alpha: True to add overlay filter to add a background color, defaults to None : This argument would be ignored if `'remove_alpha'` key is defined in `'args'`. - :type remove_alpha: bool, optional :param ofile: output file id, defaults to 0 - :type ofile: int, optional + :return: True if vf option is added or changed """ # get output opts, nothing to do if no option set outopts = args["outputs"][ofile][1] - if outopts is None: - return # extract the options fopts = { - name: outopts.pop(name) - for name in ( - "fill_color", - "crop", - "flip", - "transpose", - "square_pixels", - "remove_alpha", - ) - if name in outopts + name: outopts.pop(name, None) + for name in ("crop", "flip", "transpose", "square_pixels") } + fill_color, remove_alpha = ( + outopts.pop(name, defval) + for name, defval in zip(("fill_color", "remove_alpha"), (None, remove_alpha)) + ) + if fill_color is not None: + remove_alpha = True - # check if output needs to be scaled + # if `s` output option contains negative number, use scale filter scale = outopts.get("s", None) - do_scale = scale is not None - if do_scale: + if scale is not None: try: - m = re.match(r"(\d+)x(\d+)", scale) + # if given a string -s option value + m = re.match(r"(-?\d+)x(-?\d+)", scale) scale = (int(m[1]), int(m[2])) except: pass - try: - do_scale = len(scale) > 2 or (scale[0] <= 0 or scale[1] <= 0) - except: - do_scale = False - nfo = len(fopts) - if (nfo and (nfo > 1 or "fill_color" not in fopts)) or remove_alpha or do_scale: - if do_scale: + if len(scale) != 2 or scale[0] <= 0 or scale[1] <= 0: + # must use scale filter, move the option from output to filter + outopts.pop("s") fopts["scale"] = scale - del outopts["s"] - if remove_alpha and "remove_alpha" not in fopts: - fopts["remove_alpha"] = True + basic = any(fopts.values()) + if not (basic or remove_alpha): + return False # no filter needed - bvf = filter_video_basic(**fopts) # Graph is remove alpha else Chain - vf = outopts.get("vf", None) - if vf: - try: - outopts["vf"] = vf + bvf - except Exception as e: - raise FFmpegioError( - f"Cannot append the basic video filter to the user specified video filter (vf):\n {e}" - ) - else: - outopts["vf"] = bvf + # existing simple filter + vf = outopts.pop("filter:v", outopts.pop("vf", None)) or fgb.Chain() + + if basic: + vf = vf + filter_video_basic(**fopts) # Graph is remove alpha else Chain + + if remove_alpha: + if fill_color is None: + logger.warning( + "`fill_color` option not specified, uses white background color by default." + ) + fill_color = "white" + vf = vf + remove_video_alpha(fill_color) + + outopts["vf"] = vf + + return True def finalize_audio_read_opts( diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index fbc78fe3..05bdf324 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -13,9 +13,23 @@ from .Graph import Graph +def remove_video_alpha( + fill_color: str, input_label: str | None = None, output_label: str | None = None +) -> Graph: + + fg = fgb.Graph("scale2ref[l2],[l2]overlay=shortest=1").rconnect( + f"color=c={fill_color}", (0, 0, 0), (0, 0, 0) + ) + + if input_label is not None: + fg.add_label(input_label, (1, 0, 1)) + if output_label is not None: + fg.add_label(output_label, outpad=(1, 1, 0)) + + return fg + + def filter_video_basic( - 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, @@ -26,13 +40,7 @@ def filter_video_basic( ) -> FilterGraphObject: bg_color = fill_color or "white" - vfilters = ( - fgb.Graph( - f"color=c={bg_color}[l1];[l1][in]scale2ref[l2],[l2]overlay=shortest=1" - ) - if remove_alpha - else fgb.Chain() - ) + vfilters = fgb.Chain() if square_pixels == "upscale": vfilters += "scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1" From 455fabb4ec066b6d5103fe779283602d27f50b5b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:05:41 -0600 Subject: [PATCH 104/333] `filter_video_basic()` - overhauled --- src/ffmpegio/filtergraph/presets.py | 39 ++++++++++++++++------------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 05bdf324..6ca19882 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -7,10 +7,13 @@ from ..stream_spec import StreamSpecDict from .abc import FilterGraphObject +from functools import reduce + from .. import filtergraph as fgb if TYPE_CHECKING: from .Graph import Graph + from .Chain import Chain def remove_video_alpha( @@ -37,28 +40,30 @@ def filter_video_basic( square_pixels: ( Literal["upscale", "downscale", "upscale_even", "downscale_even"] | None ) = None, -) -> FilterGraphObject: - bg_color = fill_color or "white" - - vfilters = fgb.Chain() +) -> Chain: + vfilters = [] if square_pixels == "upscale": - vfilters += "scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1" + vfilters.append("scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1") elif square_pixels == "downscale": - vfilters += "scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1" + vfilters.append("scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1") elif square_pixels == "upscale_even": - vfilters += "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1" + vfilters.append( + "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1" + ) elif square_pixels == "downscale_even": - vfilters += "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1" + vfilters.append( + "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1" + ) elif square_pixels is not None: raise ValueError(f"unknown `square_pixels` option value given: {square_pixels}") if crop: try: assert not isinstance(crop, str) - vfilters += fgb.Filter("crop", *crop) + vfilters.append(fgb.crop(*crop)) except: - vfilters += fgb.Filter("crop", crop) + vfilters.append(fgb.crop(crop)) if flip: try: @@ -66,16 +71,16 @@ def filter_video_basic( except: raise Exception("Invalid flip filter specified.") if ftype % 2: - vfilters += "hflip" + vfilters.append("hflip") if ftype >= 2: - vfilters += "vflip" + vfilters.append("vflip") if transpose is not None: try: assert not isinstance(transpose, str) - vfilters += fgb.Filter("transpose", *transpose) + vfilters.append(fgb.transpose(*transpose)) except: - vfilters += fgb.Filter("transpose", transpose) + vfilters.append(fgb.transpose(transpose)) if scale: try: @@ -84,11 +89,11 @@ def filter_video_basic( pass try: assert not isinstance(scale, str) - vfilters += fgb.Filter("scale", *scale) + vfilters.append(fgb.scale(*scale)) except: - vfilters += fgb.Filter("scale", scale) + vfilters.append(fgb.scale(scale)) - return vfilters + return sum(vfilters) def merge_audio( From cf9c57dc763b3261340350965ed0882768431fbf Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:09:53 -0600 Subject: [PATCH 105/333] made `FFmpegioError` visible to import --- src/ffmpegio/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 94c280a9..301ff078 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -55,7 +55,7 @@ def __getattr__(name): from . import ffmpegprocess -from .errors import FFmpegError +from .errors import FFmpegError, FFmpegioError from .utils.concat import FFConcat from .filtergraph import Graph as FilterGraph from . import devices, ffmpegprocess, caps, probe, audio, image, video, media @@ -70,7 +70,7 @@ def __getattr__(name): # fmt:off __all__ = ["ffmpeg_info", "get_path", "set_path", "is_ready", "ffmpeg", "ffprobe", "transcode", "caps", "probe", "audio", "image", "video", "media", "devices", - "open", "ffmpegprocess", "FFmpegError", "FilterGraph", "FFConcat", "use"] + "open", "ffmpegprocess", "FFmpegError", "FFmpegioError", "FilterGraph", "FFConcat", "use"] # fmt:on __version__ = "0.11.1" From f1f81f14e4bd0307258bbd0472d05f9989a4768d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:10:45 -0600 Subject: [PATCH 106/333] updated `join()`'s error type --- tests/test_filtergraph_build.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_filtergraph_build.py b/tests/test_filtergraph_build.py index 62279fca..c9c3dfb0 100644 --- a/tests/test_filtergraph_build.py +++ b/tests/test_filtergraph_build.py @@ -1,6 +1,6 @@ from os import path from tempfile import TemporaryDirectory -from ffmpegio import ffmpegprocess, filtergraph as fgb +from ffmpegio import ffmpegprocess, filtergraph as fgb, FFmpegioError from ffmpegio.filtergraph import Chain from pprint import pprint import pytest @@ -48,7 +48,7 @@ def test_connect(left, right, from_left, to_right, chain_siso, ret): def test_join(left, right, how, n_links, strict, unlabeled_only, ret): if ret is None: - with pytest.raises(ValueError): + with pytest.raises(FFmpegioError): fgb.join(left, right, how, n_links, strict, unlabeled_only) else: fg = fgb.join(left, right, how, n_links, strict, unlabeled_only) From 620aafb52dd9b006e7916c64110a459265783a71 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:22:11 -0600 Subject: [PATCH 107/333] filter_video_basic() - use empty chain as a custom start value to `sum` filtergraph objects --- src/ffmpegio/filtergraph/presets.py | 2 +- tests/test_filtergraph_presets.py | 40 +++++++++++++++-------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 6ca19882..9ebf3b87 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -93,7 +93,7 @@ def filter_video_basic( except: vfilters.append(fgb.scale(scale)) - return sum(vfilters) + return sum(vfilters, start=fgb.Chain()) def merge_audio( diff --git a/tests/test_filtergraph_presets.py b/tests/test_filtergraph_presets.py index 5c582306..c9233bea 100644 --- a/tests/test_filtergraph_presets.py +++ b/tests/test_filtergraph_presets.py @@ -3,22 +3,24 @@ import ffmpegio.filtergraph.presets as presets -def test_video_basic_filter(): - print( - presets.filter_video_basic( - fill_color=None, - remove_alpha=None, - crop=None, - flip=None, - transpose=None, - ) - ) - print( - presets.filter_video_basic( - fill_color="red", - remove_alpha=True, - # crop=(100, 100, 5, 10), - # flip="horizontal", - # transpose="clock", - ) - ) \ No newline at end of file + +@pytest.mark.parametrize( + "kwargs", + [ + dict(crop=None, flip=None, transpose=None), + dict(scale=1.2, crop=100, flip="both", transpose=90, square_pixels="upscale"), + ], +) +def test_video_basic_filter(kwargs): + print(presets.filter_video_basic(**kwargs)) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"fill_color": "red"}, + {"fill_color": "red", "input_label": "in", "output_label": "[out]"}, + ], +) +def test_remove_video_alpha(kwargs): + print(presets.remove_video_alpha(**kwargs)) From 8f171b877634f77dc232c6863cae45bb8b462bc3 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:30:47 -0600 Subject: [PATCH 108/333] fixed `join()` test expected outcome --- tests/test_filtergraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index c595ea3f..1253b322 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -394,7 +394,7 @@ def test_connect(fg, r, to_l, to_r, chain, out): [ # fmt: off ("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]"), + ("[in1]fps;crop[ou1]", "[in2]trim;scale[out2]", None, True, "[in1]fps[L0];[UNC0]crop[ou1];[in2]trim[UNC1];[L0]scale[out2]"), ("fps", "overlay", 'per_chain', False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), # fmt: on ], From c670b068926717374787c22087ee964cc1547e7b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 21:31:48 -0600 Subject: [PATCH 109/333] valid label previously tested as invalid --- tests/test_filtergraph_fglinks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index 6cfa5526..fd20e3df 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -24,7 +24,6 @@ def test_iter_inpad_ids(dsts, expects): [ (("0:v",), True), (("label",), True), - (("-label",), False), ((0, True), True), ((0.0, True), False), ((0, False), False), From 9ddd497d386648107dc882d41de4e658a050eced Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:03:17 -0600 Subject: [PATCH 110/333] removed `analyze_input_filtergraph_ids()` --- src/ffmpegio/configure.py | 4 --- src/ffmpegio/utils/__init__.py | 48 ---------------------------------- tests/test_utils.py | 20 -------------- 3 files changed, 72 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index ca29ecea..9b7f71c5 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1179,10 +1179,6 @@ def retrieve_input_stream_ids( or in an ffprobe incompatible format, e.g., ffconcat) """ - src_type = info["src_type"] - if src_type == "filtergraph": - return utils.analyze_input_filtergraph_ids(url) - # file/network input - process only if seekable # get ffprobe subprocess keywords url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(url, info) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index a4813b2e..70afc4d2 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -501,51 +501,3 @@ def array_to_video_options(data: Any | None = None) -> dict: s, pix_fmt = guess_video_format(*plugins.get_hook().video_info(obj=data)) return {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} - -def analyze_input_filtergraph_ids( - fg: FilterGraphObject | str, -) -> list[tuple[int, MediaType]]: - """get labels and media types of input filtergraph output pads - - :param fg: input filtergraph expression/object - :return: list of indices and types of input streams - - FFmpeg Input Filtergraph Output Specification: - ---------------------------------------------- - - Each video open output must be labelled by a unique string of the form `"outN"`, - where `N` is a number starting from `0` corresponding to the mapped input stream - generated by the device. The first unlabelled output is automatically assigned - to the `"out0"` label, but all the others need to be specified explicitly. - - The suffix "+subcc" can be appended to the output label. - - """ - - # parse if str expression is given - fg = fgb.as_filtergraph_object(fg) - - outtypes = {} - for idx, filter, _ in fg.iter_output_pads(): - label = fg.get_label(outpad=idx) - if label is None: - if 0 in outtypes: - raise FFmpegError( - "more than one `out0` pad specified in the input filtergraph." - ) - outid = 0 - else: - m = re.match(r"out(\d+)(?:\+subcc)?$", label) - if not m: - raise FFmpegError( - f"Specified input filtergraph contains an invalid output pad label: {label}" - ) - outid = int(m[1]) - if outid in outtypes: - raise FFmpegError( - f"Specified input filtergraph defines more than one `out{outid}` pad label." - ) - - outtypes[outid] = filter.get_pad_media_type("output", idx[-1]) - - return list((i, outtypes[i]) for i in sorted(outtypes)) diff --git a/tests/test_utils.py b/tests/test_utils.py index fd163b16..dd082e3d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -77,25 +77,5 @@ def test_get_audio_format(): assert cfg[0] == " Date: Fri, 14 Feb 2025 22:10:15 -0600 Subject: [PATCH 111/333] added (missing) `set_sp_kwargs_stdin()` --- src/ffmpegio/configure.py | 2 +- src/ffmpegio/utils/__init__.py | 40 ++++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 9b7f71c5..d1d02119 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1181,7 +1181,7 @@ def retrieve_input_stream_ids( # file/network input - process only if seekable # get ffprobe subprocess keywords - url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(url, info) + url, sp_kwargs, exit_fcn = utils.set_sp_kwargs_stdin(url, info) if sp_kwargs is None: # something failed (warning logged) return [] diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 70afc4d2..2ae46056 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Sequence, Callable from numbers import Number import logging @@ -18,9 +18,7 @@ from ..stream_spec import * from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb -from ..errors import FFmpegError - -from ..typing import Any +from .._typing import Any, MediaType, InputSourceDict # TODO: auto-detect endianness # import sys @@ -501,3 +499,37 @@ def array_to_video_options(data: Any | None = None) -> dict: s, pix_fmt = guess_video_format(*plugins.get_hook().video_info(obj=data)) return {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} + +def set_sp_kwargs_stdin( + url: str | None, info: InputSourceDict, sp_kwargs: dict = {} +) -> tuple[str, dict | None, Callable]: + """configure sp_kwargs for ffprobe/ffmpeg call to pipe-in the data via stdin + + :param url: input URL + :param info: input info + :param sp_kwargs: initial sp_kwargs keyword options + :return: tuple of url (or "pipe:0" if stdin data), updated sp_kwargs, and cleanup function + """ + + # ffprobe subprocess keywords + src_type = info["src_type"] + exit_fcn = lambda: None + + if src_type not in ("url", "filtergraph"): + url = "pipe:0" + if src_type == "buffer": + sp_kwargs = {**sp_kwargs, "input": info["buffer"]} + elif src_type == "fileobj": + f = info["fileobj"] + sp_kwargs = {**sp_kwargs, "stdin": f} + if f.readable() and f.seekable(): + pos = f.tell() + exit_fcn = lambda: f.seek(pos) # restore the read cursor position + else: + logger.warning("file object must be seekable.") + else: + logger.warning("unknown input source type.") + sp_kwargs = None + + return url, sp_kwargs, exit_fcn + From 201256a2aedfab6041c6266fd80caf0a8db88e7b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:11:10 -0600 Subject: [PATCH 112/333] added (missing) `analyze_input_stream()` --- src/ffmpegio/utils/__init__.py | 53 ++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 2ae46056..d1f382e4 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -13,11 +13,10 @@ from math import cos, radians, sin import re, fractions -from .. import caps, plugins +from .. import caps, plugins, probe from .._utils import * from ..stream_spec import * -from ..filtergraph.abc import FilterGraphObject -from .. import filtergraph as fgb +from ..errors import FFmpegError, FFmpegioError from .._typing import Any, MediaType, InputSourceDict # TODO: auto-detect endianness @@ -533,3 +532,51 @@ def set_sp_kwargs_stdin( return url, sp_kwargs, exit_fcn + +def analyze_input_stream( + fields: list[str], + stream: str, + media_type: MediaType, + input_url: str | None, + input_opts: dict, + input_info: InputSourceDict, +) -> list: + """analyze a stream and return requested field values + + :param fields: a list of stream properties + :param stream: stream specifier, first one is returned if it yields more than one stream, + :param input_url: url or None if piped or fileobj + :param input_opts: input options + :param input_info: input infomration + :raises NotImplementedError: _description_ + :return values of the requested fields of the stream + """ + + fields = [*fields, "codec_type"] + input_url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(input_url, input_info) + try: + q = probe.query( + input_url, + stream, + fields, + keep_optional_fields=True, + keep_str_values=False, + cache_output=True, + sp_kwargs=sp_kwargs, + f=input_opts.get("f", None), + ) + except FFmpegError: + # no change + return [None] * (len(fields) - 1) + else: + q = [i for i in q if i["codec_type"] == media_type] + if len(q) != 1: + raise FFmpegioError( + f"Specified {stream=} must resolve to one and only one {media_type} stream." + ) + finally: + # rewind fileobj if possible + exit_fcn() + + q = q[0] + return [q.get(f, None) for f in fields[:-1]] From d5fccf7cf2b8db70db013049fc1f87dcc6c15155 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:20:14 -0600 Subject: [PATCH 113/333] `finalize_video_read_opts()` overhauled --- src/ffmpegio/configure.py | 135 +++++++++++++++++--------- src/ffmpegio/image.py | 10 +- src/ffmpegio/probe.py | 28 ------ src/ffmpegio/streams/SimpleStreams.py | 15 ++- src/ffmpegio/video.py | 9 +- tests/test_ffmpegprocess.py | 6 +- 6 files changed, 123 insertions(+), 80 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index d1d02119..aed29512 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -27,7 +27,7 @@ from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject -from .filtergraph.presets import merge_audio, filter_video_basic +from .filtergraph.presets import merge_audio, filter_video_basic, remove_video_alpha from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence from .stream_spec import ( @@ -264,47 +264,100 @@ def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: def finalize_video_read_opts( - args: FFmpegArgs, ofile: int = 0, ifile: int = 0, istream: str | None = None -) -> tuple[str, tuple[int, int], Fraction]: + args: FFmpegArgs, + ofile: int = 0, + input_info: list[InputSourceDict] = [], +) -> tuple[str, tuple[int, int, int] | None, Fraction | None]: + """finalize raw video read output options + + :param args: FFmpeg arguments (will be modified) + :param ofile: output index, defaults to 0 + :param input_info: source information of the inputs, defaults to [] + :return dtype: Numpy-style buffer data type string + :return s: video shape tuple (height, width, nb_components) + :return r: video framerate + """ + + options = ["r", "pix_fmt", "s"] + fields = ["pix_fmt", "width", "height", "r_frame_rate", "avg_frame_rate"] + + def flds2opts(pix_fmt, width, height, r1, r2): + return r1 or r2, pix_fmt, (width, height) if width and height else None - inurl, inopts = args["inputs"][ifile] - if inopts is None: - inopts = {} outopts = args["outputs"][ofile][1] + outmap = outopts["map"] + outmap_fields = parse_map_option(outmap) + has_simple_filter = "vf" in outopts or "filter:v" in outopts + fill_color = outopts.get('fill_color',None) + if fill_color is not None and 'remove_alpha' not in outopts: + outopts.pop('fill_color') - pix_fmt_in = inopts.get("pix_fmt", None) - w_in, h_in = inopts.get("s", (None, None)) - r_in = inopts.get("r", None) + # use the output option by default + opt_vals = [outopts.get(o, None) for o in options] - if ( - isinstance(inurl, (str, Path)) - and inopts.get("f", None) != "lavfi" - and not (pix_fmt_in and w_in and h_in and r_in) - ): - # TODO: handle lavfi filter processing - try: - # ["pix_fmt", "width", "height", "avg_frame_rate", "r_frame_rate"] - v_pix_fmt, v_width, v_height, vr1, vr2 = probe._video_info( - inurl, istream, None + # get the options of the input/filtergraph output + if "linklabel" in outmap_fields: # mapping filtergraph output + # must be mapped a linklabel of a filter_complex global option + logger.warning( + "Pre-analysis of complex filtergraphs is not currently available." + ) + inopt_vals = [None, None, None] + # combine all the filtergraphs only for the analysis purpose + # fg = fgb.stack(args["global_options"]["filter_complex"]) + else: + # insert basic video filter if specified + build_basic_vf(args, False, ofile) + + ifile = outmap_fields["input_file_id"] + + # check the input option data + inurl, inopts = args["inputs"][ifile] + + # get input options + inopt_vals = [inopts.get(o, None) for o in options] + + # directly from the input url (if not forced via input options) + if not all(inopt_vals): + st_vals = flds2opts( + *utils.analyze_input_stream( + fields, + outmap_fields["stream_specifier"], + "video", + inurl, + inopts, + input_info[ifile], + ) + ) + inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + + if has_simple_filter: + + # create a source chain with matching spec and attach it to the af graph + r, pix_fmt, s = inopt_vals + vf = ( + fgb.color(s=s, r=r) + + fgb.format(pix_fmts=pix_fmt) + + outopts.get("filter:v", outopts.get("vf", None)) ) - pix_fmt_in, w_in, h_in, r_in = ( - x or y - for x, y in zip( - (pix_fmt_in, w_in, h_in, r_in), - (v_pix_fmt, v_width, v_height, vr1 or vr2), + outpad = next(vf.iter_output_pads(unlabeled_only=True), None) + if outpad is not None: + vf = vf >> "[out0]" + inopt_vals = flds2opts( + *utils.analyze_input_stream( + fields, + "0", + "video", + vf, + {"f": "lavfi"}, + {"src_type": "filtergraph"}, ) ) - except: - pass # not probable, OK... maybe - s_in = (w_in, h_in) if w_in and h_in else None - if outopts is None: - outopts = {} - args["outputs"][ofile] = (args["outputs"][ofile][0], outopts) + # assign the values to individual variables + r, pix_fmt, s = opt_vals + r_in, pix_fmt_in, s_in = inopt_vals # pixel format must be specified - pix_fmt = outopts.get("pix_fmt", None) - remove_alpha = False if pix_fmt is None: # deduce output pixel format from the input pixel format try: @@ -318,24 +371,18 @@ def finalize_video_read_opts( dtype, ncomp = utils.get_pixel_format(pix_fmt) except: ncomp = dtype = None - remove_alpha = False else: _, ncomp, dtype, remove_alpha = utils.get_pixel_config(pix_fmt_in, pix_fmt) + if remove_alpha: + # append the remove-video-alpha filter chain + build_basic_vf(args, True, ofile) - # set up basic video filter if specified - build_basic_vf(args, remove_alpha, ofile) outopts["f"] = "rawvideo" - # if no filter and video shape and rate are known, all known - r = s = None - if not has_filtergraph(args, "video") and ncomp is not None: - r = outopts.get("r", r_in) - s = outopts.get("s", s_in) - if s is not None: - if isinstance(s, str): - m = re.match(r"(\d+)x(\d+)", s) - s = [int(m[1]), int(m[2])] + # use output option value or else use the input value + r = r or r_in + s = s or s_in return dtype, None if s is None else (*s[::-1], ncomp), r diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 56d6b92e..534bf94c 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -1,5 +1,4 @@ from . import ffmpegprocess, utils, configure, FFmpegError, plugins -from .probe import _video_info as _probe_video_info from .utils import log as log_utils __all__ = ["create", "read", "write", "filter"] @@ -25,7 +24,14 @@ def _run_read(*args, show_log=None, sp_kwargs=None, **kwargs): :rtype: object """ - dtype, shape, _ = configure.finalize_video_read_opts(args[0], istream="v:0") + outopts = args[0]["outputs"][0][1] + outopts["map"] = "0:v:0" + dtype, shape, _ = configure.finalize_video_read_opts( + args[0], + input_info=[ + {"src_type": "filtergraph" if outopts.get("f", None) == "lavfi" else "url"} + ], + ) if sp_kwargs is not None: kwargs = {**sp_kwargs, **kwargs} diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index cf6fa80e..a4dc4f92 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -752,34 +752,6 @@ def query( return info -def _video_info( - url: str | BinaryIO | memoryview, - stream: str | None, - sp_kwargs: dict[str, Any] | None, - f: str | None = None, -) -> tuple[ - str | None, - int | None, - int | None, - Fraction | Literal["0/0"] | None, - Fraction | None, -]: - "returns (pix_fmt, width, height, avg_frame_rate, r_frame_rate) of the specified url/stream" - - fields = ["pix_fmt", "width", "height", "avg_frame_rate", "r_frame_rate"] - q = query( - url, - "v:0" if stream is None else stream, - fields, - True, - False, - True, - sp_kwargs, - f=f, - )[0] - return tuple(q[f] for f in fields) - - def frames( url: str | BinaryIO | memoryview, entries: Sequence[str] | None = None, diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 85da1be5..2d3ab100 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -222,15 +222,24 @@ def __init__( def _finalize(self, ffmpeg_args): # finalize FFmpeg arguments and output array - inurl, inopts = ffmpeg_args.get("inputs", [])[0] + inopts = ffmpeg_args.get("inputs", [])[0][1] outopts = ffmpeg_args.get("outputs", [])[0][1] - has_fg = configure.has_filtergraph(ffmpeg_args, "video") + outopts["map"] = "0:v:0" ( self.dtype, self.shape, self.rate, - ) = configure.finalize_video_read_opts(ffmpeg_args, istream="v:0") + ) = configure.finalize_video_read_opts( + ffmpeg_args, + input_info=[ + { + "src_type": ( + "filtergraph" if outopts.get("f", None) == "lavfi" else "url" + ) + } + ], + ) pix_fmt = outopts.get("pix_fmt", None) pix_fmt_in = inopts.get("pix_fmt", None) diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 3d648bf9..80484903 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -24,7 +24,14 @@ def _run_read(*args, show_log=None, sp_kwargs=None, **kwargs): :rtype: object """ - dtype, shape, r = configure.finalize_video_read_opts(args[0], istream="v:0") + outopts = args[0]["outputs"][0][1] + outopts["map"] = "0:v:0" + dtype, shape, r = configure.finalize_video_read_opts( + args[0], + input_info=[ + {"src_type": "filtergraph" if outopts.get("f", None) == "lavfi" else "url"} + ], + ) if sp_kwargs is not None: kwargs = {**sp_kwargs, **kwargs} diff --git a/tests/test_ffmpegprocess.py b/tests/test_ffmpegprocess.py index 9a728699..e5d80db8 100644 --- a/tests/test_ffmpegprocess.py +++ b/tests/test_ffmpegprocess.py @@ -100,9 +100,11 @@ def progress(*args): ffmpeg_args = configure.empty() configure.add_url(ffmpeg_args, "input", url) - configure.add_url(ffmpeg_args, "output", "-") + configure.add_url(ffmpeg_args, "output", "-", {"map": "0:v:0"}) - dtype, shape, r = configure.finalize_video_read_opts(ffmpeg_args, istream="v:0") + dtype, shape, r = configure.finalize_video_read_opts( + ffmpeg_args, input_info=[{"src_type": "url"}] + ) samplesize = utils.get_samplesize(shape, dtype) From e76a7ddacc1fcbc34ad258e5ff0580b5620a6aed Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:22:03 -0600 Subject: [PATCH 114/333] formatting --- src/ffmpegio/audio.py | 4 +--- src/ffmpegio/configure.py | 5 +++-- src/ffmpegio/filtergraph/abc.py | 2 +- src/ffmpegio/filtergraph/build.py | 4 +--- src/ffmpegio/probe.py | 6 +++--- src/ffmpegio/utils/__init__.py | 1 + 6 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index d37b4465..1ffa08ae 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -145,9 +145,7 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) ) ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, "input", url, {**input_options, "f": "lavfi"} - )[1][1] + configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"})[1][1] configure.add_url(ffmpeg_args, "output", "-", {"sample_fmt": "dbl", **options})[1][ 1 ] diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index aed29512..9be39fae 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -481,6 +481,7 @@ def finalize_audio_read_opts( * If complex filtergraph(s) is used, args['global_options']['filter_complex'] must be a list of fgb.Graph objects """ + options = ["ar", "sample_fmt", "ac"] fields = ["sample_rate", "sample_fmt", "channels"] @@ -501,7 +502,7 @@ def finalize_audio_read_opts( # fg = fgb.stack(args["global_options"]["filter_complex"]) else: ifile = outmap_fields["input_file_id"] - + # get input option values inurl, inopts = args["inputs"][ifile] inopt_vals = [inopts.get(o, None) for o in options] @@ -1147,7 +1148,7 @@ def process_url_inputs( """analyze and process heterogeneous input url argument :param args: FFmpeg argument dict, `args['inputs']` receives all the new inputs. - If input is a buffer, a fileobj, or an FFconcat, the first element + If input is a buffer, a fileobj, or an FFconcat, the first element of the FFmpeg inputs entry is set to 'None', to be replaced by a pipe expression. :param urls: list of input urls/data or a pair of input url and its options diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 0d606382..939ca508 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -8,7 +8,7 @@ from .. import filtergraph as fgb -from .._utils import zip # pre-py310 compatibility +from .._utils import zip # pre-py310 compatibility __all__ = ["FilterGraphObject"] diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 625916eb..9261e5b3 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -121,9 +121,7 @@ def connect( left, right, from_left, to_right, from_right, to_left, False ) - return left._connect( - right, fwd_links, bwd_links, chain_siso, replace_sws_flags - ) + return left._connect(right, fwd_links, bwd_links, chain_siso, replace_sws_flags) def join( diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index a4dc4f92..6c4747fe 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -210,10 +210,10 @@ def _exec( if isinstance(url, Buffer): sp_opts["input"] = url - url = 'pipe:0' + url = "pipe:0" elif isinstance(url, IOBase): sp_opts["stdin"] = url - url = 'pipe:0' + url = "pipe:0" else: url = str(url) @@ -247,7 +247,7 @@ def _run( # TODO - enable caching if cache_output: - logger.warning('caching of previous ffprobe outputs is disabled.') + logger.warning("caching of previous ffprobe outputs is disabled.") cache_output = False entries = _compose_entries(entries) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index d1f382e4..1a5729c6 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -233,6 +233,7 @@ def get_audio_format(fmt: str, ac: int | None = None) -> str | tuple[str, tuple[ :param ac: number of channels, default to None (to return only dtype) :return: data type string and array shape tuple """ + try: dtype = { "u8": "|u1", From b473b7ad6cc9d000d6ba7b73b5ded1597048cfb2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:22:25 -0600 Subject: [PATCH 115/333] `Graph.__repr__` fixed bug when empty --- src/ffmpegio/filtergraph/Graph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 3887c588..88eebe2e 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -295,7 +295,7 @@ def __repr__(self): pos.append(len(expr)) prefix = " chain" - nzeros = floor(log10(nchains)) + 1 + nzeros = floor(log10(nchains)) + 1 if nchains else 0 fmt = f"0{nzeros}" chain_list = [ f"{prefix}[{j:{fmt}}]: {expr[i0:i1]}" From cd689a37ea915783e7115ece05f7b096763f3033 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:22:59 -0600 Subject: [PATCH 116/333] `finalize_audio_read_opts()` fixed error --- src/ffmpegio/configure.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 9be39fae..9a4bac73 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -510,7 +510,12 @@ def finalize_audio_read_opts( # fill the still missing values directly from the input url if not all(inopt_vals): st_vals = utils.analyze_input_stream( - fields, outmap, "audio", inurl, inopts, input_info[ifile] + fields, + outmap_fields["stream_specifier"], + "audio", + inurl, + inopts, + input_info[ifile], ) inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] From 564c43a470538199b1e1b06fdfe2ba170320e852 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:23:55 -0600 Subject: [PATCH 117/333] updated tests --- tests/test_configure.py | 8 ++++++- tests/test_image.py | 53 +++++++++++++++++++---------------------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/tests/test_configure.py b/tests/test_configure.py index a534835b..2089df13 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -115,7 +115,13 @@ def test_get_option(): ({"src_type": "url"}, mul_url, {}, None, mul_streams), ({"src_type": "fileobj"}, mul_url, {}, "v", mul_vid_streams), ({"src_type": "buffer"}, mul_url, {}, "v", mul_vid_streams), - ({"src_type": "filtergraph"}, "color=c=pink [out0]", {}, None, [(0, "video")]), + ( + {"src_type": "filtergraph"}, + "color=c=pink [out0]", + {"f": "lavfi"}, + None, + [(0, "video")], + ), ], ) def test_retrieve_input_stream_ids(info, url, opts, stream_spec, ret): diff --git a/tests/test_image.py b/tests/test_image.py index 98a2bdc8..a2eb7740 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,3 +1,4 @@ +import pytest from ffmpegio import image, probe, transcode, FFmpegError import tempfile, re from os import path @@ -53,7 +54,7 @@ def test_read_write(): D = image.read(url, pix_fmt="gray") with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - print(out_url, C['shape']) + print(out_url, C["shape"]) image.write(out_url, C) print(probe.video_streams_basic(out_url)) C = image.read(out_url, pix_fmt="rgba", show_log=True) @@ -74,31 +75,25 @@ def test_read_write(): # plt.show() -def test_read_basic_filter(): +@pytest.mark.parametrize( + "kwargs", + [ + dict( + pix_fmt="rgb24", + fill_color="red", + crop=(300, 50), + flip="horizontal", + transpose="clock", + ), + dict(s=(100, -2)), + dict(fill_color="red"), + dict(fill_color="red", pix_fmt="rgb24"), + ], +) +def test_read_basic_filter(kwargs): url = "tests/assets/ffmpeg-logo.png" - - B = image.read( - url, - show_log=True, - pix_fmt="rgb24", - fill_color="red", - crop=(300, 50), - flip="horizontal", - transpose="clock", - ) - - B = image.read(url, show_log=True, s=(100, -2)) - print(B['shape']) - - url = "tests/assets/ffmpeg-logo.png" - B = image.read( - url, - show_log=True, - fill_color="red", - ) - - B = image.read(url, show_log=True, fill_color="red", pix_fmt="rgb24") + image.read(url, show_log=True, **kwargs) def test_square_pixels(): @@ -113,11 +108,11 @@ def test_square_pixels(): Bue = image.read(out_url, square_pixels="upscale_even") Bde = image.read(out_url, square_pixels="downscale_even") - print(B['shape']) - print(Bu['shape']) - print(Bd['shape']) - print(Bue['shape']) - print(Bde['shape']) + print(B["shape"]) + print(Bu["shape"]) + print(Bd["shape"]) + print(Bue["shape"]) + print(Bde["shape"]) if __name__ == "__main__": From 1fd9a21048c2d19c90b1d03d0a624eec139009b0 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:27:48 -0600 Subject: [PATCH 118/333] adding missing elements --- src/ffmpegio/configure.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 9a4bac73..76350ec5 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -13,10 +13,9 @@ Buffer, InputSourceDict, ) -from collections.abc import Sequence, Callable +from collections.abc import Sequence from fractions import Fraction - import re, logging logger = logging.getLogger("ffmpegio") @@ -25,6 +24,7 @@ from namedpipe import NPopen +from . import utils, probe from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject from .filtergraph.presets import merge_audio, filter_video_basic, remove_video_alpha @@ -36,6 +36,7 @@ stream_type_to_media_type, parse_map_option, ) +from .errors import FFmpegioError ################################# ## module types @@ -929,6 +930,7 @@ def add_filtergraph( else [existing_map, *map] ) + outopts["map"] = map def resolve_raw_output_streams( From 33901ff60cf0a778f3861a5ea81d27101a3951e4 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:32:22 -0600 Subject: [PATCH 119/333] OptionTuples must have a dict not None as the second item --- src/ffmpegio/configure.py | 4 ++-- tests/test_configure.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 76350ec5..8f72e719 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -48,8 +48,8 @@ FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] -FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, dict | None] -FFmpegOutputOptionTuple = tuple[FFmpegUrlType, dict | None] +FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, dict] +FFmpegOutputOptionTuple = tuple[FFmpegUrlType, dict] class FFmpegArgs(TypedDict): diff --git a/tests/test_configure.py b/tests/test_configure.py index 2089df13..0cf97ab8 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -48,10 +48,10 @@ def test_array_to_video_input(): def test_add_url(): url = "test.mp4" - args = {} - args_expected = {} + args = configure.empty() + args_expected = configure.empty() idx, entry = configure.add_url(args, "input", url, None) - args_expected["inputs"] = [(url, None)] + args_expected["inputs"] = [(url, {})] assert idx == 0 and entry == args_expected["inputs"][0] and args == args_expected idx, entry = configure.add_url(args, "input", url, {"f": "rawvideo"}, update=True) @@ -73,15 +73,15 @@ def test_add_url(): def test_add_urls(): url = ["test.mp4", "test1.mp4", "test2.mp4", "test3.mp4", "test4.mp4"] - args = {} + args = configure.empty() # 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[0]) == [(0, (url[0], {}))] + assert configure.add_urls(args, "input", (url[1], None)) == [(1, (url[1], {}))] 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)), + (3, (url[3], {})), + (4, (url[4], {})), ] From 63d6b691dbb215934d9ebfcf49dcfc66790f8304 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:35:40 -0600 Subject: [PATCH 120/333] `add_url()` allow `None` url --- src/ffmpegio/configure.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 8f72e719..cddbdb0c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -190,36 +190,38 @@ def hasmethod(o, name): def add_url( args: FFmpegArgs, type: Literal["input", "output"], - url: FFmpegUrlType, + url: FFmpegUrlType | None, opts: dict[str, Any] | None = None, update: bool = False, ) -> tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]: """add new or modify existing url to input or output list :param args: ffmpeg arg dict (modified in place) - :param type: input or output + :param type: input or output (may use None to update later) :param url: url of the new entry :param opts: FFmpeg options associated with the url, defaults to None :param update: True to update existing input of the same url, default to False :return: file index and its entry """ - type = f"{type}s" - filelist = args.get(type, None) - if filelist is None: - filelist = args[type] = [] + # get current list of in/outputs + filelist = args[f"{type}s"] n = len(filelist) + + # if updating, get the existing id id = next((i for i in range(n) if filelist[i][0] == url), None) if update else None if id is None: + # new entry id = n - filelist.append((url, opts and {**opts})) + filelist.append((url, {} if opts is None else {**opts})) elif opts is not None: + # update option dict filelist[id] = ( url, ( - opts and {**opts} + opts if filelist[id][1] is None - else filelist[id][1] if opts is None else {**filelist[id][1], **opts} + else (filelist[id][1] if opts is None else {**filelist[id][1], **opts}) ), ) return id, filelist[id] From 372d8829b44234dfee0eff394d1dee9689bc1549 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 14 Feb 2025 22:36:01 -0600 Subject: [PATCH 121/333] `add_url()` renamed `id` to `file_id` --- src/ffmpegio/configure.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index cddbdb0c..3e149ce3 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -209,22 +209,22 @@ def add_url( n = len(filelist) # if updating, get the existing id - id = next((i for i in range(n) if filelist[i][0] == url), None) if update else None - if id is None: + file_id = next((i for i in range(n) if filelist[i][0] == url), None) if update else None + if file_id is None: # new entry - id = n + file_id = n filelist.append((url, {} if opts is None else {**opts})) elif opts is not None: # update option dict - filelist[id] = ( + filelist[file_id] = ( url, ( opts - if filelist[id][1] is None - else (filelist[id][1] if opts is None else {**filelist[id][1], **opts}) + if filelist[file_id][1] is None + else (filelist[file_id][1] if opts is None else {**filelist[file_id][1], **opts}) ), ) - return id, filelist[id] + return file_id, filelist[file_id] def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: From 0c8edc433c7f1959885d0ce599b880246a3471c7 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 11:10:36 -0600 Subject: [PATCH 122/333] refactored `temp_video_src` and `temp_audio_src` --- src/ffmpegio/configure.py | 30 ++++++++++++++--------------- src/ffmpegio/filtergraph/presets.py | 10 ++++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 3e149ce3..ce0c868d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -27,7 +27,13 @@ from . import utils, probe from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject -from .filtergraph.presets import merge_audio, filter_video_basic, remove_video_alpha +from .filtergraph.presets import ( + merge_audio, + filter_video_basic, + remove_video_alpha, + temp_video_src, + temp_audio_src, +) from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence from .stream_spec import ( @@ -66,8 +72,9 @@ class RawOutputInfoDict(TypedDict): dst_type: FFmpegOutputType # True if file path/url user_map: str | None # user specified map option media_type: MediaType | None # - input_file_id: NotRequired[int | None] + input_id: NotRequired[int | None] input_stream_id: NotRequired[int | None] + media_info: NotRequired[dict[str, Any]] pipe: NotRequired[NPopen] @@ -232,8 +239,6 @@ def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: :param args: FFmpeg argument dict :param type: filter type - :param file_id: specify output file id (ignored if type=='complex'), defaults to None (or 0) - :param stream_id: stream, defaults to None :return: True if filter graph is specified """ try: @@ -336,12 +341,10 @@ def flds2opts(pix_fmt, width, height, r1, r2): if has_simple_filter: # create a source chain with matching spec and attach it to the af graph - r, pix_fmt, s = inopt_vals - vf = ( - fgb.color(s=s, r=r) - + fgb.format(pix_fmts=pix_fmt) - + outopts.get("filter:v", outopts.get("vf", None)) + vf = temp_video_src(outopts, *inopt_vals) + outopts.get( + "filter:v", outopts.get("vf", None) ) + outpad = next(vf.iter_output_pads(unlabeled_only=True), None) if outpad is not None: vf = vf >> "[out0]" @@ -526,12 +529,9 @@ def finalize_audio_read_opts( if "af" in outopts or "filter:a" in outopts: # create a source chain with matching specs and attach it to the af graph - ar, sample_fmt, ac = inopt_vals - af = ( - fgb.aevalsrc("|".join(["0"] * ac)) - + fgb.aformat(sample_fmts=sample_fmt or "dbl", r=ar) - + outopts.get("filter:a", outopts.get("af", None)) - ) + af = temp_audio_src(*inopt_vals) + + af = af + outopts.get("filter:a", outopts.get("af", None)) inopt_vals = utils.analyze_input_stream( fields, "0", diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 9ebf3b87..4238a966 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -141,3 +141,13 @@ def match_sample(sspec, opts): afilt = [match_sample(*st) for st in streams.items()] >> fgb.amerge(inputs=n_ain) return (afilt >> output_pad_label) if output_pad_label else afilt + + +def temp_video_src(r, pix_fmt, s): + return fgb.color(s=s, r=r) + fgb.format(pix_fmts=pix_fmt) + + +def temp_audio_src(ar, sample_fmt, ac): + return fgb.aevalsrc("|".join(["0"] * ac)) + fgb.aformat( + sample_fmts=sample_fmt or "dbl", r=ar + ) From e9affe479f6e975267da9286cd35a3633a3ab68a Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 20:15:18 -0600 Subject: [PATCH 123/333] `finalize_audio_read_opts()` changed tp return `shape` not `ac` --- src/ffmpegio/audio.py | 3 ++- src/ffmpegio/configure.py | 7 +++++-- src/ffmpegio/streams/SimpleStreams.py | 5 +---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 1ffa08ae..9efa1508 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -69,6 +69,7 @@ def _run_read( info = log_utils.extract_output_stream(out.stderr) ac = info.get("ac", None) + ac = ac and (ac,) rate = info.get("ar", None) else: out = ffmpegprocess.run( @@ -80,7 +81,7 @@ def _run_read( raise FFmpegError(out.stderr, show_log) return rate, plugins.get_hook().bytes_to_audio( - b=out.stdout, dtype=dtype, shape=(ac,), squeeze=False + b=out.stdout, dtype=dtype, shape=ac, squeeze=False ) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index ce0c868d..4909aeaa 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -471,12 +471,15 @@ def finalize_audio_read_opts( args: FFmpegArgs, ofile: int = 0, input_info: list[InputSourceDict] = [], -) -> tuple[str, int | None, int | None]: +) -> tuple[str, tuple[int] | None, int | None]: """finalize a raw output audio stream :param args: FFmpeg arguments. The option dict in args['outputs'][ofile][1] may be modified. :param ofile: output file index, defaults to 0 :param input_info: list of input information, defaults to None + :return dtype: input data type (Numpy style) + :return ac: number of channels + :return ar: sampling rate * Possible Output Options Modification - "f" and "c:a" - raw audio format and codec will always be set @@ -570,7 +573,7 @@ def finalize_audio_read_opts( # sample_fmt must be given dtype, _ = utils.get_audio_format(sample_fmt, ac) - return dtype, ac, ar + return dtype, ac and (ac,), ar ################################################################################ diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 2d3ab100..bc989070 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -293,7 +293,7 @@ def _finalize(self, ffmpeg_args): outopts["map"] = "0:a:0" ( self.dtype, - ac, + self.shape, self.rate, ) = configure.finalize_audio_read_opts( ffmpeg_args, @@ -306,9 +306,6 @@ def _finalize(self, ffmpeg_args): ], ) - if ac is not None: - self.shape = (ac,) - def _finalize_array(self, info): # finalize array setup from FFmpeg log From dc0014238a60512f6a3f4cdac19d5283884534a1 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 20:18:37 -0600 Subject: [PATCH 124/333] fixed trivial errors --- src/ffmpegio/configure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 4909aeaa..7a3fdd26 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -341,7 +341,7 @@ def flds2opts(pix_fmt, width, height, r1, r2): if has_simple_filter: # create a source chain with matching spec and attach it to the af graph - vf = temp_video_src(outopts, *inopt_vals) + outopts.get( + vf = temp_video_src(*inopt_vals) + outopts.get( "filter:v", outopts.get("vf", None) ) @@ -964,7 +964,7 @@ def resolve_raw_output_streams( # check if stream specifiers single out mapping one input stream per output if all( (opt["stream_specifier"].get("stream_type", None) or "") in "avV" - and "stream_id" in opt["stream_specifier"] + and "index" in opt["stream_specifier"] for opt in map_options ): # no need to run the stream mapping analysis From a1e6c9e726179fcd7897883c3104567be1d1dc57 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 20:19:10 -0600 Subject: [PATCH 125/333] `temp_video_src()` - `s` option must be formatted --- src/ffmpegio/filtergraph/presets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 4238a966..25caa8ac 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -144,7 +144,7 @@ def match_sample(sspec, opts): def temp_video_src(r, pix_fmt, s): - return fgb.color(s=s, r=r) + fgb.format(pix_fmts=pix_fmt) + return fgb.color(s=f"{s[0]}x{s[1]}", r=r) + fgb.format(pix_fmts=pix_fmt) def temp_audio_src(ar, sample_fmt, ac): From 037bc11b7e707b05f8c1de312bbb1ceaabad864c Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 20:27:30 -0600 Subject: [PATCH 126/333] format & documentation --- src/ffmpegio/_typing.py | 2 +- src/ffmpegio/configure.py | 17 +++++++++------ src/ffmpegio/filtergraph/presets.py | 19 +++++++++++++++-- src/ffmpegio/streams/SimpleStreams.py | 30 +++++++++++++++++++++------ 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 66102191..e8b4b961 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -1,4 +1,5 @@ """ffmpegio object independent common type hints""" + from __future__ import annotations from typing import * @@ -48,4 +49,3 @@ class InputSourceDict(TypedDict): buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object pipe: NotRequired[NPopen] # pipe - diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7a3fdd26..63b5b2fb 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -216,7 +216,9 @@ def add_url( n = len(filelist) # if updating, get the existing id - file_id = next((i for i in range(n) if filelist[i][0] == url), None) if update else None + file_id = ( + next((i for i in range(n) if filelist[i][0] == url), None) if update else None + ) if file_id is None: # new entry file_id = n @@ -228,7 +230,11 @@ def add_url( ( opts if filelist[file_id][1] is None - else (filelist[file_id][1] if opts is None else {**filelist[file_id][1], **opts}) + else ( + filelist[file_id][1] + if opts is None + else {**filelist[file_id][1], **opts} + ) ), ) return file_id, filelist[file_id] @@ -296,9 +302,9 @@ def flds2opts(pix_fmt, width, height, r1, r2): outmap = outopts["map"] outmap_fields = parse_map_option(outmap) has_simple_filter = "vf" in outopts or "filter:v" in outopts - fill_color = outopts.get('fill_color',None) - if fill_color is not None and 'remove_alpha' not in outopts: - outopts.pop('fill_color') + fill_color = outopts.get("fill_color", None) + if fill_color is not None and "remove_alpha" not in outopts: + outopts.pop("fill_color") # use the output option by default opt_vals = [outopts.get(o, None) for o in options] @@ -383,7 +389,6 @@ def flds2opts(pix_fmt, width, height, r1, r2): # append the remove-video-alpha filter chain build_basic_vf(args, True, ofile) - outopts["f"] = "rawvideo" # use output option value or else use the input value diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 25caa8ac..f669f7ba 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -8,6 +8,7 @@ from .abc import FilterGraphObject from functools import reduce +from fractions import Fraction from .. import filtergraph as fgb @@ -143,11 +144,25 @@ def match_sample(sspec, opts): return (afilt >> output_pad_label) if output_pad_label else afilt -def temp_video_src(r, pix_fmt, s): +def temp_video_src(r: int | Fraction, pix_fmt: str, s: tuple[int, int]) -> fgb.Chain: + """temporary video source + + :param r: frame rate + :param pix_fmt: pixel format + :param s: frame shape (width x height) + :return: a chain of color and format filters + """ return fgb.color(s=f"{s[0]}x{s[1]}", r=r) + fgb.format(pix_fmts=pix_fmt) -def temp_audio_src(ar, sample_fmt, ac): +def temp_audio_src(ar: int, sample_fmt: str, ac: int) -> fgb.Chain: + """temporary audio source + + :param ar: sampling rate + :param sample_fmt: sample format + :param ac: number of channels + :return: a chain of aevalsrc and aformat + """ return fgb.aevalsrc("|".join(["0"] * ac)) + fgb.aformat( sample_fmts=sample_fmt or "dbl", r=ar ) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index bc989070..10d73d23 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -1,3 +1,5 @@ +"""SimpleStreams Module: FFmpeg""" + from __future__ import annotations from time import time @@ -5,6 +7,11 @@ logger = logging.getLogger("ffmpegio") +from typing import Literal +from fractions import Fraction +from .._typing import RawDataBlob +from ..filtergraph.abc import FilterGraphObject + from .. import utils, configure, ffmpegprocess, plugins from ..threading import LoggerThread, ReaderThread, WriterThread @@ -1037,7 +1044,15 @@ def _pre_open(self, ffmpeg_args): ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1) ) - def _set_options(self, options, shape, dtype, rate=None, expr=None): + def _set_options( + self, + options: dict, + shape: tuple[int] | None, + dtype: str | None, + rate: Fraction | int | None = None, + expr: FilterGraphObject | None = None, + ) -> tuple[tuple[int, ...], str]: + if rate: options["r"] = rate if expr is not None: @@ -1147,11 +1162,14 @@ def _pre_open(self, ffmpeg_args): sample_fmt = outopts["sample_fmt"] = inopts["sample_fmt"] outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) - def _set_options(self, options, shape, dtype, rate=None, expr=None): - if rate: - options["ar"] = rate - if expr is not None: - options["af"] = expr + def _set_options( + self, + options: dict, + shape: tuple[int] | None, + dtype: str | None, + rate: Fraction | int | None = None, + expr: FilterGraphObject | None = None, + ) -> tuple[tuple[int], str]: if shape is None: try: From 7cd84be8e38f306b7c20b31083bc210144c41cc1 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 20:30:21 -0600 Subject: [PATCH 127/333] added `process_raw_input()`, `process_raw_output()`, `assign_input_url()` and `assign_output_url` --- src/ffmpegio/_typing.py | 1 + src/ffmpegio/configure.py | 83 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index e8b4b961..f63f61e4 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -48,4 +48,5 @@ class InputSourceDict(TypedDict): src_type: FFmpegInputType # True if file path/url buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object + media_type: NotRequired[MediaType] # media type if input pipe pipe: NotRequired[NPopen] # pipe diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 63b5b2fb..2fc84de3 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -49,7 +49,7 @@ UrlType = Literal["input", "output"] -FFmpegOutputType = Literal["url", "fileobj"] +FFmpegOutputType = Literal["url", "fileobj", "pipe"] FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] @@ -1226,6 +1226,87 @@ def process_url_inputs( return input_info_list +def process_raw_outputs( + args: FFmpegArgs, + input_info: list[InputSourceDict], + streams: Sequence[str] | dict[str, dict[str, Any] | None] | None, + options: dict[str, Any], +) -> list[RawOutputInfoDict]: + """analyze and process piped raw outputs + + :param args: FFmpeg argument dict, A new item in`args['outputs']` is + appended for each piped output. Output URLs are left `None`. + :param input_info: list of input information (same length as `args['inputs']) + :param streams: user's list of map options to be included + :param options: default output options + :return: list of output information + """ + + # resolve requested output streams + stream_info: dict[str, RawOutputInfoDict] = ( + auto_map(args, input_info) # automatically map all the streams + if streams is None or len(streams) == 0 + else resolve_raw_output_streams(args, input_info, streams) + ) + + # add outputs to FFmpeg arguments + get_opts = isinstance(streams, dict) + for spec, info in stream_info.items(): + opts = ( + {**options, **streams[info["user_map"]], "map": spec} + if get_opts + else {**options, "map": spec} + ) + add_url(args, "output", None, opts) + + # finalize each output streams and identify the output formats + for i, (_, info) in enumerate(stream_info.items()): + # append media_info key to the output info dict + info["media_info"] = ( + finalize_audio_read_opts + if info["media_type"] == "audio" + else finalize_video_read_opts + )(args, i, input_info) + + return list(stream_info.values()) + + +def process_raw_inputs( + args: FFmpegArgs, + input_opts: list[dict], + inopts_default: dict[str, Any], +) -> list[InputSourceDict]: + # add input streams + input_info = [] + for opts in input_opts: + add_url(args, "input", None, {**inopts_default, **opts}) + input_info.append( + {"src_type": "pipe", "media_type": "audio" if "ar" in opts else "video"} + ) + + return input_info + + +def assign_input_url(args: FFmpegArgs, ifile: int, url: str): + """assign a new url to an FFmpeg input + + :param args: FFmpeg arguments (args['inputs'][ifile] to be modified) + :param ifile: file index + :param url: new url + """ + args["inputs"][ifile] = (url, args["inputs"][ifile][1]) + + +def assign_output_url(args: FFmpegArgs, ofile: int, url: str): + """assign a new url to an FFmpeg output + + :param args: FFmpeg arguments (args['outputs'][ofile] to be modified) + :param ofile: file index + :param url: new url + """ + args["outputs"][ofile] = (url, args["outputs"][ofile][1]) + + def retrieve_input_stream_ids( info: InputSourceDict, url: FFmpegUrlType | FilterGraphObject | None, From 37f7abc8dfecf2ffdf4727ea305755a27792c97b Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 20:31:33 -0600 Subject: [PATCH 128/333] Revamped `SimpleFilterBase/Video/Audio` with the new `configure` routines --- src/ffmpegio/streams/SimpleStreams.py | 241 ++++++++++++++++---------- 1 file changed, 152 insertions(+), 89 deletions(-) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 10d73d23..c3614122 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -11,6 +11,7 @@ from fractions import Fraction from .._typing import RawDataBlob from ..filtergraph.abc import FilterGraphObject +from ..errors import FFmpegioError from .. import utils, configure, ffmpegprocess, plugins from ..threading import LoggerThread, ReaderThread, WriterThread @@ -647,6 +648,8 @@ class SimpleFilterBase: """ + stream_type: Literal["a", "v"] + # fmt:off def _set_options(self, options, shape, dtype, rate=None, expr=None): ... def _pre_open(self, ffmpeg_args): ... @@ -654,12 +657,23 @@ def _finalize_output(self, info): ... # fmt:on def __init__( - # fmt:off - self, converter, data_viewer, info_viewer, expr, rate_in, shape_in=None, dtype_in=None, - rate=None, shape=None, dtype=None, blocksize=None, default_timeout=None, - progress=None, show_log=None, sp_kwargs=None, -**options, - # fmt:on + self, + converter, + data_viewer, + info_viewer, + expr, + rate_in, + shape_in=None, + dtype_in=None, + rate=None, + shape=None, + dtype=None, + blocksize=None, + default_timeout=None, + progress=None, + show_log=None, + sp_kwargs=None, + **options, ) -> None: if not rate_in: if rate: @@ -685,13 +699,13 @@ def __init__( self.rate = rate #:str: input array dtype - self.dtype_in = None + self.dtype_in = dtype_in #:tuple(int): input array shape - self.shape_in = None + self.shape_in = shape_in #:str: output array dtype - self.dtype = None + self.dtype = dtype #:tuple(int): output array shape - self.shape = None + self.shape = shape self.nin = 0 #:int: total number of input samples sent to FFmpeg self.nout = 0 #:int: total number of output sampless received from FFmpeg @@ -703,31 +717,24 @@ def __init__( self._proc = None - ffmpeg_args = configure.empty() - inopts = configure.add_url( - ffmpeg_args, "input", "-", utils.pop_extra_options(options, "_in") - )[1][1] - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - - # configuration process - # 1. during __init__ - # 1.0. set filter - # 1.1. if dtype_in or shape_in is given, deduce the input options - # 1.2. if dtype or shape is given, deduce the output options - # 1.3. if input options are incomplete, defer starting the FFmpeg until - # the first data block is given - # 2. during _open - # 2.1. if data is given (i.e., input was not completely defined) - # 2.1.1. get dtype_in and shape_in from data - # 2.1.2. deduce the input ffmpeg options - # 2.2. start ffmpeg - # 2.3. start reader if dtype & shape are already set - - self.shape_in, self.dtype_in = self._set_options( - inopts, shape_in, dtype_in, rate_in - ) + inopts = utils.pop_extra_options(options, "_in") + glopts = utils.pop_global_options(options) - self.shape, self.dtype = self._set_options(outopts, shape, dtype, rate, expr) + try: + not_ready, self.shape_in, self.dtype_in = self._set_options( + inopts, shape_in, dtype_in, rate_in + ) + except FFmpegioError as exc: + raise FFmpegioError( + exc.args[0].replace("dtype", "dtype_in").replace("shape", "shape_in") + ) from exc + + self._set_options(options, shape, dtype, rate, expr) + self._output_opts = options + + ffmpeg_args = configure.empty(glopts) + self._input_info = configure.process_raw_inputs(ffmpeg_args, [inopts], {}) + configure.assign_input_url(ffmpeg_args, 0, "pipe:0") # create the stdin writer without assigning the sink stream self._writer = WriterThread(None, 0) @@ -751,20 +758,27 @@ def __init__( ) # if input is fully configured, start FFmpeg now - if self.shape_in is not None and self.dtype_in is not None: + if not not_ready: self._open() def _open(self, data=None): ffmpeg_args = self._cfg["ffmpeg_args"] + in_opts = ffmpeg_args["inputs"][0][1] - # if data array is given, finalize the FFmpeg configuration with it + # if data array is given, finalize input options (updates the initial options) if data is not None: - self.shape_in, self.dtype_in = self._set_options( - ffmpeg_args["inputs"][0][1], *self._infoviewer(obj=data) + _, self.shape_in, self.dtype_in = self._set_options( + in_opts, *self._infoviewer(obj=data) ) - # final argument tweak before opening the ffmpeg - self._pre_open(ffmpeg_args) + # add the output pipe + self.dtype, self.shape, self.rate = configure.process_raw_outputs( + ffmpeg_args, + self._input_info, + [f"0:{self.stream_type}:0"], + self._output_opts, + )[0]["media_info"] + configure.assign_output_url(ffmpeg_args, 0, "pipe:1") # start FFmpeg self._proc = ffmpegprocess.Popen(**self._cfg) @@ -954,29 +968,29 @@ def filter(self, data, timeout=None): self.nout += len(y) // self._bps_out return self._converter(b=y, dtype=self.dtype, shape=self.shape, squeeze=False) - def flush(self, timeout=None): + def flush(self, timeout: float = None) -> RawDataBlob: """Close the stream input and retrieve the remaining output samples :param timeout: timeout duration in seconds, defaults to None - :type timeout: float, optional :return: remaining output samples - :rtype: numpy.ndarray """ timeout = timeout or self.default_timeout # If no input, close stdin and read all remaining frames - y = self._reader.read_all(timeout) - self._proc.stdin.close() + self._writer.write(None) # sentinel message + self._writer.join() # wait until all written data reaches FFmpeg + self._proc.stdin.close() # close stdin -> triggers ffmpeg to shutdown self._proc.wait() - self._reader.cool_down() - nbytes = len(y) - while nbytes: - y1 = self._reader.read_all(None) - nbytes = len(y1) - y += y1 - self.nout += len(y) // self._bps_out - return self._converter(b=y, dtype=self.dtype, shape=self.shape, squeeze=False) + y = self._reader.read_all(timeout) # read whatever is left in the read queue + nframes = len(y) // self._bps_out + self.nout += nframes + return self._converter( + b=y[: nframes * self._bps_out], + dtype=self.dtype, + shape=self.shape, + squeeze=False, + ) class SimpleVideoFilter(SimpleFilterBase): @@ -1020,6 +1034,7 @@ class SimpleVideoFilter(SimpleFilterBase): writable = True multi_read = False multi_write = False + stream_type = "v" def __init__( # fmt:off @@ -1051,30 +1066,54 @@ def _set_options( dtype: str | None, rate: Fraction | int | None = None, expr: FilterGraphObject | None = None, - ) -> tuple[tuple[int, ...], str]: + ) -> tuple[bool, tuple[int, ...], str]: + + pix_fmt = options.get("pix_fmt", None) + s = options.get("s", None) + + if ( + dtype is None + and pix_fmt is not None + and (s is not None or shape is not None) + ): + if shape is not None and s is None: + s = shape[2::-1] + if pix_fmt is not None and s is not None: + dtype_alt, shape_alt = utils.get_video_format(pix_fmt, s) + if dtype is not None and dtype != dtype_alt: + raise FFmpegioError( + f"Specifid {dtype=} and {pix_fmt=} are not compatible." + ) + if shape is not None and shape != shape_alt: + raise FFmpegioError( + f"Specifid {shape=}, {s=}, and {pix_fmt=} are not compatible." + ) + elif (pix_fmt is None or s is None) and dtype is not None and shape: + s_alt, pix_fmt_alt = utils.guess_video_format(shape, dtype) + if s is None: + s = s_alt + elif s != s_alt: + raise FFmpegioError( + f"Specifid {dtype=}, {shape=}, and {s=} are not compatible." + ) + if pix_fmt is None: + pix_fmt = pix_fmt_alt + elif pix_fmt != pix_fmt_alt: + raise FFmpegioError( + f"Specifid {dtype=}, {shape=}, and {pix_fmt=} are not compatible." + ) - if rate: + options["f"] = "rawvideo" + if rate is not None: options["r"] = rate if expr is not None: options["vf"] = expr + if s is not None: + options["s"] = s + if pix_fmt is not None: + options["pix_fmt"] = pix_fmt - options["f"] = "rawvideo" - - if shape is None or dtype is None: - # deduce them from options - if shape is not None or dtype is not None: - logger.warn( - "[SimpleVideoFilter] both dtype and shape must be defined for the arguments to take effect." - ) - - try: - dtype, shape = utils.get_video_format(options["pix_fmt"], options["s"]) - except: - return None, None - else: - options["s"], options["pix_fmt"] = utils.guess_video_format(shape, dtype) - - return shape, dtype + return pix_fmt is None or s is None, shape, dtype def _finalize_output(self, info): # finalize array setup from FFmpeg log @@ -1131,6 +1170,7 @@ class SimpleAudioFilter(SimpleFilterBase): writable = True multi_read = False multi_write = False + stream_type = "a" def __init__( self, @@ -1169,26 +1209,49 @@ def _set_options( dtype: str | None, rate: Fraction | int | None = None, expr: FilterGraphObject | None = None, - ) -> tuple[tuple[int], str]: + ) -> tuple[bool, tuple[int], str]: - if shape is None: - try: - shape = (options["ac"],) - except: - shape = None - else: - options["ac"] = shape[-1] + ac = options.get("ac", None) - if dtype is None: - try: - dtype, _ = utils.get_audio_format(options["sample_fmt"]) - except: - dtype = None - else: - options["sample_fmt"], _ = utils.guess_audio_format(dtype) - options["c:a"], options["f"] = utils.get_audio_codec(options["sample_fmt"]) + if shape is None: + if ac is not None: + shape = (ac,) + elif ac is None: + ac = shape[-1] + elif shape[-1] != ac: + raise FFmpegioError(f"{shape=} and {ac=} does not match") + + sample_fmt = options.get("sample_fmt", None) + if dtype is None and sample_fmt is not None: + if sample_fmt is not None: + dtype_alt, _ = utils.get_audio_format(options["sample_fmt"]) + if dtype is None: + dtype = dtype_alt + elif dtype != dtype_alt: + raise FFmpegioError( + f"Specifid {dtype=} and {pix_fmt=} are not compatible." + ) + elif (sample_fmt is None) and (dtype is not None): + sample_fmt_alt, _ = utils.guess_audio_format(dtype) + if sample_fmt is None: + sample_fmt = sample_fmt_alt + elif sample_fmt != sample_fmt_alt: + raise FFmpegioError( + f"Specifid {dtype=} and {sample_fmt=} are not compatible." + ) - return shape, dtype + options["f"] = "rawvideo" + if rate: + options["ar"] = rate + if expr is not None: + options["af"] = expr + if sample_fmt is not None: + options["sample_fmt"] = sample_fmt + options["c:a"], options["f"] = utils.get_audio_codec(sample_fmt) + if ac is not None: + options["ac"] = ac + + return sample_fmt is None or ac is None, shape, dtype def _finalize_output(self, info): # finalize array setup from FFmpeg log From 178e87f50d79ab7a02f2cbf3276b975d395ec0bd Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 15 Feb 2025 20:32:02 -0600 Subject: [PATCH 129/333] wip --- src/ffmpegio/configure.py | 256 ++++++++++++-- src/ffmpegio/media.py | 223 +++++++----- src/ffmpegio/streams/PipedStreams.py | 491 +++++++++++++++++++++++++++ src/ffmpegio/threading.py | 47 +++ src/ffmpegio/utils/__init__.py | 51 +++ tests/test_media.py | 8 +- tests/test_pipedstreams.py | 42 +++ 7 files changed, 1008 insertions(+), 110 deletions(-) create mode 100644 src/ffmpegio/streams/PipedStreams.py create mode 100644 tests/test_pipedstreams.py diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 2fc84de3..6a98f112 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1358,37 +1358,237 @@ def retrieve_input_stream_ids( return stream_ids +def finalize_media_read_opts(args: FFmpegArgs, out_idx: int) -> tuple[MediaType, tuple]: + """finalize an output pipe for reading an audio/video stream -def set_sp_kwargs_stdin( - url: str | None, info: InputSourceDict, sp_kwargs: dict = {} -) -> tuple[str, dict | None, Callable]: - """configure sp_kwargs for ffprobe/ffmpeg call to pipe-in the data via stdin + :param args: FFmpeg argument dict + :param out_idx: output url index to finalize + :return: a tuple of the media type of the output stream and its data blob info + """ + + inputs = args["inputs"] + out_url, out_opts = args["outputs"][out_idx] + gopts = args["global_options"] or {} + + # resolve the stream mapping + if "map" in out_opts: + map = out_opts["map"] + if not isinstance(map, str): + if len(map) != 1: + raise ValueError( + "Too many stream maps (or none listed). Only one map option is supported." + ) + map = map[0] + + # parse the map option value + map_d = parse_map_option(map, 0 if len(inputs) == 1 else None) + elif len(inputs) != 1: + raise ValueError( + "Too many files. Only one input file is supported per reader stream." + ) + else: + map_d = {"input_id": 0} + + # resolve the stream media type: 'audio' vs. 'video' + media_type = None + if "linklabel" in map_d: + if "filter_complex" not in gopts: + raise FFmpegError("`filter_complex` global option not found.") + + fgs = [ + fgb.as_filtergraph_object(fg) + for fg in as_multi_option( + gopts["filter_complex"], (str, fgb.Graph, fgb.Chain) + ) + ] + linklabel = map_d["linklabel"] + + # get the output filter for the media type + for fg in fgs: + try: + pad_index = fg.get_output_pad(linklabel) + media_type = fg[*pad_index[:-1]].get_pad_media_type( + "output", pad_index[-1] + ) + # traverse back the chain to look for stream formats + break + except fgb.FiltergraphPadNotFoundError: + pass + else: + # get the input stream media type + input_id = map_d["input_id"] + in_url, in_opts = inputs[input_id] + stream_spec = map_d.get("stream_specifier", None) + st_info = probe.streams_basic(in_url, stream_spec=stream_spec) + if len(st_info) != 1: + raise ValueError( + "Too many streams (or none found). Only one input stream is supported." + ) + media_type = st_info[0]["codec_type"] + if in_opts is None: + in_opts = {} + + media_info = ( + finalize_audio_read_opts(args, out_idx, input_id, stream_spec) + if media_type == "audio" + else finalize_video_read_opts(args, out_idx, input_id, stream_spec) + ) + + return media_type, media_info + + +def init_media_read( + urls: FFmpegInputUrlComposite, + map: Sequence[str] | dict[str, dict[str, Any] | None] | None, + options: dict[str, Any], +) -> tuple[FFmpegArgs, list[InputSourceDict], list[RawOutputInfoDict]]: + """Initialize FFmpeg arguments for media read + + :param *urls: URLs of the media files to read. + :param map: output stream mappings: + - `None` to include all input streams OR all filtergraph outputs + - a sequence of str to specify stream specifiers with file id's + - a dict with stream specifier keys to specify output options + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + :return: frame/sampling rates and raw data for each requested stream - :param url: input URL - :param info: input info - :param sp_kwargs: initial sp_kwargs keyword options - :return: tuple of url (or "pipe:0" if stdin data), updated sp_kwargs, and cleanup function + Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run + `ffmpegio.video.read()` for each url. + + Specify the streams to return by `map` output option: + + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + + Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output + 'pix_fmt' option is not explicitly set, 'rgb24' is used. + + For audio streams, if 'sample_fmt' output option is not specified, 's16'. """ - # ffprobe subprocess keywords - src_type = info["src_type"] - exit_fcn = lambda: None - - if src_type != "url": - url = "pipe:0" - if src_type == "buffer": - sp_kwargs = {**sp_kwargs, "input": info["buffer"]} - elif src_type == "fileobj": - f = info["fileobj"] - if f.readable() and f.seekable(): - sp_kwargs = {**sp_kwargs, "stdin": f} - pos = f.tell() - exit_fcn = lambda: f.seek(pos) # restore the read cursor position - else: - logger.warning("file object must be seekable.") - sp_kwargs = None + ninputs = len(urls) + if not ninputs: + raise ValueError("At least one URL must be given.") + + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") + + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None + + if "filter_complex" in gopts: + gopts["filter_complex"] = utils.as_multi_option( + gopts["filter_complex"], (str, FilterGraphObject) + ) + + # analyze and assign inputs + input_info = process_url_inputs(args, urls, inopts_default) + + # analyze and assign outputs + output_info = process_raw_outputs(args, input_info, map, options) + + return args, input_info, output_info + + +def init_media_write( + url: FFmpegOutputUrlComposite, + input_opts: list[dict], + merge_audio_streams: bool | Sequence[int], + merge_audio_ar: int | None, + merge_audio_sample_fmt: str | None, + merge_audio_outpad: str | None, + extra_inputs: Sequence[str | tuple[str, dict]] | None, + options: dict[str, Any], +) -> tuple[FFmpegArgs, list[NPopen], bytes | None]: + """write multiple streams to a url/file + + :param url: output url + :param input_opts: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's + (indices of `stream_types`) to combine only specified streams. + :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream + :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + + TIPS + ---- + + * All the input streams will be added to the output file by default, unless `map` option is specified + * If the input streams are of different durations, use `shortest=ffmpegio.FLAG` option to trim all streams to the shortest. + * Using merge_audio_streams: + - adds a `filter_complex` global option + - merged input streams are removed from the `map` option and replaced by the merged stream + + """ + + # analyze input stream_data + n_in = len(input_opts) + + # separate the input options from the rest of the options + default_in_opts = utils.pop_extra_options(options, "_in") + + # create FFmpeg argument dict + ffmpeg_args = empty() + + # add input streams + pipes = [] # named pipes and their data blobs (one for each input stream) + n_ain = 0 + for opts in input_opts: + pipe = NPopen("w", bufsize=0) + add_url(ffmpeg_args, "input", pipe.path, {**default_in_opts, **opts}) + pipes.append(pipe) + if "ar" in opts: + n_ain += 1 + + # map all input streams to output unless user specifies the mapping + map = options["map"] if "map" in options else list(range(n_in)) + do_merge = bool(merge_audio_streams) and n_ain > 1 + if do_merge: + if merge_audio_streams is True: + # if True, convert to stream indices of audio inputs + merge_audio_streams = [ + i for i, opts in enumerate(input_opts) if "ar" in opts + ] else: - logger.warning("unknown input source type.") - sp_kwargs = None + try: + assert all("ar" in input_opts[i] for i in merge_audio_streams) + except AssertionError: + raise ValueError( + "merge_audio_streams argument must be bool or a sequence of indices of input audio streams." + ) + + # assign the final map - exclude audio streams if to be merged together + options["map"] = [i for i in map if i not in merge_audio_streams] + + # add output url and options (may also contain possibly global options) + add_url(ffmpeg_args, "output", url, options) + + # add extra input arguments if given + if extra_inputs is not None: + add_urls(ffmpeg_args, "input", extra_inputs) + + if do_merge: + + # get FFmpeg input list + ffinputs = ffmpeg_args["inputs"] + audio_streams = {i: ffinputs[i][1] for i in merge_audio_streams} + afilt = merge_audio( + audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad or "aout", + ) + + # add the merging filter graph to the filter_complex argument + add_filtergraph(ffmpeg_args, afilt) - return url, sp_kwargs, exit_fcn + return ffmpeg_args, pipes diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 6000b515..727c54c0 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -8,6 +8,7 @@ ProgressCallable, RawDataBlob, Unpack, + FFmpegUrlType, ) from .stream_spec import StreamSpecDict @@ -20,13 +21,134 @@ from .threading import WriterThread from .filtergraph.presets import merge_audio -from . import ffmpegprocess, utils, configure, FFmpegError, plugins -from .utils import avi -from .threading import WriterThread +from . import ffmpegprocess, utils, configure, FFmpegError, plugins, filtergraph as fgb +from .utils import avi, pop_global_options +from .utils.log import extract_output_stream +from .threading import WriterThread, ReaderThread +from .probe import streams_basic __all__ = ["read", "write"] +def read( + *urls: * tuple[FFmpegUrlType | tuple[FFmpegUrlType, dict[str, Any] | None]], + map: Sequence[str] | dict[str, dict[str, Any] | None] | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[dict[str, Any]], +) -> tuple[dict[str, Fraction | int], dict[str, RawDataBlob]]: + """Read video and audio data from multiple media files + + :param *urls: URLs of the media files to read or a tuple of the URL and its input option dict. + :param map: FFmpeg map options + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param use_ya: True if piped video streams uses `ya8` pix_fmt instead of `gray16le`, default to None + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + :return: frame/sampling rates and raw data for each requested stream + + Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run + `ffmpegio.video.read()` for each url. + + Specify the streams to return by `map` output option: + + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + + Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output + 'pix_fmt' option is not explicitly set, 'rgb24' is used. + + For audio streams, if 'sample_fmt' output option is not specified, 's16'. + """ + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_read(urls, map, options) + + # True if there is unknown datablob info + need_stderr = any(info["media_info"] is None for info in output_info.values()) + + # run FFmpeg + capture_log = True if need_stderr else None if show_log else True + + with contextlib.ExitStack() as stack: + + # configure output pipes + outputs = args["outputs"] + nouts = len(outputs) + pipes = [None] * nouts + for i in range(nouts): + pipe = NPopen("r", bufsize=0) + stack.enter_context(pipe) + pipes[i] = pipe + + # connect the pipes and queue the stream data + for info in output_info: + info["reader"] = reader = ReaderThread(info["pipe"]) + stack.enter_context(reader) + + # run the FFmpeg + proc = ffmpegprocess.Popen( + args, progress=progress, capture_log=capture_log, sp_kwargs=sp_kwargs + ) + + # wait for the FFmpeg to finish processing + proc.wait() + + # throw error if failed + if proc.returncode: + raise FFmpegError(proc.stderr, capture_log) + + # gather output + rates = {} + data = {} + for i, info in enumerate(output_info): + spec = info["stream_spec"] + b = info["reader"].read_all() + + # get datablob info from stderr if needed + missing = any(v is None for v in info["media_info"]) + + if missing: + new_info = extract_output_stream(proc.stderr, i) + + if info["media_type"] == "video": + dtype, shape, rate = info["media_info"] + + if missing: + if dtype is None: + pix_fmt = new_info.get("pix_fmt", None) + dtype = utils.get_pixel_format(pix_fmt)[0] + if shape is None: + shape = new_info.get("s", None) + if rate is None: + rate = new_info.get("r", None) + + data[spec] = plugins.get_hook().bytes_to_video( + b=b, dtype=dtype, shape=shape, squeeze=False + ) + else: # 'audio' + dtype, ac, rate = info["media_info"] + if missing: + if dtype is None: + sample_fmt = new_info.get("sample_fmt", None) + dtype = utils.get_audio_format(sample_fmt) + if ac is None: + ac = new_info.get("ac", None) + if rate is None: + rate = new_info.get("ar", None) + + data[spec] = plugins.get_hook().bytes_to_audio( + b=b, dtype=dtype, shape=(ac,), squeeze=False + ) + rates[spec] = rate + + return rates, data + + def read_by_avi( *urls: * tuple[str], progress: ProgressCallable | None = None, @@ -157,6 +279,8 @@ def write( """ + url, stdout, _ = configure.check_url(url, True) + if not all(t in "av" for t in stream_types): raise ValueError("Elements of stream_types input must either 'a' or 'v'.") @@ -166,16 +290,8 @@ def write( if len(stream_args) != n_in: raise ValueError(f"Lengths of `stream_args` and `stream_types` not matching.") - # separate the input options from the rest of the options - default_in_opts = utils.pop_extra_options(options, "_in") - - url, stdout, _ = configure.check_url(url, True) - - # create FFmpeg argument dict - ffmpeg_args = configure.empty() - - # add input streams - pipes = [] # named pipes and their data blobs (one for each input stream) + input_opts = [] + input_byte_data = [] for mtype, arg in zip(stream_types, stream_args): @@ -194,77 +310,28 @@ def write( """ ) - pipe = NPopen("w", bufsize=0) - if mtype == "a": # audio if not isinstance(opts, dict): opts = {"ar": round(opts)} - elif "ar" not in opts: - raise ValueError( - "audio stream option dict missing the required 'ar' item to set the sampling rate." - ) - in_args = configure.array_to_audio_input( - pipe_id=pipe.path, data=data, **{**default_in_opts, **opts} - ) - byte_data = plugins.get_hook().audio_bytes(obj=data) + input_opts.append({**opts, **utils.array_to_audio_options(data)}) + input_byte_data.append(plugins.get_hook().audio_bytes(obj=data)) else: # video if not isinstance(opts, dict): opts = {"r": opts} - elif "r" not in opts: - raise ValueError( - "video stream option dict missing the required 'r' item to set the frame rate." - ) - in_args = configure.array_to_video_input( - pipe_id=pipe.path, data=data, **{**default_in_opts, **opts} - ) - byte_data = plugins.get_hook().video_bytes(obj=data) - - pipes.append((pipe, byte_data)) - - configure.add_url(ffmpeg_args, "input", *in_args) - - # map all input streams to output unless user specifies the mapping - map = options["map"] if "map" in options else list(range(n_in)) - do_merge = bool(merge_audio_streams) and stream_types.count("a") > 1 - if do_merge: - if merge_audio_streams is True: - # if True, convert to stream indices of audio inputs - merge_audio_streams = [ - i for i, mtype in enumerate(stream_types) if mtype == "a" - ] - else: - try: - assert all(stream_types[i] == "a" for i in merge_audio_streams) - except AssertionError: - raise ValueError( - "merge_audio_streams argument must be bool or a sequence of indices of input audio streams." - ) - - # assign the final map - exclude audio streams if to be merged together - options["map"] = [i for i in map if i not in merge_audio_streams] - - # add output url and options (may also contain possibly global options) - configure.add_url(ffmpeg_args, "output", url, options) - - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - if do_merge: - - # get FFmpeg input list - ffinputs = ffmpeg_args["inputs"] - audio_streams = {i: ffinputs[i][1] for i in merge_audio_streams} - afilt = merge_audio( - audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad or "aout", - ) - - # add the merging filter graph to the filter_complex argument - configure.add_filtergraph(ffmpeg_args, afilt) + input_opts.append({**opts, **utils.array_to_video_options(data)}) + input_byte_data.append(plugins.get_hook().video_bytes(obj=data)) + + ffmpeg_args, pipes = configure.init_media_write( + url, + input_opts, + merge_audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad, + extra_inputs, + options, + ) kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( @@ -281,7 +348,7 @@ def write( proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) # connect the pipes and queue the stream data - for p, data in pipes: + for p, data in zip(pipes, input_byte_data): stack.enter_context(p) writer = WriterThread(p) stack.enter_context(writer) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py new file mode 100644 index 00000000..73641d8f --- /dev/null +++ b/src/ffmpegio/streams/PipedStreams.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +from time import time +import logging + +logger = logging.getLogger("ffmpegio") + +from .. import utils, configure, ffmpegprocess, plugins +from ..threading import LoggerThread, ReaderThread, WriterThread + +from typing_extensions import Unpack +from collections.abc import Sequence +from ..typing import ( + Literal, + Any, + RawStreamDef, + ProgressCallable, + RawDataBlob, + StreamSpecDict, + FFmpegArgs +) + +import contextlib +from io import BytesIO +from fractions import Fraction + +from namedpipe import NPopen + +from ..threading import WriterThread +from ..filtergraph.presets import merge_audio + +from .. import ffmpegprocess, utils, configure, FFmpegError, plugins, filtergraph as fgb +from ..utils import avi, pop_global_options +from ..utils.log import extract_output_stream +from ..threading import WriterThread, ReaderThread +from ..probe import streams_basic +from ..configure import init_media_read, init_media_write + +# fmt:off +__all__ = ["Trancoder"] +# fmt:on + +class PipedRunner: + def __init__( + self, + args: FFmpegArgs, + input_info: list, + output_info: dict, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + blocksize:int=None, + sp_kwargs: dict | None = None, + **options: Unpack[dict[str, Any]], + ): + ... + +class PipedReader(PipedRunner): + def __init__( + self, + *urls: * tuple[str | tuple[str, dict[str, Any] | None]], + map: Sequence[str] | dict[str, dict[str, Any] | None] | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + blocksize:int=None, + sp_kwargs: dict | None = None, + **options: Unpack[dict[str, Any]], + ): + ffmpeg_args, self._output_info, self._resolve_outputs = configure.init_media_read(urls, map, options) + + # run FFmpeg + + self.dtype = None # :str: output data type + self.shape = ( + None # :tuple of ints: dimension of each video frame or audio sample + ) + self.samplesize = ( + None #:int: number of bytes of each video frame or audio sample + ) + self.blocksize = None #:positive int: number of video frames or audio samples to read when used as an iterator + self.sp_kwargs = sp_kwargs #:dict[str,Any]: additional keyword arguments for subprocess.Popen + + # abstract method to finalize the options => sets self.dtype and self.shape if known + self._finalize(ffmpeg_args) + + # create logger without assigning the source stream + self._logger = LoggerThread(None, show_log) + + kwargs = {**sp_kwargs} if sp_kwargs else {} + kwargs.update({"stdin": stdin, "progress": progress, "capture_log": True}) + + # start FFmpeg + self._proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) + + # set the log source and start the logger + self._logger.stderr = self._proc.stderr + self._logger.start() + + # if byte data is given, feed it + if input is not None: + self._proc.stdin.write(input) + + # wait until output stream log is captured if output format is unknown + try: + if self.dtype is None or self.shape is None: + logger.debug( + "[reader main] waiting for logger to provide output stream info" + ) + info = self._logger.output_stream() + logger.debug(f"[reader main] received {info}") + self._finalize_array(info) + else: + self._logger.index("Output") + except: + if self._proc.poll() is None: + raise self._logger.Exception + else: + raise ValueError("failed retrieve output data format") + + self.samplesize = utils.get_samplesize(self.shape, self.dtype) + + self.blocksize = blocksize or max(1024**2 // self.samplesize, 1) + logger.debug("[reader main] completed init") + + def close(self): + """Flush and close this stream. This method has no effect if the stream is already + closed. Once the stream is closed, any read operation on the stream will raise + a ValueError. + + As a convenience, it is allowed to call this method more than once; only the first call, + however, will have an effect. + + """ + + if self._proc is None: + return + + self._proc.stdout.close() + self._proc.stderr.close() + + if self._proc.poll() is None: + try: + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + except: + print("failed to terminate") + pass + + logger.debug(f"[reader main] FFmpeg closed? {self._proc.poll()}") + + try: + self._proc.stdin.close() + except: + pass + self._logger.join() + + @property + def closed(self): + """:bool: True if the stream is closed.""" + return self._proc.poll() is not None + + @property + def lasterror(self): + """:FFmpegError: Last error FFmpeg posted""" + if self._proc.poll(): + return self._logger.Exception() + else: + return None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def __iter__(self): + return self + + def __next__(self): + F = self.read(self.blocksize) + if F is None: + raise StopIteration + return F + + def readlog(self, n=None): + if n is not None: + self._logger.index(n) + with self._logger._newline_mutex: + return "\n".join(self._logger.logs or self._logger.logs[:n]) + + def read(self, n=-1): + """Read and return numpy.ndarray with up to n frames/samples. If + the argument is omitted, None, or negative, data is read and + returned until EOF is reached. An empty bytes object is returned + if the stream is already at EOF. + + If the argument is positive, and the underlying raw stream is not + interactive, multiple raw reads may be issued to satisfy the byte + count (unless EOF is reached first). But for interactive raw streams, + at most one raw read will be issued, and a short result does not + imply that EOF is imminent. + + A BlockingIOError is raised if the underlying raw stream is in non + blocking-mode, and has no data available at the moment.""" + logger.debug(f"[reader main] reading {n} samples") + b = self._proc.stdout.read(n * self.samplesize if n > 0 else n) + logger.debug(f"[reader main] read {len(b)} bytes") + if not len(b): + self._proc.stdout.close() + return None + return self._converter(b=b, shape=self.shape, dtype=self.dtype, squeeze=False) + + def readinto(self, array): + """Read bytes into a pre-allocated, writable bytes-like object array and + return the number of bytes read. For example, b might be a bytearray. + + Like read(), multiple reads may be issued to the underlying raw stream, + unless the latter is interactive. + + A BlockingIOError is raised if the underlying raw stream is in non + blocking-mode, and has no data available at the moment.""" + + return ( + self._proc.stdout.readinto(self._memoryviewer(obj=array)) // self.samplesize + ) + +class PipedWriter: ... + + +class PipedFilter: ... + + +class Transcoder: + """Class to merge multiple media streams in memory + + :param expr: SISO filter graph or None if implicit filtering via output options. + :type expr: str, None + :param rate_in: input sample rate + :type rate_in: int, float, Fraction, str + :param shape_in: input single-sample array shape, defaults to None + :type shape_in: seq of ints, optional + :param dtype_in: input data type string, defaults to None + :type dtype_in: str, optional + :param rate: output sample rate, defaults to None (auto-detect) + :type rate: int, float, Fraction, str, optional + :param shape: output single-sample array shape, defaults to None + :type shape: seq of ints, optional + :param dtype: output data type string, defaults to None + :type dtype: str, optional + :param blocksize: read buffer block size in samples, defaults to None + :type blocksize: int, optional + :param default_timeout: default filter timeout in seconds, defaults to None (10 ms) + :type default_timeout: float, optional + :param progress: progress callback function, defaults to None + :type progress: callable object, optional + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + + :type show_log: bool, optional + :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :type \\**options: dict, optional + + """ + + def __init__( + self, + *input_formats_or_opts: Sequence[str | dict | None], + nb_inputs: int | None = None, + output_url: str | None = None, + blocksize: int | None = None, + default_timeout: float | None = None, + progress: Callable | None = None, + show_log: bool | None = None, + sp_kwargs: dict | None = None, + np_kwargs: dict | None = None, + **output_options: dict[str, Any], + ) -> None: + + #:float: default filter operation timeout in seconds + self.default_timeout = default_timeout or 10e-3 + + # set this to false in _finalize() if guaranteed for the logger to have output stream info + self._loggertimeout = True + + nin = len(input_formats_or_opts) + if nb_inputs is None and not nin: + raise ValueError( + "At least one input format/options must be given OR specify nb_inputs." + ) + if nb_inputs is not None and nin > 0 and nb_inputs != nin: + raise ValueError( + "Both nb_inputs and input format/options are given but nb_inputs does not agree with the number of inputs specified." + ) + + inopts = ( + [ + v if isinstance(v, dict) else {} if v is None else {"f": v} + for v in input_formats_or_opts + ] + if len(input_formats_or_opts) + else [{}] * nb_inputs + ) + + nb_inputs = len(inopts) + self._input_pipes = inpipes = [ + NPopen("w", **(np_kwargs or {})) for _ in range(nb_inputs) + ] + + self._output_pipe = None + if output_url is None: + self._output_pipe = outpipe = NPopen("r", **(np_kwargs or {})) + output_url = outpipe.path + + # create input format list + self._args = ffmpeg_args = configure.empty() + ffmpeg_args["inputs"].extend([(p.path, o) for p, o in zip(inpipes, inopts)]) + configure.add_url(ffmpeg_args, "output", output_url, output_options)[1][1] + + self._proc = None + + # create the stdin writer without assigning the sink stream + self._writers = [WriterThread(p, 0) for p in inpipes] + + # create the stdout reader without assigning the source stream + self._reader = None + if self._output_pipe is not None: + self._reader = ReaderThread(self._output_pipe, blocksize, 0) + + # create logger without assigning the source stream + self._logger = LoggerThread(None, show_log) + + # FFmpeg Popen arguments + self._cfg = {**sp_kwargs} if sp_kwargs else {} + self._cfg.update( + { + "ffmpeg_args": ffmpeg_args, + "progress": progress, + "capture_log": True, + } + ) + + # start FFmpeg + self._proc = ffmpegprocess.Popen(**self._cfg) + + self._logger.stderr = self._proc.stderr + self._logger.start() + + # start the writers + for writer in self._writers: + writer.start() + + self._reader.start() + self._cfg = False + + def close(self): + """Close the stream. + + This method has no effect if the stream is already closed. Once the + stream is closed, any read operation on the stream will raise a ThreadNotActive. + + As a convenience, it is allowed to call this method more than once; only the first call, + however, will have an effect. + """ + + if self._proc is None: + return + + self._proc.stdout.close() + self._proc.stderr.close() + + # kill the process + try: + self._proc.terminate() + except: + pass + + for p in self._input_pipes: + p.close() + + try: + self._logger.join() + except: + # possibly close before opening the logger thread + pass + try: + self._reader.join() + except: + # possibly close before opening the reader thread + pass + try: + for writer in self._writers: + writer.join() + except: + # possibly close before opening the writer thread + pass + + @property + def closed(self) -> bool: + """:bool: True if the stream is closed.""" + return self._proc.poll() is not None + + @property + def lasterror(self) -> Exception: + """:FFmpegError: Last error FFmpeg posted""" + if self._proc.poll(): + return self._logger.Exception() + else: + return None + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + def readlog(self, n: int | None = None) -> str: + """get FFmpeg log lines + + :param n: number of lines to return, defaults to None (every line logged) + :type n: int, optional + :return: string containing the requested logs + :rtype: str + + """ + if n is not None: + self._logger.index(n) + with self._logger._newline_mutex: + return "\n".join(self._logger.logs or self._logger.logs[:n]) + + def write(self, stream_id: int, stream_data: bytes, timeout: float | None = None): + """Run filter operation + + :param data: input data block + :param timeout: timeout for the operation in seconds, defaults to None + + The input `data` array is expected to have the datatype specified by + Filter class' `dtype_in` property and the array shape to match Filter + class' `shape_in` property or with an additional dimension prepended. + + """ + + timeout = timeout or self.default_timeout + + timeout += time() + + writer = self._writers[stream_id] + try: + writer.write(stream_data, timeout - time()) + except BrokenPipeError as e: + # TODO check log for error in FFmpeg + raise e + + def read(self, n: int = -1, timeout: float | None = None) -> bytes: + + try: + return self._reader.read(n, timeout) + except AttributeError as e: + if self._reader is None: + raise RuntimeError( + "read() not supported. FFmpeg is outputting directly to a file" + ) + raise + + def read_nowait(self, n: int = -1) -> bytes: + + try: + return self._reader.read_nowait(n) + except AttributeError as e: + if self._reader is None: + raise RuntimeError( + "read_nowait() not supported. FFmpeg is outputting directly to a file" + ) + raise + + def flush(self, timeout: float | None = None): + """Flush the write buffers of the stream if applicable. + + :param timeout: timeout duration in seconds, defaults to None + :type timeout: float, optional + :return: remaining output samples + :rtype: numpy.ndarray + """ + + timeout = timeout or self.default_timeout + + # If no input, close stdin and read all remaining frames + y = self._reader.read_all(timeout) + for p in self._input_pipes: + p.close() + self._proc.wait() + y += self._reader.read_all(None) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 7c5e8419..79d95045 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -369,6 +369,53 @@ def run(self): logger.info("ReaderThread exiting") + def read_nowait(self, n: int = -1) -> bytes: + # wait till matching line is read by the thread + block = (self.is_alive() and self._collect) and n != 0 + if timeout is not None: + timeout = time() + timeout + + arrays = [] + n_new = max(n, -n) + + # grab any leftover data from previous read + if self._carryover: + arrays = [self._carryover] + if n_new != 0: + n_new -= len(self._carryover) // self.itemsize + self._carryover = None + + # loop till enough data are collected + nreads = 1 if n <= 0 else max(n_new, 0) + nr = 0 + while True: + try: + b = self._queue.get_nowait(block) + self._queue.task_done() + assert b is not None + except (Empty, AssertionError): + if len(arrays): + break + raise + + arrays.append(b) + + nr += len(b) // self.itemsize + if nr >= nreads: # enough read + if n < 0: + block = False # keep reading until queue is empty + else: + break + + # combine all the data and return requested amount + all_data = b"".join(arrays) + if n <= 0: + return all_data + nbytes = self.itemsize * n + if len(all_data) > nbytes: + self._carryover = all_data[nbytes:] + return all_data[:nbytes] + def read(self, n: int = -1, timeout: float | None = None) -> bytes: """read n samples diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 1a5729c6..85a9cf20 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -18,6 +18,8 @@ from ..stream_spec import * from ..errors import FFmpegError, FFmpegioError from .._typing import Any, MediaType, InputSourceDict +from ..filtergraph.abc import FilterGraphObject +from .. import filtergraph as fgb # TODO: auto-detect endianness # import sys @@ -581,3 +583,52 @@ def analyze_input_stream( q = q[0] return [q.get(f, None) for f in fields[:-1]] + + +def analyze_complex_filtergraphs( + filtergraphs: list[FilterGraphObject], + inputs: list[tuple[str | None, dict]], + inputs_info: list[InputSourceDict], + fields: list[str] | None = None, +) -> list: + """analyze filtergraphs and return requested field values + + :param fields: a list of stream properties + :param stream: stream specifier, first one is returned if it yields more than one stream, + :param input_url: url or None if piped or fileobj + :param input_opts: input options + :param input_info: input infomration + :raises NotImplementedError: _description_ + :return values of the requested fields of the stream + """ + + fg = fgb.stack(filtergraphs) + + fields = [*fields, "codec_type"] + input_url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(input_url, input_info) + try: + q = probe.query( + input_url, + stream, + fields, + keep_optional_fields=True, + keep_str_values=False, + cache_output=True, + sp_kwargs=sp_kwargs, + f=input_opts.get("f", None), + ) + except FFmpegError: + # no change + return [None] * (len(fields) - 1) + else: + q = [i for i in q if i["codec_type"] == media_type] + if len(q) != 1: + raise FFmpegioError( + f"Specified {stream=} must resolve to one and only one {media_type} stream." + ) + finally: + # rewind fileobj if possible + exit_fcn() + + q = q[0] + return [q.get(f, None) for f in fields[:-1]] diff --git a/tests/test_media.py b/tests/test_media.py index 1a1180d0..297fece4 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -9,10 +9,10 @@ def test_media_read(): url = "tests/assets/testmulti-1m.mp4" url1 = "tests/assets/testvideo-1m.mp4" url2 = "tests/assets/testaudio-1m.mp3" - rates, data = ff.media.read(url, t=1) - rates, data = ff.media.read(url, map=("v:0", "v:1", "a:1", "a:0"), t=1) - rates, data = ff.media.read(url1, url2, t=1) - rates, data = ff.media.read(url2, url, map=("1:v:0", (0, "a:0")), t=1) + rates, data = ff.media.read(url, t=1, show_log=True) + rates, data = ff.media.read(url, map=("v:0", "v:1", "a:1", "a:0"), t=1, show_log=True) + rates, data = ff.media.read(url1, url2, t=1, show_log=True) + rates, data = ff.media.read(url2, url, map=("1:v:0", (0, "a:0")), t=1, show_log=True) print(rates) print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py new file mode 100644 index 00000000..2164d309 --- /dev/null +++ b/tests/test_pipedstreams.py @@ -0,0 +1,42 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +from os import path +import pytest + +import ffmpegio as ff + +video_url = "tests/assets/testvideo-1m.mp4" +audio_url = "tests/assets/testaudio-1m.mp3" +outext = ".mp4" + +@pytest.mark.skip(reason="to be implemented") +def test_transcoder(): + from ffmpegio.streams.PipedStreams import Transcoder + + vsz = path.getsize(video_url) // 100 + asz = path.getsize(audio_url) // 100 + logging.info(f"{vsz=}") + logging.info(f"{asz=}") + + with ( + open(video_url, "rb") as vf, + open(audio_url, "rb") as af, + Transcoder(nb_inputs=2, show_log=True) as merger, + ): + while True: + vdata = vf.read(vsz) + if vdata: + merger.write(0, vdata) + + adata = af.read(asz) + if adata: + merger.write(1, adata) + + F = merger.read_nowait() + logging.info(f"read {len(F)} bytes") + + +if __name__ == "__main__": + test_merger() From 4fea4537d0e822a593dbed7910d8659de8265a08 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 09:49:02 -0600 Subject: [PATCH 130/333] ReaderThread.cool_down() - does not need to read from the queue --- src/ffmpegio/threading.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 79d95045..443ca444 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -309,10 +309,6 @@ def start(self): def cool_down(self): # stop enqueue read samples self._collect = False - try: - self._queue.get_nowait() - except: - pass def join(self, timeout=None): if self._queue.full(): From 3ea8f918947e59ab58e177885e2b3d5fd81548f2 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 09:50:31 -0600 Subject: [PATCH 131/333] ReaderThread - added logging commands in the runner --- src/ffmpegio/threading.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 443ca444..ff0fa319 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -337,10 +337,13 @@ def run(self): blocksize = ( self.nmin if self.nmin is not None else 1 if self.itemsize > 1024 else 1024 ) * self.itemsize + logger.debug("waiting for pipe to open") stream = self.stdout.wait() if is_npipe else self.stdout + logger.debug("starting to read") while self._collect: try: data = stream.read(blocksize) + logger.debug("read %d bytes", len(data)) except: # stdout stream closed/FFmpeg terminated, end the thread as well data = None @@ -359,6 +362,8 @@ def run(self): self._queue.put(data) # print(f"reader thread: queued samples") + logger.debug("stopping to read") + if self._collect: # True until self.cooloff logger.info("ReaderThread sending the sentinel") self._queue.put(None) # sentinel for eos From f9fa417ecae8b44f1cef8c0cff53d48c21d66499 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 09:51:22 -0600 Subject: [PATCH 132/333] CopyFileObjThread - added `auto_close` property --- src/ffmpegio/threading.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index ff0fa319..69b9514f 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -866,6 +866,8 @@ class CopyFileObjThread(Thread): :param length: The integer length, if given, is the buffer size. In particular, a negative length value means to copy the data without looping over the source data in chunks; defaults to 0; the data is read in chunks to avoid uncontrolled memory consumption. + :param auto_close: True for the thread to close fsrc and fdst after copy, + defaults to False Thread terminates when the copy operation is completed. @@ -874,13 +876,18 @@ class CopyFileObjThread(Thread): """ def __init__( - self, fsrc: BinaryIO | NPopen, fdst: BinaryIO | NPopen, length: int = 0 + self, + fsrc: BinaryIO | NPopen, + fdst: BinaryIO | NPopen, + length: int = 0, + *, + auto_close: bool = False, ): - super().__init__() self._fsrc = fsrc self._fdst = fdst self.length = length + self.auto_close = auto_close def __enter__(self): self.start() @@ -896,3 +903,6 @@ def run(self): dst_is_namedpipe = isinstance(self._fdst, NPopen) dst = self._fdst.wait() if dst_is_namedpipe else self._fdst copyfileobj(src, dst, self.length) + if self.auto_close: + src.close() + dst.close() From 7a5d1b8c8ab3e068624fd994f40834b5d1bd926a Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 09:52:32 -0600 Subject: [PATCH 133/333] `parse_map_option()` to accept a tuple of file id & stream spec as `map` argument --- src/ffmpegio/stream_spec.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 521f59c1..c489bba9 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -309,11 +309,14 @@ def stream_spec( def parse_map_option( - map: str, *, input_file_id: int | None = None, parse_stream: bool = False + map: str | tuple[int, str], + *, + input_file_id: int | None = None, + parse_stream: bool = False, ) -> MapOptionDict: """parse the FFmpeg -map option str - :param map: option string value + :param map: option string value, optionally a tuple of a file id and a stream specifier. :param input_file_id: if specified, auto-insert this id if a file id is missing in the given value, defaults to None to error out if missing. :param parse_stream: True to also parse stream spec (if given) @@ -328,6 +331,9 @@ def parse_map_option( See the FFmpeg manual for the specification: https://ffmpeg.org/ffmpeg.html#Advanced-options """ + if isinstance(map, tuple): + map = f"{map[0]}:{map[1]}" + map = str(map) # -map [-]input_file_id[:stream_specifier][:view_specifier][:?] | [linklabel] From 50e3a4b23e33553e46c199d316e2c50d8001111e Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 09:53:31 -0600 Subject: [PATCH 134/333] `resolve_raw_output_streams()` - returns full map option if missing file id --- src/ffmpegio/configure.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 6a98f112..422992a4 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -41,6 +41,7 @@ StreamSpecDict, stream_type_to_media_type, parse_map_option, + map_option as compose_map_option ) from .errors import FFmpegioError @@ -974,7 +975,7 @@ def resolve_raw_output_streams( ): # no need to run the stream mapping analysis return { - spec: { + compose_map_option(**opt): { "dst_type": dst_type, "user_map": spec, "media_type": stream_type_to_media_type( From 371fa82dcd27100ff77104ef2e69cc749f886877 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 09:54:29 -0600 Subject: [PATCH 135/333] `resolve_raw_output_streams` - input file id must be given --- src/ffmpegio/configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 422992a4..79dca7bb 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -981,7 +981,7 @@ def resolve_raw_output_streams( "media_type": stream_type_to_media_type( opt["stream_specifier"].get("stream_type", None) ), - "input_file_id": opt.get("input_file_id", None), + "input_file_id": opt["input_file_id"], "input_stream_id": None, } for spec, opt in zip(streams, map_options) From ab04aa501aa888ab605fe2cc6aa13f4a056c94e8 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 10:01:45 -0600 Subject: [PATCH 136/333] `auto_map()` - returned keys uses stream specifiers with media types --- src/ffmpegio/configure.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 79dca7bb..b9015e68 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -41,7 +41,7 @@ StreamSpecDict, stream_type_to_media_type, parse_map_option, - map_option as compose_map_option + map_option as compose_map_option, ) from .errors import FFmpegioError @@ -1061,9 +1061,19 @@ def auto_map( for linklabel, media_type in analyze_fg_outputs(args).items() } + counter = {"file": None, "audio": 0, "video": 0} + + def next_map_option(i, media_type): + if i != counter["file"]: + counter["audio"] = counter["video"] = 0 + counter["file"] = i + j = counter[media_type] + counter[media_type] = j + 1 + return f"{i}:{media_type[0]}:{j}" + # if no filtergraph, get all video & audio streams from all the input urls return { - f"{i}:{j}": { + next_map_option(i, media_type): { "dst_type": "pipe", "user_map": None, "media_type": media_type, From d8b01bfccf25341aea01605fb73770aaac412770 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 10:02:52 -0600 Subject: [PATCH 137/333] `RawOutputInfoDict` type - fixed 'input_id' -> 'input_file_id' and added `"reader"` --- src/ffmpegio/configure.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index b9015e68..66d8192a 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -44,6 +44,7 @@ map_option as compose_map_option, ) from .errors import FFmpegioError +from .threading import ReaderThread ################################# ## module types @@ -73,10 +74,11 @@ class RawOutputInfoDict(TypedDict): dst_type: FFmpegOutputType # True if file path/url user_map: str | None # user specified map option media_type: MediaType | None # - input_id: NotRequired[int | None] + input_file_id: NotRequired[int | None] input_stream_id: NotRequired[int | None] media_info: NotRequired[dict[str, Any]] pipe: NotRequired[NPopen] + reader: NotRequired[ReaderThread] ################################# From 0c54b43dc12e6b285da350bbbbe912a19668118a Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 16 Feb 2025 10:04:11 -0600 Subject: [PATCH 138/333] `read()` - revamped with new configure routines --- src/ffmpegio/media.py | 71 ++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 727c54c0..b39e1ad1 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -1,5 +1,9 @@ from __future__ import annotations +import logging + +logger = logging.getLogger("ffmpegio") + from collections.abc import Sequence from ._typing import ( Literal, @@ -19,13 +23,12 @@ from namedpipe import NPopen from .threading import WriterThread -from .filtergraph.presets import merge_audio from . import ffmpegprocess, utils, configure, FFmpegError, plugins, filtergraph as fgb from .utils import avi, pop_global_options from .utils.log import extract_output_stream -from .threading import WriterThread, ReaderThread -from .probe import streams_basic +from .threading import WriterThread, ReaderThread, CopyFileObjThread +from .errors import FFmpegioError __all__ = ["read", "write"] @@ -69,26 +72,37 @@ def read( args, input_info, output_info = configure.init_media_read(urls, map, options) # True if there is unknown datablob info - need_stderr = any(info["media_info"] is None for info in output_info.values()) + need_stderr = any(info["media_info"] is None for info in output_info) # run FFmpeg capture_log = True if need_stderr else None if show_log else True with contextlib.ExitStack() as stack: + # configure input pipes (if needed) + for i, (input, info) in enumerate(zip(args["inputs"], input_info)): + if input[0] is None: # no url == fileobj / buffer / other data via a pipe + pipe = NPopen("w", bufsize=0) + stack.enter_context(pipe) + configure.assign_input_url(args, i, pipe.path) + src_type = info["src_type"] + if src_type == "fileobj": + writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) + elif src_type == "buffer": + writer = WriterThread() + writer.write(info["buffer"]) + writer.write(None) # close the + else: + raise FFmpegioError(f"{src_type=} is an unknown input data type.") + stack.enter_context(writer) # starts thread & wait for pipe connection + # configure output pipes - outputs = args["outputs"] - nouts = len(outputs) - pipes = [None] * nouts - for i in range(nouts): + for i, info in enumerate(output_info): pipe = NPopen("r", bufsize=0) stack.enter_context(pipe) - pipes[i] = pipe - - # connect the pipes and queue the stream data - for info in output_info: - info["reader"] = reader = ReaderThread(info["pipe"]) - stack.enter_context(reader) + configure.assign_output_url(args, i, pipe.path) + info["reader"] = reader = ReaderThread(pipe) + stack.enter_context(reader) # starts thread & wait for pipe connection # run the FFmpeg proc = ffmpegprocess.Popen( @@ -102,17 +116,26 @@ def read( if proc.returncode: raise FFmpegError(proc.stderr, capture_log) + # wind-down the readers + for info in output_info: + info['reader'].cool_down() + # gather output rates = {} data = {} for i, info in enumerate(output_info): - spec = info["stream_spec"] + spec = ( + info["user_map"] or f"{info['input_file_id']}:{info['input_stream_id']}" + ) b = info["reader"].read_all() # get datablob info from stderr if needed missing = any(v is None for v in info["media_info"]) if missing: + logger.warning( + 'Retrieving stream "%s" information from FFmpeg log.', spec + ) new_info = extract_output_stream(proc.stderr, i) if info["media_type"] == "video": @@ -120,29 +143,29 @@ def read( if missing: if dtype is None: - pix_fmt = new_info.get("pix_fmt", None) + pix_fmt = new_info["pix_fmt"] dtype = utils.get_pixel_format(pix_fmt)[0] if shape is None: - shape = new_info.get("s", None) + shape = new_info["s"] if rate is None: - rate = new_info.get("r", None) + rate = new_info["r"] data[spec] = plugins.get_hook().bytes_to_video( b=b, dtype=dtype, shape=shape, squeeze=False ) else: # 'audio' - dtype, ac, rate = info["media_info"] + dtype, shape, rate = info["media_info"] if missing: if dtype is None: - sample_fmt = new_info.get("sample_fmt", None) + sample_fmt = new_info["sample_fmt"] dtype = utils.get_audio_format(sample_fmt) - if ac is None: - ac = new_info.get("ac", None) + if shape is None: + shape = (new_info["ac"],) if rate is None: - rate = new_info.get("ar", None) + rate = new_info["ar"] data[spec] = plugins.get_hook().bytes_to_audio( - b=b, dtype=dtype, shape=(ac,), squeeze=False + b=b, dtype=dtype, shape=shape, squeeze=False ) rates[spec] = rate From 3e7d105816bb0378e0db531e686592880faa38dd Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:06:15 +0900 Subject: [PATCH 139/333] `RawStreamDef` may initially get `None` url --- src/ffmpegio/_typing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index f63f61e4..58942edb 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -18,7 +18,8 @@ RawDataBlob = Any # depends on raw data reader plugin RawStreamDef = ( - tuple[int | float | Fraction, RawDataBlob] | tuple[RawDataBlob, dict[str, Any]] + tuple[int | float | Fraction, RawDataBlob] + | tuple[RawDataBlob | None, dict[str, Any]] ) From 56612cd3b7cf95d22d981e95130d36fb448fa1f3 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:06:58 +0900 Subject: [PATCH 140/333] `FFmpegInputType` removed `'pipe'` as an option --- src/ffmpegio/_typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 58942edb..2bc8ec37 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -40,7 +40,7 @@ FFmpegUrlType = Union[str, Path, ParseResult] -FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj", "pipe"] +FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] class InputSourceDict(TypedDict): From 6595bd7eb4b21395da727cea3b4ddd33408f02b8 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:08:42 +0900 Subject: [PATCH 141/333] `as_multi_option` to accept `SeqCls` argument to specify output type --- src/ffmpegio/_utils.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index cd9c2367..b93febde 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -66,21 +66,25 @@ def is_non_str_sequence( return isinstance(value, Sequence) and not isinstance(value, class_excluded) -def as_multi_option(value: Any, exclude_classes: tuple[type] = None) -> Sequence[Any]: +def as_multi_option( + value: Any, exclude_classes: tuple[type] = str, SeqCls: type = list +) -> Sequence[Any]: """Put value in a list if it is not already a sequence :param value: value to be put in a list :param exclude_classes: sequence classes to be treated as an option value, defaults to None - :return: option values in a sequence + :param SeqCls: output sequence type + :return: option value in a sequence, unless value is `None` """ - if exclude_classes is None: - exclude_classes = str - return ( value - if isinstance(value, Sequence) and not isinstance(value, exclude_classes) - else [value] + if value is None or isinstance(value, SeqCls) + else ( + SeqCls(value) + if isinstance(value, Sequence) and not isinstance(value, exclude_classes) + else SeqCls(value) + ) ) From bc0d4181a04040d6a68e4ad1344106aed27fefa0 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:12:50 +0900 Subject: [PATCH 142/333] `_get_filter_option` - fixed the regex support unforeseen option line format --- src/ffmpegio/caps.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index c7425030..66edc61c 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -894,8 +894,7 @@ def _get_filter_option(str, name): # first line is the main option definition m0 = re.match( - r" (?: |-)?([^ \n]+) {1,17}(?:\<([^ >]+)\> {1,12}| {13})" - r"[.E][.D][.F]([.V])([.A])[.S][.X][.R][.B]([.T])[.P]", + r" (?: |-)(.+?)\s+\[?\<(.+?)\>\s*\]?[.E][.D][.F]([.V])([.A])[.S][.X][.R][.B]([.T])[.P]", lines[0], ) if not m0: From 3b5af8ebac14f2d53a9670295ea20fb19d690870 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:13:47 +0900 Subject: [PATCH 143/333] added `is_unique_stream()` --- src/ffmpegio/stream_spec.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index c489bba9..ed1eb166 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -13,6 +13,7 @@ Union, Tuple, FFmpegMediaType, + MediaType, NotRequired, ) @@ -305,6 +306,24 @@ def stream_spec( return spec if no_join else ":".join(spec) +def is_unique_stream( + spec: StreamSpecDict, *, return_media_type: bool = False +) -> bool | MediaType: + """True if a stream is uniquely specified by the stream specifier dictionary + + :param spec: _description_ + :param return_media_type: True to return the media type (e.g., 'video' and 'audio'), + defaults to False + :return: True or a media type string if the stream specifier yields a unique stream. + """ + if "index" in spec: + if return_media_type and "stream_type" in spec: + return stream_type_to_media_type(spec["stream_type"]) + else: + return True + return False + + ################################# From 3ebefd67a768f89ba43b7e0317dc80b8f964e53a Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:14:17 +0900 Subject: [PATCH 144/333] optimization --- src/ffmpegio/stream_spec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index ed1eb166..256476d5 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -361,7 +361,7 @@ def parse_map_option( if input_file_id is not None: s1 = map.split(":", 1) - if len(s1) == 1 or not s1[0].isdigit(): + if not s1[0].isdigit(): map = f"{input_file_id}:{map}" m = re.match(r"(-)?(\d+)(\:[^?]+?)?(\?)?$", map) From 03bc08fc7dbe5086ef31112e2bba3e437c675fab Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:16:25 +0900 Subject: [PATCH 145/333] `probe` - `streams` arguments to accept `StreamSpecDict` --- src/ffmpegio/probe.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 6c4747fe..5bd57d6b 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -16,6 +16,7 @@ from .path import ffprobe, PIPE from .errors import FFmpegError +from .stream_spec import StreamSpecDict, stream_spec as compose_stream_spec # fmt:off __all__ = ['full_details', 'format_basic', 'streams_basic', @@ -54,6 +55,8 @@ def try_conv(v): def _add_select_streams(args, stream_specifier): if stream_specifier: + if isinstance(stream_specifier, dict): + stream_specifier = compose_stream_spec(**stream_specifier) args.extend(["-select_streams", str(stream_specifier)]) return args @@ -169,7 +172,7 @@ def _exec( url: str | IO | Buffer, entries: str, sp_kwargs: tuple[tuple[str, Any]] | None = None, - streams: str | int | None = None, + streams: str | int | StreamSpecDict | None = None, intervals: IntervalSpec | Sequence[IntervalSpec] | None = None, count_frames: bool | None = False, count_packets: bool | None = False, @@ -414,7 +417,7 @@ def streams_basic( keep_str_values: bool | None = False, cache_output: bool | None = False, sp_kwargs: dict[str, Any] | None = None, - stream_spec: str | None = None, + stream_spec: str | int | StreamSpecDict | None = None, *, f: str | None = None, ) -> list[dict[str, str | Number | Fraction]]: @@ -678,7 +681,7 @@ def adjust(res): def query( url: str | BinaryIO | memoryview, - streams: str | int | bool | None = None, + streams: str | int | StreamSpecDict | bool | None = None, fields: Sequence[str] | None = None, keep_optional_fields: bool | None = None, keep_str_values: bool | None = False, @@ -755,7 +758,7 @@ def query( def frames( url: str | BinaryIO | memoryview, entries: Sequence[str] | None = None, - streams: str | int | None = None, + streams: str | int | StreamSpecDict | None = None, intervals: IntervalSpec | Sequence[IntervalSpec] | None = None, accurate_time: bool | None = False, sp_kwargs: dict[str, Any] | None = None, From 4d0ade2a1622d7392af7468b8d7aa21039ad62f4 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:18:55 +0900 Subject: [PATCH 146/333] `__exit__()` to return `False` so exceptions propagate --- src/ffmpegio/threading.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 69b9514f..5c87fa2f 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -72,6 +72,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): self.join() + return False def run(self): callback, tempdir, timeout = self._args @@ -157,7 +158,7 @@ def __enter__(self): def __exit__(self, *_): self.stderr.close() self.join() # will wait until stderr is closed - return self + return False def run(self): logger.debug("[logger] starting") @@ -330,7 +331,7 @@ def __enter__(self): def __exit__(self, *_): self.stdout.close() self.join() # will wait until stdout is closed - return self + return False def run(self): is_npipe = isinstance(self.stdout, NPopen) @@ -532,7 +533,7 @@ def __enter__(self): def __exit__(self, *_): self.join() # will wait until stdout is closed - return self + return False def run(self): @@ -895,7 +896,7 @@ def __enter__(self): def __exit__(self, *_): self.join() - return self + return False def run(self): src_is_namedpipe = isinstance(self._fsrc, NPopen) From d4c0977dd948dd36a84fe94bc021219cc91d7259 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:20:16 +0900 Subject: [PATCH 147/333] code formatting --- src/ffmpegio/_utils.py | 1 + src/ffmpegio/caps.py | 34 ++++++++++++++++------------------ src/ffmpegio/threading.py | 8 +++++--- src/ffmpegio/utils/__init__.py | 7 ++++--- 4 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index b93febde..c59efcbf 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -173,6 +173,7 @@ def is_fileobj( return True + def escape(txt: str) -> str: """apply FFmpeg single quote escaping diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index 66edc61c..976aa778 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -185,17 +185,17 @@ def filters(type=None): data[match[4]] = FilterSummary( description=match[7], input=intype, - num_inputs=0 - if intype == "none" - else len(match[5]) - if intype != "dynamic" - else None, + num_inputs=( + 0 + if intype == "none" + else len(match[5]) if intype != "dynamic" else None + ), output=outtype, - num_outputs=0 - if outtype == "none" - else len(match[6]) - if outtype != "dynamic" - else None, + num_outputs=( + 0 + if outtype == "none" + else len(match[6]) if outtype != "dynamic" else None + ), timeline_support=match[1] == "T", slice_threading=match[2] == "S", command_support=match[3] == "C", @@ -920,11 +920,11 @@ def _get_filter_option(str, name): conv = ( partial(_conv_func, int) if type in ("int", "int64", "uint64") - else partial(_conv_func, float) - if type in ("float", "double") - else partial(_conv_func, Fraction) - if type == "rational" - else (lambda s: s) + else ( + partial(_conv_func, float) + if type in ("float", "double") + else partial(_conv_func, Fraction) if type == "rational" else (lambda s: s) + ) ) ranges = ( @@ -1092,9 +1092,7 @@ def filter_info(name): options = extra_options.pop(opt_name) elif len(extra_options) == 1: o_name, options = extra_options.popitem() - logger.info( - f"filter_info({name}): assigned mismatched AVOptions {o_name}." - ) + logger.info(f"filter_info({name}): assigned mismatched AVOptions {o_name}.") else: logger.warning( f"filter_info({name}): none of the AVOption sets appears to be the main option set:\n {[k for k in extra_options]}" diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 5c87fa2f..27678395 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -268,8 +268,8 @@ def join_and_raise(self, timeout: float | None = None): :raises e: FFmpegError only if log is present Note: This method throws the exception regardless of the thread's status if log is available. - """ - + """ + self.join(timeout) e = self.Exception if e is not None: @@ -488,7 +488,9 @@ def read_all(self, timeout: float | None = None) -> bytes: while True: # if not self.is_alive() or timeout and timeout > time(): try: - data = self._queue.get(self.is_alive() and self._collect, timeout and timeout - time()) + data = self._queue.get( + self.is_alive() and self._collect, timeout and timeout - time() + ) self._queue.task_done() assert data is not None arrays.append(data) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 85a9cf20..2b5bd86b 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -11,7 +11,8 @@ from math import cos, radians, sin -import re, fractions +import re +from fractions import Fraction from .. import caps, plugins, probe from .._utils import * @@ -299,9 +300,9 @@ def parse_video_size(expr: str | tuple[int, int]) -> tuple[int, int]: return expr -def parse_frame_rate(expr) -> fractions.Fraction: +def parse_frame_rate(expr) -> Fraction: try: - return fractions.Fraction(expr) + return Fraction(expr) except ValueError: return caps.frame_rate_presets[expr] From 30878d4c80075aa20730a001d894502a400aa0e6 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:22:53 +0900 Subject: [PATCH 148/333] refactoring `utils.audio_codecs` and added `configure.raw_formats`, borrowing values form `utils.audio_codecs` --- src/ffmpegio/configure.py | 2 ++ src/ffmpegio/utils/__init__.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 66d8192a..3c5363fb 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -59,6 +59,8 @@ FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, dict] FFmpegOutputOptionTuple = tuple[FFmpegUrlType, dict] +raw_formats = ("rawvideo", *(formats for _, formats in utils.audio_codecs.values())) + class FFmpegArgs(TypedDict): """FFmpeg arguments""" diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 2b5bd86b..351a10f4 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -210,6 +210,16 @@ def get_rotated_shape(w: int, h: int, deg: float) -> tuple[int, int]: # return int(round(abs(X[0, 0] - X[0, 2]))), int(round(abs(X[1, 1]))), theta +audio_codecs = dict( + u8=("pcm_u8", "u8"), + s16=("pcm_s16le", "s16le"), + s32=("pcm_s32le", "s32le"), + s64=("pcm_s64le", "s64le"), + flt=("pcm_f32le", "f32le"), + dbl=("pcm_f64le", "f64le"), +) + + def get_audio_codec(fmt: str) -> tuple[str, str]: """get pcm audio codec & format @@ -217,14 +227,7 @@ def get_audio_codec(fmt: str) -> tuple[str, str]: :return: tuple of pcm codec name and container format """ try: - return dict( - u8=("pcm_u8", "u8"), - s16=("pcm_s16le", "s16le"), - s32=("pcm_s32le", "s32le"), - s64=("pcm_s64le", "s64le"), - flt=("pcm_f32le", "f32le"), - dbl=("pcm_f64le", "f64le"), - )[fmt] + return audio_codecs[fmt] except: raise ValueError(f"{fmt} is not a valid raw audio sample_fmt") From 7dd64bcbc835a964c744acf6294e9c79718e19fb Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:25:21 +0900 Subject: [PATCH 149/333] refactored `analyze_input_file()` from `analyze_input_stream()` --- src/ffmpegio/utils/__init__.py | 64 +++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 20 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 351a10f4..4a351f1c 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -540,31 +540,28 @@ def set_sp_kwargs_stdin( return url, sp_kwargs, exit_fcn -def analyze_input_stream( +def analyze_input_file( fields: list[str], - stream: str, - media_type: MediaType, input_url: str | None, input_opts: dict, input_info: InputSourceDict, -) -> list: - """analyze a stream and return requested field values + stream: str | StreamSpecDict | None = None, +) -> list[list]: + """analyze a file and return requested field values of all returned streams :param fields: a list of stream properties - :param stream: stream specifier, first one is returned if it yields more than one stream, :param input_url: url or None if piped or fileobj :param input_opts: input options :param input_info: input infomration - :raises NotImplementedError: _description_ + :param stream: stream specifier, defaults to None to return all streams :return values of the requested fields of the stream """ - fields = [*fields, "codec_type"] input_url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(input_url, input_info) try: - q = probe.query( + return probe.query( input_url, - stream, + stream or True, fields, keep_optional_fields=True, keep_str_values=False, @@ -572,21 +569,48 @@ def analyze_input_stream( sp_kwargs=sp_kwargs, f=input_opts.get("f", None), ) - except FFmpegError: - # no change - return [None] * (len(fields) - 1) - else: - q = [i for i in q if i["codec_type"] == media_type] - if len(q) != 1: - raise FFmpegioError( - f"Specified {stream=} must resolve to one and only one {media_type} stream." - ) + except: + raise finally: # rewind fileobj if possible exit_fcn() + +def analyze_input_stream( + fields: list[str], + stream: str, + media_type: MediaType, + input_url: str | None, + input_opts: dict, + input_info: InputSourceDict, +) -> list: + """analyze a stream and return requested field values + + :param fields: a list of stream properties + :param stream: stream specifier, first one is returned if it yields more than one stream, + :param input_url: url or None if piped or fileobj + :param input_opts: input options + :param input_info: input infomration + :raises NotImplementedError: _description_ + :return values of the requested fields of the stream + """ + + try: + q = analyze_input_file( + [*fields, "codec_type"], input_url, input_opts, input_info, stream + ) + except FFmpegError: + # no change + return [None] * len(fields) + + q = [i for i in q if media_type is None or i["codec_type"] == media_type] + if len(q) != 1: + raise FFmpegioError( + f"Specified {stream=} must resolve to one and only one {media_type} stream." + ) + q = q[0] - return [q.get(f, None) for f in fields[:-1]] + return [q.get(f, None) for f in fields] def analyze_complex_filtergraphs( From 4514f880d961a5783f8a845757949b75c6c2fc7a Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:29:25 +0900 Subject: [PATCH 150/333] refactored `utils.analyze_video_stream()` from `configure.finalize_video_read_opts()` --- src/ffmpegio/configure.py | 44 +++++++++------------------------- src/ffmpegio/utils/__init__.py | 35 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 3c5363fb..069e7ba5 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -298,14 +298,12 @@ def finalize_video_read_opts( """ options = ["r", "pix_fmt", "s"] - fields = ["pix_fmt", "width", "height", "r_frame_rate", "avg_frame_rate"] - - def flds2opts(pix_fmt, width, height, r1, r2): - return r1 or r2, pix_fmt, (width, height) if width and height else None outopts = args["outputs"][ofile][1] outmap = outopts["map"] - outmap_fields = parse_map_option(outmap) + outmap_fields = parse_map_option( + outmap, input_file_id=0 if len(args["inputs"]) == 1 else None + ) has_simple_filter = "vf" in outopts or "filter:v" in outopts fill_color = outopts.get("fill_color", None) if fill_color is not None and "remove_alpha" not in outopts: @@ -329,25 +327,12 @@ def flds2opts(pix_fmt, width, height, r1, r2): ifile = outmap_fields["input_file_id"] - # check the input option data - inurl, inopts = args["inputs"][ifile] - - # get input options - inopt_vals = [inopts.get(o, None) for o in options] - - # directly from the input url (if not forced via input options) - if not all(inopt_vals): - st_vals = flds2opts( - *utils.analyze_input_stream( - fields, - outmap_fields["stream_specifier"], - "video", - inurl, - inopts, - input_info[ifile], - ) - ) - inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + # get input option values + inopt_vals = utils.analyze_video_stream( + outmap_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) if has_simple_filter: @@ -359,15 +344,8 @@ def flds2opts(pix_fmt, width, height, r1, r2): outpad = next(vf.iter_output_pads(unlabeled_only=True), None) if outpad is not None: vf = vf >> "[out0]" - inopt_vals = flds2opts( - *utils.analyze_input_stream( - fields, - "0", - "video", - vf, - {"f": "lavfi"}, - {"src_type": "filtergraph"}, - ) + inopt_vals = utils.analyze_video_stream( + "0", vf, {"f": "lavfi"}, {"src_type": "filtergraph"} ) # assign the values to individual variables diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 4a351f1c..3fa2410b 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -613,6 +613,41 @@ def analyze_input_stream( return [q.get(f, None) for f in fields] +def video_fields_to_options(pix_fmt, width, height, r1, r2): + return r1 or r2, pix_fmt, (width, height) if width and height else None + + +def analyze_video_stream( + stream_specifier: str, inurl: str, inopts: dict, input_info: InputSourceDict +) -> tuple[int | Fraction | None, str | None, tuple[int, int] | None]: + """analyze video stream core attributes + + :param args: FFmpeg arguments (will be modified) + :param ofile: output index, defaults to 0 + :param input_info: source information of the inputs, defaults to [] + :return r: video framerate + :return pix_fmt: pixel format + :return s: video shape tuple (width, height) + """ + + options = ["r", "pix_fmt", "s"] + fields = ["pix_fmt", "width", "height", "r_frame_rate", "avg_frame_rate"] + + # get input options + inopt_vals = [inopts.get(o, None) for o in options] + + # directly from the input url (if not forced via input options) + if not all(inopt_vals): + st_vals = video_fields_to_options( + *analyze_input_stream( + fields, stream_specifier, "video", inurl, inopts, input_info + ) + ) + inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + + return inopt_vals + + def analyze_complex_filtergraphs( filtergraphs: list[FilterGraphObject], inputs: list[tuple[str | None, dict]], From f277d549d4886707b02f70eac83f8619339e7d57 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:31:38 +0900 Subject: [PATCH 151/333] refactored `utils.analyze_audio_stream()` from `configure.finalize_audio_read_opts()` --- src/ffmpegio/configure.py | 24 ++++++++-------------- src/ffmpegio/utils/__init__.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 069e7ba5..6d5a9f21 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -480,11 +480,12 @@ def finalize_audio_read_opts( """ options = ["ar", "sample_fmt", "ac"] - fields = ["sample_rate", "sample_fmt", "channels"] outopts = args["outputs"][ofile][1] outmap = outopts["map"] - outmap_fields = parse_map_option(outmap) + outmap_fields = parse_map_option( + outmap, input_file_id=0 if len(args["inputs"]) == 1 else None + ) # use the output options by default opt_vals = [outopts.get(o, None) for o in options] @@ -501,20 +502,11 @@ def finalize_audio_read_opts( ifile = outmap_fields["input_file_id"] # get input option values - inurl, inopts = args["inputs"][ifile] - inopt_vals = [inopts.get(o, None) for o in options] - - # fill the still missing values directly from the input url - if not all(inopt_vals): - st_vals = utils.analyze_input_stream( - fields, - outmap_fields["stream_specifier"], - "audio", - inurl, - inopts, - input_info[ifile], - ) - inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + inopt_vals = utils.analyze_audio_stream( + outmap_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) # if a simple filter is present, use the stream specs of its output if "af" in outopts or "filter:a" in outopts: diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 3fa2410b..23b5f81a 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -648,6 +648,43 @@ def analyze_video_stream( return inopt_vals +def analyze_audio_stream( + stream_specifier: str, inurl: str, inopts: dict, input_info: InputSourceDict +) -> tuple[int | None, str | None, int | None]: + """analyze input audio stream + + :param args: FFmpeg arguments. The option dict in args['outputs'][ofile][1] may be modified. + :param ofile: output file index, defaults to 0 + :param input_info: list of input information, defaults to None + :return ar: sampling rate + :return sample_fmt: input data type (Numpy style) + :return ac: number of channels + + * Possible Output Options Modification + - "f" and "c:a" - raw audio format and codec will always be set + - "sample_fmt" - planar format to non-planar equivalent format or 'dbl' if format is unknown + - + + * args['outputs'][ofile]['map'] is a valid mapping str (not a list of str) + * If complex filtergraph(s) is used, args['global_options']['filter_complex'] must be a list of fgb.Graph objects + + """ + + options = ["ar", "sample_fmt", "ac"] + fields = ["sample_rate", "sample_fmt", "channels"] + + inopt_vals = [inopts.get(o, None) for o in options] + + # fill the still missing values directly from the input url + if not all(inopt_vals): + st_vals = analyze_input_stream( + fields, stream_specifier, "audio", inurl, inopts, input_info + ) + inopt_vals = [v or s for v, s in zip(inopt_vals, st_vals)] + + return inopt_vals + + def analyze_complex_filtergraphs( filtergraphs: list[FilterGraphObject], inputs: list[tuple[str | None, dict]], From 640377d1c0f8a29c9c32dfd17532a74f7fa9cbe9 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:34:01 +0900 Subject: [PATCH 152/333] `FilterGraphObject.get_num_filters()` can now return the total number of filters (default) --- src/ffmpegio/filtergraph/Chain.py | 5 +++-- src/ffmpegio/filtergraph/Filter.py | 5 +++-- src/ffmpegio/filtergraph/Graph.py | 8 ++++++-- src/ffmpegio/filtergraph/abc.py | 5 +++-- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index bd808016..b472ddb3 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -101,10 +101,11 @@ def get_num_chains(self) -> int: """get the number of chains""" return len(self) - def get_num_filters(self, chain: int) -> int: + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ if chain: diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 768b283b..dbe48ff9 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -491,10 +491,11 @@ def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: return index - def get_num_filters(self, chain: int) -> int: + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ if chain: diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 88eebe2e..efb303af 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -127,12 +127,16 @@ def get_num_chains(self) -> int: """get the number of hains""" return len(self) - def get_num_filters(self, chain: int) -> int: + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ + if chain is None: + return sum(len(fc) for fc in self) + if chain < 0 or chain >= len(self): raise ValueError(f"{chain=} is invalid.") return len(self[chain]) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 939ca508..de956a7c 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -40,10 +40,11 @@ def get_num_chains(self) -> int: """get the number of chains""" @abstractmethod - def get_num_filters(self, chain: int) -> int: + def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain + :param chain: id of the chain, defaults to None to get the total number + of filters across all chains """ def next_input_pad( From 47a2d1e6b5c22544816473cede87d39e9cfc84bd Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:42:27 +0900 Subject: [PATCH 153/333] `iter_input_pads` added two keyword arguments: `eclude_stram_specs` and `only_stream_specs` changed behavior of `include_connected` keyword --- src/ffmpegio/filtergraph/Chain.py | 4 ++++ src/ffmpegio/filtergraph/Filter.py | 4 ++++ src/ffmpegio/filtergraph/Graph.py | 15 +++++++++------ src/ffmpegio/filtergraph/abc.py | 4 ++++ tests/test_filtergraph.py | 2 +- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index b472ddb3..d25eabcf 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -315,6 +315,8 @@ def iter_input_pads( filter: int | None = None, chain: Literal[0] | None = None, *, + exclude_stream_specs: bool = False, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -327,6 +329,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index dbe48ff9..42dc5945 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -599,6 +599,8 @@ def iter_input_pads( filter: Literal[0] | None = None, chain: Literal[0] | None = None, *, + exclude_stream_specs: bool = False, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -611,6 +613,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index efb303af..0b68bed1 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -499,6 +499,8 @@ def iter_input_pads( filter: int | None = None, chain: int | None = None, *, + exclude_stream_specs: bool = True, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -511,6 +513,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False @@ -533,10 +537,9 @@ def iter_input_pads( chainable_only, ): # exclude a pad connected to an input stream - if ( - not include_connected - and isinstance(other_pidx, str) - and is_map_option(other_pidx, allow_missing_file_id=True) + is_stream_spec = is_map_option(other_pidx, allow_missing_file_id=True) + if (is_stream_spec and exclude_stream_specs) or ( + not is_stream_spec and only_stream_specs ): continue @@ -584,13 +587,13 @@ def iter_output_pads( yield v def get_num_inputs(self, chainable_only=False): - return len(list(self.iter_input_pads(chainable_only=chainable_only))) + return len(list(self.iter_input_pads(exclude_stream_specs=True, chainable_only=chainable_only))) def get_num_outputs(self, chainable_only=False): return len(list(self.iter_output_pads(chainable_only=chainable_only))) def iter_input_labels( - self, exclude_stream_specs: bool = False + self, exclude_stream_specs: bool = False, only_stream_specs: bool = False ) -> Generator[tuple[str, PAD_INDEX]]: """iterate over the dangling labeled input pads of the filtergraph object diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index de956a7c..d531bd54 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -161,6 +161,8 @@ def iter_input_pads( filter: int | None = None, chain: int | None = None, *, + exclude_stream_specs: bool = False, + only_stream_specs: bool = False, exclude_chainable: bool = False, chainable_first: bool = False, include_connected: bool = False, @@ -173,6 +175,8 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None + :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index 1253b322..0ba58283 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -11,7 +11,7 @@ [ # 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][1:v]vstack", None, None, None, False, False, True, False, []), ("[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)]), From 52c46867005da973a7b1b2421f9cdf8bedfa9776 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:45:22 +0900 Subject: [PATCH 154/333] `iter_input_labels()` added only_stream_specs` argument --- src/ffmpegio/filtergraph/Graph.py | 5 ++++- src/ffmpegio/filtergraph/GraphLinks.py | 10 ++++++---- src/ffmpegio/filtergraph/abc.py | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 0b68bed1..19972a7d 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -598,9 +598,12 @@ def iter_input_labels( """iterate over the dangling labeled input pads of the filtergraph object :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :yield: a tuple of 3-tuple pad index and the pad index of the connected output pad if connected """ - for label_index in self._links.iter_inputs(exclude_stream_specs): + for label_index in self._links.iter_inputs( + exclude_stream_specs, only_stream_specs + ): yield label_index def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 47d261c6..2ee0a5d3 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -456,18 +456,20 @@ def iter(label, inpad, outpad): yield v def iter_inputs( - self, exclude_stream_specs: bool = True + self, exclude_stream_specs: bool = True, only_stream_specs: bool = False ) -> Generator[tuple[str, PAD_INDEX]]: """Iterate over only input labels, possibly repeating the same label if shared among multiple input pad ids :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :yield: label and pad index """ for label, (inpad, outpad) in self.data.items(): - if outpad is None and not ( - exclude_stream_specs - and is_map_option(label, allow_missing_file_id=True) + is_stream = is_map_option(label, allow_missing_file_id=True) + if outpad is None and ( + (is_stream and not exclude_stream_specs) + or not (is_stream or only_stream_specs) ): for d in self.iter_inpad_ids(inpad): yield (label, d) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index d531bd54..e5d83009 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -217,11 +217,12 @@ def iter_output_pads( # Label management methods (default operation for non-Graph objects) def iter_input_labels( - self, exclude_stream_specs: bool = False + self, exclude_stream_specs: bool = False, only_stream_specs: bool = False ) -> Generator[tuple[str, PAD_INDEX]]: """iterate over the dangling labeled input pads of the filtergraph object :param exclude_stream_specs: True to not include input streams + :param only_stream_specs: True to only include input streams :yield: a tuple of 3-tuple pad index and the pad index of the connected output pad if connected """ From f4b4418ea6e667134a6aae50ce1d1832bbc05246 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:46:14 +0900 Subject: [PATCH 155/333] `get_num_chains()` always returns `1` --- src/ffmpegio/filtergraph/Chain.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index d25eabcf..f0befc04 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -99,7 +99,7 @@ def __setitem__(self, key, value): def get_num_chains(self) -> int: """get the number of chains""" - return len(self) + return 1 def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain From 5d8ece1514f1746eec7febb259182623f88d3645 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:48:15 +0900 Subject: [PATCH 156/333] fixed `iter_input_labels()` and `iter_output_labels()` for non-`Graph` filtergraph objects (nothing to iterate) --- src/ffmpegio/filtergraph/abc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index e5d83009..522f3e96 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -226,7 +226,7 @@ def iter_input_labels( :yield: a tuple of 3-tuple pad index and the pad index of the connected output pad if connected """ - raise StopIteration() + yield from () def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: """iterate over the dangling labeled output pads of the filtergraph object @@ -234,7 +234,7 @@ def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: :yield: a tuple of 3-tuple pad index and the pad index of the connected input pad if connected """ - raise StopIteration() + yield from () def get_label( self, From d692874403e7876b62298f074b819863639b3cc9 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:49:01 +0900 Subject: [PATCH 157/333] added `has_label()` --- src/ffmpegio/filtergraph/Graph.py | 30 +++++++++++++++++++++++++++++- src/ffmpegio/filtergraph/abc.py | 11 +++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 19972a7d..8696bbcf 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -15,7 +15,7 @@ from ..stream_spec import is_map_option from .. import filtergraph as fgb -from .typing import PAD_INDEX +from .typing import PAD_INDEX, Literal from .exceptions import * from .GraphLinks import GraphLinks @@ -711,6 +711,34 @@ def link( return self._links.link(inpad, outpad, label, preserve_label, force) + def has_label( + self, label: str, only_if: Literal["input", "output", "internal"] | None = None + ) -> bool: + """True if a linklabel is defined + + :param label: name of the link label + :param only_if: also check for the type of the label + :return: True if exists + """ + try: + link = self._links[label] + except KeyError: + return False + + return ( + True + if only_if is None + else ( + (only_if == "input" and link[1] is None) + or (only_if == "output" and link[0] is None) + or ( + only_if == "internal" + and link[0] is not None + and link[1] is not None + ) + ) + ) + def add_label( self, label: str, diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 522f3e96..8a0b8755 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -336,6 +336,17 @@ def add_label( """ + def has_label( + self, label: str, only_if: Literal["input", "output", "internal"] | None = None + ) -> bool: + """True if a linklabel is defined + + :param label: name of the link label + :param only_if: also check for the type of the label + :return: True if exists + """ + return False # reimplemented by Graph + @abstractmethod def _connect( self, From f5207230cbe9d7730c6249d8cf5f3d6dcfaab18e Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:50:20 +0900 Subject: [PATCH 158/333] shift operators to check for empty other filtergraph --- src/ffmpegio/filtergraph/abc.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 8a0b8755..27d6a5ac 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -732,6 +732,10 @@ def parse_other(other): # if output is a list if isinstance(other, list): + + if len(other) == 0: + raise ValueError("At least one `other` filtergraph must be specified.") + # match the pad indices first right, left_on, right_on = [ [*t] for t in zip(*(parse_other(o) for o in other)) @@ -782,6 +786,10 @@ def parse_other(other): # if output is a list if isinstance(other, list): + + if len(other) == 0: + raise ValueError("At least one `other` filtergraph must be specified.") + # match the pad indices first left, right_on, left_on = [ [*t] for t in zip(*(parse_other(o) for o in other)) From d937ef33a5673f2d31b47d510d8e8a76c86ba148 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:54:06 +0900 Subject: [PATCH 159/333] `Graph.remove_label()` added `inpad` argument. Also added `FilterGraphObject.remove_label()` (does nothing by default) --- src/ffmpegio/filtergraph/Graph.py | 6 ++++-- src/ffmpegio/filtergraph/abc.py | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 8696bbcf..d53ade64 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -797,13 +797,15 @@ def add_label( return self - def remove_label(self, label: str): + def remove_label(self, label: str, inpad: PAD_INDEX | None = None): """remove an input/output label :param label: linkn label + :param inpad: specify input pad if multiple pads receives the same input + stream, defaults to `None` to delete all input pads. """ - self._links.remove_label(label) + self._links.remove_label(label, inpad) def rename_label(self, old_label: str, new_label: str) -> str | None: """rename an existing link label diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 27d6a5ac..e49e52bd 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -347,6 +347,14 @@ def has_label( """ return False # reimplemented by Graph + def remove_label(self, label: str, inpad: PAD_INDEX | None = None): + """remove an input/output label + + :param label: linkn label + :param inpad: specify input pad if multiple pads receives the same input + stream, defaults to `None` to delete all input pads. + """ + @abstractmethod def _connect( self, From 27b4902fa19a7a11dc28e3b0397034bea3250aff Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:54:53 +0900 Subject: [PATCH 160/333] `join()` to check empty FGs --- src/ffmpegio/filtergraph/build.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 9261e5b3..5d9b7321 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -1,6 +1,7 @@ from __future__ import annotations from itertools import islice +from copy import copy from .typing import PAD_INDEX, JOIN_HOW, Literal, get_args @@ -160,6 +161,18 @@ def join( :return: Graph with the appended filter chains or None if inplace=True. """ + # if one of the filtergraphs is empty, return the other (or a copy thereof) + if not fgb.as_filtergraph_object(right).get_num_filters(): + if inplace: + return left + else: + return fgb.as_filtergraph_object(left).copy() + if not fgb.as_filtergraph_object(left).get_num_filters(): + if inplace: + return right + else: + return copy(fgb.as_filtergraph_object(right)) + if how is None: how = "auto" if n_links is None: From 56e6960efffff5476cf1994f268a39e5115088e4 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:55:24 +0900 Subject: [PATCH 161/333] `stack()` to ignore empty chain or graph --- src/ffmpegio/filtergraph/build.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 5d9b7321..189c4fe9 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -403,7 +403,11 @@ def stack( TO-CHECK/TO-DO: what happens if common link labels are already linked """ - fgs = [fg for fg in fgs if fg.get_num_chains()] + fgs = [ + fg + for fg in (fgb.as_filtergraph_object(fg1) for fg1 in fgs) + if fg.get_num_filters() + ] n = len(fgs) if not n: return fgb.Graph() From b63b229bb21b5a6d0c1135a28e3e96264a3e2c36 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 11:57:52 +0900 Subject: [PATCH 162/333] completed `analyze_complex_filtergraphs()` --- src/ffmpegio/utils/__init__.py | 163 +++++++++++++++++++++++++-------- 1 file changed, 127 insertions(+), 36 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 23b5f81a..754f6958 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -21,6 +21,7 @@ from .._typing import Any, MediaType, InputSourceDict from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb +from ..filtergraph.presets import temp_video_src, temp_audio_src # TODO: auto-detect endianness # import sys @@ -686,49 +687,139 @@ def analyze_audio_stream( def analyze_complex_filtergraphs( - filtergraphs: list[FilterGraphObject], + filtergraphs: list[FilterGraphObject | str], inputs: list[tuple[str | None, dict]], inputs_info: list[InputSourceDict], - fields: list[str] | None = None, -) -> list: +) -> tuple[list[FilterGraphObject], dict[str, dict]]: """analyze filtergraphs and return requested field values :param fields: a list of stream properties :param stream: stream specifier, first one is returned if it yields more than one stream, - :param input_url: url or None if piped or fileobj - :param input_opts: input options - :param input_info: input infomration - :raises NotImplementedError: _description_ - :return values of the requested fields of the stream + :param inputs: input url/options pairs + :param input_info: input information + :return filters_complex: list of filtergraphs with all the unnamed outputs auto-named + :return fg_info: all the output pads and their properties """ - fg = fgb.stack(filtergraphs) - - fields = [*fields, "codec_type"] - input_url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(input_url, input_info) - try: - q = probe.query( - input_url, - stream, - fields, - keep_optional_fields=True, - keep_str_values=False, - cache_output=True, - sp_kwargs=sp_kwargs, - f=input_opts.get("f", None), - ) - except FFmpegError: - # no change - return [None] * (len(fields) - 1) - else: - q = [i for i in q if i["codec_type"] == media_type] - if len(q) != 1: - raise FFmpegioError( - f"Specified {stream=} must resolve to one and only one {media_type} stream." + filtergraphs = [ + fgb.as_filtergraph_object(fg, copy=True) + for fg in as_multi_option(filtergraphs, (str, FilterGraphObject)) + ] + + # name the output + i = 0 + for j, fg in enumerate(filtergraphs): + new_labels = [] + for padidx, filt, _ in fg.iter_output_pads( + full_pad_index=True, unlabeled_only=True + ): + label = f"out{i}" + while fg.has_label(label): + i += 1 + label = f"out{i}" + i += 1 + new_labels.append((padidx, label)) + for padidx, label in new_labels: + fg = fg.add_label(label, outpad=padidx) + filtergraphs[j] = fg + + # combine all filtergraphs + fg = fgb.stack(*filtergraphs, auto_link=True) + + # get list of connected input streams + sources = [] + labels = set() + + # for a filter or a filterchain, no labels. Connect all its inputs + for i, (padidx, filt, _) in enumerate( + fg.iter_input_pads(full_pad_index=True, exclude_stream_specs=False) + ): + + label = fg.get_label(inpad=padidx) + media_type = filt.get_pad_media_type("input", padidx[-1]) + + if label is None: + file_id = 0 + sspec = None + if i > 0: + raise FFmpegioError( + f"All the input pads of a filtergraph with more than one inputs must have them labeled." + ) + else: + map_option = parse_map_option(label) + file_id = map_option["input_file_id"] + sspec = map_option.get("stream_specifier", None) + labels.add(label) + + if media_type == "audio": + src = temp_audio_src( + *analyze_audio_stream( + sspec or "a:0", *inputs[file_id], inputs_info[file_id] + ) ) - finally: - # rewind fileobj if possible - exit_fcn() + elif media_type == "video": + src = temp_video_src( + *analyze_video_stream( + sspec or "v:0", *inputs[file_id], inputs_info[file_id] + ) + ) + else: + raise FFmpegioError(f"unknown media type of a filter") + + sources.append((src, (0, len(src) - 1, 0), padidx)) + + # remove all the input labels + for label in labels: + fg.remove_label(label) + + # add sources to the filtergraph + fg = sources >> fg + + # rename the output + fg_outputs = [] + for i, (padidx, filt, _) in enumerate(fg.iter_output_pads(full_pad_index=True)): + label = fg.get_label(outpad=padidx) + fg_outputs.append((label, f"out{i}", padidx)) + + for label, new_label, padidx in fg_outputs: + fg = fg.add_label(new_label, outpad=padidx, force=True) + + # query the filtergraph + fields = [ + "codec_type", + "pix_fmt", + "width", + "height", + "r_frame_rate", + "avg_frame_rate", + "sample_rate", + "sample_fmt", + "channels", + ] + streams = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + ) - q = q[0] - return [q.get(f, None) for f in fields[:-1]] + fg_info = {} + for (label, *_), q in zip(fg_outputs, streams): + label = f"[{label}]" + if q["codec_type"] == "audio": + fg_info[label] = { + "media_type": "audio", + "sample_fmt": q["sample_fmt"], + "ac": q["channels"], + "r": q["sample_rate"], + } + elif q["codec_type"] == "video": + fg_info[label] = { + "media_type": "video", + **{ + k: v + for k, v in zip( + ("r", "pix_fmt", "s"), + video_fields_to_options(*(q[f] for f in fields[1:6])), + ) + }, + } + + return filtergraphs, fg_info From 23e2cc4c706e39e8cc589d03fc5c4b2d294acb0e Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 13:58:30 +0900 Subject: [PATCH 163/333] `as_multi_option()` - fixed bug when converting --- src/ffmpegio/_utils.py | 2 +- src/ffmpegio/configure.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index c59efcbf..ae58c602 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -83,7 +83,7 @@ def as_multi_option( else ( SeqCls(value) if isinstance(value, Sequence) and not isinstance(value, exclude_classes) - else SeqCls(value) + else SeqCls([value]) ) ) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 6d5a9f21..08293793 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -286,6 +286,7 @@ def finalize_video_read_opts( args: FFmpegArgs, ofile: int = 0, input_info: list[InputSourceDict] = [], + fg_info: dict[str, dict] | None = None, ) -> tuple[str, tuple[int, int, int] | None, Fraction | None]: """finalize raw video read output options @@ -313,11 +314,12 @@ def finalize_video_read_opts( opt_vals = [outopts.get(o, None) for o in options] # get the options of the input/filtergraph output - if "linklabel" in outmap_fields: # mapping filtergraph output - # must be mapped a linklabel of a filter_complex global option - logger.warning( - "Pre-analysis of complex filtergraphs is not currently available." - ) + if linklabel := outmap_fields.get("linklabel", None): + if fg_info is None or (info := fg_info.get(linklabel, None)): + raise FFmpegioError( + f"Complex filtergraph or the specified {linklabel=} do not exist." + ) + inopt_vals = [None, None, None] # combine all the filtergraphs only for the analysis purpose # fg = fgb.stack(args["global_options"]["filter_complex"]) From 07c9862183940e270a15128e35f74685ce09a25b Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 13:58:54 +0900 Subject: [PATCH 164/333] `analyze_complex_filtergraphs()` fixed a bug --- src/ffmpegio/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 754f6958..b4918dc4 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -808,7 +808,7 @@ def analyze_complex_filtergraphs( "media_type": "audio", "sample_fmt": q["sample_fmt"], "ac": q["channels"], - "r": q["sample_rate"], + "ar": q["sample_rate"], } elif q["codec_type"] == "video": fg_info[label] = { From b62c4e66d4df604eb313fede38b1f1a5dff11854 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 13:59:38 +0900 Subject: [PATCH 165/333] `stack()` returns a copy even if tehere is only 1 input fg. --- src/ffmpegio/filtergraph/build.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 189c4fe9..2bc2e2c2 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -411,10 +411,12 @@ def stack( n = len(fgs) if not n: return fgb.Graph() - if n == 1: - return fgs[0] fg = fgb.as_filtergraph(fgs[0], copy=not inplace) + + if n == 1: + return fg + replace_sws_flags = None for other in fgs[1:]: if use_last_sws_flags is not None: From 95fc143eb6f953c84994615ff85475bae8e836f2 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:00:15 +0900 Subject: [PATCH 166/333] `stack()` - no need to cast more than once --- src/ffmpegio/filtergraph/build.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 2bc2e2c2..f36ed97a 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -421,6 +421,6 @@ def stack( for other in fgs[1:]: if use_last_sws_flags is not None: replace_sws_flags = True if fg.sws_flags is None else use_last_sws_flags - fg = fg._stack(fgb.as_filtergraph_object(other), auto_link, replace_sws_flags) + fg = fg._stack(other, auto_link, replace_sws_flags) return fg From 8276cca5c13fb2ade8da371545f967b01fc67b32 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:01:28 +0900 Subject: [PATCH 167/333] completed `process_raw_inputs()` --- src/ffmpegio/configure.py | 66 ++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 08293793..247d192f 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -12,6 +12,7 @@ IO, Buffer, InputSourceDict, + RawStreamDef, ) from collections.abc import Sequence @@ -1260,16 +1261,65 @@ def process_raw_outputs( def process_raw_inputs( args: FFmpegArgs, - input_opts: list[dict], + stream_types: Sequence[Literal["a", "v"]], + stream_args: Sequence[RawStreamDef], inopts_default: dict[str, Any], ) -> list[InputSourceDict]: - # add input streams - input_info = [] - for opts in input_opts: - add_url(args, "input", None, {**inopts_default, **opts}) - input_info.append( - {"src_type": "pipe", "media_type": "audio" if "ar" in opts else "video"} - ) + + input_info: list[InputSourceDict] = [] + for mtype, arg in zip(stream_types, stream_args): + + try: + a1, a2 = arg + if isinstance(a1, (int, float, Fraction)): + data = a2 + if mtype == "a": + opts = {"ar": round(a1)} + elif mtype == "v": + opts = {"r": a1} + else: + raise FFmpegioError( + "stream_type not specified, cannot resolve the `rate` input." + ) + else: + assert isinstance(a2, dict) + if mtype not in "av": # unknown + if "ar" in opts: + mtype = "a" + elif "r" in opts: + mtype = "v" + else: + raise FFmpegioError(f"unknown input stream media type") + data, opts = a1, a2 + except FFmpegioError: + raise + except: + raise ValueError( + f"""Invalid raw stream definition: {arg}.\nEach item of `stream_args` must be a two-element tuple: + - a rate (numeric) and a data_blob + - a data_blob and a dict of options + """ + ) + + opts = {**inopts_default, **opts} + + if mtype == "a": # audio + media_type = "audio" + if data is not None: + opts.update(utils.array_to_audio_options(data)) + data = plugins.get_hook().audio_bytes(obj=data) + + else: # video + media_type = "video" + if data is not None: + opts.update(utils.array_to_video_options(data)) + data = plugins.get_hook().video_bytes(obj=data) + + info = {"src_type": "buffer", "media_type": media_type} + if data is not None: + info["buffer"] = data + add_url(args, "input", None, opts) + input_info.append(info) return input_info From d2337fe04ad749885e5a5a95852f9e4819d87203 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:10:08 +0900 Subject: [PATCH 168/333] complex filtergraph support --- src/ffmpegio/configure.py | 171 +++++++++++++++++++------------------- 1 file changed, 85 insertions(+), 86 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 247d192f..f637946f 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -25,7 +25,7 @@ from namedpipe import NPopen -from . import utils, probe +from . import utils, probe, plugins from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject from .filtergraph.presets import ( @@ -41,6 +41,7 @@ stream_spec as compose_stream_spec, StreamSpecDict, stream_type_to_media_type, + is_unique_stream, parse_map_option, map_option as compose_map_option, ) @@ -79,6 +80,7 @@ class RawOutputInfoDict(TypedDict): media_type: MediaType | None # input_file_id: NotRequired[int | None] input_stream_id: NotRequired[int | None] + linklabel: NotRequired[str | None] media_info: NotRequired[dict[str, Any]] pipe: NotRequired[NPopen] reader: NotRequired[ReaderThread] @@ -316,14 +318,11 @@ def finalize_video_read_opts( # get the options of the input/filtergraph output if linklabel := outmap_fields.get("linklabel", None): - if fg_info is None or (info := fg_info.get(linklabel, None)): + if fg_info is None or not (info := fg_info.get(linklabel, None)): raise FFmpegioError( f"Complex filtergraph or the specified {linklabel=} do not exist." ) - - inopt_vals = [None, None, None] - # combine all the filtergraphs only for the analysis purpose - # fg = fgb.stack(args["global_options"]["filter_complex"]) + inopt_vals = [info["r"], info["pix_fmt"], info["s"]] else: # insert basic video filter if specified build_basic_vf(args, False, ofile) @@ -462,6 +461,7 @@ def finalize_audio_read_opts( args: FFmpegArgs, ofile: int = 0, input_info: list[InputSourceDict] = [], + fg_info: dict[str, dict] | None = None, ) -> tuple[str, tuple[int] | None, int | None]: """finalize a raw output audio stream @@ -493,14 +493,12 @@ def finalize_audio_read_opts( # use the output options by default opt_vals = [outopts.get(o, None) for o in options] if not all(opt_vals): - if "linklabel" in outmap_fields: # mapping filtergraph output - # must be mapped a linklabel of a filter_complex global option - logger.warning( - "Pre-analysis of complex filtergraphs is not currently available." - ) - st_vals = [None, None, None] - # combine all the filtergraphs only for the analysis purpose - # fg = fgb.stack(args["global_options"]["filter_complex"]) + if linklabel := outmap_fields.get("linklabel", None): + if fg_info is None or not (info := fg_info.get(linklabel, None)): + raise FFmpegioError( + f"Complex filtergraph or the specified {linklabel=} do not exist." + ) + opt_vals = [info["ar"], info["sample_fmt"], info["ac"]] else: ifile = outmap_fields["input_file_id"] @@ -922,7 +920,10 @@ def add_filtergraph( def resolve_raw_output_streams( - args: FFmpegArgs, input_info: list[InputSourceDict], streams: Sequence[str] + args: FFmpegArgs, + input_info: list[InputSourceDict], + fg_info: dict[str, dict], + streams: Sequence[str], ) -> dict[str:RawOutputInfoDict]: """resolve the raw output streams from given sequence of map options @@ -944,82 +945,73 @@ def resolve_raw_output_streams( ) ] - # check if stream specifiers single out mapping one input stream per output - if all( - (opt["stream_specifier"].get("stream_type", None) or "") in "avV" - and "index" in opt["stream_specifier"] - for opt in map_options - ): - # no need to run the stream mapping analysis - return { - compose_map_option(**opt): { + inputs = args["inputs"] + stream_info = {} # one stream per item, value: map spec & media_type + for spec, opt in zip(streams, map_options): + # get output stream information + if (info := fg_info and fg_info.get(spec, None)) is not None: + # filtergraph output + stream_info[spec] = { "dst_type": dst_type, - "user_map": spec, - "media_type": stream_type_to_media_type( - opt["stream_specifier"].get("stream_type", None) - ), - "input_file_id": opt["input_file_id"], + "user_map": spec[1:-1], + "media_type": info["media_type"], + "input_file_id": None, "input_stream_id": None, } - for spec, opt in zip(streams, map_options) - } - else: - # resolve all the output streams - - # if any linklabel given, analyze the filter_complex global option values - fg_map: dict[str, MediaType] = ( - analyze_fg_outputs(args) - if any("linklabel" in opt for opt in map_options) - else {} - ) - - # given as an FFmpeg map option, convert to the dict format - inputs = args["inputs"] - stream_info = {} # one stream per item, value: map spec & media_type - for spec, map_option in zip(streams, map_options): - - # filtergraph output - media_type = fg_map.get(spec, None) - - if media_type is None: - # input url - file_index = map_option["input_file_id"] - info = input_info[file_index] - stream_spec = map_option["stream_specifier"] + elif ( + "index" in opt["stream_specifier"] + and (opt["stream_specifier"].get("stream_type", None) or "") in "avV" + ): + # specific input stream with known media type + file_index = opt["input_file_id"] + info = input_info[file_index] + stream_spec = opt["stream_specifier"] + media_type = is_unique_stream(stream_spec, return_media_type=True) + if isinstance(media_type, str): + # stream specified by media type (stream index not known, but may not be needed) + stream_data = [(None, media_type)] + else: stream_data = retrieve_input_stream_ids( info, *inputs[file_index], stream_spec=stream_spec ) - unique_stream = len(stream_data) == 1 - for stream_index, media_type in stream_data: - stream_info[ - (spec if unique_stream else f"{file_index}:{stream_index}") - ] = { - "dst_type": dst_type, - "user_map": spec, - "media_type": media_type, - "input_file_id": file_index, - "input_stream_id": stream_index, - } - else: - # filtergraph output - stream_info[spec] = { + + unique_stream = len(stream_data) == 1 + for stream_index, media_type in stream_data: + stream_info[ + (spec if unique_stream else f"{file_index}:{stream_index}") + ] = { "dst_type": dst_type, "user_map": spec, "media_type": media_type, - "input_file_id": None, - "input_stream_id": None, + "input_file_id": file_index, + "input_stream_id": stream_index, } - - return stream_info + else: + # posibly multiple streams + for spec, opt in zip(streams, map_options): + stream_info[spec] = { + compose_map_option(**opt): { + "dst_type": dst_type, + "user_map": spec, + "media_type": stream_type_to_media_type( + opt["stream_specifier"].get("stream_type", None) + ), + "input_file_id": opt["input_file_id"], + "input_stream_id": None, + } + } + return stream_info def auto_map( - args: FFmpegArgs, input_info: list[InputSourceDict] + args: FFmpegArgs, input_info: list[InputSourceDict], fg_info: dict[str, dict] | None ) -> dict[str, RawOutputInfoDict]: """list all available streams from all FFmpeg input sources :param args: FFmpeg argument dict. `filter_complex` argument may be modified. :param input_info: a list of input data source information + :param fg_info: list of filtergraph outputs or None if complex fitlergraph is + not specified :return: a map of input/filtergraph output labels and their stream information. Mapping Input Streams vs. Complex Filtergraph Outputs @@ -1031,11 +1023,15 @@ def auto_map( """ - gopts = args.get("global_options", None) or {} - if "filter_complex" in gopts: + if fg_info is not None: return { - linklabel: {"dst_type": "pipe", "user_map": None, "media_type": media_type} - for linklabel, media_type in analyze_fg_outputs(args).items() + linklabel: { + "dst_type": "pipe", + "user_map": None, + "media_type": info["media_type"], + "linklabel": linklabel[1:-1], + } + for linklabel, info in fg_info.items() } counter = {"file": None, "audio": 0, "video": 0} @@ -1217,6 +1213,7 @@ def process_url_inputs( def process_raw_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], + fg_info: dict[str, dict] | None, streams: Sequence[str] | dict[str, dict[str, Any] | None] | None, options: dict[str, Any], ) -> list[RawOutputInfoDict]: @@ -1232,9 +1229,9 @@ def process_raw_outputs( # resolve requested output streams stream_info: dict[str, RawOutputInfoDict] = ( - auto_map(args, input_info) # automatically map all the streams + auto_map(args, input_info, fg_info) # automatically map all the streams if streams is None or len(streams) == 0 - else resolve_raw_output_streams(args, input_info, streams) + else resolve_raw_output_streams(args, input_info, fg_info, streams) ) # add outputs to FFmpeg arguments @@ -1254,7 +1251,7 @@ def process_raw_outputs( finalize_audio_read_opts if info["media_type"] == "audio" else finalize_video_read_opts - )(args, i, input_info) + )(args, i, input_info, fg_info) return list(stream_info.values()) @@ -1519,16 +1516,18 @@ def init_media_read( gopts = args["global_options"] # global options dict gopts["y"] = None - if "filter_complex" in gopts: - gopts["filter_complex"] = utils.as_multi_option( - gopts["filter_complex"], (str, FilterGraphObject) - ) - # analyze and assign inputs input_info = process_url_inputs(args, urls, inopts_default) + fg_info = None + if "filter_complex" in gopts: + # prepare complex filter output + gopts["filter_complex"], fg_info = utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info + ) + # analyze and assign outputs - output_info = process_raw_outputs(args, input_info, map, options) + output_info = process_raw_outputs(args, input_info, fg_info, map, options) return args, input_info, output_info From 03defcf1dd538c1525d79c17a0408ce9912e8703 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:11:04 +0900 Subject: [PATCH 169/333] `finalize_audio_read_opts()` - fixed using a wrong analysis function --- src/ffmpegio/configure.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index f637946f..49390ff6 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -514,15 +514,9 @@ def finalize_audio_read_opts( # create a source chain with matching specs and attach it to the af graph af = temp_audio_src(*inopt_vals) - af = af + outopts.get("filter:a", outopts.get("af", None)) - inopt_vals = utils.analyze_input_stream( - fields, - "0", - "audio", - af, - {"f": "lavfi"}, - {"src_type": "filtergraph"}, + inopt_vals = utils.analyze_audio_stream( + "0", af, {"f": "lavfi"}, {"src_type": "filtergraph"} ) opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] From f1af0ff9008d8f80271716fcceb82056785cb2d9 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:11:55 +0900 Subject: [PATCH 170/333] `process_raw_outputs()` - backwards compatibility support for the tuple stream specificcation --- src/ffmpegio/configure.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 49390ff6..1821386d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1231,6 +1231,9 @@ def process_raw_outputs( # add outputs to FFmpeg arguments get_opts = isinstance(streams, dict) for spec, info in stream_info.items(): + if isinstance(spec, tuple): + spec = ":".join((str(s) for s in spec)) + opts = ( {**options, **streams[info["user_map"]], "map": spec} if get_opts From dfd7121245a44fc507a7b39fddc8b5499418fa89 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:12:19 +0900 Subject: [PATCH 171/333] added `process_url_outputs()` --- src/ffmpegio/configure.py | 71 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 1821386d..aa8c5e81 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1318,6 +1318,77 @@ def process_raw_inputs( return input_info +def process_url_outputs( + args: FFmpegArgs, + input_info: list[InputSourceDict], + fg_info: dict[str, dict] | None, + urls: list[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] + ], + options: dict[str, Any], +) -> list[RawOutputInfoDict]: + """analyze and process url outputs + + :param args: FFmpeg argument dict, A new item in`args['outputs']` is + appended for each piped output. Output URLs are left `None`. + :param input_info: list of input information (same length as `args['inputs']) + :param fg_info: list of filtergraph outputs or None if complex fitlergraph is + not specified + :param urls: output file names and optionally with file-specific options + :param options: default output options. If `"map"` option is given, it is appended + to the per-file `"map"` option in `streams` argument + :return: list of output information + """ + + missing_map = False + output_info_list = [None] * len(urls) + for i, url in enumerate(urls): # add inputs + # get the option dict + if utils.is_non_str_sequence(url, (str, FilterGraphObject, Buffer)): + if len(url) != 2: + raise ValueError( + "url-options pair input must be a tuple of the length 2." + ) + url, opts = url + opts = {**options} if opts is None else {**options, **opts} + else: + # only URL given + opts = {**options} + + # check url (must be url and not fileobj) + if utils.is_fileobj(url, readable=True): + output_info = {"dst_type": "fileobj", "fileobj": url} + url = None + elif url == "pipe": + # convert to buffer + output_info = {"dst_type": "pipe"} + url = None + elif utils.is_url(url, pipe_ok=False): + output_info = {"dst_type": "url"} + else: + raise TypeError("Unknown output {url}.") + + url_opts, output_info_list[i] = (url, opts), output_info + + # leave the URL None if data needs to be piped in + add_url(args, "output", *url_opts) + + if "map" not in opts: + missing_map = True + + if missing_map: + # some output file is missing `map` option + # add all input streams or all complex filter outputs + map_opts = [*auto_map(args, input_info, fg_info)] + + # add outputs to FFmpeg arguments + for _, opts in args["outputs"]: + if "map" not in opts: + opts["map"] = map_opts + + return output_info_list + + def assign_input_url(args: FFmpegArgs, ifile: int, url: str): """assign a new url to an FFmpeg input From e096fe9d12d292d4733285f07d062d419099e2f7 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:13:48 +0900 Subject: [PATCH 172/333] `retrieve_input_stream_ids()` - improved stream specifier detection --- src/ffmpegio/configure.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index aa8c5e81..e4c8ef29 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1434,20 +1434,29 @@ def retrieve_input_stream_ids( # something failed (warning logged) return [] + def get_spec(info, opts): + # check raw formats first + from_buffer = info["src_type"] == "buffer" + if from_buffer and opts.get("f", None) in raw_formats: + return [{"index": 0, "codec_type": info["media_type"]}] + + # run ffprobe + return probe.streams_basic( + url, + f=opts.get("f", None), + sp_kwargs=sp_kwargs, + stream_spec=( + compose_stream_spec(**stream_spec) + if isinstance(stream_spec, dict) + else stream_spec + ), + ) + # get the stream list if ffprobe can try: stream_ids = [ (info["index"], info["codec_type"]) - for info in probe.streams_basic( - url, - f=opts.get("f", None), - sp_kwargs=sp_kwargs, - stream_spec=( - compose_stream_spec(**stream_spec) - if isinstance(stream_spec, dict) - else stream_spec - ), - ) + for info in get_spec(info, opts) if info["codec_type"] in get_args(MediaType) ] except: From fa51dc05fc5360e08f6656ddbc357b5c1678ef77 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:14:46 +0900 Subject: [PATCH 173/333] `init_media_write()` - revamped --- src/ffmpegio/configure.py | 95 ++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e4c8ef29..44672130 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1610,15 +1610,20 @@ def init_media_read( def init_media_write( - url: FFmpegOutputUrlComposite, - input_opts: list[dict], + urls: list[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] + ], + stream_types: Sequence[Literal["a", "v"]], + stream_args: Sequence[RawStreamDef], merge_audio_streams: bool | Sequence[int], merge_audio_ar: int | None, merge_audio_sample_fmt: str | None, merge_audio_outpad: str | None, - extra_inputs: Sequence[str | tuple[str, dict]] | None, + extra_inputs: ( + Sequence[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]] | None + ), options: dict[str, Any], -) -> tuple[FFmpegArgs, list[NPopen], bytes | None]: +) -> tuple[FFmpegArgs, list[InputSourceDict], list[RawOutputInfoDict]]: """write multiple streams to a url/file :param url: output url @@ -1643,56 +1648,40 @@ def init_media_write( """ - # analyze input stream_data - n_in = len(input_opts) + noutputs = len(urls) + if not noutputs: + raise FFmpegioError("At least one URL must be given.") - # separate the input options from the rest of the options - default_in_opts = utils.pop_extra_options(options, "_in") + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") - # create FFmpeg argument dict - ffmpeg_args = empty() + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict - # add input streams - pipes = [] # named pipes and their data blobs (one for each input stream) - n_ain = 0 - for opts in input_opts: - pipe = NPopen("w", bufsize=0) - add_url(ffmpeg_args, "input", pipe.path, {**default_in_opts, **opts}) - pipes.append(pipe) - if "ar" in opts: - n_ain += 1 + # analyze and assign inputs + input_info = process_raw_inputs(args, stream_types, stream_args, inopts_default) # map all input streams to output unless user specifies the mapping - map = options["map"] if "map" in options else list(range(n_in)) - do_merge = bool(merge_audio_streams) and n_ain > 1 + a_ids = [i for i, info in enumerate(input_info) if info["media_type"] == "audio"] + do_merge = bool(merge_audio_streams) and len(a_ids) > 1 if do_merge: if merge_audio_streams is True: # if True, convert to stream indices of audio inputs - merge_audio_streams = [ - i for i, opts in enumerate(input_opts) if "ar" in opts - ] + merge_audio_streams = a_ids else: + inputs = args["inputs"] try: - assert all("ar" in input_opts[i] for i in merge_audio_streams) + assert all( + i in a_ids and "ar" in inputs[i][1] for i in merge_audio_streams + ) except AssertionError: raise ValueError( - "merge_audio_streams argument must be bool or a sequence of indices of input audio streams." + "To merge audio streams their sampling rate must be the same." ) - # assign the final map - exclude audio streams if to be merged together - options["map"] = [i for i in map if i not in merge_audio_streams] - - # add output url and options (may also contain possibly global options) - add_url(ffmpeg_args, "output", url, options) - - # add extra input arguments if given - if extra_inputs is not None: - add_urls(ffmpeg_args, "input", extra_inputs) - - if do_merge: - # get FFmpeg input list - ffinputs = ffmpeg_args["inputs"] + ffinputs = args["inputs"] audio_streams = {i: ffinputs[i][1] for i in merge_audio_streams} afilt = merge_audio( audio_streams, @@ -1701,7 +1690,29 @@ def init_media_write( merge_audio_outpad or "aout", ) - # add the merging filter graph to the filter_complex argument - add_filtergraph(ffmpeg_args, afilt) + if "filter_complex" in gopts: + # prepare complex filter output + gopts["filter_complex"] = utils.as_multi_option( + gopts["filter_complex"], (str, FilterGraphObject) + ).append(afilt) + else: + gopts["filter_complex"] = [afilt] + + if extra_inputs is not None: + input_info.extend(process_url_inputs(args, extra_inputs, {})) - return ffmpeg_args, pipes + if "filter_complex" in gopts: + gopts["filter_complex"], fg_info = ( + utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info + ) + if "filter_complex" in gopts + else None + ) + else: + fg_info = None + + # analyze and assign outputs + output_info = process_url_outputs(args, input_info, fg_info, urls, options) + + return args, input_info, output_info From c7791a998068824f28b155c6e519d600128c99c3 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:15:18 +0900 Subject: [PATCH 174/333] removed `finalize_media_read_opts()` --- src/ffmpegio/configure.py | 79 --------------------------------------- 1 file changed, 79 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 44672130..3fa59422 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1469,85 +1469,6 @@ def get_spec(info, opts): return stream_ids -def finalize_media_read_opts(args: FFmpegArgs, out_idx: int) -> tuple[MediaType, tuple]: - """finalize an output pipe for reading an audio/video stream - - :param args: FFmpeg argument dict - :param out_idx: output url index to finalize - :return: a tuple of the media type of the output stream and its data blob info - """ - - inputs = args["inputs"] - out_url, out_opts = args["outputs"][out_idx] - gopts = args["global_options"] or {} - - # resolve the stream mapping - if "map" in out_opts: - map = out_opts["map"] - if not isinstance(map, str): - if len(map) != 1: - raise ValueError( - "Too many stream maps (or none listed). Only one map option is supported." - ) - map = map[0] - - # parse the map option value - map_d = parse_map_option(map, 0 if len(inputs) == 1 else None) - elif len(inputs) != 1: - raise ValueError( - "Too many files. Only one input file is supported per reader stream." - ) - else: - map_d = {"input_id": 0} - - # resolve the stream media type: 'audio' vs. 'video' - media_type = None - if "linklabel" in map_d: - if "filter_complex" not in gopts: - raise FFmpegError("`filter_complex` global option not found.") - - fgs = [ - fgb.as_filtergraph_object(fg) - for fg in as_multi_option( - gopts["filter_complex"], (str, fgb.Graph, fgb.Chain) - ) - ] - linklabel = map_d["linklabel"] - - # get the output filter for the media type - for fg in fgs: - try: - pad_index = fg.get_output_pad(linklabel) - media_type = fg[*pad_index[:-1]].get_pad_media_type( - "output", pad_index[-1] - ) - # traverse back the chain to look for stream formats - break - except fgb.FiltergraphPadNotFoundError: - pass - else: - # get the input stream media type - input_id = map_d["input_id"] - in_url, in_opts = inputs[input_id] - stream_spec = map_d.get("stream_specifier", None) - st_info = probe.streams_basic(in_url, stream_spec=stream_spec) - if len(st_info) != 1: - raise ValueError( - "Too many streams (or none found). Only one input stream is supported." - ) - media_type = st_info[0]["codec_type"] - if in_opts is None: - in_opts = {} - - media_info = ( - finalize_audio_read_opts(args, out_idx, input_id, stream_spec) - if media_type == "audio" - else finalize_video_read_opts(args, out_idx, input_id, stream_spec) - ) - - return media_type, media_info - - def init_media_read( urls: FFmpegInputUrlComposite, map: Sequence[str] | dict[str, dict[str, Any] | None] | None, From 3d4dd7de04c3f285b73e5897c8fc67c62fcdda9e Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 14:18:28 +0900 Subject: [PATCH 175/333] `read()` - fixed errors - added linklabel support --- src/ffmpegio/media.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index b39e1ad1..3fbd601f 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -15,6 +15,7 @@ FFmpegUrlType, ) from .stream_spec import StreamSpecDict +from .configure import FFmpegOutputUrlComposite, FFmpegInputUrlComposite import contextlib from io import BytesIO @@ -34,7 +35,9 @@ def read( - *urls: * tuple[FFmpegUrlType | tuple[FFmpegUrlType, dict[str, Any] | None]], + *urls: * tuple[ + FFmpegInputUrlComposite | tuple[FFmpegUrlType, dict[str, Any] | None] + ], map: Sequence[str] | dict[str, dict[str, Any] | None] | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, @@ -89,7 +92,7 @@ def read( if src_type == "fileobj": writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) elif src_type == "buffer": - writer = WriterThread() + writer = WriterThread(pipe) writer.write(info["buffer"]) writer.write(None) # close the else: @@ -118,14 +121,16 @@ def read( # wind-down the readers for info in output_info: - info['reader'].cool_down() + info["reader"].cool_down() # gather output rates = {} data = {} for i, info in enumerate(output_info): spec = ( - info["user_map"] or f"{info['input_file_id']}:{info['input_stream_id']}" + info["user_map"] + or info.get("linklabel", None) + or f"{info['input_file_id']}:{info['input_stream_id']}" ) b = info["reader"].read_all() From dbd75c92cc3ed3348ad656eed81148259de3316f Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 18:17:51 +0900 Subject: [PATCH 176/333] `write()` - revamped to use `init_media_write()` and full pipe management scheme --- src/ffmpegio/media.py | 134 +++++++++++++++++++++++------------------- 1 file changed, 75 insertions(+), 59 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 3fbd601f..7bf077a5 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -262,7 +262,10 @@ def read_by_avi( def write( - url: str, + urls: ( + FFmpegOutputUrlComposite + | list[FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict]] + ), stream_types: Sequence[Literal["a", "v"]], *stream_args: * tuple[RawStreamDef, ...], merge_audio_streams: bool | Sequence[int] = False, @@ -307,52 +310,13 @@ def write( """ - url, stdout, _ = configure.check_url(url, True) + if not isinstance(urls, list): + urls = [urls] - if not all(t in "av" for t in stream_types): - raise ValueError("Elements of stream_types input must either 'a' or 'v'.") - - # analyze input stream_data - n_in = len(stream_types) - - if len(stream_args) != n_in: - raise ValueError(f"Lengths of `stream_args` and `stream_types` not matching.") - - input_opts = [] - input_byte_data = [] - - for mtype, arg in zip(stream_types, stream_args): - - try: - a1, a2 = arg - if isinstance(a1, (int, float, Fraction)): - opts, data = a1, a2 - else: - assert isinstance(a2, dict) - data, opts = a1, a2 - except: - raise ValueError( - f"""Invalid raw stream definition: {arg}.\nEach item of `stream_args` must be a two-element tuple: - - a rate (numeric) and a data_blob - - a data_blob and a dict of options - """ - ) - - if mtype == "a": # audio - if not isinstance(opts, dict): - opts = {"ar": round(opts)} - input_opts.append({**opts, **utils.array_to_audio_options(data)}) - input_byte_data.append(plugins.get_hook().audio_bytes(obj=data)) - - else: # video - if not isinstance(opts, dict): - opts = {"r": opts} - input_opts.append({**opts, **utils.array_to_video_options(data)}) - input_byte_data.append(plugins.get_hook().video_bytes(obj=data)) - - ffmpeg_args, pipes = configure.init_media_write( - url, - input_opts, + args, input_info, output_info = configure.init_media_write( + urls, + stream_types, + stream_args, merge_audio_streams, merge_audio_ar, merge_audio_sample_fmt, @@ -364,27 +328,79 @@ def write( kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( { - "stdout": stdout, "progress": progress, "overwrite": overwrite, + "capture_log": None if show_log else False, } ) - kwargs["capture_log"] = None if show_log else False with contextlib.ExitStack() as stack: - # run the FFmpeg - proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) + # configure input pipes (if needed) + for i, (input, info) in enumerate(zip(args["inputs"], input_info)): + if input[0] is None: # no url == fileobj / buffer / other data via a pipe + pipe = NPopen("w", bufsize=0) + stack.enter_context(pipe) + configure.assign_input_url(args, i, pipe.path) + src_type = info["src_type"] + if src_type == "fileobj": + writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) + stack.enter_context( + writer + ) # starts thread & wait for pipe connection + elif src_type == "buffer": + writer = WriterThread(pipe) + stack.enter_context( + writer + ) # starts thread & wait for pipe connection + writer.write(info["buffer"]) + writer.write(None) # close the + else: + raise FFmpegioError(f"{src_type=} is an unknown input data type.") - # connect the pipes and queue the stream data - for p, data in zip(pipes, input_byte_data): - stack.enter_context(p) - writer = WriterThread(p) - stack.enter_context(writer) - writer.write(data) # send bytes in out_bytes to the client - writer.write(None) # sentinel message + # configure output pipes + pipes_out = False + for i, (output, info) in enumerate(zip(args["outputs"], output_info)): + if output[0] is None: + # if fileobj or buffer output, use pipe + pipe = NPopen("r", bufsize=0) + stack.enter_context(pipe) + configure.assign_output_url(args, i, pipe.path) + src_type = info["src_type"] + if src_type == "fileobj": + reader = CopyFileObjThread(info["fileobj"], pipe) + elif src_type == "buffer": + pipes_out = True + info["reader"] = reader = ReaderThread(pipe) + else: + raise FFmpegioError(f"{src_type=} is an unknown output data type.") + stack.enter_context(reader) # starts thread & wait for pipe connection + + # run the FFmpeg + proc = ffmpegprocess.Popen( + args, + progress=progress, + capture_log=None if show_log else True, + sp_kwargs=sp_kwargs, + ) # wait for the FFmpeg to finish processing proc.wait() - if proc.returncode: - raise FFmpegError(proc.stderr, show_log) + # throw error if failed + if proc.returncode: + raise FFmpegError(proc.stderr, show_log) + + # wind-down the readers + for info in output_info: + if "reader" in info: + info["reader"].cool_down() + + if not pipes_out: + # no buffered output + return + + # gather output + data = {} + for i, info in enumerate(output_info): + if info["src_type"] == "buffer": + data[i] = info["reader"].read_all() From 92c20509ae6d4a3c7e7177399e584cd713da9eb3 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 18:18:12 +0900 Subject: [PATCH 177/333] removed `read_by_avi()` --- src/ffmpegio/media.py | 84 ------------------------------------------- 1 file changed, 84 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 7bf077a5..f506ee86 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -177,90 +177,6 @@ def read( return rates, data -def read_by_avi( - *urls: * tuple[str], - progress: ProgressCallable | None = None, - show_log: bool | None = None, - **options: Unpack[dict[str, Any]], -) -> tuple[dict[StreamSpecDict, Fraction | int], dict[StreamSpecDict, RawDataBlob]]: - """Read video and audio frames by AVI reader (old media.read()) - - :param *urls: URLs of the media files to read. - :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param use_ya: True if piped video streams uses `ya8` pix_fmt instead of `gray16le`, default to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - :return: frame/sampling rates and raw data for each requested stream - - Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run - `ffmpegio.video.read()` for each url. - - Specify the streams to return by `map` output option: - - map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. - - For audio streams, if 'sample_fmt' output option is not specified, 's16'. - """ - - ninputs = len(urls) - if not ninputs: - raise ValueError("At least one URL must be given.") - - # separate the options - spec_inopts = utils.pop_extra_options_multi(options, r"_in(\d+)$") - inopts = utils.pop_extra_options(options, "_in") - - # create a new FFmpeg dict - args = configure.empty() - configure.add_url(args, "output", "-", options) # add piped output - for i, url in enumerate(urls): # add inputs - opts = {**inopts, **spec_inopts.get(i, {})} - # check url (must be url and not fileobj) - configure.check_url( - url, nodata=True, nofileobj=True, format=opts.get("f", None) - ) - configure.add_url(args, "input", url, opts) - - # configure output options - use_ya = configure.finalize_avi_read_opts(args) - - # run FFmpeg - out = ffmpegprocess.run( - args, - progress=progress, - capture_log=None if show_log else True, - ) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - - # fire up the AVI reader and process the stdout bytes - # TODO: Convert to use pipe/thread - reader = avi.AviReader() - reader.start(BytesIO(out.stdout), use_ya) - # get frame rates and sample rates of all media streams - rates = { - v["spec"]: v["frame_rate"] if v["type"] == "v" else v["sample_rate"] - for v in reader.streams.values() - } - data = {k: [] for k in reader.streams} - for st, frame in reader: - data[st].append(frame) - - data = { - reader.streams[k]["spec"]: reader.from_bytes(k, b"".join(v)) - for k, v in data.items() - } - - return rates, data - - def write( urls: ( FFmpegOutputUrlComposite From 511fc3cead82799dcd1a89e0d98960280fd45637 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 18:20:36 +0900 Subject: [PATCH 178/333] updated tests --- tests/test_configure.py | 32 +++++++++++++++++++++----------- tests/test_media.py | 32 ++++++++++++++++++++++++-------- tests/test_threading.py | 38 -------------------------------------- 3 files changed, 45 insertions(+), 57 deletions(-) diff --git a/tests/test_configure.py b/tests/test_configure.py index 0cf97ab8..ceb5cacd 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -2,6 +2,8 @@ from pprint import pprint from ffmpegio import configure +from ffmpegio import filtergraph as fgb +from ffmpegio.utils import analyze_complex_filtergraphs vid_url = "tests/assets/testvideo-1m.mp4" img_url = "tests/assets/ffmpeg-logo.png" @@ -180,16 +182,16 @@ def test_process_url_inputs(url, opts, defopts, ret): ("inputs", "input_info", "filters_complex", "ret"), [ ( - [(mul_url, None)], + [(mul_url, {})], [{"src_type": "url"}], None, { - f"0:{i}": { + f"0:{mtype[0]}:{j}": { "media_type": mtype, "input_file_id": 0, "input_stream_id": i, } - for i, mtype in mul_streams + for (i, mtype), j in zip(mul_streams, [0, 0, 1, 1]) }, ), ( @@ -197,12 +199,12 @@ def test_process_url_inputs(url, opts, defopts, ret): [{"src_type": "url"}, {"src_type": "url"}], None, { - "0:0": { + "0:v:0": { "media_type": "video", "input_file_id": 0, "input_stream_id": 0, }, - "1:0": { + "1:a:0": { "media_type": "audio", "input_file_id": 1, "input_stream_id": 0, @@ -210,9 +212,9 @@ def test_process_url_inputs(url, opts, defopts, ret): }, ), ( - [(mul_url, None)], + [(mul_url, {})], [{"src_type": "url"}], - ["split=n=2"], + ["split=outputs=2"], {"[out0]": {"media_type": "video"}, "[out1]": {"media_type": "video"}}, ), ], @@ -221,8 +223,11 @@ def test_auto_map(inputs, input_info, filters_complex, ret): args = configure.empty() args["inputs"].extend(inputs) if filters_complex is not None: + filters_complex, fg_info = analyze_complex_filtergraphs( + fgb.as_filtergraph(filters_complex), args["inputs"], input_info + ) args["global_options"] = {"filter_complex": filters_complex} - out = configure.auto_map(args, input_info) + out = configure.auto_map(args, input_info, filters_complex and fg_info) assert out == { spec: {"dst_type": "pipe", "user_map": None, **info} for spec, info in ret.items() @@ -259,7 +264,7 @@ def ffmpeg_url_inputs_vid_aud(): [ ("ffmpeg_url_inputs_mul", None, ["v"]), ("ffmpeg_url_inputs_vid_aud", None, ["0:v:0", "1:a:0"]), - ("ffmpeg_url_inputs_mul", ["split=n=2"], ["[out0]", "[out1]", "a:0"]), + ("ffmpeg_url_inputs_mul", ["split=2"], ["[out0]", "[out1]", "a:0"]), ], ) def test_resolve_raw_output_streams( @@ -268,7 +273,12 @@ def test_resolve_raw_output_streams( args, input_info = request.getfixturevalue(ffmpeg_url_inputs) - if filters_complex is not None: + if filters_complex is None: + fg_info = None + else: + filters_complex, fg_info = analyze_complex_filtergraphs( + fgb.as_filtergraph(filters_complex), args["inputs"], input_info + ) args["global_options"] = {"filter_complex": filters_complex} - out = configure.resolve_raw_output_streams(args, input_info, streams) + out = configure.resolve_raw_output_streams(args, input_info, fg_info, streams) pprint(out) diff --git a/tests/test_media.py b/tests/test_media.py index 297fece4..3c04837c 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,19 +1,35 @@ from tempfile import TemporaryDirectory from os import path from pprint import pprint +import pytest import ffmpegio as ff +url = "tests/assets/testmulti-1m.mp4" +url1 = "tests/assets/testvideo-1m.mp4" +url2 = "tests/assets/testaudio-1m.mp3" -def test_media_read(): - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - rates, data = ff.media.read(url, t=1, show_log=True) - rates, data = ff.media.read(url, map=("v:0", "v:1", "a:1", "a:0"), t=1, show_log=True) - rates, data = ff.media.read(url1, url2, t=1, show_log=True) - rates, data = ff.media.read(url2, url, map=("1:v:0", (0, "a:0")), t=1, show_log=True) +@pytest.mark.parametrize( + "urls,kwargs", + [ + ((url,), dict(t=1, show_log=True)), + ((url,), dict(map=("v:0", "v:1", "a:1", "a:0"), t=1, show_log=True)), + ((url1, url2), dict(t=1, show_log=True)), + ((url2, url), dict(map=("1:v:0", (0, "a:0")), t=1, show_log=True)), + ], +) +def test_media_read(urls, kwargs): + rates, data = ff.media.read(*urls, **kwargs) + print(rates) + print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) + + +def test_media_read_filter_complex(): + urls = (url2, url) # aud + mul + kwargs = dict(t=1, show_log=True, filter_complex='[0:a]aformat=f=dbl:r=8000:cl=mono;[1:v:1]setpts=0.5*PTS') + # kwargs = dict(map=(['[vout]','[aout]']), t=1, show_log=True, filter_complex='[0:a]aformat=f=dbl:r=8000:cl=mono[aout];[1:v:1]setpts=0.5*PTS[vout]') + rates, data = ff.media.read(*urls, **kwargs) print(rates) print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) diff --git a/tests/test_threading.py b/tests/test_threading.py index eb2f482c..b944a8e5 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -43,41 +43,3 @@ def test_copyfileobj(): assert data == data_out - -if __name__ == "__main__": - - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - - from pprint import pprint - - from ffmpegio import ffmpeg, configure - import io - - args = { - "inputs": [(url1, None), (url2, None)], - "outputs": [ - ( - "-", - { - "vframes": 16, - }, - ) - ], - } - use_ya = configure.finalize_media_read_opts(args) - pprint(args) - - # create a short example with both audio & video - f = io.BytesIO(ffmpegprocess.run(args).stdout) - - reader = threading.AviReaderThread() - reader.start(f, use_ya) - try: - reader.wait() - print(f"thread is running {reader.is_alive()}") - pprint(reader.streams) - pprint(reader.rates) - except: - reader.join() From 8638844318721f16c61160727679b0c14338dc50 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 18:38:29 +0900 Subject: [PATCH 179/333] `SimpleFIlterBase._open()` - modified for `process_raw_outputs` compatibilty --- src/ffmpegio/streams/SimpleStreams.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index c3614122..8f230938 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -775,6 +775,7 @@ def _open(self, data=None): self.dtype, self.shape, self.rate = configure.process_raw_outputs( ffmpeg_args, self._input_info, + None, [f"0:{self.stream_type}:0"], self._output_opts, )[0]["media_info"] From 07c9673a41313b120ce0d148ee87c358ba2a8184 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 18:39:17 +0900 Subject: [PATCH 180/333] `SimpleFilterXXX` - raise error if `filter_complex` is specified --- src/ffmpegio/streams/SimpleStreams.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 8f230938..a80e1640 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -720,6 +720,10 @@ def __init__( inopts = utils.pop_extra_options(options, "_in") glopts = utils.pop_global_options(options) + if "filter_complex" in glopts: + # prepare complex filter output + FFmpegioError("To use complex filtergraph (i.e., the `filter_complex` global option), use the PipedFilter class instead.") + try: not_ready, self.shape_in, self.dtype_in = self._set_options( inopts, shape_in, dtype_in, rate_in From 480b16a9dbb2cac4b125b8e2afe73b83b9273757 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 18:40:06 +0900 Subject: [PATCH 181/333] `SimpleFilterBase` updated `process_raw_inputs()` call --- src/ffmpegio/streams/SimpleStreams.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index a80e1640..45d6451e 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -737,7 +737,9 @@ def __init__( self._output_opts = options ffmpeg_args = configure.empty(glopts) - self._input_info = configure.process_raw_inputs(ffmpeg_args, [inopts], {}) + self._input_info = configure.process_raw_inputs( + ffmpeg_args, self.stream_type, [(None, inopts)], {} + ) configure.assign_input_url(ffmpeg_args, 0, "pipe:0") # create the stdin writer without assigning the sink stream From 911f25e81b88e15ca2f0a77239b2a28c9a143481 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Thu, 20 Feb 2025 20:24:28 +0900 Subject: [PATCH 182/333] refactored `configure.init_named_pipes()` from `media.read()` and `media.write()` --- src/ffmpegio/configure.py | 77 +++++++++++++++++++++++++++++++++--- src/ffmpegio/media.py | 82 ++++----------------------------------- tests/test_configure.py | 2 +- 3 files changed, 79 insertions(+), 82 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 3fa59422..0dd37323 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -24,6 +24,7 @@ from io import IOBase from namedpipe import NPopen +from contextlib import ExitStack from . import utils, probe, plugins from . import filtergraph as fgb @@ -46,14 +47,14 @@ map_option as compose_map_option, ) from .errors import FFmpegioError -from .threading import ReaderThread +from .threading import ReaderThread, WriterThread, CopyFileObjThread ################################# ## module types UrlType = Literal["input", "output"] -FFmpegOutputType = Literal["url", "fileobj", "pipe"] +FFmpegOutputType = Literal["url", "fileobj", "buffer"] FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] @@ -927,7 +928,7 @@ def resolve_raw_output_streams( :return: output information keyed by a unique map option string """ - dst_type = "pipe" + dst_type = "buffer" # parse all mapping option values input_file_id = 0 if len(input_info) == 1 else None @@ -1020,7 +1021,7 @@ def auto_map( if fg_info is not None: return { linklabel: { - "dst_type": "pipe", + "dst_type": "buffer", "user_map": None, "media_type": info["media_type"], "linklabel": linklabel[1:-1], @@ -1041,7 +1042,7 @@ def next_map_option(i, media_type): # if no filtergraph, get all video & audio streams from all the input urls return { next_map_option(i, media_type): { - "dst_type": "pipe", + "dst_type": "buffer", "user_map": None, "media_type": media_type, "input_file_id": i, @@ -1361,7 +1362,7 @@ def process_url_outputs( url = None elif url == "pipe": # convert to buffer - output_info = {"dst_type": "pipe"} + output_info = {"dst_type": "buffer"} url = None elif utils.is_url(url, pipe_ok=False): output_info = {"dst_type": "url"} @@ -1637,3 +1638,67 @@ def init_media_write( output_info = process_url_outputs(args, input_info, fg_info, urls, options) return args, input_info, output_info + + +def init_named_pipes( + args: FFmpegArgs, + input_info: list[InputSourceDict], + output_info: list[RawOutputInfoDict], + stack: ExitStack, +) -> list[int]: + """initialize named pipes for read & write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param input_info: FFmpeg input information, its length matches that of `args['inputs']` + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) + :param stack: a context manager to combine the context managers used to manage pipes and threads + :returns: a list of indices of the FFmpeg outputs that are raw data streams + + In addition to the retured list, this function modifies the dicts in its arguements. + + - The named pipe paths are assigned to the URLs of FFmpeg outputs (`args['outputs'][][0]`) + - The reader threads for FFmpeg outputs that are written to buffers (i.e., + `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` + so the reader object can be used to retrieve the data. + """ + + # configure input pipes (if needed) + for i, (input, info) in enumerate(zip(args["inputs"], input_info)): + if input[0] is None: # no url == fileobj / buffer / other data via a pipe + pipe = NPopen("w", bufsize=0) + stack.enter_context(pipe) + assign_input_url(args, i, pipe.path) + src_type = info["src_type"] + if src_type == "fileobj": + writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) + stack.enter_context(writer) + # starts thread & wait for pipe connection + elif src_type == "buffer": + writer = WriterThread(pipe) + # starts thread & wait for pipe connection + stack.enter_context(writer) + writer.write(info["buffer"]) + writer.write(None) # close the + else: + raise FFmpegioError(f"{src_type=} is an unknown input data type.") + + # configure output pipes + pipes_out = [] + for i, (output, info) in enumerate(zip(args["outputs"], output_info)): + if output[0] is None: + # if fileobj or buffer output, use pipe + pipe = NPopen("r", bufsize=0) + stack.enter_context(pipe) + assign_output_url(args, i, pipe.path) + dst_type = info["dst_type"] + if dst_type == "fileobj": + reader = CopyFileObjThread(info["fileobj"], pipe) + elif dst_type == "buffer": + if "media_info" in info: + pipes_out.append(i) + info["reader"] = reader = ReaderThread(pipe) + else: + raise FFmpegioError(f"{dst_type=} is an unknown output data type.") + stack.enter_context(reader) # starts thread & wait for pipe connection + + return pipes_out \ No newline at end of file diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index f506ee86..7d3eced2 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -14,22 +14,13 @@ Unpack, FFmpegUrlType, ) -from .stream_spec import StreamSpecDict from .configure import FFmpegOutputUrlComposite, FFmpegInputUrlComposite import contextlib -from io import BytesIO from fractions import Fraction -from namedpipe import NPopen - -from .threading import WriterThread - -from . import ffmpegprocess, utils, configure, FFmpegError, plugins, filtergraph as fgb -from .utils import avi, pop_global_options +from . import ffmpegprocess, utils, configure, FFmpegError, plugins from .utils.log import extract_output_stream -from .threading import WriterThread, ReaderThread, CopyFileObjThread -from .errors import FFmpegioError __all__ = ["read", "write"] @@ -81,31 +72,8 @@ def read( capture_log = True if need_stderr else None if show_log else True with contextlib.ExitStack() as stack: - - # configure input pipes (if needed) - for i, (input, info) in enumerate(zip(args["inputs"], input_info)): - if input[0] is None: # no url == fileobj / buffer / other data via a pipe - pipe = NPopen("w", bufsize=0) - stack.enter_context(pipe) - configure.assign_input_url(args, i, pipe.path) - src_type = info["src_type"] - if src_type == "fileobj": - writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) - elif src_type == "buffer": - writer = WriterThread(pipe) - writer.write(info["buffer"]) - writer.write(None) # close the - else: - raise FFmpegioError(f"{src_type=} is an unknown input data type.") - stack.enter_context(writer) # starts thread & wait for pipe connection - - # configure output pipes - for i, info in enumerate(output_info): - pipe = NPopen("r", bufsize=0) - stack.enter_context(pipe) - configure.assign_output_url(args, i, pipe.path) - info["reader"] = reader = ReaderThread(pipe) - stack.enter_context(reader) # starts thread & wait for pipe connection + # configure named pipes + configure.init_named_pipes(args, input_info, output_info, stack) # run the FFmpeg proc = ffmpegprocess.Popen( @@ -251,45 +219,9 @@ def write( ) with contextlib.ExitStack() as stack: - # configure input pipes (if needed) - for i, (input, info) in enumerate(zip(args["inputs"], input_info)): - if input[0] is None: # no url == fileobj / buffer / other data via a pipe - pipe = NPopen("w", bufsize=0) - stack.enter_context(pipe) - configure.assign_input_url(args, i, pipe.path) - src_type = info["src_type"] - if src_type == "fileobj": - writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) - stack.enter_context( - writer - ) # starts thread & wait for pipe connection - elif src_type == "buffer": - writer = WriterThread(pipe) - stack.enter_context( - writer - ) # starts thread & wait for pipe connection - writer.write(info["buffer"]) - writer.write(None) # close the - else: - raise FFmpegioError(f"{src_type=} is an unknown input data type.") - - # configure output pipes - pipes_out = False - for i, (output, info) in enumerate(zip(args["outputs"], output_info)): - if output[0] is None: - # if fileobj or buffer output, use pipe - pipe = NPopen("r", bufsize=0) - stack.enter_context(pipe) - configure.assign_output_url(args, i, pipe.path) - src_type = info["src_type"] - if src_type == "fileobj": - reader = CopyFileObjThread(info["fileobj"], pipe) - elif src_type == "buffer": - pipes_out = True - info["reader"] = reader = ReaderThread(pipe) - else: - raise FFmpegioError(f"{src_type=} is an unknown output data type.") - stack.enter_context(reader) # starts thread & wait for pipe connection + + # configure named pipes + pipes_out = configure.init_named_pipes(args, input_info, output_info, stack) # run the FFmpeg proc = ffmpegprocess.Popen( @@ -311,7 +243,7 @@ def write( if "reader" in info: info["reader"].cool_down() - if not pipes_out: + if not len(pipes_out): # no buffered output return diff --git a/tests/test_configure.py b/tests/test_configure.py index ceb5cacd..d26e76b6 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -229,7 +229,7 @@ def test_auto_map(inputs, input_info, filters_complex, ret): args["global_options"] = {"filter_complex": filters_complex} out = configure.auto_map(args, input_info, filters_complex and fg_info) assert out == { - spec: {"dst_type": "pipe", "user_map": None, **info} + spec: {"dst_type": "buffer", "user_map": None, **info} for spec, info in ret.items() } From 62812f6ebad7f64a1cbca6f8deb0c8e5b9db7764 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 08:59:39 +0900 Subject: [PATCH 183/333] "user_map" to always hold a user-friendly unique stream specifier --- src/ffmpegio/configure.py | 9 +++++---- src/ffmpegio/media.py | 6 +----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 0dd37323..99b81b3e 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -952,6 +952,7 @@ def resolve_raw_output_streams( "media_type": info["media_type"], "input_file_id": None, "input_stream_id": None, + "linklabel": spec, } elif ( "index" in opt["stream_specifier"] @@ -1022,9 +1023,9 @@ def auto_map( return { linklabel: { "dst_type": "buffer", - "user_map": None, + "user_map": linklabel[1:-1], "media_type": info["media_type"], - "linklabel": linklabel[1:-1], + "linklabel": linklabel, } for linklabel, info in fg_info.items() } @@ -1041,9 +1042,9 @@ def next_map_option(i, media_type): # if no filtergraph, get all video & audio streams from all the input urls return { - next_map_option(i, media_type): { + (spec := next_map_option(i, media_type)): { "dst_type": "buffer", - "user_map": None, + "user_map": spec, "media_type": media_type, "input_file_id": i, "input_stream_id": j, diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 7d3eced2..c3506df9 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -95,11 +95,7 @@ def read( rates = {} data = {} for i, info in enumerate(output_info): - spec = ( - info["user_map"] - or info.get("linklabel", None) - or f"{info['input_file_id']}:{info['input_stream_id']}" - ) + spec = info["user_map"] b = info["reader"].read_all() # get datablob info from stderr if needed From fcf21f00de442c24caa326b1f34f1951f02292d8 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 09:00:14 +0900 Subject: [PATCH 184/333] documentation --- src/ffmpegio/_utils.py | 15 +++++++++++++-- src/ffmpegio/configure.py | 5 ++++- src/ffmpegio/streams/SimpleStreams.py | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index ae58c602..c08ddba9 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -88,11 +88,22 @@ def as_multi_option( ) -def dtype_itemsize(dtype): +def dtype_itemsize(dtype: str) -> int: + """get the byte size of each dtype sample + + :param dtype: numpy-style data type string + :return: number of bytes per audio sample/video pixel + """ return int(dtype[-1]) -def get_samplesize(shape, dtype): +def get_samplesize(shape: int, dtype: str) -> int: + """get the byte size of each video frame or audio sample + + :param shape: sample shape + :param dtype: numpy-style data type string + :return: number of bytes per audio sample (all channels) or video frame + """ return prod(shape) * dtype_itemsize(dtype) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 99b81b3e..3cd1ff22 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -337,6 +337,7 @@ def finalize_video_read_opts( input_info[ifile], ) + # directly from the input url (if not forced via input options) if has_simple_filter: # create a source chain with matching spec and attach it to the af graph @@ -1472,7 +1473,9 @@ def get_spec(info, opts): def init_media_read( - urls: FFmpegInputUrlComposite, + urls: list[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict[str, Any] | None] + ], map: Sequence[str] | dict[str, dict[str, Any] | None] | None, options: dict[str, Any], ) -> tuple[FFmpegArgs, list[InputSourceDict], list[RawOutputInfoDict]]: diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 45d6451e..cebf685c 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -989,7 +989,7 @@ def flush(self, timeout: float = None) -> RawDataBlob: self._writer.join() # wait until all written data reaches FFmpeg self._proc.stdin.close() # close stdin -> triggers ffmpeg to shutdown self._proc.wait() - y = self._reader.read_all(timeout) # read whatever is left in the read queue + y = self._reader.read_all(timeout) # read whatever is left in the read queue nframes = len(y) // self._bps_out self.nout += nframes return self._converter( From 6de428816871819ac3a4c142367a879d9fadf508 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 18:50:16 +0900 Subject: [PATCH 185/333] `init_named_pipes()` to setup pipe reader's `itemsize` and `nmin` --- src/ffmpegio/configure.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 3cd1ff22..f076c0a4 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -79,12 +79,14 @@ class RawOutputInfoDict(TypedDict): dst_type: FFmpegOutputType # True if file path/url user_map: str | None # user specified map option media_type: MediaType | None # - input_file_id: NotRequired[int | None] - input_stream_id: NotRequired[int | None] - linklabel: NotRequired[str | None] + input_file_id: NotRequired[int] + input_stream_id: NotRequired[int] + linklabel: NotRequired[str] media_info: NotRequired[dict[str, Any]] pipe: NotRequired[NPopen] reader: NotRequired[ReaderThread] + itemsize: NotRequired[int] + nmin: NotRequired[int] ################################# @@ -1649,6 +1651,8 @@ def init_named_pipes( input_info: list[InputSourceDict], output_info: list[RawOutputInfoDict], stack: ExitStack, + update_rate: float | None = None, + queue_size: int | None = None, ) -> list[int]: """initialize named pipes for read & write operations with FFmpeg @@ -1656,6 +1660,7 @@ def init_named_pipes( :param input_info: FFmpeg input information, its length matches that of `args['inputs']` :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) :param stack: a context manager to combine the context managers used to manage pipes and threads + :param update_rate: target rate at which queue transactions will occur :returns: a list of indices of the FFmpeg outputs that are raw data streams In addition to the retured list, this function modifies the dicts in its arguements. @@ -1667,6 +1672,7 @@ def init_named_pipes( """ # configure input pipes (if needed) + wr_kws = {"queuesize": queue_size} if queue_size else {} for i, (input, info) in enumerate(zip(args["inputs"], input_info)): if input[0] is None: # no url == fileobj / buffer / other data via a pipe pipe = NPopen("w", bufsize=0) @@ -1678,7 +1684,7 @@ def init_named_pipes( stack.enter_context(writer) # starts thread & wait for pipe connection elif src_type == "buffer": - writer = WriterThread(pipe) + writer = WriterThread(pipe, **wr_kws) # starts thread & wait for pipe connection stack.enter_context(writer) writer.write(info["buffer"]) @@ -1698,11 +1704,16 @@ def init_named_pipes( if dst_type == "fileobj": reader = CopyFileObjThread(info["fileobj"], pipe) elif dst_type == "buffer": + kws = {**wr_kws} if "media_info" in info: pipes_out.append(i) - info["reader"] = reader = ReaderThread(pipe) + dtype, shape, rate = info["media_info"] + kws["itemsize"] = utils.get_samplesize(shape, dtype) + if update_rate is not None: + kws["nmin"] = int(rate / update_rate) or 1 + info["reader"] = reader = ReaderThread(pipe, **kws) else: raise FFmpegioError(f"{dst_type=} is an unknown output data type.") stack.enter_context(reader) # starts thread & wait for pipe connection - return pipes_out \ No newline at end of file + return pipes_out From 73b8deb304be22ad7437bf1f6a37234c8e403849 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 18:51:30 +0900 Subject: [PATCH 186/333] removed `ReaderThread.readnowait()` --- src/ffmpegio/threading.py | 47 --------------------------------------- 1 file changed, 47 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 27678395..b84cbd99 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -371,53 +371,6 @@ def run(self): logger.info("ReaderThread exiting") - def read_nowait(self, n: int = -1) -> bytes: - # wait till matching line is read by the thread - block = (self.is_alive() and self._collect) and n != 0 - if timeout is not None: - timeout = time() + timeout - - arrays = [] - n_new = max(n, -n) - - # grab any leftover data from previous read - if self._carryover: - arrays = [self._carryover] - if n_new != 0: - n_new -= len(self._carryover) // self.itemsize - self._carryover = None - - # loop till enough data are collected - nreads = 1 if n <= 0 else max(n_new, 0) - nr = 0 - while True: - try: - b = self._queue.get_nowait(block) - self._queue.task_done() - assert b is not None - except (Empty, AssertionError): - if len(arrays): - break - raise - - arrays.append(b) - - nr += len(b) // self.itemsize - if nr >= nreads: # enough read - if n < 0: - block = False # keep reading until queue is empty - else: - break - - # combine all the data and return requested amount - all_data = b"".join(arrays) - if n <= 0: - return all_data - nbytes = self.itemsize * n - if len(all_data) > nbytes: - self._carryover = all_data[nbytes:] - return all_data[:nbytes] - def read(self, n: int = -1, timeout: float | None = None) -> bytes: """read n samples From 250dc1469e9b82fe05042adb239a3ca95bb9ec22 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 18:53:03 +0900 Subject: [PATCH 187/333] `ReaderThread` improved thread control --- src/ffmpegio/threading.py | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index b84cbd99..ee3d4943 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -296,7 +296,8 @@ def __init__( self.itemsize = itemsize or 2**20 #:int: number of bytes per time sample self._queue = Queue(queuesize or 0) # inter-thread data I/O self._carryover = None # extra data that was not previously read by user - self._collect = True + self._halt = Event() + self._running = Event() self._retry_delay = 0.001 if retry_delay is None else retry_delay def start(self): @@ -309,9 +310,10 @@ def start(self): def cool_down(self): # stop enqueue read samples - self._collect = False + self._halt.set() def join(self, timeout=None): + self._halt.set() if self._queue.full(): if timeout: self._queue.not_full.wait(timeout) @@ -324,6 +326,12 @@ def join(self, timeout=None): # if queue is full, super().join(timeout) + def is_running(self): + return self._running.is_set() + + def wait_till_running(self, timeout: float | None = None) -> bool: + return self._running.wait(timeout) + def __enter__(self): self.start() return self @@ -341,7 +349,8 @@ def run(self): logger.debug("waiting for pipe to open") stream = self.stdout.wait() if is_npipe else self.stdout logger.debug("starting to read") - while self._collect: + self._running.set() + while not self._halt.is_set(): try: data = stream.read(blocksize) logger.debug("read %d bytes", len(data)) @@ -353,23 +362,24 @@ def run(self): if not data: if stream.closed: # just in case logger.info("ReaderThread no data, stream is closed, exiting") + self._halt.set() break else: # pause a bit then try again sleep(self._retry_delay) continue - if self._collect: # True until self.cooloff + if not self._halt.is_set(): # True until self.cooloff self._queue.put(data) # print(f"reader thread: queued samples") logger.debug("stopping to read") - if self._collect: # True until self.cooloff - logger.info("ReaderThread sending the sentinel") - self._queue.put(None) # sentinel for eos + logger.info("ReaderThread sending the sentinel") + self._queue.put(None) # sentinel for eos logger.info("ReaderThread exiting") + self._running.clear() def read(self, n: int = -1, timeout: float | None = None) -> bytes: """read n samples @@ -380,7 +390,7 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: """ # wait till matching line is read by the thread - block = (self.is_alive() and self._collect) and n != 0 + block = (self.is_alive() and not self._halt.is_set()) and n != 0 if timeout is not None: timeout = time() + timeout @@ -399,7 +409,7 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: nr = 0 while True: tout = timeout and timeout - time() - if tout <= 0: + if timeout and tout <= 0: break try: b = self._queue.get(block, tout) @@ -442,7 +452,7 @@ def read_all(self, timeout: float | None = None) -> bytes: # if not self.is_alive() or timeout and timeout > time(): try: data = self._queue.get( - self.is_alive() and self._collect, timeout and timeout - time() + self.is_alive() and not self._halt, timeout and timeout - time() ) self._queue.task_done() assert data is not None From eab5f8ab2befbbcd0f793d8b349dc9ab2325a8b0 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 19:01:39 +0900 Subject: [PATCH 188/333] completed `PipedMediaReader` class --- src/ffmpegio/streams/PipedStreams.py | 300 +++++++++++++++------------ src/ffmpegio/streams/__init__.py | 3 +- tests/test_pipedstreams.py | 36 +--- 3 files changed, 178 insertions(+), 161 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 73641d8f..32ecd954 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -1,125 +1,154 @@ from __future__ import annotations -from time import time import logging logger = logging.getLogger("ffmpegio") -from .. import utils, configure, ffmpegprocess, plugins -from ..threading import LoggerThread, ReaderThread, WriterThread - from typing_extensions import Unpack from collections.abc import Sequence -from ..typing import ( - Literal, - Any, - RawStreamDef, - ProgressCallable, - RawDataBlob, - StreamSpecDict, - FFmpegArgs -) - -import contextlib -from io import BytesIO +from .._typing import Any, ProgressCallable, RawDataBlob +from ..configure import FFmpegInputUrlComposite, FFmpegArgs, FFmpegUrlType, MediaType + +import sys +from time import time from fractions import Fraction +from contextlib import ExitStack from namedpipe import NPopen -from ..threading import WriterThread -from ..filtergraph.presets import merge_audio - -from .. import ffmpegprocess, utils, configure, FFmpegError, plugins, filtergraph as fgb -from ..utils import avi, pop_global_options -from ..utils.log import extract_output_stream -from ..threading import WriterThread, ReaderThread -from ..probe import streams_basic -from ..configure import init_media_read, init_media_write +from .. import configure, ffmpegprocess, plugins +from ..threading import LoggerThread, ReaderThread, WriterThread +from ..errors import FFmpegError, FFmpegioError # fmt:off -__all__ = ["Trancoder"] +__all__ = ["PipedMediaReader"] # fmt:on -class PipedRunner: - def __init__( - self, - args: FFmpegArgs, - input_info: list, - output_info: dict, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - blocksize:int=None, - sp_kwargs: dict | None = None, - **options: Unpack[dict[str, Any]], - ): - ... -class PipedReader(PipedRunner): +class PipedMediaReader(ExitStack): def __init__( self, - *urls: * tuple[str | tuple[str, dict[str, Any] | None]], + *urls: * tuple[ + FFmpegInputUrlComposite | tuple[FFmpegUrlType, dict[str, Any] | None] + ], map: Sequence[str] | dict[str, dict[str, Any] | None] | None = None, + ref_stream: int = 0, + blocksize: int | None = None, + default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, - blocksize:int=None, + queuesize: int | None = None, sp_kwargs: dict | None = None, **options: Unpack[dict[str, Any]], ): - ffmpeg_args, self._output_info, self._resolve_outputs = configure.init_media_read(urls, map, options) + """Read video and audio data from multiple media files - # run FFmpeg + :param *urls: URLs of the media files to read or a tuple of the URL and its input option dict. + :param map: FFmpeg map options + :param ref_stream: index of the reference stream to pace read operation, defaults to 0. The + reference stream is guaranteed to have a frame data on every read operation. + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) - self.dtype = None # :str: output data type - self.shape = ( - None # :tuple of ints: dimension of each video frame or audio sample - ) - self.samplesize = ( - None #:int: number of bytes of each video frame or audio sample - ) - self.blocksize = None #:positive int: number of video frames or audio samples to read when used as an iterator - self.sp_kwargs = sp_kwargs #:dict[str,Any]: additional keyword arguments for subprocess.Popen + Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run + `ffmpegio.video.read()` for each url. + + Specify the streams to return by `map` output option: + + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + + Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output + 'pix_fmt' option is not explicitly set, 'rgb24' is used. - # abstract method to finalize the options => sets self.dtype and self.shape if known - self._finalize(ffmpeg_args) + For audio streams, if 'sample_fmt' output option is not specified, 's16'. + """ + + super().__init__() + + # initialize FFmpeg argument dict and get input & output information + args, self._input_info, self._output_info = configure.init_media_read( + urls, map, options + ) # create logger without assigning the source stream self._logger = LoggerThread(None, show_log) - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update({"stdin": stdin, "progress": progress, "capture_log": True}) + # prepare FFmpeg keyword arguments + self._args = { + "ffmpeg_args": args, + "progress": progress, + "capture_log": True, + "sp_kwargs": sp_kwargs, + "on_exit": self._stop_readers, + } + + # set the default read block size for the referenc stream + info = self._output_info[ref_stream] + if blocksize is None: + blocksize = 1 if info["media_type"] == "video" else 1024 + self.blocksize = blocksize + self.default_timeout = default_timeout + self._ref = ref_stream + self._rates = [v["media_info"][2] for v in self._output_info] + self._n0 = [0] * len(self._output_info) # timestamps of the last read sample + self._pipe_kws = { + "queue_size": queuesize, + "update_rate": self._rates[self._ref] / Fraction(blocksize), + } + + hook = plugins.get_hook() + self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} + self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} - # start FFmpeg - self._proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) + def __enter__(self): + super().__enter__() + + # set up and activate pipes and read/write threads + self._piped_outputs = configure.init_named_pipes( + self._args["ffmpeg_args"], + self._input_info, + self._output_info, + self, + **self._pipe_kws, + ) + + self._n0 = [0] * len(self._output_info) # timestamps of the last read sample + + # run the FFmpeg + self._proc = ffmpegprocess.Popen(**self._args) # set the log source and start the logger self._logger.stderr = self._proc.stderr self._logger.start() - # if byte data is given, feed it - if input is not None: - self._proc.stdin.write(input) + # wait until all the reader threads are running + for info in self._output_info: + info["reader"].wait_till_running() - # wait until output stream log is captured if output format is unknown - try: - if self.dtype is None or self.shape is None: - logger.debug( - "[reader main] waiting for logger to provide output stream info" - ) - info = self._logger.output_stream() - logger.debug(f"[reader main] received {info}") - self._finalize_array(info) - else: - self._logger.index("Output") - except: - if self._proc.poll() is None: - raise self._logger.Exception - else: - raise ValueError("failed retrieve output data format") + return self - self.samplesize = utils.get_samplesize(self.shape, self.dtype) + def open(self): + self.__enter__() - self.blocksize = blocksize or max(1024**2 // self.samplesize, 1) - logger.debug("[reader main] completed init") + def __exit__(self, *exc_details): + try: + if self._proc is not None and self._proc.poll() is None: + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + self._proc = None + except: + if not exc_details[0]: + exc_details = sys.exc_info() + finally: + self._logger.join() + super().__exit__(*exc_details) def close(self): """Flush and close this stream. This method has no effect if the stream is already @@ -131,68 +160,70 @@ def close(self): """ - if self._proc is None: - return + self.__exit__(None, None, None) - self._proc.stdout.close() - self._proc.stderr.close() + def specs(self) -> list[str]: + """list of specifiers of the streams""" - if self._proc.poll() is None: - try: - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - except: - print("failed to terminate") - pass + return [v["user_map"] for v in self._output_info] - logger.debug(f"[reader main] FFmpeg closed? {self._proc.poll()}") + def types(self) -> dict[str, MediaType]: + """media type associated with the streams (key)""" + return {v["user_map"]: v["media_type"] for v in self._output_info} - try: - self._proc.stdin.close() - except: - pass - self._logger.join() + def rates(self) -> dict[str, int | Fraction]: + """sample or frame rates associated with the streams (key)""" + return {v["user_map"]: v["media_info"][2] for v in self._output_info} + + def dtypes(self) -> dict[str, str]: + """frame/sample data type associated with the streams (key)""" + return {v["user_map"]: v["media_info"][0] for v in self._output_info} + + def shapes(self) -> dict[str, tuple[int]]: + """frame/sample shape associated with the streams (key)""" + return {v["user_map"]: v["media_info"][1] for v in self._output_info} @property - def closed(self): - """:bool: True if the stream is closed.""" + def closed(self) -> bool: + """True if the stream is closed.""" return self._proc.poll() is not None @property - def lasterror(self): - """:FFmpegError: Last error FFmpeg posted""" + def lasterror(self) -> FFmpegError: + """Last error FFmpeg posted""" if self._proc.poll(): return self._logger.Exception() else: return None - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() + def _stop_readers(self, returncode): + # ffmpegprocess on_exit callback function + for info in self._output_info: + info["reader"].cool_down() def __iter__(self): return self def __next__(self): - F = self.read(self.blocksize) - if F is None: + F = self.read(self.blocksize, self.default_timeout) + if not any( + len(self._get_bytes[info["media_type"]](obj=f)) + for f, info in zip(F.values(), self._output_info) + ): raise StopIteration return F - def readlog(self, n=None): + def readlog(self, n: int = None) -> str: if n is not None: self._logger.index(n) with self._logger._newline_mutex: return "\n".join(self._logger.logs or self._logger.logs[:n]) - def read(self, n=-1): + def read(self, n: int = -1, timeout: float | None = None) -> dict[str, RawDataBlob]: """Read and return numpy.ndarray with up to n frames/samples. If - the argument is omitted, None, or negative, data is read and - returned until EOF is reached. An empty bytes object is returned - if the stream is already at EOF. + the argument is omitted or negative, data is read and returned until + EOF is reached. An empty bytes object is returned if the stream is + already at EOF. If the argument is positive, and the underlying raw stream is not interactive, multiple raw reads may be issued to satisfy the byte @@ -202,27 +233,32 @@ def read(self, n=-1): A BlockingIOError is raised if the underlying raw stream is in non blocking-mode, and has no data available at the moment.""" - logger.debug(f"[reader main] reading {n} samples") - b = self._proc.stdout.read(n * self.samplesize if n > 0 else n) - logger.debug(f"[reader main] read {len(b)} bytes") - if not len(b): - self._proc.stdout.close() - return None - return self._converter(b=b, shape=self.shape, dtype=self.dtype, squeeze=False) - def readinto(self, array): - """Read bytes into a pre-allocated, writable bytes-like object array and - return the number of bytes read. For example, b might be a bytearray. + # compute the number of frames to read per stream + if self._n0 and n > 0: + T = n / self._rates[self._ref] # duration - Like read(), multiple reads may be issued to the underlying raw stream, - unless the latter is interactive. + n1 = [(T * r) + n0 for r, n0 in zip(self._rates, self._n0)] + nread = [int(n1 - n0) for n0, n1 in zip(self._n0, n1)] + self._n0 = n1 + else: + nread = [n] * len(self._output_info) + self._n0 = None + + data = {} + for info, nr in zip(self._output_info, nread): + + converter = self._converters[info["media_type"]] + dtype, shape, _ = info["media_info"] + data[info["user_map"]] = converter( + b=info["reader"].read(nr, timeout) if nr else b"", + dtype=dtype, + shape=shape, + squeeze=False, + ) - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" + return data - return ( - self._proc.stdout.readinto(self._memoryviewer(obj=array)) // self.samplesize - ) class PipedWriter: ... diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index 861ebda0..ceaf5460 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -6,6 +6,7 @@ SimpleVideoFilter, SimpleAudioFilter, ) +from .PipedStreams import PipedMediaReader from .AviStreams import AviMediaReader # TODO multi-stream write @@ -13,6 +14,6 @@ # fmt: off __all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", + "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter","PipedMediaReader" "AviMediaReader"] # fmt: on diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index 2164d309..26092fac 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -6,37 +6,17 @@ import pytest import ffmpegio as ff +from ffmpegio import streams +mult_url = "tests/assets/testmulti-1m.mp4" video_url = "tests/assets/testvideo-1m.mp4" audio_url = "tests/assets/testaudio-1m.mp3" outext = ".mp4" -@pytest.mark.skip(reason="to be implemented") -def test_transcoder(): - from ffmpegio.streams.PipedStreams import Transcoder - vsz = path.getsize(video_url) // 100 - asz = path.getsize(audio_url) // 100 - logging.info(f"{vsz=}") - logging.info(f"{asz=}") - - with ( - open(video_url, "rb") as vf, - open(audio_url, "rb") as af, - Transcoder(nb_inputs=2, show_log=True) as merger, - ): - while True: - vdata = vf.read(vsz) - if vdata: - merger.write(0, vdata) - - adata = af.read(asz) - if adata: - merger.write(1, adata) - - F = merger.read_nowait() - logging.info(f"read {len(F)} bytes") - - -if __name__ == "__main__": - test_merger() +def test_PipedMediaReader(): + with streams.PipedMediaReader(mult_url, t=1) as reader: + # data = reader.read(2) + for data in reader: + for k, v in data.items(): + print(f"{k}: {len(v['buffer'])}") From 268a8ad340b662a79104816b2019fd4f604e81c1 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 20:45:51 +0900 Subject: [PATCH 189/333] `stack()` to maintain the filtergraph type if only one item --- src/ffmpegio/filtergraph/build.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index f36ed97a..468403b1 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -411,6 +411,8 @@ def stack( n = len(fgs) if not n: return fgb.Graph() + if len(fgs) == 1: + return fgb.as_filtergraph_object(fgs[0], copy=True) fg = fgb.as_filtergraph(fgs[0], copy=not inplace) From 575a5c138a847452c75b568ec90e31e4df146316 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 20:46:26 +0900 Subject: [PATCH 190/333] updated test --- tests/test_configure.py | 11 +++++++++-- tests/test_filtergraph.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_configure.py b/tests/test_configure.py index d26e76b6..35f69670 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -215,7 +215,10 @@ def test_process_url_inputs(url, opts, defopts, ret): [(mul_url, {})], [{"src_type": "url"}], ["split=outputs=2"], - {"[out0]": {"media_type": "video"}, "[out1]": {"media_type": "video"}}, + { + "[out0]": {"media_type": "video", "linklabel": "[out0]"}, + "[out1]": {"media_type": "video", "linklabel": "[out1]"}, + }, ), ], ) @@ -229,7 +232,11 @@ def test_auto_map(inputs, input_info, filters_complex, ret): args["global_options"] = {"filter_complex": filters_complex} out = configure.auto_map(args, input_info, filters_complex and fg_info) assert out == { - spec: {"dst_type": "buffer", "user_map": None, **info} + spec: { + "dst_type": "buffer", + "user_map": spec[1:-1] if "linklabel" in info else spec, + **info, + } for spec, info in ret.items() } diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index 0ba58283..091d3a91 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -479,7 +479,7 @@ def test_filter_empty_handling(): assert (fg4 * 2).compose() == "" assert (fg1 + fg3).compose() == "trim,crop" - assert (fg1 | fg3).compose() == "trim,crop" + assert (fg1 | fg3).compose() == "[UNC0]trim,crop[UNC1]" assert (fg2 + fg3).compose() == "[UNC0]fps[UNC2];[UNC1]scale[UNC3]" assert (fg2 | fg3).compose() == "[UNC0]fps[UNC2];[UNC1]scale[UNC3]" From 2f2bd673b32b3c8ac589b2ca90689fa54279a3d5 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 21 Feb 2025 20:48:14 +0900 Subject: [PATCH 191/333] `process_xxx_outputs()` to process complex filtergraphs internally --- src/ffmpegio/configure.py | 75 +++++++++++++++++---------- src/ffmpegio/streams/SimpleStreams.py | 3 +- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index f076c0a4..d078c8e6 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1212,10 +1212,9 @@ def process_url_inputs( def process_raw_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], - fg_info: dict[str, dict] | None, streams: Sequence[str] | dict[str, dict[str, Any] | None] | None, options: dict[str, Any], -) -> list[RawOutputInfoDict]: +) -> tuple[list[RawOutputInfoDict], dict[str, dict] | None]: """analyze and process piped raw outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -1223,9 +1222,22 @@ def process_raw_outputs( :param input_info: list of input information (same length as `args['inputs']) :param streams: user's list of map options to be included :param options: default output options - :return: list of output information + :return output_info: list of output information + :return fg_info: dict of filtergraph outputs, keyed by their linklabels """ + gopts = args["global_options"] + if "filter_complex" in gopts: + gopts["filter_complex"], fg_info = ( + utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info + ) + if "filter_complex" in gopts + else None + ) + else: + fg_info = None + # resolve requested output streams stream_info: dict[str, RawOutputInfoDict] = ( auto_map(args, input_info, fg_info) # automatically map all the streams @@ -1255,7 +1267,7 @@ def process_raw_outputs( else finalize_video_read_opts )(args, i, input_info, fg_info) - return list(stream_info.values()) + return list(stream_info.values()), fg_info def process_raw_inputs( @@ -1326,12 +1338,11 @@ def process_raw_inputs( def process_url_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], - fg_info: dict[str, dict] | None, urls: list[ FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] ], options: dict[str, Any], -) -> list[RawOutputInfoDict]: +) -> tuple[list[RawOutputInfoDict], dict[str, Any] | None]: """analyze and process url outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -1342,9 +1353,22 @@ def process_url_outputs( :param urls: output file names and optionally with file-specific options :param options: default output options. If `"map"` option is given, it is appended to the per-file `"map"` option in `streams` argument - :return: list of output information + :return output_info: list of output information + :return fg_info: dict of filtergraph outputs, keyed by their linklabels """ + gopts = args["global_options"] + if "filter_complex" in gopts: + gopts["filter_complex"], fg_info = ( + utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info + ) + if "filter_complex" in gopts + else None + ) + else: + fg_info = None + missing_map = False output_info_list = [None] * len(urls) for i, url in enumerate(urls): # add inputs @@ -1391,7 +1415,7 @@ def process_url_outputs( if "map" not in opts: opts["map"] = map_opts - return output_info_list + return output_info_list, fg_info def assign_input_url(args: FFmpegArgs, ifile: int, url: str): @@ -1524,15 +1548,8 @@ def init_media_read( # analyze and assign inputs input_info = process_url_inputs(args, urls, inopts_default) - fg_info = None - if "filter_complex" in gopts: - # prepare complex filter output - gopts["filter_complex"], fg_info = utils.analyze_complex_filtergraphs( - gopts["filter_complex"], args["inputs"], input_info - ) - # analyze and assign outputs - output_info = process_raw_outputs(args, input_info, fg_info, map, options) + output_info, fg_info = process_raw_outputs(args, input_info, map, options) return args, input_info, output_info @@ -1629,19 +1646,21 @@ def init_media_write( if extra_inputs is not None: input_info.extend(process_url_inputs(args, extra_inputs, {})) - if "filter_complex" in gopts: - gopts["filter_complex"], fg_info = ( - utils.analyze_complex_filtergraphs( - gopts["filter_complex"], args["inputs"], input_info - ) - if "filter_complex" in gopts - else None - ) - else: - fg_info = None + # make sure all inputs are complete + ready = True + for (url, opts), info in zip(args["inputs"], input_info): + if url is None and info["src_type"] == "buffer": + opt_names = { + "audio": ("ar", "sample_fmt", "ac"), + "video": ("r", "pix_fmt", "s"), + } + if not all(o in opts for o in opt_names[info["media_type"]]): + ready = False + break - # analyze and assign outputs - output_info = process_url_outputs(args, input_info, fg_info, urls, options) + if ready: + # analyze and assign outputs + output_info, fg_info = process_url_outputs(args, input_info, urls, options) return args, input_info, output_info diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index cebf685c..bf7a6163 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -781,10 +781,9 @@ def _open(self, data=None): self.dtype, self.shape, self.rate = configure.process_raw_outputs( ffmpeg_args, self._input_info, - None, [f"0:{self.stream_type}:0"], self._output_opts, - )[0]["media_info"] + )[0][0]["media_info"] configure.assign_output_url(ffmpeg_args, 0, "pipe:1") # start FFmpeg From b8e124000c2dd7564f19afda44cac2ba3a941954 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 09:48:21 -0600 Subject: [PATCH 192/333] added optional `writer' attribute to `InputSourceDict` --- src/ffmpegio/_typing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 2bc8ec37..0e338c34 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -9,8 +9,8 @@ from pathlib import Path from urllib.parse import ParseResult -from namedpipe import NPopen - +if TYPE_CHECKING: + from .threading import WriterThread # from typing_extensions import * @@ -50,4 +50,4 @@ class InputSourceDict(TypedDict): buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe - pipe: NotRequired[NPopen] # pipe + writer: NotRequired[WriterThread] # pipe From 7e43bf269a180a0b485d352a947687db5f26f754 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:02:54 -0600 Subject: [PATCH 193/333] moved filtergraph analysis inside `automap()` --- src/ffmpegio/configure.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index d078c8e6..3ff297e9 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1022,6 +1022,21 @@ def auto_map( """ + if fg_info is None and "filter_complex" in args["global_options"]: + # if filter_complex is specified but no fg_info + # run the analysis + gopts = args["global_options"] + if "filter_complex" in gopts: + gopts["filter_complex"], fg_info = ( + utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info + ) + if "filter_complex" in gopts + else None + ) + else: + fg_info = None + if fg_info is not None: return { linklabel: { @@ -1357,18 +1372,6 @@ def process_url_outputs( :return fg_info: dict of filtergraph outputs, keyed by their linklabels """ - gopts = args["global_options"] - if "filter_complex" in gopts: - gopts["filter_complex"], fg_info = ( - utils.analyze_complex_filtergraphs( - gopts["filter_complex"], args["inputs"], input_info - ) - if "filter_complex" in gopts - else None - ) - else: - fg_info = None - missing_map = False output_info_list = [None] * len(urls) for i, url in enumerate(urls): # add inputs @@ -1408,14 +1411,14 @@ def process_url_outputs( if missing_map: # some output file is missing `map` option # add all input streams or all complex filter outputs - map_opts = [*auto_map(args, input_info, fg_info)] + map_opts = [*auto_map(args, input_info, None)] # add outputs to FFmpeg arguments for _, opts in args["outputs"]: if "map" not in opts: opts["map"] = map_opts - return output_info_list, fg_info + return output_info_list def assign_input_url(args: FFmpegArgs, ifile: int, url: str): From 44b1f2e2e1fe4f9bfc06708070ba7346e8541f0e Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:04:14 -0600 Subject: [PATCH 194/333] `proess_raw_inputs()` to accept 2 optional arguments: `dtypes` and `shapes` --- src/ffmpegio/configure.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 3ff297e9..0fb4ffcb 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1290,10 +1290,12 @@ def process_raw_inputs( stream_types: Sequence[Literal["a", "v"]], stream_args: Sequence[RawStreamDef], inopts_default: dict[str, Any], + dtypes: list[str] | None = None, + shapes: list[tuple[int]] | None = None, ) -> list[InputSourceDict]: input_info: list[InputSourceDict] = [] - for mtype, arg in zip(stream_types, stream_args): + for i, (mtype, arg) in enumerate(zip(stream_types, stream_args)): try: a1, a2 = arg @@ -1335,11 +1337,21 @@ def process_raw_inputs( opts.update(utils.array_to_audio_options(data)) data = plugins.get_hook().audio_bytes(obj=data) + elif dtypes and shapes: + sample_fmt, ac = utils.guess_audio_format(dtypes[i], shapes[i]) + acodec, f = utils.get_audio_codec(sample_fmt) + opts.update({"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f}) + else: # video media_type = "video" if data is not None: opts.update(utils.array_to_video_options(data)) data = plugins.get_hook().video_bytes(obj=data) + elif dtypes and shapes: + pix_fmt, s = utils.guess_video_format(shapes[i], dtypes[i]) + opts.update( + {"f": "rawvideo", f"c:v": "rawvideo", "pix_fmt": pix_fmt, "s": s} + ) info = {"src_type": "buffer", "media_type": media_type} if data is not None: From 36c9903621622db60ba9efed2990654eadbbdb29 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:06:58 -0600 Subject: [PATCH 195/333] `process_url_outputs()` added `skip_automapping` argument --- src/ffmpegio/configure.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 0fb4ffcb..bd683112 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1369,6 +1369,7 @@ def process_url_outputs( FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] ], options: dict[str, Any], + skip_automapping: bool = False, ) -> tuple[list[RawOutputInfoDict], dict[str, Any] | None]: """analyze and process url outputs @@ -1380,6 +1381,8 @@ def process_url_outputs( :param urls: output file names and optionally with file-specific options :param options: default output options. If `"map"` option is given, it is appended to the per-file `"map"` option in `streams` argument + :param skip_automapping: True to skip automapping, uses the default mapping, + defaults to False :return output_info: list of output information :return fg_info: dict of filtergraph outputs, keyed by their linklabels """ @@ -1420,7 +1423,8 @@ def process_url_outputs( if "map" not in opts: missing_map = True - if missing_map: + if missing_map and not skip_automapping: + # some output file is missing `map` option # add all input streams or all complex filter outputs map_opts = [*auto_map(args, input_info, None)] @@ -1673,11 +1677,9 @@ def init_media_write( ready = False break - if ready: - # analyze and assign outputs - output_info, fg_info = process_url_outputs(args, input_info, urls, options) - - return args, input_info, output_info + output_info = process_url_outputs( + args, input_info, urls, options, skip_automapping=any(not_ready) + ) def init_named_pipes( From e392e5eca291017a59f963e1509e8c5cda30838f Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:09:12 -0600 Subject: [PATCH 196/333] fixed `retrieve_input_stream_ids()` handling of `buffer` input --- src/ffmpegio/configure.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index bd683112..c1f85b94 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1475,6 +1475,11 @@ def retrieve_input_stream_ids( or in an ffprobe incompatible format, e.g., ffconcat) """ + # check raw formats first + if info["src_type"] == "buffer" and "buffer" not in info: + # raw input real-time stream + return [[0, info["media_type"]]] + # file/network input - process only if seekable # get ffprobe subprocess keywords url, sp_kwargs, exit_fcn = utils.set_sp_kwargs_stdin(url, info) @@ -1483,11 +1488,6 @@ def retrieve_input_stream_ids( return [] def get_spec(info, opts): - # check raw formats first - from_buffer = info["src_type"] == "buffer" - if from_buffer and opts.get("f", None) in raw_formats: - return [{"index": 0, "codec_type": info["media_type"]}] - # run ffprobe return probe.streams_basic( url, From 2244faf376a1f618c762bf4dbbe194ea28f66d4e Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:12:54 -0600 Subject: [PATCH 197/333] added to `init_media_write()` new arguments `dtypes` and `shapes` --- src/ffmpegio/configure.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index c1f85b94..55da9918 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1587,7 +1587,9 @@ def init_media_write( Sequence[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]] | None ), options: dict[str, Any], -) -> tuple[FFmpegArgs, list[InputSourceDict], list[RawOutputInfoDict]]: + dtypes: list[str] | None = None, + shapes: list[tuple[int]] | None = None, +) -> tuple[FFmpegArgs, list[InputSourceDict], list[RawOutputInfoDict], list[bool]]: """write multiple streams to a url/file :param url: output url @@ -1600,6 +1602,10 @@ def init_media_write( string or a pair of a url string and an option dict. :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options will be applied to all input streams unless the option has been already defined in `stream_data` + :param dtypes: list of numpy-style data type strings of input samples or frames + of input media streams, defaults to `None` (auto-detect). + :param shapes: list of shapes of input samples or frames of input media streams, + defaults to `None` (auto-detect). TIPS ---- @@ -1624,7 +1630,9 @@ def init_media_write( gopts = args["global_options"] # global options dict # analyze and assign inputs - input_info = process_raw_inputs(args, stream_types, stream_args, inopts_default) + input_info = process_raw_inputs( + args, stream_types, stream_args, inopts_default, dtypes, shapes + ) # map all input streams to output unless user specifies the mapping a_ids = [i for i, info in enumerate(input_info) if info["media_type"] == "audio"] From 5fa4cfd85cbda01b3190df884d630e17a9d0b8c1 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:17:38 -0600 Subject: [PATCH 198/333] `init_media_write()` returns additional output: a `not_ready` list --- src/ffmpegio/configure.py | 19 +++++++++++-------- src/ffmpegio/media.py | 2 +- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 55da9918..794eae31 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1606,6 +1606,10 @@ def init_media_write( of input media streams, defaults to `None` (auto-detect). :param shapes: list of shapes of input samples or frames of input media streams, defaults to `None` (auto-detect). + :return ffmpeg_args: FFmpeg argument dict + :return input_info: input stream information + :return output_info: output file information + :return not_ready: An elemtn is true if corresponding input is missing data format information TIPS ---- @@ -1674,22 +1678,21 @@ def init_media_write( input_info.extend(process_url_inputs(args, extra_inputs, {})) # make sure all inputs are complete - ready = True - for (url, opts), info in zip(args["inputs"], input_info): + opt_names = {"audio": ("sample_fmt", "ac"), "video": ("pix_fmt", "s")} + not_ready = [False] * len(input_info) + for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)): if url is None and info["src_type"] == "buffer": - opt_names = { - "audio": ("ar", "sample_fmt", "ac"), - "video": ("r", "pix_fmt", "s"), - } if not all(o in opts for o in opt_names[info["media_type"]]): - ready = False - break + not_ready[i] = True output_info = process_url_outputs( args, input_info, urls, options, skip_automapping=any(not_ready) ) + return args, input_info, output_info, not_ready + + def init_named_pipes( args: FFmpegArgs, input_info: list[InputSourceDict], diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index c3506df9..f5236505 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -193,7 +193,7 @@ def write( if not isinstance(urls, list): urls = [urls] - args, input_info, output_info = configure.init_media_write( + args, input_info, output_info, _ = configure.init_media_write( urls, stream_types, stream_args, From 9c83e84db42ab61ee131891fe239a7b999d44bf6 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:18:43 -0600 Subject: [PATCH 199/333] `init_media_write()` added `f` option check for piped outputs --- src/ffmpegio/configure.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 794eae31..e6253ff3 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1689,6 +1689,12 @@ def init_media_write( args, input_info, urls, options, skip_automapping=any(not_ready) ) + # if output is piped, it must have the -f option specified + for url, opts in args["outputs"]: + if url is None and "f" not in opts: + raise FFmpegioError( + 'all piped encoded output stream must have its format (`"f"`) defined in its option dict' + ) return args, input_info, output_info, not_ready From 6d0d8a4b319fd1f7d33c8847774f69e52080fb94 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:20:27 -0600 Subject: [PATCH 200/333] `init_named_pipes()` to return the `writer` object if input data is not provided --- src/ffmpegio/configure.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e6253ff3..3e782ba1 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1740,8 +1740,13 @@ def init_named_pipes( writer = WriterThread(pipe, **wr_kws) # starts thread & wait for pipe connection stack.enter_context(writer) - writer.write(info["buffer"]) - writer.write(None) # close the + if "buffer" in info: + # data buffer given, feed the data and terminate + writer.write(info["buffer"]) + writer.write(None) # close the + else: + # if no data given, provide the access to the writer + info["writer"] = writer else: raise FFmpegioError(f"{src_type=} is an unknown input data type.") From 5cc36a7bf6b1c4b4d0973f1db8d6245fcfe0cbf4 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:22:18 -0600 Subject: [PATCH 201/333] `init_named_pipes()` returns all piped output indices regardless of encoded or raw --- src/ffmpegio/configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 3e782ba1..8acac6a1 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1762,9 +1762,9 @@ def init_named_pipes( if dst_type == "fileobj": reader = CopyFileObjThread(info["fileobj"], pipe) elif dst_type == "buffer": + pipes_out.append(i) kws = {**wr_kws} if "media_info" in info: - pipes_out.append(i) dtype, shape, rate = info["media_info"] kws["itemsize"] = utils.get_samplesize(shape, dtype) if update_rate is not None: From 6848f0693546aafc1aaa75cf935cbc0a40d6b97b Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:23:28 -0600 Subject: [PATCH 202/333] `init_named_pipes()` adds `-y` flag to FFmpeg options if there is any output pipes defined --- src/ffmpegio/configure.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 8acac6a1..b62c7330 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1774,4 +1774,9 @@ def init_named_pipes( raise FFmpegioError(f"{dst_type=} is an unknown output data type.") stack.enter_context(reader) # starts thread & wait for pipe connection + if len(pipes_out): + # if any output is piped, must run in the overwrite mode + args["global_options"].pop("n", None) + args["global_options"]["y"] = None + return pipes_out From c0e97ae6034aa36ea88318a31ba42d7f15f6e60d Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:25:04 -0600 Subject: [PATCH 203/333] `write()` validates if output is established --- src/ffmpegio/media.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index f5236505..73ef8ef9 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -21,6 +21,7 @@ from . import ffmpegprocess, utils, configure, FFmpegError, plugins from .utils.log import extract_output_stream +from .errors import FFmpegioError __all__ = ["read", "write"] @@ -205,6 +206,9 @@ def write( options, ) + if output_info is None: + raise FFmpegioError("failed to format output...") + kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( { From 94ce5ab6d7ab8beedcc91c38cfe10ca99203f6ff Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:25:48 -0600 Subject: [PATCH 204/333] added `NotEmpty` exception --- src/ffmpegio/threading.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index ee3d4943..2d302a39 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -30,6 +30,11 @@ # fmt:on +class NotEmpty(Exception): + "Exception raised by WriterThread.flush(timeout) if timedout." + pass + + class ThreadNotActive(RuntimeError): pass From 34e34f3a7724977446d2a6846ef3f83ad9a52047 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:27:05 -0600 Subject: [PATCH 205/333] `ReaderThread` runner to terminate immediately if `_halt` flag is already set --- src/ffmpegio/threading.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 2d302a39..e0d14651 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -351,6 +351,8 @@ def run(self): blocksize = ( self.nmin if self.nmin is not None else 1 if self.itemsize > 1024 else 1024 ) * self.itemsize + if self._halt.is_set(): + return logger.debug("waiting for pipe to open") stream = self.stdout.wait() if is_npipe else self.stdout logger.debug("starting to read") From a2265f08fa677747a09ba4019cba9b5d20744a95 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:27:55 -0600 Subject: [PATCH 206/333] added `flush()` method to `WriterThread` --- src/ffmpegio/threading.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index e0d14651..bb04504e 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -487,6 +487,8 @@ def __init__(self, stdin: BinaryIO | NPopen, queuesize: int | None = None): super().__init__() self.stdin = stdin #:writable stream: data sink self._queue = Queue(queuesize or 0) # inter-thread data I/O + self._empty_cond = Condition() + self._empty = True def join(self, timeout: float | None = None): # close the stream if not already closed @@ -514,7 +516,15 @@ def run(self): while True: # get next data block - data = self._queue.get() + try: + data = self._queue.get_nowait() + except Empty: + # if empty, set the flag and block + with self._empty_cond: + self._empty = True + self._empty_cond.notify_all() + data = self._queue.get() + self._queue.task_done() if data is None: logger.info(f"writer thread: received a sentinel to stop the writer") @@ -543,7 +553,21 @@ def write(self, data, timeout=None): if not self.is_alive(): raise ThreadNotActive("WriterThread is not running") - data = self._queue.put(data, timeout) + with self._empty_cond: + self._queue.put(data, timeout) + self._empty = False + + def flush(self, timeout: float | None = None): + """block until the write buffer is emptied + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait until empty + :raise NotEmpty: if a timeout is set, and the buffer is not emptied in time + """ + + with self._empty_cond: + if not (self._empty or self._empty_cond.wait(timeout)): + raise NotEmpty() class AviReaderThread(Thread): From 23d6f5b5533d911fba927bc9b9567763cbeaa9cc Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:30:13 -0600 Subject: [PATCH 207/333] `PipedMediaReader` changed to use `ExitStack` by composition --- src/ffmpegio/streams/PipedStreams.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 32ecd954..f1e5a4cc 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -25,7 +25,7 @@ # fmt:on -class PipedMediaReader(ExitStack): +class PipedMediaReader: def __init__( self, *urls: * tuple[ @@ -68,7 +68,7 @@ def __init__( For audio streams, if 'sample_fmt' output option is not specified, 's16'. """ - super().__init__() + self._stack = ExitStack() # initialize FFmpeg argument dict and get input & output information args, self._input_info, self._output_info = configure.init_media_read( @@ -106,14 +106,14 @@ def __init__( self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} def __enter__(self): - super().__enter__() + self._stack.__enter__() # set up and activate pipes and read/write threads self._piped_outputs = configure.init_named_pipes( self._args["ffmpeg_args"], self._input_info, self._output_info, - self, + self._stack, **self._pipe_kws, ) @@ -148,7 +148,7 @@ def __exit__(self, *exc_details): exc_details = sys.exc_info() finally: self._logger.join() - super().__exit__(*exc_details) + self._stack.__exit__(*exc_details) def close(self): """Flush and close this stream. This method has no effect if the stream is already From b46414e695c3cad4a2c397683cc481ee26cb3e9b Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:33:07 -0600 Subject: [PATCH 208/333] `array_to_audio_options()` & `array_to_video_options()` - support empty data case --- src/ffmpegio/utils/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index b4918dc4..34118202 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -489,8 +489,9 @@ def array_to_audio_options(data: Any) -> dict: :returns: dict of audio options """ - shape = dtype = None shape, dtype = plugins.get_hook().audio_info(obj=data) + if shape is None: + return {} sample_fmt, ac = guess_audio_format(dtype, shape) codec, f = get_audio_codec(sample_fmt) return {"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt} @@ -504,7 +505,11 @@ def array_to_video_options(data: Any | None = None) -> dict: """ s, pix_fmt = guess_video_format(*plugins.get_hook().video_info(obj=data)) - return {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} + return ( + {"f": "rawvideo", f"c:v": "rawvideo"} + if s is None + else {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} + ) def set_sp_kwargs_stdin( From 1fb423d8743d44dd2c86640a56ed4568fdf90ec8 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:34:19 -0600 Subject: [PATCH 209/333] added `PipedMediaWriter` class --- src/ffmpegio/streams/PipedStreams.py | 403 ++++++++++++++++++++++++++- src/ffmpegio/streams/__init__.py | 5 +- tests/test_pipedstreams.py | 40 +++ 3 files changed, 440 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index f1e5a4cc..72fcda07 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -6,8 +6,13 @@ from typing_extensions import Unpack from collections.abc import Sequence -from .._typing import Any, ProgressCallable, RawDataBlob -from ..configure import FFmpegInputUrlComposite, FFmpegArgs, FFmpegUrlType, MediaType +from .._typing import Any, ProgressCallable, RawDataBlob, Literal +from ..configure import ( + FFmpegInputUrlComposite, + FFmpegUrlType, + MediaType, + FFmpegOutputUrlComposite, +) import sys from time import time @@ -16,12 +21,12 @@ from namedpipe import NPopen -from .. import configure, ffmpegprocess, plugins -from ..threading import LoggerThread, ReaderThread, WriterThread +from .. import configure, ffmpegprocess, plugins, utils +from ..threading import LoggerThread, ReaderThread, WriterThread, NotEmpty from ..errors import FFmpegError, FFmpegioError # fmt:off -__all__ = ["PipedMediaReader"] +__all__ = ["PipedMediaReader", "PipedMediaWriter"] # fmt:on @@ -260,7 +265,393 @@ def read(self, n: int = -1, timeout: float | None = None) -> dict[str, RawDataBl return data -class PipedWriter: ... +class PipedMediaWriter: + + _array_to_opts = { + "video": utils.array_to_video_options, + "audio": utils.array_to_audio_options, + } + _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} + + def __init__( + self, + urls: ( + FFmpegOutputUrlComposite + | list[FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict]] + ), + stream_types: Sequence[Literal["a", "v"]], + *stream_rates_or_opts: * tuple[int | Fraction | dict, ...], + dtypes_in: list[str] | None = None, + shapes_in: list[tuple[int]] | None = None, + merge_audio_streams: bool | Sequence[int] = False, + merge_audio_ar: int | None = None, + merge_audio_sample_fmt: str | None = None, + merge_audio_outpad: str | None = None, + extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + default_timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + queuesize: int | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[dict[str, Any]], + ): + """Write video and audio data from multiple media streams to one or more files + + :param url: output url + :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) + :param stream_rates_or_opts: either sample rate (audio) or frame rate (video) + or a dict of input options. The option dict must + include `'ar'` (audio) or `'r'` (video) to specify + the rate. + :param dtypes_in: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param shapes_in: list of shapes of input samples or frames of input media + streams, defaults to `None` (auto-detect). + :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's + (indices of `stream_types`) to combine only specified streams. + :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream + :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + """ + + self._stack = ExitStack() + + if not isinstance(urls, list): + urls = [urls] + + stream_args = [ + (None, v) if isinstance(v, dict) else (v, None) + for v in stream_rates_or_opts + ] + args, self._input_info, self._output_info, self._deferred_open = ( + configure.init_media_write( + urls, + stream_types, + stream_args, + merge_audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad, + extra_inputs, + options, + dtypes_in, + shapes_in, + ) + ) + + if any(self._deferred_open): + # temporary storage + self._deferred_data = [[] for _ in range(len(self._deferred_open))] + else: + # no need for deferral + self._deferred_open = False + self._deferred_data = None + + # create logger without assigning the source stream + self._logger = LoggerThread(None, show_log) + + # prepare FFmpeg keyword arguments + self._args = { + "ffmpeg_args": args, + "progress": progress, + "capture_log": True, + "sp_kwargs": sp_kwargs, + "on_exit": self._stop_readers, + } + + # set the default read block size for the referenc stream + self.default_timeout = default_timeout + self._pipe_kws = {"queue_size": queuesize} + + hook = plugins.get_hook() + self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} + self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} + + self._proc = None + self._piped_outputs = [] + + def _open(self, deferred: bool): + + if deferred: + ffmpeg_args = self._args["ffmpeg_args"] + outputs = ffmpeg_args["outputs"] + if not any("map" in url_opts[1] for url_opts in outputs): + # some output file is missing `map` option + # add all input streams or all complex filter outputs + input_info = self._input_info + map_opts = [*configure.auto_map(ffmpeg_args, input_info, None)] + + # add outputs to FFmpeg arguments + for _, opts in outputs: + if "map" not in opts: + opts["map"] = map_opts + + # set up and activate pipes and read/write threads + self._piped_outputs = configure.init_named_pipes( + self._args["ffmpeg_args"], + self._input_info, + self._output_info, + self._stack, + **self._pipe_kws, + ) + + # run the FFmpeg + self._proc = ffmpegprocess.Popen(**self._args) + + # set the log source and start the logger + self._logger.stderr = self._proc.stderr + self._logger.start() + + # if any pending data, queue them + for src, info in zip(self._deferred_data, self._input_info): + if "writer" in info and len(src): + writer = info["writer"] + for data in src: + writer.write(data) + self._deferred_data = [] + + # wait until all the reader threads are running + # for info in self._output_info: + # if "reader" in info: + # info["reader"].wait_till_running() + + self._deferred_open = False + + return self + + def __enter__(self): + self._stack.__enter__() + + if self._deferred_open is False: + self._open(False) + + return self + + def open(self): + self.__enter__() + + def __exit__(self, *exc_details): + + self.wait(self.default_timeout) + + try: + if self._proc is not None and self._proc.poll() is None: + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + self._proc = None + except: + if not exc_details[0]: + exc_details = sys.exc_info() + finally: + self._logger.join() + self._stack.__exit__(*exc_details) + + def close(self): + """Flush and close this stream. This method has no effect if the stream is already + closed. Once the stream is closed, any read operation on the stream will raise + a ValueError. + + As a convenience, it is allowed to call this method more than once; only the first call, + however, will have an effect. + + """ + + self.__exit__(None, None, None) + + def types(self) -> dict[str, MediaType]: + """media type associated with the streams (key)""" + return {v["user_map"]: v["media_type"] for v in self._output_info} + + def rates(self) -> dict[str, int | Fraction]: + """sample or frame rates associated with the streams (key)""" + return {v["user_map"]: v["media_info"][2] for v in self._output_info} + + def dtypes(self) -> dict[str, str]: + """frame/sample data type associated with the streams (key)""" + return {v["user_map"]: v["media_info"][0] for v in self._output_info} + + def shapes(self) -> dict[str, tuple[int]]: + """frame/sample shape associated with the streams (key)""" + return {v["user_map"]: v["media_info"][1] for v in self._output_info} + + @property + def closed(self) -> bool: + """True if the stream is closed.""" + return self._proc.poll() is not None + + @property + def lasterror(self) -> FFmpegError: + """Last error FFmpeg posted""" + if self._proc.poll(): + return self._logger.Exception() + else: + return None + + def _stop_readers(self, returncode): + # ffmpegprocess on_exit callback function + for info in self._output_info: + if "reader" in info: + info["reader"].cool_down() + if returncode: + raise self._logger.Exception or FFmpegError( + "FFmpeg failed for an unknown reason. Please check the log." + ) + + def readlog(self, n: int = None) -> str: + if n is not None: + self._logger.index(n) + with self._logger._newline_mutex: + return "\n".join(self._logger.logs or self._logger.logs[:n]) + + def write(self, stream_id: int, data: RawDataBlob) -> bytes | None: + """write a raw media data to a specified stream + + :param stream_id: input stream index + :param data: media data blob (depends on the active data conversion plugin) + :return: currently available encoded data (bytes) if returning the encoded + data back to Python + + Write the given numpy.ndarray object, data, and return the number + of bytes written (always equal to the number of data frames/samples, + since if the write fails an OSError will be raised). + + When in non-blocking mode, a BlockingIOError is raised if the data + needed to be written to the raw stream but it couldn’t accept all + the data without blocking. + + The caller may release or mutate data after this method returns, + so the implementation should only access data during the method call. + + """ + + # get input stream information + info = self._input_info[stream_id] + media_type = info["media_type"] + b = getattr(plugins.get_hook(), self._media_bytes[media_type])(obj=data) + + if self._deferred_open is not False: + # need to collect input data type and shape from the actual data + # before starting the FFmpeg + if self._deferred_open[stream_id]: + # first frame of the input stream with missing information + # update the + input_args = self._args["ffmpeg_args"]["inputs"][stream_id] + self._args["ffmpeg_args"]["inputs"][stream_id] = ( + input_args[0], + {**input_args[1], **self._array_to_opts[media_type](data)}, + ) + self._deferred_open[stream_id] = False + + self._deferred_data[stream_id].append(b) + + if not any(self._deferred_open): + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) + + else: + + logger.debug("[writer main] writing...") + + try: + self._input_info[stream_id]["writer"].write(b) + except (BrokenPipeError, OSError): + self._logger.join_and_raise() + + def flush(self, timeout: float | None = None): + """block until the write buffers are emptied. + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait until empty + :raise `NotEmpty`: if a timeout is set, and the buffer is not emptied in time + + ---- + Note + ---- + + This function may hang or throw `NotEmpty` when input streams are written + in an unbalanced fashion. The behavior is dictated by how FFmpeg reads + its input data. Use the `timeout` argument to avoid hanging if in doubt. + + """ + for info in self._input_info: + if "writer" in info and info["writer"].is_alive(): + info["writer"].flush(timeout) + + def wait(self, timeout: float | None = None) -> int | None: + """close the input pipes and wait for FFmpeg to finish + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait until empty + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to + catch this exception and retry the wait. + :return returncode: return returncode attribute + + Note that the piped output will remain accessible until `pop_encoded()` is called. + """ + + if self._proc: + # write the sentinel to each input queue + try: + self.flush(timeout) + except NotEmpty as e: + raise TimeoutError() from e + + # wait until the FFmpeg finishes the job + try: + rc = self._proc.wait(timeout) + except TimeoutError: + raise + else: + self._proc = None + else: + rc = None + return rc + + def pop_encoded(self, pipe_id: int | None = 0) -> bytes | tuple[bytes]: + """retrieve piped encoded bytes + + :param pipe_id: index of the output piped, defaults to `None` to return + all piped outputs. Indexing is specific to only piped + outputs, e.g., `pipe_id=0` means the first piped output + regardless of where the first `"pipe"` url was specified + in the `urls` argument of the constructor. + :return: `bytes` object if index specified or a tuple of bytes if all + piped outputs requested. + """ + + if pipe_id is None: + if not len(self._piped_outputs): + raise FFmpegioError("None of the outputs is piped.") + readers = [self._output_info[i]["reader"] for i in self._piped_outputs] + else: + try: + info = self._output_info[self._piped_outputs[pipe_id]] + except IndexError: + if pipe_id != 0: + raise FFmpegioError( + f"{pipe_id=} is not a valid piped output index." + ) + else: + raise FFmpegioError(f"This writer has no piped output defined.") + else: + readers = [info["reader"]] + + data_it = (reader.read_all(timeout=0) for reader in readers) + + return tuple(data_it) if pipe_id is None else next(data_it) class PipedFilter: ... diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index ceaf5460..f4534b4b 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -6,7 +6,7 @@ SimpleVideoFilter, SimpleAudioFilter, ) -from .PipedStreams import PipedMediaReader +from .PipedStreams import PipedMediaReader, PipedMediaWriter from .AviStreams import AviMediaReader # TODO multi-stream write @@ -14,6 +14,7 @@ # fmt: off __all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter","PipedMediaReader" + "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", + "PipedMediaReader","PipedMediaWriter", "AviMediaReader"] # fmt: on diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index 26092fac..2dafd353 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -4,6 +4,7 @@ from os import path import pytest +from tempfile import TemporaryDirectory import ffmpegio as ff from ffmpegio import streams @@ -20,3 +21,42 @@ def test_PipedMediaReader(): for data in reader: for k, v in data.items(): print(f"{k}: {len(v['buffer'])}") + + +def test_PipedMediaWriter_audio(): + + ff.use('read_numpy') + + rates, data = ff.media.read(audio_url, t=1,ar=8000,sample_fmt='s16') + stream_types = [spec.split(":", 2)[1] for spec in data] + + with streams.PipedMediaWriter( + "pipe", stream_types, *rates.values(), show_log=True, f="matroska", loglevel="debug" + ) as writer: + for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): + writer.write(i, frame) + writer.write(i, None) + + writer.wait(1) + b = writer.pop_encoded() + + +def test_PipedMediaWriter(): + + ff.use('read_numpy') + + rates, data = ff.media.read(mult_url, t=1) + stream_types = [spec.split(":", 2)[1] for spec in data] + + with streams.PipedMediaWriter( + "pipe", stream_types, *rates.values(), show_log=True, f="matroska", loglevel="debug" + ) as writer: + for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): + if mtype == "v": + writer.write(i, frame[0]) + else: + writer.write(i, frame[0:100]) + + writer.wait(1) + b = writer.pop_encoded(0) + assert isinstance(b,bytes) \ No newline at end of file From 7178ed6f904ce7b23ea79648ae49ad89ddcf0aa0 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Fri, 28 Feb 2025 10:34:37 -0600 Subject: [PATCH 210/333] documentation --- src/ffmpegio/configure.py | 9 +++++++-- src/ffmpegio/probe.py | 4 ++-- src/ffmpegio/utils/__init__.py | 9 +++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index b62c7330..4de881d4 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1593,14 +1593,15 @@ def init_media_write( """write multiple streams to a url/file :param url: output url - :param input_opts: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param stream_types: list/string of 'a' or 'v', specifying the input raw streams' media types + :param stream_args: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's (indices of `stream_types`) to combine only specified streams. :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options will be applied to all input streams unless the option has been already defined in `stream_data` :param dtypes: list of numpy-style data type strings of input samples or frames of input media streams, defaults to `None` (auto-detect). @@ -1685,6 +1686,7 @@ def init_media_write( if not all(o in opts for o in opt_names[info["media_type"]]): not_ready[i] = True + # analyze and assign outputs output_info = process_url_outputs( args, input_info, urls, options, skip_automapping=any(not_ready) ) @@ -1722,6 +1724,9 @@ def init_named_pipes( - The reader threads for FFmpeg outputs that are written to buffers (i.e., `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` so the reader object can be used to retrieve the data. + + + if any output is a piped, overwrite flag (-y) is automatically inserted """ # configure input pipes (if needed) diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 5bd57d6b..a79f58b3 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -420,7 +420,7 @@ def streams_basic( stream_spec: str | int | StreamSpecDict | None = None, *, f: str | None = None, -) -> list[dict[str, str | Number | Fraction]]: +) -> list: """Retrieve basic info of media streams :param url: URL of the media file/stream @@ -436,7 +436,7 @@ def streams_basic( :param stream_spec: Specify stream specification, defaults to None :type stream_spec: str | None, optional :param f: Use the specified media container format, defaults to None (auto-detect) - :return: List of media stream information. + :return: List of the requested information of the matching media streams. Media Stream Information dict Entries diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 34118202..f92c331e 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -18,7 +18,7 @@ from .._utils import * from ..stream_spec import * from ..errors import FFmpegError, FFmpegioError -from .._typing import Any, MediaType, InputSourceDict +from .._typing import Any, MediaType, InputSourceDict, RawDataBlob from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb from ..filtergraph.presets import temp_video_src, temp_audio_src @@ -225,7 +225,8 @@ def get_audio_codec(fmt: str) -> tuple[str, str]: """get pcm audio codec & format :param fmt: ffmpeg sample_fmt - :return: tuple of pcm codec name and container format + :return acodec: pcm codec name + :return f: container format """ try: return audio_codecs[fmt] @@ -483,7 +484,7 @@ def pop_global_options(options: dict[str, Any]) -> dict[str, Any]: return {k: options.pop(k) for k in [k for k in options.keys() if k in all_gopts]} -def array_to_audio_options(data: Any) -> dict: +def array_to_audio_options(data: RawDataBlob | None) -> dict: """create an input option dict for the given raw audio data blob :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) :returns: dict of audio options @@ -497,7 +498,7 @@ def array_to_audio_options(data: Any) -> dict: return {"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt} -def array_to_video_options(data: Any | None = None) -> dict: +def array_to_video_options(data: RawDataBlob | None = None) -> dict: """create an input option dict for the given raw video data blob :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) From 431682ee74785abd79fdcdc6993a894e878e52f1 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:23:55 -0600 Subject: [PATCH 211/333] `init_named_pipes()` round instead of truncate to set `nmin` --- src/ffmpegio/configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 4de881d4..8fafe8d3 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1773,7 +1773,7 @@ def init_named_pipes( dtype, shape, rate = info["media_info"] kws["itemsize"] = utils.get_samplesize(shape, dtype) if update_rate is not None: - kws["nmin"] = int(rate / update_rate) or 1 + kws["nmin"] = round(rate / update_rate) or 1 info["reader"] = reader = ReaderThread(pipe, **kws) else: raise FFmpegioError(f"{dst_type=} is an unknown output data type.") From b6448b421847025571aced3a21df9aca20a12b5f Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:25:36 -0600 Subject: [PATCH 212/333] added `process_raw_outputs()` optional input `fg_info` in case filtergraph has been preanalyzed --- src/ffmpegio/configure.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 8fafe8d3..65802f52 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1229,6 +1229,7 @@ def process_raw_outputs( input_info: list[InputSourceDict], streams: Sequence[str] | dict[str, dict[str, Any] | None] | None, options: dict[str, Any], + fg_info: dict[str, dict] | None = None, ) -> tuple[list[RawOutputInfoDict], dict[str, dict] | None]: """analyze and process piped raw outputs @@ -1237,12 +1238,16 @@ def process_raw_outputs( :param input_info: list of input information (same length as `args['inputs']) :param streams: user's list of map options to be included :param options: default output options + :param fg_info: filtergraph outputs if filtergraph has been pre-analyzed, + defaults to None to perform the filtergraph analysis internally :return output_info: list of output information :return fg_info: dict of filtergraph outputs, keyed by their linklabels """ gopts = args["global_options"] - if "filter_complex" in gopts: + + # only analyze the filtergraph again if it has not been pre-analyzed + if fg_info is None and "filter_complex" in gopts: gopts["filter_complex"], fg_info = ( utils.analyze_complex_filtergraphs( gopts["filter_complex"], args["inputs"], input_info @@ -1250,8 +1255,6 @@ def process_raw_outputs( if "filter_complex" in gopts else None ) - else: - fg_info = None # resolve requested output streams stream_info: dict[str, RawOutputInfoDict] = ( From 2489e6f29aff36f29ef4ff1adc3e1f2375ca83b3 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:26:19 -0600 Subject: [PATCH 213/333] requires `namedpipe` to be v0.2.5 or later --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e6fcaeec..722009b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "pluggy", "packaging", "typing_extensions >= 4.12", - "namedpipe >= 0.2", + "namedpipe >= 0.2.5", ] [project.urls] From 5b1effd8a7f09f4b6533c0ca1f21f1b4eca4b8c6 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:30:25 -0600 Subject: [PATCH 214/333] `monitor_process()` to block exceptions raised by `on_exit` callbacks --- src/ffmpegio/ffmpegprocess.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/ffmpegprocess.py b/src/ffmpegio/ffmpegprocess.py index e7bde8f5..58b75728 100644 --- a/src/ffmpegio/ffmpegprocess.py +++ b/src/ffmpegio/ffmpegprocess.py @@ -205,7 +205,12 @@ def monitor_process(proc, on_exit=None): if on_exit is not None: returncode = proc.returncode for fcn in on_exit: - fcn(returncode) + try: + fcn(returncode) + except Exception as e: + pass + #TODO - need to re-raise these exceptions? + logger.debug("[monitor] executed all on_exit callbacks") From 240221117224468018f8a65105f3591cf955e7ed Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:37:07 -0600 Subject: [PATCH 215/333] `init_named_pipes()` returns ExitStack object & cleaned up those which use this function --- src/ffmpegio/configure.py | 21 ++-- src/ffmpegio/media.py | 161 ++++++++++++++------------- src/ffmpegio/streams/PipedStreams.py | 49 +++----- 3 files changed, 111 insertions(+), 120 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 65802f52..b9ee355d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1708,16 +1708,14 @@ def init_named_pipes( args: FFmpegArgs, input_info: list[InputSourceDict], output_info: list[RawOutputInfoDict], - stack: ExitStack, update_rate: float | None = None, queue_size: int | None = None, -) -> list[int]: +) -> ExitStack | None: """initialize named pipes for read & write operations with FFmpeg :param args: FFmpeg option arguments (modified) :param input_info: FFmpeg input information, its length matches that of `args['inputs']` :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) - :param stack: a context manager to combine the context managers used to manage pipes and threads :param update_rate: target rate at which queue transactions will occur :returns: a list of indices of the FFmpeg outputs that are raw data streams @@ -1732,6 +1730,8 @@ def init_named_pipes( if any output is a piped, overwrite flag (-y) is automatically inserted """ + stack = ExitStack() + # configure input pipes (if needed) wr_kws = {"queuesize": queue_size} if queue_size else {} for i, (input, info) in enumerate(zip(args["inputs"], input_info)): @@ -1751,7 +1751,7 @@ def init_named_pipes( if "buffer" in info: # data buffer given, feed the data and terminate writer.write(info["buffer"]) - writer.write(None) # close the + writer.write(None) # close the writer immediately else: # if no data given, provide the access to the writer info["writer"] = writer @@ -1759,9 +1759,12 @@ def init_named_pipes( raise FFmpegioError(f"{src_type=} is an unknown input data type.") # configure output pipes - pipes_out = [] + has_pipeout = False for i, (output, info) in enumerate(zip(args["outputs"], output_info)): if output[0] is None: + + has_pipeout = True + # if fileobj or buffer output, use pipe pipe = NPopen("r", bufsize=0) stack.enter_context(pipe) @@ -1770,21 +1773,21 @@ def init_named_pipes( if dst_type == "fileobj": reader = CopyFileObjThread(info["fileobj"], pipe) elif dst_type == "buffer": - pipes_out.append(i) kws = {**wr_kws} if "media_info" in info: dtype, shape, rate = info["media_info"] kws["itemsize"] = utils.get_samplesize(shape, dtype) if update_rate is not None: kws["nmin"] = round(rate / update_rate) or 1 - info["reader"] = reader = ReaderThread(pipe, **kws) + reader = ReaderThread(pipe, **kws) else: raise FFmpegioError(f"{dst_type=} is an unknown output data type.") stack.enter_context(reader) # starts thread & wait for pipe connection + info["reader"] = reader - if len(pipes_out): + if has_pipeout: # if any output is piped, must run in the overwrite mode args["global_options"].pop("n", None) args["global_options"]["y"] = None - return pipes_out + return stack if len(input_info) or len(output_info) else None diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 73ef8ef9..45c8d0f3 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -72,72 +72,77 @@ def read( # run FFmpeg capture_log = True if need_stderr else None if show_log else True - with contextlib.ExitStack() as stack: - # configure named pipes - configure.init_named_pipes(args, input_info, output_info, stack) + # configure named pipes + stack = configure.init_named_pipes(args, input_info, output_info) - # run the FFmpeg + def on_exit(rc): + stack.close() + + # run the FFmpeg + try: proc = ffmpegprocess.Popen( - args, progress=progress, capture_log=capture_log, sp_kwargs=sp_kwargs + args, + progress=progress, + capture_log=capture_log, + sp_kwargs=sp_kwargs, + on_exit=on_exit, ) + except: + # if Popen failed to start FFmpeg process, need to call the callback + stack.close() + raise - # wait for the FFmpeg to finish processing - proc.wait() + # wait for the FFmpeg to finish processing + proc.wait() - # throw error if failed - if proc.returncode: - raise FFmpegError(proc.stderr, capture_log) + # throw error if failed + if proc.returncode: + raise FFmpegError(proc.stderr, capture_log) - # wind-down the readers - for info in output_info: - info["reader"].cool_down() + # gather output + rates = {} + data = {} + for i, info in enumerate(output_info): + spec = info["user_map"] + b = info["reader"].read_all() - # gather output - rates = {} - data = {} - for i, info in enumerate(output_info): - spec = info["user_map"] - b = info["reader"].read_all() + # get datablob info from stderr if needed + missing = any(v is None for v in info["media_info"]) - # get datablob info from stderr if needed - missing = any(v is None for v in info["media_info"]) + if missing: + logger.warning('Retrieving stream "%s" information from FFmpeg log.', spec) + new_info = extract_output_stream(proc.stderr, i) + if info["media_type"] == "video": + dtype, shape, rate = info["media_info"] + + if missing: + if dtype is None: + pix_fmt = new_info["pix_fmt"] + dtype = utils.get_pixel_format(pix_fmt)[0] + if shape is None: + shape = new_info["s"] + if rate is None: + rate = new_info["r"] + + data[spec] = plugins.get_hook().bytes_to_video( + b=b, dtype=dtype, shape=shape, squeeze=False + ) + else: # 'audio' + dtype, shape, rate = info["media_info"] if missing: - logger.warning( - 'Retrieving stream "%s" information from FFmpeg log.', spec - ) - new_info = extract_output_stream(proc.stderr, i) - - if info["media_type"] == "video": - dtype, shape, rate = info["media_info"] - - if missing: - if dtype is None: - pix_fmt = new_info["pix_fmt"] - dtype = utils.get_pixel_format(pix_fmt)[0] - if shape is None: - shape = new_info["s"] - if rate is None: - rate = new_info["r"] - - data[spec] = plugins.get_hook().bytes_to_video( - b=b, dtype=dtype, shape=shape, squeeze=False - ) - else: # 'audio' - dtype, shape, rate = info["media_info"] - if missing: - if dtype is None: - sample_fmt = new_info["sample_fmt"] - dtype = utils.get_audio_format(sample_fmt) - if shape is None: - shape = (new_info["ac"],) - if rate is None: - rate = new_info["ar"] - - data[spec] = plugins.get_hook().bytes_to_audio( - b=b, dtype=dtype, shape=shape, squeeze=False - ) - rates[spec] = rate + if dtype is None: + sample_fmt = new_info["sample_fmt"] + dtype = utils.get_audio_format(sample_fmt) + if shape is None: + shape = (new_info["ac"],) + if rate is None: + rate = new_info["ar"] + + data[spec] = plugins.get_hook().bytes_to_audio( + b=b, dtype=dtype, shape=shape, squeeze=False + ) + rates[spec] = rate return rates, data @@ -218,37 +223,37 @@ def write( } ) - with contextlib.ExitStack() as stack: + # configure named pipes + stack = configure.init_named_pipes(args, input_info, output_info) - # configure named pipes - pipes_out = configure.init_named_pipes(args, input_info, output_info, stack) - - # run the FFmpeg + # run the FFmpeg + try: proc = ffmpegprocess.Popen( args, progress=progress, capture_log=None if show_log else True, sp_kwargs=sp_kwargs, + on_exit=lambda _: stack.close(), ) + except: + stack.close() + raise - # wait for the FFmpeg to finish processing - proc.wait() + # wait for the FFmpeg to finish processing + proc.wait() - # throw error if failed - if proc.returncode: - raise FFmpegError(proc.stderr, show_log) + # throw error if failed + if proc.returncode: + raise FFmpegError(proc.stderr, show_log) - # wind-down the readers - for info in output_info: - if "reader" in info: - info["reader"].cool_down() + # wind-down the readers + for info in output_info: + if "reader" in info: + info["reader"].cool_down() - if not len(pipes_out): - # no buffered output - return + # gather output + data = {} + for i, info in enumerate(output_info): + if info["dst_type"] == "buffer": + data[i] = info["reader"].read_all() - # gather output - data = {} - for i, info in enumerate(output_info): - if info["src_type"] == "buffer": - data[i] = info["reader"].read_all() diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 72fcda07..82b2688d 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -73,8 +73,6 @@ def __init__( For audio streams, if 'sample_fmt' output option is not specified, 's16'. """ - self._stack = ExitStack() - # initialize FFmpeg argument dict and get input & output information args, self._input_info, self._output_info = configure.init_media_read( urls, map, options @@ -89,7 +87,6 @@ def __init__( "progress": progress, "capture_log": True, "sp_kwargs": sp_kwargs, - "on_exit": self._stop_readers, } # set the default read block size for the referenc stream @@ -111,21 +108,25 @@ def __init__( self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} def __enter__(self): - self._stack.__enter__() # set up and activate pipes and read/write threads - self._piped_outputs = configure.init_named_pipes( + stack = configure.init_named_pipes( self._args["ffmpeg_args"], self._input_info, self._output_info, - self._stack, **self._pipe_kws, ) self._n0 = [0] * len(self._output_info) # timestamps of the last read sample # run the FFmpeg - self._proc = ffmpegprocess.Popen(**self._args) + try: + self._proc = ffmpegprocess.Popen( + **self._args, on_exit=lambda _: stack.close() + ) + except: + stack.close() + raise # set the log source and start the logger self._logger.stderr = self._proc.stderr @@ -153,7 +154,6 @@ def __exit__(self, *exc_details): exc_details = sys.exc_info() finally: self._logger.join() - self._stack.__exit__(*exc_details) def close(self): """Flush and close this stream. This method has no effect if the stream is already @@ -201,11 +201,6 @@ def lasterror(self) -> FFmpegError: else: return None - def _stop_readers(self, returncode): - # ffmpegprocess on_exit callback function - for info in self._output_info: - info["reader"].cool_down() - def __iter__(self): return self @@ -323,8 +318,6 @@ def __init__( preference (see :doc:`options` for custom options) """ - self._stack = ExitStack() - if not isinstance(urls, list): urls = [urls] @@ -365,7 +358,6 @@ def __init__( "progress": progress, "capture_log": True, "sp_kwargs": sp_kwargs, - "on_exit": self._stop_readers, } # set the default read block size for the referenc stream @@ -396,16 +388,21 @@ def _open(self, deferred: bool): opts["map"] = map_opts # set up and activate pipes and read/write threads - self._piped_outputs = configure.init_named_pipes( + stack = configure.init_named_pipes( self._args["ffmpeg_args"], self._input_info, self._output_info, - self._stack, **self._pipe_kws, ) # run the FFmpeg - self._proc = ffmpegprocess.Popen(**self._args) + try: + self._proc = ffmpegprocess.Popen( + **self._args, on_exit=lambda _: stack.close() + ) + except: + stack.close() + raise # set the log source and start the logger self._logger.stderr = self._proc.stderr @@ -429,7 +426,6 @@ def _open(self, deferred: bool): return self def __enter__(self): - self._stack.__enter__() if self._deferred_open is False: self._open(False) @@ -441,8 +437,6 @@ def open(self): def __exit__(self, *exc_details): - self.wait(self.default_timeout) - try: if self._proc is not None and self._proc.poll() is None: # kill the ffmpeg runtime @@ -455,7 +449,6 @@ def __exit__(self, *exc_details): exc_details = sys.exc_info() finally: self._logger.join() - self._stack.__exit__(*exc_details) def close(self): """Flush and close this stream. This method has no effect if the stream is already @@ -498,16 +491,6 @@ def lasterror(self) -> FFmpegError: else: return None - def _stop_readers(self, returncode): - # ffmpegprocess on_exit callback function - for info in self._output_info: - if "reader" in info: - info["reader"].cool_down() - if returncode: - raise self._logger.Exception or FFmpegError( - "FFmpeg failed for an unknown reason. Please check the log." - ) - def readlog(self, n: int = None) -> str: if n is not None: self._logger.index(n) From fb19cbb3ddae1b370d9e2d00350b392c6d3dccce Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:40:42 -0600 Subject: [PATCH 216/333] `ReaderThread` & `WriterThread` - improved handling of named pipe --- src/ffmpegio/threading.py | 42 +++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index bb04504e..bbf508d3 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -289,14 +289,16 @@ def Exception(self) -> FFmpegError | None: class ReaderThread(Thread): def __init__( self, - stdout: BinaryIO | NPopen, + stdout_or_pipe: BinaryIO | NPopen, nmin: int | None = None, queuesize: int | None = None, itemsize: int | None = None, retry_delay: float | None = None, ): super().__init__() - self.stdout = stdout #:readable stream: data source + is_pipe = isinstance(stdout_or_pipe, NPopen) + self.pipe = stdout_or_pipe if is_pipe else None # readable named pipe + self.stdout = None if is_pipe else stdout_or_pipe #:readable stream self.nmin = nmin #:positive int: expected minimum number of read()'s n arg (not enforced) self.itemsize = itemsize or 2**20 #:int: number of bytes per time sample self._queue = Queue(queuesize or 0) # inter-thread data I/O @@ -318,6 +320,16 @@ def cool_down(self): self._halt.set() def join(self, timeout=None): + + if self.pipe: + if self.stdout is None: + # FFmpeg never opened the pipe, open it to release the runner from waiting + with open(self.pipe.path, "w"): + ... + self.pipe.close() + else: + self.stdout.close() + self._halt.set() if self._queue.full(): if timeout: @@ -342,19 +354,20 @@ def __enter__(self): return self def __exit__(self, *_): - self.stdout.close() self.join() # will wait until stdout is closed return False def run(self): - is_npipe = isinstance(self.stdout, NPopen) + is_npipe = self.stdout is None blocksize = ( self.nmin if self.nmin is not None else 1 if self.itemsize > 1024 else 1024 ) * self.itemsize if self._halt.is_set(): return logger.debug("waiting for pipe to open") - stream = self.stdout.wait() if is_npipe else self.stdout + if is_npipe: + self.stdout = self.pipe.wait() + stream = self.stdout logger.debug("starting to read") self._running.set() while not self._halt.is_set(): @@ -483,16 +496,21 @@ class WriterThread(Thread): :param bufsize: maximum number of bytes to write at once, defaults to None (1048576 bytes) """ - def __init__(self, stdin: BinaryIO | NPopen, queuesize: int | None = None): + def __init__(self, stdin_or_pipe: BinaryIO | NPopen, queuesize: int | None = None): super().__init__() - self.stdin = stdin #:writable stream: data sink + is_pipe = isinstance(stdin_or_pipe, NPopen) + self.pipe = stdin_or_pipe if is_pipe else None + self.stdin = None if is_pipe else stdin_or_pipe #:writable stream: data sink self._queue = Queue(queuesize or 0) # inter-thread data I/O self._empty_cond = Condition() self._empty = True def join(self, timeout: float | None = None): - # close the stream if not already closed - self.stdin.close() + + if self.stdin is None: + # pipe not yet connected, open it to release the runner + with open(self.pipe.path, "rb"): + ... # if empty, queue a dummy item to wake up the thread if self._queue.empty(): @@ -511,8 +529,10 @@ def __exit__(self, *_): def run(self): - is_namedpipe = isinstance(self.stdin, NPopen) - stream = self.stdin.wait() if is_namedpipe else self.stdin + if self.stdin is None: + self.stdin = self.pipe.wait() + + stream = self.stdin while True: # get next data block From fc2240b2c4c78fa098a9a1c83d0b1786ce55acde Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:41:51 -0600 Subject: [PATCH 217/333] `ReaderThread` & `WriterThread` - use local queue variable in the runner --- src/ffmpegio/threading.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index bbf508d3..2a8a5d42 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -368,6 +368,8 @@ def run(self): if is_npipe: self.stdout = self.pipe.wait() stream = self.stdout + queue = self._queue + logger.debug("starting to read") self._running.set() while not self._halt.is_set(): @@ -390,13 +392,13 @@ def run(self): continue if not self._halt.is_set(): # True until self.cooloff - self._queue.put(data) + queue.put(data) # print(f"reader thread: queued samples") logger.debug("stopping to read") logger.info("ReaderThread sending the sentinel") - self._queue.put(None) # sentinel for eos + queue.put(None) # sentinel for eos logger.info("ReaderThread exiting") self._running.clear() @@ -533,19 +535,20 @@ def run(self): self.stdin = self.pipe.wait() stream = self.stdin + queue = self._queue while True: # get next data block try: - data = self._queue.get_nowait() + data = queue.get_nowait() except Empty: # if empty, set the flag and block with self._empty_cond: self._empty = True self._empty_cond.notify_all() - data = self._queue.get() + data = queue.get() - self._queue.task_done() + queue.task_done() if data is None: logger.info(f"writer thread: received a sentinel to stop the writer") break From bc4e14704f454afac8ed57ca5cf8ea5766c0aeeb Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:43:01 -0600 Subject: [PATCH 218/333] `write()` return piped output encoded data if produced --- src/ffmpegio/media.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 45c8d0f3..4876878e 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -257,3 +257,4 @@ def write( if info["dst_type"] == "buffer": data[i] = info["reader"].read_all() + return data if len(data) else None From 6e47714a667ca55b989a7c574072db7b86ceb49a Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:44:37 -0600 Subject: [PATCH 219/333] `WriterThread` improved queue monitoring and termination logic --- src/ffmpegio/threading.py | 43 ++++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 2a8a5d42..b04dd527 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -506,6 +506,7 @@ def __init__(self, stdin_or_pipe: BinaryIO | NPopen, queuesize: int | None = Non self._queue = Queue(queuesize or 0) # inter-thread data I/O self._empty_cond = Condition() self._empty = True + self._no_more = False # true if sentinel has been written to the queue def join(self, timeout: float | None = None): @@ -567,16 +568,48 @@ def run(self): logger.info(f"writer thread: somethin' else happened") break - if is_namedpipe: + # set flag to prevent any more writes + with self._empty_cond: + self._no_more = True + + # close the pipe/stream + if self.pipe: + self.pipe.close() + else: self.stdin.close() + # completely flush the queue + # check if queue has any remaining items + not_empty = True + while True: + try: + queue.get_nowait() + except Empty: + break + else: + # queue was empty + not_empty = False + + # if queue was not empty, notify its empty state now + if not_empty: + with self._empty_cond: + self._empty = True + self._empty_cond.notify_all() + logger.info(f"writer thread exiting") def write(self, data, timeout=None): - if not self.is_alive(): - raise ThreadNotActive("WriterThread is not running") - with self._empty_cond: + + if self._no_more: + if data is None: + return + else: + raise ThreadNotActive("WriterThread is no longer running") + + if data is None: + self._no_more = True + self._queue.put(data, timeout) self._empty = False @@ -589,7 +622,7 @@ def flush(self, timeout: float | None = None): """ with self._empty_cond: - if not (self._empty or self._empty_cond.wait(timeout)): + if not (self._no_more or self._empty or self._empty_cond.wait(timeout)): raise NotEmpty() From ec09508fd09ac2a8aef77c7e735c20f6879080ba Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:46:39 -0600 Subject: [PATCH 220/333] `CopyFileObjThread` - added a warning if copy failed --- src/ffmpegio/threading.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index b04dd527..3cd8a0e6 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -955,7 +955,11 @@ def run(self): src = self._fsrc.wait() if src_is_namedpipe else self._fsrc dst_is_namedpipe = isinstance(self._fdst, NPopen) dst = self._fdst.wait() if dst_is_namedpipe else self._fdst - copyfileobj(src, dst, self.length) + try: + copyfileobj(src, dst, self.length) + except: + #TODO - test the behavior when FFmpeg is prematurely terminated + logger.warning("CopyFileObjThread runner failed to complete the job.") if self.auto_close: src.close() dst.close() From d4b45a2bc2aee1b8e896c1b9f96bef1df5369d15 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:48:37 -0600 Subject: [PATCH 221/333] `PipeMediaWriter.wait()` - must write sentinels to piped inputs --- src/ffmpegio/streams/PipedStreams.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 82b2688d..dbd1d28b 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -586,7 +586,12 @@ def wait(self, timeout: float | None = None) -> int | None: """ if self._proc: + # write the sentinel to each input queue + for info in self._input_info: + if "writer" in info: + info["writer"].write(None) + try: self.flush(timeout) except NotEmpty as e: From ed456a5e9dbc40b2e17811cc458a511b7b74fab2 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 21:58:42 -0600 Subject: [PATCH 222/333] `PipedMediaWriter._piped_outputs` - changed to house ReaderThread objects --- src/ffmpegio/streams/PipedStreams.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index dbd1d28b..7013889f 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -369,7 +369,7 @@ def __init__( self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} self._proc = None - self._piped_outputs = [] + self._piped_outputs = None def _open(self, deferred: bool): @@ -416,6 +416,12 @@ def _open(self, deferred: bool): writer.write(data) self._deferred_data = [] + self._piped_outputs = [ + info["reader"] + for info in self._output_info + if "reader" in info and info["dst_type"] == "buffer" + ] + # wait until all the reader threads are running # for info in self._output_info: # if "reader" in info: @@ -623,10 +629,10 @@ def pop_encoded(self, pipe_id: int | None = 0) -> bytes | tuple[bytes]: if pipe_id is None: if not len(self._piped_outputs): raise FFmpegioError("None of the outputs is piped.") - readers = [self._output_info[i]["reader"] for i in self._piped_outputs] + readers = self._piped_outputs else: try: - info = self._output_info[self._piped_outputs[pipe_id]] + reader = self._piped_outputs[pipe_id] except IndexError: if pipe_id != 0: raise FFmpegioError( @@ -635,7 +641,7 @@ def pop_encoded(self, pipe_id: int | None = 0) -> bytes | tuple[bytes]: else: raise FFmpegioError(f"This writer has no piped output defined.") else: - readers = [info["reader"]] + readers = [reader] data_it = (reader.read_all(timeout=0) for reader in readers) From 04e7f1f345a5ab961d0d37fe08de6f6dd050b179 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sat, 1 Mar 2025 22:00:18 -0600 Subject: [PATCH 223/333] tests updated --- tests/test_pipedstreams.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index 2dafd353..24c41553 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -25,38 +25,46 @@ def test_PipedMediaReader(): def test_PipedMediaWriter_audio(): - ff.use('read_numpy') + ff.use("read_numpy") - rates, data = ff.media.read(audio_url, t=1,ar=8000,sample_fmt='s16') + rates, data = ff.media.read(audio_url, t=1, ar=8000, sample_fmt="s16") stream_types = [spec.split(":", 2)[1] for spec in data] with streams.PipedMediaWriter( - "pipe", stream_types, *rates.values(), show_log=True, f="matroska", loglevel="debug" + "pipe", + stream_types, + *rates.values(), + show_log=True, + f="matroska", + # loglevel="debug", ) as writer: for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): writer.write(i, frame) writer.write(i, None) - writer.wait(1) + # close the input and wait for FFmpeg to finish encoding and terminate + writer.wait(10) + + # read the encoded bytes b = writer.pop_encoded() def test_PipedMediaWriter(): - ff.use('read_numpy') + ff.use("read_numpy") rates, data = ff.media.read(mult_url, t=1) stream_types = [spec.split(":", 2)[1] for spec in data] with streams.PipedMediaWriter( - "pipe", stream_types, *rates.values(), show_log=True, f="matroska", loglevel="debug" + "pipe", stream_types, *rates.values(), show_log=True, f="matroska" ) as writer: for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): if mtype == "v": writer.write(i, frame[0]) else: - writer.write(i, frame[0:100]) + writer.write(i, frame) - writer.wait(1) + writer.wait(10) b = writer.pop_encoded(0) - assert isinstance(b,bytes) \ No newline at end of file + assert isinstance(b, bytes) From 040ba96741c87900258d67bbcf2bdf09de115c1c Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 2 Mar 2025 15:03:48 -0600 Subject: [PATCH 224/333] `resolve_raw_output_streams()` changed `streams` argument to dict so custom stream names can be passed in --- src/ffmpegio/configure.py | 67 ++++++++++++++++++++++++++++----------- tests/test_configure.py | 10 ++++-- 2 files changed, 56 insertions(+), 21 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index b9ee355d..7d979304 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -920,14 +920,16 @@ def add_filtergraph( def resolve_raw_output_streams( args: FFmpegArgs, input_info: list[InputSourceDict], - fg_info: dict[str, dict], - streams: Sequence[str], -) -> dict[str:RawOutputInfoDict]: + fg_info: dict[str, dict] | None, + streams: dict[str, str | None], +) -> dict[str, RawOutputInfoDict]: """resolve the raw output streams from given sequence of map options :param args: FFmpeg argument dict :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` - :param streams: a sequence of map options defining the streams + :param streams: FFmpeg -map option values of output streams as their keys and + their custom names as the values. To use the map value as + the stream names, specify None as a value. :return: output information keyed by a unique map option string """ @@ -935,23 +937,30 @@ def resolve_raw_output_streams( # parse all mapping option values input_file_id = 0 if len(input_info) == 1 else None + + def parse_map(spec): + try: + return parse_map_option( + spec, parse_stream=True, input_file_id=input_file_id + ) + except: + if fg_info is not None and (linklabel := f"[{spec}]") in fg_info: + return {"linklabel": linklabel, "user_label": spec} + raise + map_options = [ - {"stream_specifier": {}, **opt} - for opt in ( - parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) - for spec in streams - ) + {"stream_specifier": {}, **opt} for opt in (parse_map(spec) for spec in streams) ] inputs = args["inputs"] stream_info = {} # one stream per item, value: map spec & media_type - for spec, opt in zip(streams, map_options): + for (spec, user_map), opt in zip(streams.items(), map_options): # get output stream information - if (info := fg_info and fg_info.get(spec, None)) is not None: + if (fg_info and (info := fg_info.get(spec, None))) is not None: # filtergraph output stream_info[spec] = { "dst_type": dst_type, - "user_map": spec[1:-1], + "user_map": user_map or spec, "media_type": info["media_type"], "input_file_id": None, "input_stream_id": None, @@ -980,7 +989,7 @@ def resolve_raw_output_streams( (spec if unique_stream else f"{file_index}:{stream_index}") ] = { "dst_type": dst_type, - "user_map": spec, + "user_map": user_map or spec, "media_type": media_type, "input_file_id": file_index, "input_stream_id": stream_index, @@ -1257,11 +1266,33 @@ def process_raw_outputs( ) # resolve requested output streams - stream_info: dict[str, RawOutputInfoDict] = ( - auto_map(args, input_info, fg_info) # automatically map all the streams - if streams is None or len(streams) == 0 - else resolve_raw_output_streams(args, input_info, fg_info, streams) - ) + stream_info: dict[str, RawOutputInfoDict] + if streams is None or len(streams) == 0: + stream_info = auto_map(args, input_info, fg_info) + else: + # analyze for custom labels + user_maps = {} + stream_maps = {} + for k, v in ( + streams.items() if isinstance(streams, dict) else ((s, {}) for s in streams) + ): + if "map" in v: + st_map = v["map"] + if not isinstance(st_map, str): + raise FFmpegioError( + "Only one FFmpeg map is allowable for filtering operation." + ) + user_maps[st_map] = k + elif fg_info is not None and (st_map := f"[{k}]") in fg_info: + # filtergraph linklabel without bracket given + user_maps[st_map] = k + else: + # an input stream specifier or a filtergraph output linklabel + user_maps[k], st_map = k, k + stream_maps[st_map] = v + + # automatically map all the streams + stream_info = resolve_raw_output_streams(args, input_info, fg_info, user_maps) # add outputs to FFmpeg arguments get_opts = isinstance(streams, dict) diff --git a/tests/test_configure.py b/tests/test_configure.py index 35f69670..b6004bd1 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -269,9 +269,13 @@ def ffmpeg_url_inputs_vid_aud(): @pytest.mark.parametrize( ("ffmpeg_url_inputs", "filters_complex", "streams"), [ - ("ffmpeg_url_inputs_mul", None, ["v"]), - ("ffmpeg_url_inputs_vid_aud", None, ["0:v:0", "1:a:0"]), - ("ffmpeg_url_inputs_mul", ["split=2"], ["[out0]", "[out1]", "a:0"]), + ("ffmpeg_url_inputs_mul", None, {"v": None}), + ("ffmpeg_url_inputs_vid_aud", None, {"0:v:0": None, "1:a:0": None}), + ( + "ffmpeg_url_inputs_mul", + ["split=2"], + {"[out0]": None, "[out1]": "out1", "a:0": None}, + ), ], ) def test_resolve_raw_output_streams( From c1a803d944934c7a8fa46d0457564c18905cbdc7 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 2 Mar 2025 15:06:10 -0600 Subject: [PATCH 225/333] refactored `_gather_outputs()` --- src/ffmpegio/media.py | 98 +++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 4876878e..9992a5dd 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -26,6 +26,56 @@ __all__ = ["read", "write"] +def _gather_outputs( + output_info, proc +) -> tuple[dict[str, int | Fraction], dict[str, RawDataBlob]]: + rates = {} + data = {} + for i, info in enumerate(output_info): + spec = info["user_map"] + b = info["reader"].read_all() + + # get datablob info from stderr if needed + missing = any(v is None for v in info["media_info"]) + + if missing: + logger.warning('Retrieving stream "%s" information from FFmpeg log.', spec) + new_info = extract_output_stream(proc.stderr, i) + + if info["media_type"] == "video": + dtype, shape, rate = info["media_info"] + + if missing: + if dtype is None: + pix_fmt = new_info["pix_fmt"] + dtype = utils.get_pixel_format(pix_fmt)[0] + if shape is None: + shape = new_info["s"] + if rate is None: + rate = new_info["r"] + + data[spec] = plugins.get_hook().bytes_to_video( + b=b, dtype=dtype, shape=shape, squeeze=False + ) + else: # 'audio' + dtype, shape, rate = info["media_info"] + if missing: + if dtype is None: + sample_fmt = new_info["sample_fmt"] + dtype = utils.get_audio_format(sample_fmt) + if shape is None: + shape = (new_info["ac"],) + if rate is None: + rate = new_info["ar"] + + data[spec] = plugins.get_hook().bytes_to_audio( + b=b, dtype=dtype, shape=shape, squeeze=False + ) + rates[spec] = rate + + return rates, data + + def read( *urls: * tuple[ FFmpegInputUrlComposite | tuple[FFmpegUrlType, dict[str, Any] | None] @@ -99,52 +149,8 @@ def on_exit(rc): if proc.returncode: raise FFmpegError(proc.stderr, capture_log) - # gather output - rates = {} - data = {} - for i, info in enumerate(output_info): - spec = info["user_map"] - b = info["reader"].read_all() - - # get datablob info from stderr if needed - missing = any(v is None for v in info["media_info"]) - - if missing: - logger.warning('Retrieving stream "%s" information from FFmpeg log.', spec) - new_info = extract_output_stream(proc.stderr, i) - - if info["media_type"] == "video": - dtype, shape, rate = info["media_info"] - - if missing: - if dtype is None: - pix_fmt = new_info["pix_fmt"] - dtype = utils.get_pixel_format(pix_fmt)[0] - if shape is None: - shape = new_info["s"] - if rate is None: - rate = new_info["r"] - - data[spec] = plugins.get_hook().bytes_to_video( - b=b, dtype=dtype, shape=shape, squeeze=False - ) - else: # 'audio' - dtype, shape, rate = info["media_info"] - if missing: - if dtype is None: - sample_fmt = new_info["sample_fmt"] - dtype = utils.get_audio_format(sample_fmt) - if shape is None: - shape = (new_info["ac"],) - if rate is None: - rate = new_info["ar"] - - data[spec] = plugins.get_hook().bytes_to_audio( - b=b, dtype=dtype, shape=shape, squeeze=False - ) - rates[spec] = rate - - return rates, data + # gather and return output + return _gather_outputs(output_info, proc) def write( From 5145f4025aa1d7c2d4f3915a908b98d39072532c Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 2 Mar 2025 15:06:42 -0600 Subject: [PATCH 226/333] `write()` - fixed `overwrite` argument being ignored --- src/ffmpegio/media.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 9992a5dd..82f74b01 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -220,15 +220,6 @@ def write( if output_info is None: raise FFmpegioError("failed to format output...") - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - { - "progress": progress, - "overwrite": overwrite, - "capture_log": None if show_log else False, - } - ) - # configure named pipes stack = configure.init_named_pipes(args, input_info, output_info) @@ -240,6 +231,7 @@ def write( capture_log=None if show_log else True, sp_kwargs=sp_kwargs, on_exit=lambda _: stack.close(), + overwrite=overwrite ) except: stack.close() From b4ad6c633542e926b4bd1c8853bb12f87b510b17 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 2 Mar 2025 15:08:45 -0600 Subject: [PATCH 227/333] added `are_inputs_ready()` --- src/ffmpegio/utils/__init__.py | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index f92c331e..c5acea32 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -829,3 +829,39 @@ def analyze_complex_filtergraphs( } return filtergraphs, fg_info + + +def are_inputs_ready( + inputs: list[tuple[str, dict]], input_info: list[InputSourceDict] +) -> list[bool]: + """Test if all the input information is provided for raw output initialization + + :param inputs: url-option pairs of input sources + :param input_info: input source information + :return: If i-th element is True, it indicates that the i-th input is ready + + What it checks + -------------- + + * OK if input is NOT buffered (e.g., given url or file object) + * buffered input is OK if its data is given in info[i]['buffer'] + * buffered input without data is OK only if necessary information is provided + in the input options to deduce the raw output data type and shape: + + video: `pix_fmt` and `s` + audio: `sample_fmt` and `ac` + """ + + required_options = { + "audio": ("sample_fmt", "ac"), + "video": ("pix_fmt", "s"), + } + + return [ + ( + info["src_type"] != "buffer" + or "buffer" in info + or not all(o in opts for o in required_options[info["media_type"]]) + ) + for (_, opts), info in zip(inputs, input_info) + ] From a1da31ed05705285fef6f7bfaf418a9d7d46c9b2 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 2 Mar 2025 15:09:16 -0600 Subject: [PATCH 228/333] added `init_media_filter()` --- src/ffmpegio/configure.py | 127 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7d979304..5481213c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1735,6 +1735,133 @@ def init_media_write( return args, input_info, output_info, not_ready +def init_media_filter( + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + input_types: Sequence[Literal["a", "v"]], + input_args: Sequence[RawStreamDef], + extra_inputs: ( + Sequence[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]] | None + ), + input_dtypes: list[str] | None, + input_shapes: list[tuple[int]] | None, + options: dict[str, Any], + output_options: dict[str, dict[str, Any]], +) -> tuple[FFmpegArgs, list[InputSourceDict], dict[str | None, dict[str, Any]] | None]: + """Prepare FFmpeg arguments for media read + + :param expr: complex filtergraph definition(s). + :param input_types: list/string of 'a' or 'v', specifying the input raw streams' media types + :param input_args: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param input_dtypes: list of numpy-style data type strings of input samples or frames + of input media streams, use `None` to auto-detect. + :param input_shapes: list of shapes of input samples or frames of input media streams, + use `None` to auto-detect. + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + :param output_options: FFmpeg output options for specific filtergraph outputs, + overriding the output options in the `options` argument + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return output_info: output stream information, None if outputs not initialized + :return output_options: output options, None if outputs already initialized + + """ + + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") + if "filter_complex" in options or "lavfi" in options: + raise ValueError("Cannot have a `filter_complex` or `lavfi` option set.") + + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None + gopts["filter_complex"] = expr + + # analyze and assign inputs + input_info = process_raw_inputs( + args, input_types, input_args, inopts_default, input_dtypes, input_shapes + ) + + if extra_inputs is not None: + input_info.extend(process_url_inputs(args, extra_inputs, {})) + + # make sure all inputs are complete + ready = utils.are_inputs_ready(args["inputs"], input_info) + + # add the default output options to output_options dict with None as the key + output_options[None] = options + + if all(ready): + output_info = init_media_filter_outputs(args, input_info, output_options) + output_options = None + else: + output_info = None + + return args, input_info, output_info, output_options + + +def init_media_filter_outputs( + args: FFmpegArgs, + input_info: list[InputSourceDict], + output_options: dict[str | None, dict[str, Any]], +) -> list[RawOutputInfoDict]: + """Initialize FFmpeg arguments for media read + + :param args: partial FFmpeg arguments (to be modified) + :param input_info: list of input information + :param output_options: default and specific output options + :return output_info: output file information + + """ + + # analyze filtergraph and create an output stream for each filtergraph output + gopts = args["global_options"] + gopts["filter_complex"], fg_info = utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info + ) + + # get default output options + default_opts = output_options.pop(None, {}) + + # adjust output_options + out_maps = {} + for k, v in output_options.items(): + if "map" in v: + try: + out_maps[v["map"]] = k + except TypeError: + raise FFmpegioError( + "The `map` option of a raw output can specify only one stream." + ) + elif (st_map := f"[{k}]") in fg_info: + out_maps[st_map] = k + else: + out_maps[k] = k + + # create output map (stream name excludes the brackets) + streams = {} + for spec in fg_info: + if spec in out_maps: + name = out_maps[spec] + streams[name] = output_options[name] + else: + label = spec[1:-1] + streams[label] = {} + + # analyze and assign outputs + output_info, fg_info = process_raw_outputs( + args, input_info, streams, default_opts, fg_info + ) + + return output_info + + def init_named_pipes( args: FFmpegArgs, input_info: list[InputSourceDict], From 552a0f812a42fb7d4972e33b1a6df44d48e08aaf Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 2 Mar 2025 15:10:17 -0600 Subject: [PATCH 229/333] added `filter()` --- src/ffmpegio/media.py | 82 +++++++++++++++++++++++++++++++++++++++++++ tests/test_media.py | 25 +++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 82f74b01..4f0378d1 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -22,6 +22,7 @@ from . import ffmpegprocess, utils, configure, FFmpegError, plugins from .utils.log import extract_output_stream from .errors import FFmpegioError +from .filtergraph.abc import FilterGraphObject __all__ = ["read", "write"] @@ -256,3 +257,84 @@ def write( data[i] = info["reader"].read_all() return data if len(data) else None + + +def filter( + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + input_types: Sequence[Literal["a", "v"]], + *input_args: * tuple[RawStreamDef, ...], + extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + output_options: dict[str, dict[str, Any]] | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[dict[str, Any]], +) -> tuple[dict[str, Fraction | int], dict[str, RawDataBlob]]: + """write multiple streams to a url/file + + :param expr: complex filtergraph expression or a list of expressions + :param input_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) + :param input_args: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob + or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param output_options: specific options for keyed filtergraph output pads. + :param progress: progress callback function, defaults to None + :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + + TIPS + ---- + + * Unlike `media.read()` all filtergraph outputs are always captured. The output + options specified as keyword arguments for all outputs, and `output_options` + argument can be used to specify additional (overriding) FFmpeg output options + for some outputs as needed. + + """ + + args, input_info, output_info, _ = configure.init_media_filter( + expr, + input_types, + input_args, + extra_inputs, + None, + None, + options, + output_options or {}, + ) + + # if any input buffer is empty, invalid + for info in input_info: + if info["src_type"] == "buffer" and "buffer" not in info: + raise ValueError("All inputs must be raw media data.") + + # configure named pipes + stack = configure.init_named_pipes(args, input_info, output_info) + + # run the FFmpeg + try: + proc = ffmpegprocess.Popen( + args, + progress=progress, + capture_log=None if show_log else True, + sp_kwargs=sp_kwargs, + on_exit=lambda _: stack.close(), + ) + except: + stack.close() + raise + + # wait for the FFmpeg to finish processing + proc.wait() + + # throw error if failed + if proc.returncode: + raise FFmpegError(proc.stderr, show_log) + + # gather and return output + return _gather_outputs(output_info, proc) diff --git a/tests/test_media.py b/tests/test_media.py index 3c04837c..3c73e061 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -74,3 +74,28 @@ def test_media_write_audio_merge(): ) pprint(ff.probe.format_basic(outfile)) pprint(ff.probe.audio_streams_basic(outfile)) + + +def test_media_filter(): + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") + + fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) + + print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") + + with TemporaryDirectory() as tmpdirname: + outrates, outdata = ff.media.filter( + ["[0:V:0][1:V:0]vstack,split", "[2:a:0][3:a:0]amerge"], + "vvaa", + (fps, F), + (fps, F), + (fs, x), + (fs, x), + output_options={"[out0]": {}, "audio": {"map": "[out2]"}}, + show_log=True, + shortest=ff.FLAG, + ) + + assert all(k in ("[out0]", "out1", "audio") for k in outrates) + + print(outrates) From 6aaab067f68f4372b40329859ec7248c685411cb Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 2 Mar 2025 22:57:15 -0600 Subject: [PATCH 230/333] `ReaderThread.read()` immediately returns if `n=0` --- src/ffmpegio/threading.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 3cd8a0e6..12e49767 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -411,6 +411,9 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: :return: n*itemsize bytes """ + if n == 0: + return b"" + # wait till matching line is read by the thread block = (self.is_alive() and not self._halt.is_set()) and n != 0 if timeout is not None: From 57e02c6b1a3da25fbffe66adc0044a1efeaf9339 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 2 Mar 2025 23:05:05 -0600 Subject: [PATCH 231/333] added `get_output_stream_id()` --- src/ffmpegio/utils/__init__.py | 31 ++++++++++++++++++++++++++++++- tests/test_utils.py | 14 +++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index c5acea32..efa8ae44 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -18,11 +18,14 @@ from .._utils import * from ..stream_spec import * from ..errors import FFmpegError, FFmpegioError -from .._typing import Any, MediaType, InputSourceDict, RawDataBlob +from .._typing import Any, MediaType, InputSourceDict, RawDataBlob, TYPE_CHECKING from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb from ..filtergraph.presets import temp_video_src, temp_audio_src +if TYPE_CHECKING: + from ..configure import RawOutputInfoDict + # TODO: auto-detect endianness # import sys # sys.byteorder @@ -865,3 +868,29 @@ def are_inputs_ready( ) for (_, opts), info in zip(inputs, input_info) ] + + +def get_output_stream_id( + output_info: list[RawOutputInfoDict], stream: str | int +) -> int: + """get output stream id + + :param output_info: list of output stream information + :param stream: name or index of an output stream + :return: index of the output stream + """ + if isinstance(stream, str): + try: + stream = next( + i for i, info in enumerate(output_info) if stream == info["user_map"] + ) + except StopIteration: + raise FFmpegioError( + f'"{stream=}") does not match any of the output stream names {tuple(output_info)}' + ) + elif stream < 0 or stream >= len(output_info): + raise FFmpegioError( + f'"{stream=}") is not a valid output index (0-{len(output_info)-1})' + ) + + return stream diff --git a/tests/test_utils.py b/tests/test_utils.py index dd082e3d..a962ec77 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,5 @@ import math -from ffmpegio import utils +from ffmpegio import utils, FFmpegioError import pytest @@ -77,5 +77,13 @@ def test_get_audio_format(): assert cfg[0] == " Date: Sun, 2 Mar 2025 23:10:05 -0600 Subject: [PATCH 232/333] added `probesize=32` default input option to all stream classes --- src/ffmpegio/streams/PipedStreams.py | 4 ++-- src/ffmpegio/streams/SimpleStreams.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 7013889f..1e01547b 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -75,7 +75,7 @@ def __init__( # initialize FFmpeg argument dict and get input & output information args, self._input_info, self._output_info = configure.init_media_read( - urls, map, options + urls, map, {"probesize_in": 32, **options} ) # create logger without assigning the source stream @@ -335,7 +335,7 @@ def __init__( merge_audio_sample_fmt, merge_audio_outpad, extra_inputs, - options, + {"probesize_in": 32, **options}, dtypes_in, shapes_in, ) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index bf7a6163..b2b8f13d 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -49,6 +49,7 @@ def __init__( self.sp_kwargs = sp_kwargs #:dict[str,Any]: additional keyword arguments for subprocess.Popen # get url/file stream + options = {"probesize_in": 32, **options} input_options = utils.pop_extra_options(options, "_in") url, stdin, input = configure.check_url( url, False, format=input_options.get("f", None) @@ -352,6 +353,7 @@ def __init__( # get url/file stream url, stdout, _ = configure.check_url(url, True) + options = {"probesize_in": 32, **options} input_options = utils.pop_extra_options(options, "_in") ffmpeg_args = configure.empty() @@ -717,6 +719,7 @@ def __init__( self._proc = None + options = {"probesize_in": 32, **options} inopts = utils.pop_extra_options(options, "_in") glopts = utils.pop_global_options(options) From 19e368b1da7cde1a8c22f7df7f2e2ac579118261 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 3 Mar 2025 23:22:38 -0600 Subject: [PATCH 233/333] docstring update --- src/ffmpegio/plugins/hookspecs.py | 25 ++--------- src/ffmpegio/plugins/rawdata_bytes.py | 65 ++++++++++++++++----------- src/ffmpegio/plugins/rawdata_mpl.py | 10 ++--- src/ffmpegio/plugins/rawdata_numpy.py | 25 +++-------- 4 files changed, 51 insertions(+), 74 deletions(-) diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index 24be7e07..79c2d7d0 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -16,9 +16,8 @@ def video_info(obj: object) -> Tuple[Tuple[int, int, int], str]: """get video frame info :param obj: object containing video frame data with arbitrary number of frames - :type obj: object - :return: shape (height,width,components) and data type in numpy dtype str expression - :rtype: Tuple[Tuple[int, int, int], str] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ @@ -27,9 +26,8 @@ def audio_info(obj: object) -> Tuple[int, str]: """get audio sample info :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: object - :return: number of channels and sample data type in numpy dtype str expression - :rtype: Tuple[Tuple[int], str] + :return ac: number of channels + :return dtype: sample data type in numpy dtype str expression """ @@ -38,9 +36,7 @@ def video_bytes(obj: object) -> memoryview: """return bytes-like object of packed video pixels, associated with `video_info()` :param obj: object containing video frame data with arbitrary number of frames - :type obj: object :return: packed bytes of video frames - :rtype: bytes-like object """ @@ -49,12 +45,9 @@ def audio_bytes(obj: object) -> memoryview: """return bytes-like object of packed audio samples :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: object :return: packed bytes of audio samples - :rtype: bytes-like object """ - @hookspec(firstresult=True) def bytes_to_video( b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool @@ -62,15 +55,10 @@ def bytes_to_video( """convert bytes to rawvideo object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: data type numpy dtype string (e.g., '|u1', ' ob """convert bytes to rawaudio object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: numpy dtype string of the bytes (e.g., ' Tuple[Tuple[int, int, int], str]: +def video_info(obj: BytesRawDataBlob) -> Tuple[Tuple[int, int, int], str]: """get video frame info :param obj: dict containing video frame data with arbitrary number of frames - :type obj: object - :return: shape (height,width,components) and data type in numpy dtype str expression - :rtype: Tuple[Tuple[int, int, int], str] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ try: @@ -22,13 +46,12 @@ def video_info(obj: dict) -> Tuple[Tuple[int, int, int], str]: @hookimpl -def audio_info(obj: object) -> Tuple[int, str]: +def audio_info(obj: BytesRawDataBlob) -> Tuple[int, str]: """get audio sample info :param obj: dict containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: dict - :return: number of channels and sample data type in numpy dtype str expression - :rtype: Tuple[Tuple[int], str] + :return ac: number of channels + :return dtype: sample data type in numpy dtype str expression """ try: return obj["shape"][-1:], obj["dtype"] @@ -37,13 +60,11 @@ def audio_info(obj: object) -> Tuple[int, str]: @hookimpl -def video_bytes(obj: object) -> memoryview: +def video_bytes(obj: BytesRawDataBlob) -> memoryview: """return bytes-like object of packed video pixels, associated with `video_info()` :param obj: dict containing video frame data with arbitrary number of frames - :type obj: dict :return: packed bytes of video frames - :rtype: bytes-like object """ try: @@ -53,13 +74,11 @@ def video_bytes(obj: object) -> memoryview: @hookimpl -def audio_bytes(obj: object) -> memoryview: +def audio_bytes(obj: BytesRawDataBlob) -> memoryview: """return bytes-like object of packed audio samples :param obj: dict containing audio data (with interleaving channels) with arbitrary number of samples - :type obj: dict :return: packed bytes of audio samples - :rtype: bytes-like object """ try: @@ -71,19 +90,14 @@ def audio_bytes(obj: object) -> memoryview: @hookimpl def bytes_to_video( b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool -) -> object: +) -> BytesRawDataBlob: """convert bytes to rawvideo object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: data type numpy dtype string (e.g., '|u1', ' object: +def bytes_to_audio( + b: bytes, dtype: str, shape: Tuple[int], squeeze: bool +) -> BytesRawDataBlob: """convert bytes to rawaudio object :param b: byte data of arbitrary number of video frames - :type b: bytes :param dtype: numpy dtype string of the bytes (e.g., ' 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] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ try: return (int(obj.bbox.bounds[3]), int(obj.bbox.bounds[2]), 4), "|u1" @@ -28,9 +29,7 @@ def video_bytes(obj: Figure) -> memoryview: """return bytes-like object of rawvideo NumPy array :param obj: video frame data with arbitrary number of frames - :type obj: Figure :return: memoryview of video frames - :rtype: memoryview """ try: @@ -40,4 +39,3 @@ def video_bytes(obj: Figure) -> memoryview: 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 index ff8487b7..83308824 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -1,5 +1,6 @@ """ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" +from __future__ import annotations import numpy as np from pluggy import HookimplMarker from typing import Tuple @@ -23,9 +24,8 @@ 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] + :return shape: shape (height,width,components) + :return dtype: data type in numpy dtype str expression """ try: return obj.shape[-3:] if obj.ndim != 2 else [*obj.shape, 1], obj.dtype.str @@ -38,9 +38,8 @@ 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] + :return ac: number of channels + :return dtype: sample data type in numpy dtype str expression """ try: return obj.shape[-1:] if obj.ndim > 1 else [1], obj.dtype.str @@ -53,9 +52,7 @@ 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: @@ -69,9 +66,7 @@ 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: @@ -87,15 +82,10 @@ def bytes_to_video( """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', ' Ar """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, 3 Mar 2025 23:23:02 -0600 Subject: [PATCH 234/333] added `video_frames` and `audio_samples` hooks --- src/ffmpegio/plugins/hookspecs.py | 17 +++++++++++++++ src/ffmpegio/plugins/rawdata_bytes.py | 30 +++++++++++++++++++++++++++ src/ffmpegio/plugins/rawdata_numpy.py | 30 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index 79c2d7d0..32c9fa2c 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -48,6 +48,23 @@ def audio_bytes(obj: object) -> memoryview: :return: packed bytes of audio samples """ +@hookspec(firstresult=True) +def video_frames(obj: object) -> int: + """get number of video frames in obj + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + """ + + +@hookspec(firstresult=True) +def audio_samples(obj: object) -> int: + """get audio sample info + + :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples + :return: number of samples in obj + """ + @hookspec(firstresult=True) def bytes_to_video( b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index ffac9fcc..ca2f27a8 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -8,6 +8,8 @@ "BytesRawDataBlob", "video_info", "audio_info", + "video_frames", + "audio_samples", "video_bytes", "audio_bytes", "bytes_to_video", @@ -87,6 +89,34 @@ def audio_bytes(obj: BytesRawDataBlob) -> memoryview: return None +@hookimpl +def video_frames(obj: BytesRawDataBlob) -> int: + """get number of video frames in obj + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + """ + + try: + return len(obj["buffer"]) // get_samplesize(obj["shape"], obj["dtype"]) + except: + return None + + +@hookimpl +def audio_samples(obj: BytesRawDataBlob) -> int: + """get audio sample info + + :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples + :return: number of samples in obj + """ + + try: + return len(obj["buffer"]) // get_samplesize(obj["shape"], obj["dtype"]) + except: + return None + + @hookimpl def bytes_to_video( b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index 83308824..94f243e9 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -12,6 +12,8 @@ __all__ = [ "video_info", "audio_info", + "video_frames", + "audio_samples", "video_bytes", "audio_bytes", "bytes_to_video", @@ -47,6 +49,34 @@ def audio_info(obj: ArrayLike) -> Tuple[int, str]: return None +@hookimpl +def video_frames(obj: ArrayLike) -> int: + """get number of video frames in obj + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + """ + + try: + return obj.shape[0] + except: + return None + + +@hookimpl +def audio_samples(obj: ArrayLike) -> int: + """get audio sample info + + :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples + :return: number of samples in obj + """ + + try: + return obj.shape[0] + except: + return None + + @hookimpl def video_bytes(obj: ArrayLike) -> memoryview: """return bytes-like object of rawvideo NumPy array From 0cd9bf496398d1a586c7082c0384dc7d260dd655 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 3 Mar 2025 23:23:50 -0600 Subject: [PATCH 235/333] `set_sp_kwargs_stdin()` fixed to check if input data is available --- src/ffmpegio/utils/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index efa8ae44..de0de34a 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -534,7 +534,8 @@ def set_sp_kwargs_stdin( if src_type not in ("url", "filtergraph"): url = "pipe:0" if src_type == "buffer": - sp_kwargs = {**sp_kwargs, "input": info["buffer"]} + if "buffer" in info: + sp_kwargs = {**sp_kwargs, "input": info["buffer"]} elif src_type == "fileobj": f = info["fileobj"] sp_kwargs = {**sp_kwargs, "stdin": f} From ab6add3acf163c0cc46ad505a867d1ca857aa733 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 3 Mar 2025 23:24:30 -0600 Subject: [PATCH 236/333] `are_inputs_ready()` - fixed required option check --- src/ffmpegio/utils/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index de0de34a..6cbb61a6 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -865,7 +865,7 @@ def are_inputs_ready( ( info["src_type"] != "buffer" or "buffer" in info - or not all(o in opts for o in required_options[info["media_type"]]) + or all(o in opts for o in required_options[info["media_type"]]) ) for (_, opts), info in zip(inputs, input_info) ] From 199850fa95678cbf41305bbee8ec5a6be9ae33c3 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 3 Mar 2025 23:27:35 -0600 Subject: [PATCH 237/333] `init_media_filter()` now returns `input_ready` list --- src/ffmpegio/configure.py | 5 +++-- src/ffmpegio/media.py | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 5481213c..41fa0e68 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1746,7 +1746,7 @@ def init_media_filter( input_shapes: list[tuple[int]] | None, options: dict[str, Any], output_options: dict[str, dict[str, Any]], -) -> tuple[FFmpegArgs, list[InputSourceDict], dict[str | None, dict[str, Any]] | None]: +) -> tuple[FFmpegArgs, list[InputSourceDict], list[bool], dict[str | None, dict[str, Any]] | None]: """Prepare FFmpeg arguments for media read :param expr: complex filtergraph definition(s). @@ -1764,6 +1764,7 @@ def init_media_filter( overriding the output options in the `options` argument :return ffmpeg_args: FFmpeg argument dict (partial) :return input_info: input stream information + :return input_ready: Element is True if corresponding input is ready (known dtype and shape) :return output_info: output stream information, None if outputs not initialized :return output_options: output options, None if outputs already initialized @@ -1803,7 +1804,7 @@ def init_media_filter( else: output_info = None - return args, input_info, output_info, output_options + return args, input_info, ready, output_info, output_options def init_media_filter_outputs( diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 4f0378d1..0a5fa8f0 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -297,7 +297,7 @@ def filter( """ - args, input_info, output_info, _ = configure.init_media_filter( + args, input_info, input_ready, output_info, _ = configure.init_media_filter( expr, input_types, input_args, @@ -309,9 +309,10 @@ def filter( ) # if any input buffer is empty, invalid - for info in input_info: - if info["src_type"] == "buffer" and "buffer" not in info: - raise ValueError("All inputs must be raw media data.") + if not all(input_ready): + raise FFmpegioError( + "Data type and shape of some inputs could not be determined." + ) # configure named pipes stack = configure.init_named_pipes(args, input_info, output_info) From 0f167d8d1febe5eb4ee9fcf19aada63e75cf2971 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 15:27:15 -0600 Subject: [PATCH 238/333] fixed `video_frame()` and `audio_samples()` --- src/ffmpegio/plugins/rawdata_bytes.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index ca2f27a8..c8ad3d0f 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -98,7 +98,7 @@ def video_frames(obj: BytesRawDataBlob) -> int: """ try: - return len(obj["buffer"]) // get_samplesize(obj["shape"], obj["dtype"]) + return obj["shape"][0] except: return None @@ -112,7 +112,7 @@ def audio_samples(obj: BytesRawDataBlob) -> int: """ try: - return len(obj["buffer"]) // get_samplesize(obj["shape"], obj["dtype"]) + return obj["shape"][0] except: return None From 683aeae8110e85c77328ca647c67e87b15711f0d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 20:10:13 -0600 Subject: [PATCH 239/333] `init_named_pipes()` reversed input/output order for proper ExitStack'ing --- src/ffmpegio/configure.py | 50 +++++++++++++++++++-------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 41fa0e68..451e7c03 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1890,32 +1890,7 @@ def init_named_pipes( """ stack = ExitStack() - - # configure input pipes (if needed) wr_kws = {"queuesize": queue_size} if queue_size else {} - for i, (input, info) in enumerate(zip(args["inputs"], input_info)): - if input[0] is None: # no url == fileobj / buffer / other data via a pipe - pipe = NPopen("w", bufsize=0) - stack.enter_context(pipe) - assign_input_url(args, i, pipe.path) - src_type = info["src_type"] - if src_type == "fileobj": - writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) - stack.enter_context(writer) - # starts thread & wait for pipe connection - elif src_type == "buffer": - writer = WriterThread(pipe, **wr_kws) - # starts thread & wait for pipe connection - stack.enter_context(writer) - if "buffer" in info: - # data buffer given, feed the data and terminate - writer.write(info["buffer"]) - writer.write(None) # close the writer immediately - else: - # if no data given, provide the access to the writer - info["writer"] = writer - else: - raise FFmpegioError(f"{src_type=} is an unknown input data type.") # configure output pipes has_pipeout = False @@ -1944,6 +1919,31 @@ def init_named_pipes( stack.enter_context(reader) # starts thread & wait for pipe connection info["reader"] = reader + # configure input pipes (if needed) + for i, (input, info) in enumerate(zip(args["inputs"], input_info)): + if input[0] is None: # no url == fileobj / buffer / other data via a pipe + pipe = NPopen("w", bufsize=0) + stack.enter_context(pipe) + assign_input_url(args, i, pipe.path) + src_type = info["src_type"] + if src_type == "fileobj": + writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) + stack.enter_context(writer) + # starts thread & wait for pipe connection + elif src_type == "buffer": + writer = WriterThread(pipe, **wr_kws) + # starts thread & wait for pipe connection + stack.enter_context(writer) + if "buffer" in info: + # data buffer given, feed the data and terminate + writer.write(info["buffer"]) + writer.write(None) # close the writer immediately + else: + # if no data given, provide the access to the writer + info["writer"] = writer + else: + raise FFmpegioError(f"{src_type=} is an unknown input data type.") + if has_pipeout: # if any output is piped, must run in the overwrite mode args["global_options"].pop("n", None) From d46836dbe8df8c7402572ef69caccbd666f2d55f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 20:13:50 -0600 Subject: [PATCH 240/333] fixed `timeout` handling --- src/ffmpegio/threading.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 12e49767..2e604134 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -226,7 +226,7 @@ def index(self, prefix, start=None, block=True, timeout=None): timeout = time() + timeout start = len(self.logs) while True: - tout = timeout and timeout - time() + tout = timeout and max(timeout - time(), 0) # wait till the next log update if (tout is not None and tout < 0) or not self.newline.wait(tout): raise TimeoutError("Specified line not found") @@ -433,7 +433,7 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: nreads = 1 if n <= 0 else max(n_new, 0) nr = 0 while True: - tout = timeout and timeout - time() + tout = timeout and max(timeout - time(), 0) if timeout and tout <= 0: break try: From acf10d81ad56e8c6c7e363056446d635d41ffbc9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 20:14:55 -0600 Subject: [PATCH 241/333] fixed detecting the use of a named pipe --- src/ffmpegio/threading.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 2e604134..241dd800 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -321,14 +321,14 @@ def cool_down(self): def join(self, timeout=None): - if self.pipe: + if self.pipe is None: + self.stdout.close() + else: if self.stdout is None: # FFmpeg never opened the pipe, open it to release the runner from waiting with open(self.pipe.path, "w"): ... self.pipe.close() - else: - self.stdout.close() self._halt.set() if self._queue.full(): @@ -576,10 +576,10 @@ def run(self): self._no_more = True # close the pipe/stream - if self.pipe: - self.pipe.close() - else: + if self.pipe is None: self.stdin.close() + else: + self.pipe.close() # completely flush the queue # check if queue has any remaining items From 9d0aeb33f5f924f69fd35f111ff6fea3747dfea9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 20:39:56 -0600 Subject: [PATCH 242/333] `Popen.terminate()` join monitor only if really terminated --- src/ffmpegio/ffmpegprocess.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/ffmpegprocess.py b/src/ffmpegio/ffmpegprocess.py index 58b75728..0e08d642 100644 --- a/src/ffmpegio/ffmpegprocess.py +++ b/src/ffmpegio/ffmpegprocess.py @@ -355,10 +355,12 @@ def wait(self, timeout=None): def terminate(self): """Terminate the FFmpeg process""" super().terminate() - try: - self._monitor.join() - except: - pass + + if self.poll() is not None: + try: + self._monitor.join() + except: + pass def kill(self): """Kill the FFmpeg process""" From f6c7204a7294c5c70345f3290fb87ec3637625d3 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 20:41:00 -0600 Subject: [PATCH 243/333] `ReaderThread.read()` - full revamp --- src/ffmpegio/threading.py | 80 ++++++++++++++------------------------- 1 file changed, 28 insertions(+), 52 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 241dd800..2d045954 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -406,91 +406,67 @@ def run(self): def read(self, n: int = -1, timeout: float | None = None) -> bytes: """read n samples - :param n: number of samples/frames to read, if non-positive, read all, defaults to -1 + :param n: number of samples/frames to read, if non-positive, read all + (until the pipe is broken), defaults to -1 :param timeout: timeout in seconds, defaults to None :return: n*itemsize bytes """ + # no sample requested, return empty bytes object if n == 0: return b"" + read_all = n < 0 + # wait till matching line is read by the thread - block = (self.is_alive() and not self._halt.is_set()) and n != 0 if timeout is not None: timeout = time() + timeout arrays = [] - n_new = max(n, -n) + m = n * self.itemsize # bytes needed + mread = 0 # bytes read # grab any leftover data from previous read if self._carryover: + mread = len(self._carryover) arrays = [self._carryover] - if n_new != 0: - n_new -= len(self._carryover) // self.itemsize + m -= mread self._carryover = None # loop till enough data are collected - nreads = 1 if n <= 0 else max(n_new, 0) - nr = 0 - while True: + while read_all or m > 0: tout = timeout and max(timeout - time(), 0) - if timeout and tout <= 0: - break + block = not self._running.is_set() and tout + try: b = self._queue.get(block, tout) - assert b is not None + assert b is not None # encountered sentinel self._queue.task_done() arrays.append(b) + mr = len(b) + m -= mr + mread += mr + assert mr and tout > 0 # no more read time left except (Empty, AssertionError): break - nr += len(b) // self.itemsize - if nr >= nreads: # enough read - if n < 0: - block = False # keep reading until queue is empty - else: - break - # combine all the data and return requested amount - if not len(arrays): - return b"" - all_data = b"".join(arrays) - if n <= 0: - return all_data - nbytes = self.itemsize * n - if len(all_data) > nbytes: - self._carryover = all_data[nbytes:] - return all_data[:nbytes] - def read_all(self, timeout: float | None = None) -> bytes: - # wait till matching line is read by the thread - if timeout is not None: - timeout = time() + timeout + nread = mread // self.itemsize # number of frames read + if n >= 0: + nread = min(nread, n) # adjust to number of frames needed - arrays = arrays = [self._carryover] if self._carryover else [] - self._carryover = None + mbytes = nread * self.itemsize # number of bytes needed - # loop till enough data are collected - logger.info("ReaderThread:read_all - start reading") - while True: - # if not self.is_alive() or timeout and timeout > time(): - try: - data = self._queue.get( - self.is_alive() and not self._halt, timeout and timeout - time() - ) - self._queue.task_done() - assert data is not None - arrays.append(data) - except (AssertionError, Empty): - logger.info("ReaderThread:read_all - the sentinel received") - break - except Exception as e: - logger.info(f"ReaderThread:read_all - exception: {type(e)}") - raise + # update carryover buffer + self._carryover = all_data[mbytes:] if mbytes < mread else None - # combine all the data and return requested amount - return b"".join(arrays) + # return retrieved bytes array + return all_data[:mbytes] + + def read_all(self, timeout: float | None = None) -> bytes: + return self.read(-1, timeout) class WriterThread(Thread): From c6ec9ab0f6d18fcbe540ecc3892c24dfef977cda Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 21:20:22 -0600 Subject: [PATCH 244/333] `ReaderThread.read()` fixed the no timeout case --- src/ffmpegio/threading.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 2d045954..13b9f114 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -446,7 +446,7 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: mr = len(b) m -= mr mread += mr - assert mr and tout > 0 # no more read time left + assert mr and (read_all or tout > 0) # no more read time left except (Empty, AssertionError): break From 5e6a319617aea0c4ae6cc0a76cdeefe05fd904ce Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 21:37:17 -0600 Subject: [PATCH 245/333] renamed `blocksize` properties to `_blocksize` --- src/ffmpegio/streams/PipedStreams.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 1e01547b..d693e4ff 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -93,7 +93,7 @@ def __init__( info = self._output_info[ref_stream] if blocksize is None: blocksize = 1 if info["media_type"] == "video" else 1024 - self.blocksize = blocksize + self._blocksize = blocksize self.default_timeout = default_timeout self._ref = ref_stream self._rates = [v["media_info"][2] for v in self._output_info] @@ -205,7 +205,7 @@ def __iter__(self): return self def __next__(self): - F = self.read(self.blocksize, self.default_timeout) + F = self.read(self._blocksize, self.default_timeout) if not any( len(self._get_bytes[info["media_type"]](obj=f)) for f, info in zip(F.values(), self._output_info) From 5f33cc3b4d6749e0fae0cf6d779636f6b8111e79 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 21:41:32 -0600 Subject: [PATCH 246/333] fully activated `default_timeout` property --- src/ffmpegio/streams/PipedStreams.py | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index d693e4ff..3b339ba2 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -52,6 +52,7 @@ def __init__( :param map: FFmpeg map options :param ref_stream: index of the reference stream to pace read operation, defaults to 0. The reference stream is guaranteed to have a frame data on every read operation. + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) @@ -205,7 +206,7 @@ def __iter__(self): return self def __next__(self): - F = self.read(self._blocksize, self.default_timeout) + F = self.read(self._blocksize, None) if not any( len(self._get_bytes[info["media_type"]](obj=f)) for f, info in zip(F.values(), self._output_info) @@ -234,6 +235,9 @@ def read(self, n: int = -1, timeout: float | None = None) -> dict[str, RawDataBl A BlockingIOError is raised if the underlying raw stream is in non blocking-mode, and has no data available at the moment.""" + if timeout is None: + timeout = self.default_timeout + # compute the number of frames to read per stream if self._n0 and n > 0: T = n / self._rates[self._ref] # duration @@ -309,6 +313,7 @@ def __init__( :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) @@ -503,11 +508,17 @@ def readlog(self, n: int = None) -> str: with self._logger._newline_mutex: return "\n".join(self._logger.logs or self._logger.logs[:n]) - def write(self, stream_id: int, data: RawDataBlob) -> bytes | None: + def write( + self, stream_id: int, data: RawDataBlob, timeout: float | None = None + ) -> bytes | None: """write a raw media data to a specified stream :param stream_id: input stream index :param data: media data blob (depends on the active data conversion plugin) + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue :return: currently available encoded data (bytes) if returning the encoded data back to Python @@ -553,8 +564,11 @@ def write(self, stream_id: int, data: RawDataBlob) -> bytes | None: logger.debug("[writer main] writing...") + if timeout is None: + timeout = self.default_timeout + try: - self._input_info[stream_id]["writer"].write(b) + self._input_info[stream_id]["writer"].write(b, timeout) except (BrokenPipeError, OSError): self._logger.join_and_raise() @@ -583,8 +597,8 @@ def wait(self, timeout: float | None = None) -> int | None: :param timeout: a timeout for blocking in seconds, or fractions thereof, defaults to None, to wait until empty - :raise `TimeoutExpired`: if a timeout is set, and the process does not - terminate after timeout seconds. It is safe to + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to catch this exception and retry the wait. :return returncode: return returncode attribute @@ -670,8 +684,6 @@ class Transcoder: :type dtype: str, optional :param blocksize: read buffer block size in samples, defaults to None :type blocksize: int, optional - :param default_timeout: default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional :param progress: progress callback function, defaults to None :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, @@ -697,8 +709,8 @@ def __init__( **output_options: dict[str, Any], ) -> None: - #:float: default filter operation timeout in seconds - self.default_timeout = default_timeout or 10e-3 + #:float|None: default filter operation timeout in seconds + self.default_timeout = default_timeout # set this to false in _finalize() if guaranteed for the logger to have output stream info self._loggertimeout = True From b260cef910195e5957c2a5a2d117e2b42527f0f5 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 21:41:50 -0600 Subject: [PATCH 247/333] updated test --- tests/test_filtergraph.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index 091d3a91..0ba58283 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -479,7 +479,7 @@ def test_filter_empty_handling(): assert (fg4 * 2).compose() == "" assert (fg1 + fg3).compose() == "trim,crop" - assert (fg1 | fg3).compose() == "[UNC0]trim,crop[UNC1]" + assert (fg1 | fg3).compose() == "trim,crop" assert (fg2 + fg3).compose() == "[UNC0]fps[UNC2];[UNC1]scale[UNC3]" assert (fg2 | fg3).compose() == "[UNC0]fps[UNC2];[UNC1]scale[UNC3]" From 6b33b2ee2c9c445a0e0c2c677b813b779668e5fd Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 4 Mar 2025 22:30:35 -0600 Subject: [PATCH 248/333] added `PipedMediaFilter` class --- src/ffmpegio/streams/PipedStreams.py | 529 ++++++++++++++++++++++++++- src/ffmpegio/streams/__init__.py | 4 +- tests/test_pipedstreams.py | 33 ++ 3 files changed, 561 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 3b339ba2..a1f53161 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -13,11 +13,12 @@ MediaType, FFmpegOutputUrlComposite, ) +from ..filtergraph.abc import FilterGraphObject +from ..configure import RawOutputInfoDict import sys from time import time from fractions import Fraction -from contextlib import ExitStack from namedpipe import NPopen @@ -26,7 +27,7 @@ from ..errors import FFmpegError, FFmpegioError # fmt:off -__all__ = ["PipedMediaReader", "PipedMediaWriter"] +__all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter"] # fmt:on @@ -662,8 +663,530 @@ def pop_encoded(self, pipe_id: int | None = 0) -> bytes | tuple[bytes]: return tuple(data_it) if pipe_id is None else next(data_it) -class PipedFilter: ... +class PipedMediaFilter: + _array_to_opts = { + "video": utils.array_to_video_options, + "audio": utils.array_to_audio_options, + } + _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} + + def __init__( + self, + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + input_types: Sequence[Literal["a", "v"]], + *input_rates_or_opts: * tuple[int | Fraction | dict, ...], + input_dtypes: list[str] | None = None, + input_shapes: list[tuple[int]] | None = None, + extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + ref_output: int = 0, + output_options: dict[str, dict[str, Any]] | None = None, + default_timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[dict[str, Any]], + ): + """Filter audio/video data streams with FFmpeg filtergraphs + + :param expr: complex filtergraph expression or a list of filtergraphs + :param input_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) + :param input_rates_or_opts: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob + or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param input_dtypes: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param input_shapes: list of shapes of input samples or frames of input media + streams, defaults to `None` (auto-detect). + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param ref_output: index or label of the reference stream to pace read operation, defaults to 0. + `PipedMediaFilter.read()` operates around the reference stream. + :param output_options: specific options for keyed filtergraph output pads. + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) + : defaults to `None` to use 1 video frame or 1024 audio frames + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + """ + + input_args = [ + (None, v) if isinstance(v, dict) else (v, None) for v in input_rates_or_opts + ] + + ( + args, + self._input_info, + self._input_ready, + self._output_info, + self._deferred_output_options, + ) = configure.init_media_filter( + expr, + input_types, + input_args, + extra_inputs, + input_dtypes, + input_shapes, + {"probesize_in": 32, **options}, + output_options or {}, + ) + + if all(self._input_ready): + self._input_ready = True + self._deferred_data = None + else: + # input data must be initially buffered + self._deferred_data = [[] for _ in range(len(self._input_info))] + + # create logger without assigning the source stream + self._logger = LoggerThread(None, show_log) + + # prepare FFmpeg keyword arguments + self._args = { + "ffmpeg_args": args, + "progress": progress, + "capture_log": True, + "sp_kwargs": sp_kwargs, + } + + # set the default read block size for the referenc stream + self.default_timeout = default_timeout + self._blocksize = blocksize + self._ref = ref_output + self._pipe_kws = {"queue_size": queuesize} + self._rates = None + self._n0 = None # timestamps of the last read sample + + hook = plugins.get_hook() + self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} + self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} + self._get_num = {"video": hook.video_frames, "audio": hook.audio_samples} + + self._proc = None + + def _open(self, deferred: bool): + + ffmpeg_args = self._args["ffmpeg_args"] + + if deferred: + self._output_info = configure.init_media_filter_outputs( + ffmpeg_args, self._input_info, self._deferred_output_options + ) + + self._ref = utils.get_output_stream_id(self._output_info, self._ref) + + # set the default read block size for the referenc stream + info = self._output_info[self._ref] + if self._blocksize is None: + self._blocksize = 1 if info["media_type"] == "video" else 1024 + self._rates = [v["media_info"][2] for v in self._output_info] + self._n0 = [0] * len(self._output_info) # timestamps of the last read sample + self._pipe_kws = { + **self._pipe_kws, + "update_rate": self._rates[self._ref] / Fraction(self._blocksize), + } + + # set up and activate pipes and read/write threads + stack = configure.init_named_pipes( + ffmpeg_args, + self._input_info, + self._output_info, + **self._pipe_kws, + ) + + # run the FFmpeg + try: + self._proc = ffmpegprocess.Popen( + **self._args, on_exit=lambda _: stack.close() + ) + except: + stack.close() + raise + + # set the log source and start the logger + self._logger.stderr = self._proc.stderr + self._logger.start() + + # if any pending data, queue them + for src, info in zip(self._deferred_data, self._input_info): + if "writer" in info and len(src): + writer = info["writer"] + for data in src: + writer.write(data) + self._deferred_data = [] + self._input_ready = True + + return self + + def __enter__(self): + + if self._input_ready is True: + self._open(False) + + return self + + def open(self): + """start FFmpeg processing + + Note + ---- + + It may flag to defer starting the FFmpeg process if the input streams + are not fully specified and must wait to deduce them from the written + data. + + """ + self.__enter__() + + def __exit__(self, *exc_details): + + try: + if self._proc is not None and self._proc.poll() is None: + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + self._proc = None + except: + if not exc_details[0]: + exc_details = sys.exc_info() + finally: + try: + self._logger.join() + except RuntimeError: + pass + + def close(self): + """Kill FFmpeg process and close the streams.""" + + self.__exit__(None, None, None) + + @property + def output_labels(self) -> list[str]: + """FFmpeg/custom labels of output streams""" + return [v["user_map"] for v in self._output_info] + + @property + def output_types(self) -> dict[str, MediaType]: + """media type associated with the output streams (key)""" + return {v["user_map"]: v["media_type"] for v in self._output_info} + + @property + def output_rates(self) -> dict[str, int | Fraction]: + """sample or frame rates associated with the output streams (key)""" + return {v["user_map"]: v["media_info"][2] for v in self._output_info} + + @property + def output_dtypes(self) -> dict[str, str]: + """frame/sample data type associated with the output streams (key)""" + return {v["user_map"]: v["media_info"][0] for v in self._output_info} + + @property + def output_shapes(self) -> dict[str, tuple[int]]: + """frame/sample shape associated with the output streams (key)""" + return {v["user_map"]: v["media_info"][1] for v in self._output_info} + + @property + def output_counts(self) -> dict[str, int]: + """number of frames/samples read""" + return {v["user_map"]: n for v, n in zip(self._output_info, self._n0)} + + @property + def closed(self) -> bool: + """True if the stream is closed.""" + return self._proc.poll() is not None + + @property + def lasterror(self) -> FFmpegError: + """Last error FFmpeg posted""" + if self._proc.poll(): + return self._logger.Exception() + else: + return None + + def readlog(self, n: int) -> str: + """read FFmpeg log lines + + :param n: number of lines to read + :return: logged messages + """ + if n is not None: + self._logger.index(n) + with self._logger._newline_mutex: + return "\n".join(self._logger.logs or self._logger.logs[:n]) + + def _write_stream( + self, + info: RawOutputInfoDict, + stream_id: int, + data: RawDataBlob, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + media_type = info["media_type"] + b = self._get_bytes[media_type](obj=data) + + if self._input_ready is not True: + # need to collect input data type and shape from the actual data + # before starting the FFmpeg + if not self._input_ready[stream_id]: + # first frame of the input stream with missing information + # update the + input_args = self._args["ffmpeg_args"]["inputs"][stream_id] + self._args["ffmpeg_args"]["inputs"][stream_id] = ( + input_args[0], + {**input_args[1], **self._array_to_opts[media_type](data)}, + ) + self._input_ready[stream_id] = True + + self._deferred_data[stream_id].append(b) + + if all(self._input_ready): + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) + + else: + + logger.debug("[writer main] writing...") + + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (BrokenPipeError, OSError): + self._logger.join_and_raise() + + def write_stream( + self, stream_id: int, data: RawDataBlob, timeout: float | None = None + ): + """write a raw media data to a specified stream + + :param stream_id: input stream index or label + :param data: media data blob (depends on the active data conversion plugin) + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue + :return: currently available encoded data (bytes) if returning the encoded + data back to Python + + Write the given NDArray object, data, and return the number + of bytes written (always equal to the number of data frames/samples, + since if the write fails an OSError will be raised). + + When in non-blocking mode, a BlockingIOError is raised if the data + needed to be written to the raw stream but it couldn’t accept all + the data without blocking. + + The caller may release or mutate data after this method returns, + so the implementation should only access data during the method call. + + """ + + # get input stream information + try: + info = self._input_info[stream_id] + except IndexError: + raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") + + if timeout is None: + timeout = self.default_timeout + + self._write_stream(info, stream_id, data, timeout) + + def _read_stream( + self, + info: RawOutputInfoDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + converter = self._converters[info["media_type"]] + dtype, shape, _ = info["media_info"] + + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=False + ) + + # update the frame/sample counter + n = self._get_num[info["media_type"]](obj=data) # actual number read + self._n0[stream_id] += n + + return data + + def read_stream( + self, stream_id: int | str, n: int, timeout: float | None = None + ) -> RawDataBlob: + """read selected output stream + + :param stream_id: stream index or label + :param n: number of frames/samples to read, defaults to -1 to read as many as available + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is read + from the buffer queue + :return: retrieved data + + Effect of mixing `n` and `timeout` + ---------------------------------- + + === ========= ========================================================================= + `n` `timeout` Behavior + === ========= ========================================================================= + 0 --- Immediately returns + >0 `None` Wait indefinitely until `n` frames/samples are retrieved + >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes + <0 `None` Wait indefinitely until FFmpeg terminates + <0 `float` Retrieve as many frames/samples until `timeout` seconds passes + === ========= ========================================================================= + + """ + + if timeout is None: + timeout = self.default_timeout + + info = self._output_info + stream_id = utils.get_output_stream_id(info, stream_id) + return self._read_stream(info[stream_id], stream_id, n, timeout) + + def write( + self, + data: Sequence[RawDataBlob] | dict[int, RawDataBlob], + timeout: float | None = None, + ) -> bytes | None: + """write data to all input streams + + :param data: media data blob keyed by stream index + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue + + """ + + it_data = data.items() if isinstance(data, dict) else enumerate(data) + + if timeout is None: + timeout = self.default_timeout + + if timeout is not None: + timeout += time() + + info = self._input_info + for stream_id, stream_data in it_data: + self._write_stream( + info[stream_id], + stream_id, + stream_data, + None if timeout is None else timeout - time(), + ) + + def read(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob]: + """Read data from all output streams + + :param n: number of frames/samples of the reference output stream to read + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is read + from the buffer queue + :return: retrieved data keyed by output streams + + Read all output streams and return retrieved data up to `n` frames/samples + of the reference output stream. The amount of the data of the other output + streams are calculated to match the time span of the retrieved reference + data. + + The returned `dict` is keyed by the output labels. + + Effect of mixing `n` and `timeout` + ---------------------------------- + + === ========= ========================================================================= + `n` `timeout` Behavior + === ========= ========================================================================= + 0 --- Immediately returns + >0 `None` Wait indefinitely until `n` frames/samples are retrieved + >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes + <0 `None` Wait indefinitely until FFmpeg terminates + <0 `float` Retrieve as many frames/samples until `timeout` seconds passes + === ========= ========================================================================= + """ + + data = {} # output + + if timeout is None: + timeout = self.default_timeout + + if timeout is not None: + timeout += time() + + get_timeout = lambda: None if timeout is None else max(timeout - time(), 0) + + get_all = n < 0 and timeout is None + + # read the reference stream + i0 = self._ref + n0 = self._n0[i0] + ref_data = self._read_stream(self._output_info[i0], i0, n, get_timeout()) + if not get_all: + # get the timestamp of the final frame + T = (self._n0[i0] - n0) / self._rates[i0] + + # retrieve all the other streams up to T seconds mark + for i, info in enumerate(self._output_info): + if i != i0: + if not get_all: + n1 = int(T * self._rates[i]) + n = max(n1 - self._n0[i], 0) + stream_data = self._read_stream(info, i, n, get_timeout()) + else: + stream_data = ref_data + data[info["user_map"]] = stream_data + + return data + + def wait(self, timeout: float | None = None) -> int | None: + """close the input pipes and wait for FFmpeg to exit + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait indefinitely + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to + catch this exception and retry the wait. + :return returncode: return returncode attribute + """ + + if self._proc: + + if timeout is not None: + timeout += time() + + # write the sentinel to each input queue + for info in self._input_info: + if "writer" in info: + info["writer"].write( + None, None if timeout is None else timeout - time() + ) + + # wait until the FFmpeg finishes the job + try: + rc = self._proc.wait(None if timeout is None else timeout - time()) + except TimeoutError: + raise + else: + self._proc = None + else: + rc = None + return rc class Transcoder: """Class to merge multiple media streams in memory diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index f4534b4b..c461ee76 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -6,7 +6,7 @@ SimpleVideoFilter, SimpleAudioFilter, ) -from .PipedStreams import PipedMediaReader, PipedMediaWriter +from .PipedStreams import PipedMediaReader, PipedMediaWriter, PipedMediaFilter from .AviStreams import AviMediaReader # TODO multi-stream write @@ -15,6 +15,6 @@ # fmt: off __all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", - "PipedMediaReader","PipedMediaWriter", + "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "AviMediaReader"] # fmt: on diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index 24c41553..638fc4bb 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -68,3 +68,36 @@ def test_PipedMediaWriter(): writer.wait(10) b = writer.pop_encoded(0) assert isinstance(b, bytes) + + +def test_PipedMediaFilter(): + + ff.use("read_bytes") + + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) + + fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", to=1) + + print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") + + with streams.PipedMediaFilter( + ["[0:V:0][1:V:0]vstack,split", "[2:a:0][3:a:0]amerge"], + "vvaa", + fps, + fps, + fs, + fs, + output_options={"[out0]": {}, "audio": {"map": "[out2]"}}, + show_log=True, + loglevel="debug", + # queuesize=4, + ) as f: + # f.write([F, F]) + f.write([F, F, x, x]) + # sleep(1) + f.wait(10) + data = f.read(F["shape"][0], 10) + + assert all(k in ("[out0]", "out1", "audio") for k in data) + n = f.output_counts + assert all(v["shape"][0] == n[k] for k, v in data.items()) From 20d5362270b1a1347ab5255d5556c36e3a7d543f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 5 Mar 2025 08:48:07 -0600 Subject: [PATCH 249/333] added `FFmpegioNoPipeAllowed` exception --- src/ffmpegio/errors.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ffmpegio/errors.py b/src/ffmpegio/errors.py index a20696b1..c1469985 100644 --- a/src/ffmpegio/errors.py +++ b/src/ffmpegio/errors.py @@ -5,6 +5,8 @@ class FFmpegioError(Exception): pass +class FFmpegioNoPipeAllowed(FFmpegioError): + pass ERROR_MESSAGES = ( # cmdutils.c::parse_optgroup() From df543b47faf566779eeaccf1a5c71bf7afc618a9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 5 Mar 2025 08:51:59 -0600 Subject: [PATCH 250/333] added `no_pipe` argument to url processors --- src/ffmpegio/configure.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 451e7c03..207ae3ec 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -46,7 +46,7 @@ parse_map_option, map_option as compose_map_option, ) -from .errors import FFmpegioError +from .errors import FFmpegioError, FFmpegioNoPipeAllowed from .threading import ReaderThread, WriterThread, CopyFileObjThread ################################# @@ -1168,6 +1168,7 @@ def process_url_inputs( args: FFmpegArgs, urls: list[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]], inopts_default: dict[str, Any], + no_pipe: bool = False, ) -> list[InputSourceDict]: """analyze and process heterogeneous input url argument @@ -1177,6 +1178,7 @@ def process_url_inputs( a pipe expression. :param urls: list of input urls/data or a pair of input url and its options :param inopts_default: default input options + :param no_pipe: True to raise exception if output is piped without data buffer, defaults to False :return: list of input information """ @@ -1216,6 +1218,12 @@ def process_url_inputs( # convert to buffer input_info = {"src_type": "buffer", "buffer": FFConcat.input} url = None + elif url == "pipe": + if no_pipe: + raise FFmpegioNoPipeAllowed("No input pipe allowed.") + input_info = {"src_type": "buffer"} + url = None + else: try: buffer = memoryview(url) @@ -1404,7 +1412,7 @@ def process_url_outputs( ], options: dict[str, Any], skip_automapping: bool = False, -) -> tuple[list[RawOutputInfoDict], dict[str, Any] | None]: + no_pipe: bool = False, """analyze and process url outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -1417,6 +1425,8 @@ def process_url_outputs( to the per-file `"map"` option in `streams` argument :param skip_automapping: True to skip automapping, uses the default mapping, defaults to False + :param no_pipe: True to raise exception if output is piped without data buffer, + defaults to False :return output_info: list of output information :return fg_info: dict of filtergraph outputs, keyed by their linklabels """ @@ -1441,6 +1451,8 @@ def process_url_outputs( output_info = {"dst_type": "fileobj", "fileobj": url} url = None elif url == "pipe": + if no_pipe: + raise FFmpegioNoPipeAllowed("No output pipe allowed.") # convert to buffer output_info = {"dst_type": "buffer"} url = None @@ -1710,7 +1722,10 @@ def init_media_write( gopts["filter_complex"] = [afilt] if extra_inputs is not None: - input_info.extend(process_url_inputs(args, extra_inputs, {})) + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed as e: + raise FFmpegioError("extra_inputs cannot be piped in.") # make sure all inputs are complete opt_names = {"audio": ("sample_fmt", "ac"), "video": ("pix_fmt", "s")} @@ -1790,7 +1805,10 @@ def init_media_filter( ) if extra_inputs is not None: - input_info.extend(process_url_inputs(args, extra_inputs, {})) + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed as e: + raise FFmpegioError("extra_inputs cannot be piped in.") # make sure all inputs are complete ready = utils.are_inputs_ready(args["inputs"], input_info) From 4777a0443c6fa90fe739245bdd3049b48a94e64b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 5 Mar 2025 09:01:41 -0600 Subject: [PATCH 251/333] renamed `configure.RawOutputInfoDict` to `_typing.OutputDestinationDict` --- src/ffmpegio/_typing.py | 21 ++++++++++++++++- src/ffmpegio/configure.py | 35 ++++++++-------------------- src/ffmpegio/streams/PipedStreams.py | 6 ++--- src/ffmpegio/utils/__init__.py | 7 ++---- 4 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 0e338c34..ef5e1839 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -9,8 +9,10 @@ from pathlib import Path from urllib.parse import ParseResult + if TYPE_CHECKING: - from .threading import WriterThread + from namedpipe import NPopen + from .threading import WriterThread, ReaderThread # from typing_extensions import * @@ -41,6 +43,7 @@ FFmpegUrlType = Union[str, Path, ParseResult] FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] +FFmpegOutputType = Literal["url", "fileobj", "buffer"] class InputSourceDict(TypedDict): @@ -51,3 +54,19 @@ class InputSourceDict(TypedDict): fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe writer: NotRequired[WriterThread] # pipe + + +class OutputDestinationDict(TypedDict): + """output source info""" + + dst_type: FFmpegOutputType # True if file path/url + user_map: str | None # user specified map option + media_type: MediaType | None # + input_file_id: NotRequired[int] + input_stream_id: NotRequired[int] + linklabel: NotRequired[str] + media_info: NotRequired[dict[str, Any]] + pipe: NotRequired[NPopen] + reader: NotRequired[ReaderThread] + itemsize: NotRequired[int] + nmin: NotRequired[int] diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 207ae3ec..7d94265c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -7,11 +7,11 @@ MediaType, FFmpegUrlType, Union, - NotRequired, TypedDict, IO, Buffer, InputSourceDict, + OutputDestinationDict, RawStreamDef, ) from collections.abc import Sequence @@ -54,8 +54,6 @@ UrlType = Literal["input", "output"] -FFmpegOutputType = Literal["url", "fileobj", "buffer"] - FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] @@ -75,20 +73,6 @@ class FFmpegArgs(TypedDict): global_options: dict # FFmpeg global options -class RawOutputInfoDict(TypedDict): - dst_type: FFmpegOutputType # True if file path/url - user_map: str | None # user specified map option - media_type: MediaType | None # - input_file_id: NotRequired[int] - input_stream_id: NotRequired[int] - linklabel: NotRequired[str] - media_info: NotRequired[dict[str, Any]] - pipe: NotRequired[NPopen] - reader: NotRequired[ReaderThread] - itemsize: NotRequired[int] - nmin: NotRequired[int] - - ################################# ## module functions @@ -922,7 +906,7 @@ def resolve_raw_output_streams( input_info: list[InputSourceDict], fg_info: dict[str, dict] | None, streams: dict[str, str | None], -) -> dict[str, RawOutputInfoDict]: +) -> dict[str, OutputDestinationDict]: """resolve the raw output streams from given sequence of map options :param args: FFmpeg argument dict @@ -1013,7 +997,7 @@ def parse_map(spec): def auto_map( args: FFmpegArgs, input_info: list[InputSourceDict], fg_info: dict[str, dict] | None -) -> dict[str, RawOutputInfoDict]: +) -> dict[str, OutputDestinationDict]: """list all available streams from all FFmpeg input sources :param args: FFmpeg argument dict. `filter_complex` argument may be modified. @@ -1247,7 +1231,7 @@ def process_raw_outputs( streams: Sequence[str] | dict[str, dict[str, Any] | None] | None, options: dict[str, Any], fg_info: dict[str, dict] | None = None, -) -> tuple[list[RawOutputInfoDict], dict[str, dict] | None]: +) -> tuple[list[OutputDestinationDict], dict[str, dict] | None]: """analyze and process piped raw outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -1274,7 +1258,7 @@ def process_raw_outputs( ) # resolve requested output streams - stream_info: dict[str, RawOutputInfoDict] + stream_info: dict[str, OutputDestinationDict] if streams is None or len(streams) == 0: stream_info = auto_map(args, input_info, fg_info) else: @@ -1413,6 +1397,7 @@ def process_url_outputs( options: dict[str, Any], skip_automapping: bool = False, no_pipe: bool = False, +) -> tuple[list[OutputDestinationDict], dict[str, Any] | None]: """analyze and process url outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -1569,7 +1554,7 @@ def init_media_read( ], map: Sequence[str] | dict[str, dict[str, Any] | None] | None, options: dict[str, Any], -) -> tuple[FFmpegArgs, list[InputSourceDict], list[RawOutputInfoDict]]: +) -> tuple[FFmpegArgs, list[InputSourceDict], list[OutputDestinationDict]]: """Initialize FFmpeg arguments for media read :param *urls: URLs of the media files to read. @@ -1635,7 +1620,7 @@ def init_media_write( options: dict[str, Any], dtypes: list[str] | None = None, shapes: list[tuple[int]] | None = None, -) -> tuple[FFmpegArgs, list[InputSourceDict], list[RawOutputInfoDict], list[bool]]: +) -> tuple[FFmpegArgs, list[InputSourceDict], list[OutputDestinationDict], list[bool]]: """write multiple streams to a url/file :param url: output url @@ -1829,7 +1814,7 @@ def init_media_filter_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], output_options: dict[str | None, dict[str, Any]], -) -> list[RawOutputInfoDict]: +) -> list[OutputDestinationDict]: """Initialize FFmpeg arguments for media read :param args: partial FFmpeg arguments (to be modified) @@ -1884,7 +1869,7 @@ def init_media_filter_outputs( def init_named_pipes( args: FFmpegArgs, input_info: list[InputSourceDict], - output_info: list[RawOutputInfoDict], + output_info: list[OutputDestinationDict], update_rate: float | None = None, queue_size: int | None = None, ) -> ExitStack | None: diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index a1f53161..34bfd178 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -14,7 +14,7 @@ FFmpegOutputUrlComposite, ) from ..filtergraph.abc import FilterGraphObject -from ..configure import RawOutputInfoDict +from ..configure import OutputDestinationDict import sys from time import time @@ -924,7 +924,7 @@ def readlog(self, n: int) -> str: def _write_stream( self, - info: RawOutputInfoDict, + info: OutputDestinationDict, stream_id: int, data: RawDataBlob, timeout: float | None, @@ -1003,7 +1003,7 @@ def write_stream( def _read_stream( self, - info: RawOutputInfoDict, + info: OutputDestinationDict, stream_id: int | str, n: int, timeout: float | None = None, diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 6cbb61a6..fc88864a 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -18,14 +18,11 @@ from .._utils import * from ..stream_spec import * from ..errors import FFmpegError, FFmpegioError -from .._typing import Any, MediaType, InputSourceDict, RawDataBlob, TYPE_CHECKING +from .._typing import Any, MediaType, InputSourceDict, RawDataBlob, OutputDestinationDict from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb from ..filtergraph.presets import temp_video_src, temp_audio_src -if TYPE_CHECKING: - from ..configure import RawOutputInfoDict - # TODO: auto-detect endianness # import sys # sys.byteorder @@ -872,7 +869,7 @@ def are_inputs_ready( def get_output_stream_id( - output_info: list[RawOutputInfoDict], stream: str | int + output_info: list[OutputDestinationDict], stream: str | int ) -> int: """get output stream id From e69c858c6529b6471791c1b3480530be8c2706a0 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Wed, 5 Mar 2025 12:34:56 -0600 Subject: [PATCH 252/333] `is_pipe()` includes "pipe" --- src/ffmpegio/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index c08ddba9..8165eff7 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -133,7 +133,7 @@ def is_url(value: Any, *, pipe_ok: bool = False) -> bool: def is_pipe(value: Any) -> bool: """True if FFmpeg pipe protocol string""" - return value == "-" or bool(re.match(r"pipe\:\d*", value)) + return value == "-" or bool(re.match(r"pipe(\:\d*)?", value)) def is_namedpipe( From 3e5e16105c43f8e856ec36bf6c99c6e0989d6c4b Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Wed, 5 Mar 2025 12:37:04 -0600 Subject: [PATCH 253/333] `process_url_xxx()` - check all possible pipe urls --- src/ffmpegio/configure.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7d94265c..e2c7b486 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1196,17 +1196,17 @@ def process_url_inputs( elif utils.is_fileobj(url, readable=True): input_info = {"src_type": "fileobj", "fileobj": url} url = None - elif utils.is_url(url, pipe_ok=False): + elif utils.is_pipe(url): + if no_pipe: + raise FFmpegioNoPipeAllowed("No input pipe allowed.") + input_info = {"src_type": "buffer"} + url = None + elif utils.is_url(url): input_info = {"src_type": "url"} elif isinstance(url, FFConcat): # convert to buffer input_info = {"src_type": "buffer", "buffer": FFConcat.input} url = None - elif url == "pipe": - if no_pipe: - raise FFmpegioNoPipeAllowed("No input pipe allowed.") - input_info = {"src_type": "buffer"} - url = None else: try: @@ -1435,13 +1435,13 @@ def process_url_outputs( if utils.is_fileobj(url, readable=True): output_info = {"dst_type": "fileobj", "fileobj": url} url = None - elif url == "pipe": + elif utils.is_pipe(url): if no_pipe: raise FFmpegioNoPipeAllowed("No output pipe allowed.") # convert to buffer output_info = {"dst_type": "buffer"} url = None - elif utils.is_url(url, pipe_ok=False): + elif utils.is_url(url): output_info = {"dst_type": "url"} else: raise TypeError("Unknown output {url}.") From 6cd18967d9b91c350fee0294592c5a796d48e51d Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Wed, 5 Mar 2025 12:38:31 -0600 Subject: [PATCH 254/333] `init_named_pipes()` - set `itemsize = 1` and `nmin = 1MB` if reading encoded data, --- src/ffmpegio/configure.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e2c7b486..aeb2194f 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1916,6 +1916,10 @@ def init_named_pipes( kws["itemsize"] = utils.get_samplesize(shape, dtype) if update_rate is not None: kws["nmin"] = round(rate / update_rate) or 1 + else: + # assume encoded output + kws["itemsize"] = 1 + kws["nmin"] = 2**20 reader = ReaderThread(pipe, **kws) else: raise FFmpegioError(f"{dst_type=} is an unknown output data type.") From 02bbf7cf22f03ca63540735773cace0fcf000285 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Wed, 5 Mar 2025 12:39:57 -0600 Subject: [PATCH 255/333] added `init_media_transcode()` --- src/ffmpegio/configure.py | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index aeb2194f..ca471a2c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1866,6 +1866,74 @@ def init_media_filter_outputs( return output_info +def init_media_transcode( + inputs: Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict[str, Any] | None] + ], + outputs: Sequence[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] + ], + extra_inputs: Sequence[str | tuple[str, dict]] | None, + extra_outputs: Sequence[str | tuple[str, dict]] | None, + options: dict[str, Any], +) -> tuple[FFmpegArgs, InputSourceDict, OutputDestinationDict]: + """initialize media transcoder + + :param input_options: FFmpeg input options of piped inputs + :param output_options: FFmpeg output options of piped outputs + :param extra_inputs: a list of extra inputs: their URLs and optional options + :param extra_outputs: a list of extra outputs: their URLs and optional options + :return ffmpeg_args: FFmpeg argument dict + :return input_info: input stream information + :return output_info: output stream information + """ + + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") + + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None + + input_info = process_url_inputs(args, inputs, inopts_default) + output_info = process_url_outputs( + args, input_info, outputs, options, skip_automapping=True + ) + + if extra_inputs is not None: + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed as e: + raise FFmpegioError("extra_inputs cannot be piped in.") + + if not len(input_info): + raise ValueError("At least one input must be given.") + + if extra_outputs is not None: + try: + output_info.extend( + process_url_outputs( + args, + input_info, + extra_outputs, + {}, + skip_automapping=True, + no_pipe=True, + ) + ) + except FFmpegioNoPipeAllowed: + raise FFmpegioError("extra_outputs cannot be piped out.") + + if not len(output_info): + raise ValueError("At least one output must be given.") + + return args, input_info, output_info + + def init_named_pipes( args: FFmpegArgs, input_info: list[InputSourceDict], From 7ed65aa362359f3e8e074e9d6703aa89c867aaae Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Wed, 5 Mar 2025 12:42:03 -0600 Subject: [PATCH 256/333] added `PipedMediaTranscoder` class (removed preliminary `Transcoder` class) --- src/ffmpegio/streams/PipedStreams.py | 470 ++++++++++++++++----------- src/ffmpegio/streams/__init__.py | 4 +- tests/test_pipedstreams.py | 25 ++ 3 files changed, 305 insertions(+), 194 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 34bfd178..5b16fe89 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -27,7 +27,7 @@ from ..errors import FFmpegError, FFmpegioError # fmt:off -__all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter"] +__all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] # fmt:on @@ -1188,260 +1188,346 @@ def wait(self, timeout: float | None = None) -> int | None: rc = None return rc -class Transcoder: - """Class to merge multiple media streams in memory - - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None - :param rate_in: input sample rate - :type rate_in: int, float, Fraction, str - :param shape_in: input single-sample array shape, defaults to None - :type shape_in: seq of ints, optional - :param dtype_in: input data type string, defaults to None - :type dtype_in: str, optional - :param rate: output sample rate, defaults to None (auto-detect) - :type rate: int, float, Fraction, str, optional - :param shape: output single-sample array shape, defaults to None - :type shape: seq of ints, optional - :param dtype: output data type string, defaults to None - :type dtype: str, optional - :param blocksize: read buffer block size in samples, defaults to None - :type blocksize: int, optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - - :type show_log: bool, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - - """ + +class PipedMediaTranscoder: + """Class to transcode encoded media streams""" def __init__( self, - *input_formats_or_opts: Sequence[str | dict | None], - nb_inputs: int | None = None, - output_url: str | None = None, - blocksize: int | None = None, + input_options: Sequence[dict[str, Any]], + output_options: Sequence[dict[str, Any]], + extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + extra_outputs: Sequence[str | tuple[str, dict]] | None = None, + *, default_timeout: float | None = None, - progress: Callable | None = None, + progress: ProgressCallable | None = None, show_log: bool | None = None, - sp_kwargs: dict | None = None, - np_kwargs: dict | None = None, - **output_options: dict[str, Any], - ) -> None: - - #:float|None: default filter operation timeout in seconds - self.default_timeout = default_timeout - - # set this to false in _finalize() if guaranteed for the logger to have output stream info - self._loggertimeout = True + blocksize: int | None = None, + queuesize: int | None = None, + sp_kwargs: dict = None, + **options: Unpack[dict], + ): + """Encoded media stream transcoder - nin = len(input_formats_or_opts) - if nb_inputs is None and not nin: - raise ValueError( - "At least one input format/options must be given OR specify nb_inputs." - ) - if nb_inputs is not None and nin > 0 and nb_inputs != nin: - raise ValueError( - "Both nb_inputs and input format/options are given but nb_inputs does not agree with the number of inputs specified." - ) + :param input_options: FFmpeg input option dicts of all the input pipes. Each dict + must contain the `"f"` option to specify the media format. + :param output_options: FFmpeg output option dicts of all the output pipes. Each dict + must contain the `"f"` option to specify the media format. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param extra_outputs: list of additional output destinations, defaults to None. Each destination + may be url string or a pair of a url string and an option dict. + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) + defaults to `None` to use 1 video frame or 1024 audio frames + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + """ - inopts = ( - [ - v if isinstance(v, dict) else {} if v is None else {"f": v} - for v in input_formats_or_opts - ] - if len(input_formats_or_opts) - else [{}] * nb_inputs + args, self._input_info, self._output_info = configure.init_media_transcode( + [("-", opts) for opts in input_options], + [("-", opts) for opts in output_options], + extra_inputs, + extra_outputs, + options, ) - nb_inputs = len(inopts) - self._input_pipes = inpipes = [ - NPopen("w", **(np_kwargs or {})) for _ in range(nb_inputs) - ] - - self._output_pipe = None - if output_url is None: - self._output_pipe = outpipe = NPopen("r", **(np_kwargs or {})) - output_url = outpipe.path + # create logger without assigning the source stream + self._logger = LoggerThread(None, show_log) - # create input format list - self._args = ffmpeg_args = configure.empty() - ffmpeg_args["inputs"].extend([(p.path, o) for p, o in zip(inpipes, inopts)]) - configure.add_url(ffmpeg_args, "output", output_url, output_options)[1][1] + # prepare FFmpeg keyword arguments + self._args = { + "ffmpeg_args": args, + "progress": progress, + "capture_log": True, + "sp_kwargs": sp_kwargs, + } + # set the default read block size for the referenc stream + self.default_timeout = default_timeout + self._blocksize = blocksize + self._pipe_kws = {"queue_size": queuesize} self._proc = None - # create the stdin writer without assigning the sink stream - self._writers = [WriterThread(p, 0) for p in inpipes] - - # create the stdout reader without assigning the source stream - self._reader = None - if self._output_pipe is not None: - self._reader = ReaderThread(self._output_pipe, blocksize, 0) + def __enter__(self): - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) + ffmpeg_args = self._args["ffmpeg_args"] - # FFmpeg Popen arguments - self._cfg = {**sp_kwargs} if sp_kwargs else {} - self._cfg.update( - { - "ffmpeg_args": ffmpeg_args, - "progress": progress, - "capture_log": True, - } + # set up and activate pipes and read/write threads + stack = configure.init_named_pipes( + ffmpeg_args, self._input_info, self._output_info, **self._pipe_kws ) - # start FFmpeg - self._proc = ffmpegprocess.Popen(**self._cfg) + # run the FFmpeg + try: + self._proc = ffmpegprocess.Popen( + **self._args, on_exit=lambda _: stack.close() + ) + except: + stack.close() + raise + # set the log source and start the logger self._logger.stderr = self._proc.stderr self._logger.start() - # start the writers - for writer in self._writers: - writer.start() + return self - self._reader.start() - self._cfg = False + def open(self): + """start FFmpeg processing - def close(self): - """Close the stream. + Note + ---- - This method has no effect if the stream is already closed. Once the - stream is closed, any read operation on the stream will raise a ThreadNotActive. + It may flag to defer starting the FFmpeg process if the input streams + are not fully specified and must wait to deduce them from the written + data. - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. """ + self.__enter__() - if self._proc is None: - return - - self._proc.stdout.close() - self._proc.stderr.close() + def __exit__(self, *exc_details): - # kill the process try: - self._proc.terminate() + if self._proc is not None and self._proc.poll() is None: + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + self._proc = None except: - pass + if not exc_details[0]: + exc_details = sys.exc_info() + finally: + try: + self._logger.join() + except RuntimeError: + pass - for p in self._input_pipes: - p.close() + def close(self): + """Kill FFmpeg process and close the streams.""" - try: - self._logger.join() - except: - # possibly close before opening the logger thread - pass - try: - self._reader.join() - except: - # possibly close before opening the reader thread - pass - try: - for writer in self._writers: - writer.join() - except: - # possibly close before opening the writer thread - pass + self.__exit__(None, None, None) @property def closed(self) -> bool: - """:bool: True if the stream is closed.""" + """True if the stream is closed.""" return self._proc.poll() is not None @property - def lasterror(self) -> Exception: - """:FFmpegError: Last error FFmpeg posted""" + def lasterror(self) -> FFmpegError: + """Last error FFmpeg posted""" if self._proc.poll(): return self._logger.Exception() else: return None - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def readlog(self, n: int | None = None) -> str: - """get FFmpeg log lines - - :param n: number of lines to return, defaults to None (every line logged) - :type n: int, optional - :return: string containing the requested logs - :rtype: str + def readlog(self, n: int) -> str: + """read FFmpeg log lines + :param n: number of lines to read + :return: logged messages """ if n is not None: self._logger.index(n) with self._logger._newline_mutex: return "\n".join(self._logger.logs or self._logger.logs[:n]) - def write(self, stream_id: int, stream_data: bytes, timeout: float | None = None): - """Run filter operation + def _write_encoded_stream( + self, + info: OutputDestinationDict, + data: bytes, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" - :param data: input data block - :param timeout: timeout for the operation in seconds, defaults to None + try: + info["writer"].write(data, timeout) + except: + raise FFmpegioError("Cannot write to a non-piped input.") - The input `data` array is expected to have the datatype specified by - Filter class' `dtype_in` property and the array shape to match Filter - class' `shape_in` property or with an additional dimension prepended. + def write_encoded_stream( + self, stream_id: int, data: bytes, timeout: float | None = None + ): + """write a raw media data to a specified stream - """ + :param stream_id: input stream index or label + :param data: media data bytes + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue + :return: currently available encoded data (bytes) if returning the encoded + data back to Python - timeout = timeout or self.default_timeout + Write the given NDArray object, data, and return the number + of bytes written (always equal to the number of data frames/samples, + since if the write fails an OSError will be raised). - timeout += time() + When in non-blocking mode, a BlockingIOError is raised if the data + needed to be written to the raw stream but it couldn’t accept all + the data without blocking. - writer = self._writers[stream_id] - try: - writer.write(stream_data, timeout - time()) - except BrokenPipeError as e: - # TODO check log for error in FFmpeg - raise e + The caller may release or mutate data after this method returns, + so the implementation should only access data during the method call. - def read(self, n: int = -1, timeout: float | None = None) -> bytes: + """ + # get input stream information try: - return self._reader.read(n, timeout) - except AttributeError as e: - if self._reader is None: - raise RuntimeError( - "read() not supported. FFmpeg is outputting directly to a file" - ) - raise + info = self._input_info[stream_id] + except IndexError: + raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") - def read_nowait(self, n: int = -1) -> bytes: + if timeout is None: + timeout = self.default_timeout - try: - return self._reader.read_nowait(n) - except AttributeError as e: - if self._reader is None: - raise RuntimeError( - "read_nowait() not supported. FFmpeg is outputting directly to a file" - ) - raise + self._write_encoded_stream(info, data, timeout) - def flush(self, timeout: float | None = None): - """Flush the write buffers of the stream if applicable. + def _read_encoded_stream( + self, + info: OutputDestinationDict, + n: int, + timeout: float | None = None, + ) -> bytes: + """read selected output stream (shared backend)""" + + return info["reader"].read(n, timeout) + + def read_encoded_stream( + self, stream_id: int, n: int, timeout: float | None = None + ) -> bytes: + """read selected output stream + + :param stream_id: stream index or label + :param n: number of bytes to read + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is read + from the buffer queue + :return: retrieved data + + Effect of mixing `n` and `timeout` + ---------------------------------- + + === ========= ========================================================================= + `n` `timeout` Behavior + === ========= ========================================================================= + 0 --- Immediately returns + >0 `None` Wait indefinitely until `n` bytes are retrieved + >0 `float` Retrieve as many bytes up to `n` before `timeout` seconds passes + <0 `None` Wait indefinitely until FFmpeg terminates + <0 `float` Retrieve as many bytes until `timeout` seconds passes + === ========= ========================================================================= + + """ + + if timeout is None: + timeout = self.default_timeout + + info = self._output_info + stream_id = utils.get_output_stream_id(info, stream_id) + return self._read_encoded_stream(info[stream_id], n, timeout) + + def write_encoded( + self, + data: Sequence[RawDataBlob] | dict[int, RawDataBlob], + timeout: float | None = None, + ) -> bytes | None: + """write data to all input streams + + :param data: media byte data keyed by stream index + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue - :param timeout: timeout duration in seconds, defaults to None - :type timeout: float, optional - :return: remaining output samples - :rtype: numpy.ndarray """ - timeout = timeout or self.default_timeout + it_data = data.items() if isinstance(data, dict) else enumerate(data) + + if timeout is None: + timeout = self.default_timeout + + if timeout is not None: + timeout += time() - # If no input, close stdin and read all remaining frames - y = self._reader.read_all(timeout) - for p in self._input_pipes: - p.close() - self._proc.wait() - y += self._reader.read_all(None) + info = self._input_info + for stream_id, stream_data in it_data: + self._write_encoded_stream( + info[stream_id], + stream_id, + stream_data, + None if timeout is None else timeout - time(), + ) + + def readall_encoded(self, timeout: float | None = None) -> dict[str, bytes]: + """Read available data from all output streams + + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until FFmpeg stops + :return: retrieved data keyed by output streams + + """ + + data = {} # output + + if timeout is None: + timeout = self.default_timeout + + if timeout is not None: + timeout += time() + + get_timeout = lambda: None if timeout is None else max(timeout - time(), 0) + + # retrieve all the other streams up to T seconds mark + for i, info in enumerate(self._output_info): + data[i] = self._read_encoded_stream(info, -1, get_timeout()) + + return data + + def wait(self, timeout: float | None = None) -> int | None: + """close the input pipes and wait for FFmpeg to exit + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait indefinitely + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to + catch this exception and retry the wait. + :return returncode: return returncode attribute + """ + + if self._proc: + + if timeout is not None: + timeout += time() + + # write the sentinel to each input queue + for info in self._input_info: + if "writer" in info: + info["writer"].write( + None, None if timeout is None else timeout - time() + ) + + # wait until the FFmpeg finishes the job + try: + rc = self._proc.wait(None if timeout is None else timeout - time()) + except TimeoutError: + raise + else: + self._proc = None + else: + rc = None + return rc diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index c461ee76..f17e285f 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -6,7 +6,7 @@ SimpleVideoFilter, SimpleAudioFilter, ) -from .PipedStreams import PipedMediaReader, PipedMediaWriter, PipedMediaFilter +from .PipedStreams import PipedMediaReader, PipedMediaWriter, PipedMediaFilter, PipedMediaTranscoder from .AviStreams import AviMediaReader # TODO multi-stream write @@ -15,6 +15,6 @@ # fmt: off __all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", - "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", + "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder", "AviMediaReader"] # fmt: on diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index 638fc4bb..28ec6c59 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -101,3 +101,28 @@ def test_PipedMediaFilter(): assert all(k in ("[out0]", "out1", "audio") for k in data) n = f.output_counts assert all(v["shape"][0] == n[k] for k, v in data.items()) + + +def test_PipedMediaTranscoder(): + url = "tests/assets/testmulti-1m.mp4" + + with streams.PipedMediaTranscoder( + [], + [{"f": "matroska", "codec": "copy", "to": 1}], + extra_inputs=[url], + show_log=False, + ) as f: + if f.wait(timeout=10): + raise f.lasterror + data = f.read_encoded_stream(0, -1, timeout=10) + + with streams.PipedMediaTranscoder( + [{"f": "matroska"}], + [{"f": "flac"}, {"f": "matroska", "codec": "copy"}], + show_log=False, + ) as f: + f.write_encoded_stream(0, data, timeout=10) + if f.wait(timeout=10): + raise f.lasterror + enc_data = f.readall_encoded(timeout=10) + assert len(enc_data) == 2 From f736564768bbda7abb977c4baedb52649c36399b Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Wed, 5 Mar 2025 12:42:23 -0600 Subject: [PATCH 257/333] removed unused imports --- src/ffmpegio/streams/PipedStreams.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 5b16fe89..a5a4f626 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -20,10 +20,8 @@ from time import time from fractions import Fraction -from namedpipe import NPopen - from .. import configure, ffmpegprocess, plugins, utils -from ..threading import LoggerThread, ReaderThread, WriterThread, NotEmpty +from ..threading import LoggerThread, NotEmpty from ..errors import FFmpegError, FFmpegioError # fmt:off From 942656acc9e8b4bd205527573d3435ab56dfe6d1 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 5 Mar 2025 20:27:42 -0600 Subject: [PATCH 258/333] added `FFmpegOptionDict` type --- src/ffmpegio/configure.py | 95 +++++++++++++++++----------- src/ffmpegio/media.py | 20 +++--- src/ffmpegio/streams/PipedStreams.py | 46 +++++++------- 3 files changed, 89 insertions(+), 72 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index ca471a2c..43fd142f 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -57,8 +57,13 @@ FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] -FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, dict] -FFmpegOutputOptionTuple = tuple[FFmpegUrlType, dict] +FFmpegOptionDict = dict[str, Any] +"""FFmpeg options with their values keyed by the option names without preceding dash. +For option flags (e.g., -y) without any value, use `None` or its alias `ffmpegio.FLAG`""" + + +FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, FFmpegOptionDict] +FFmpegOutputOptionTuple = tuple[FFmpegUrlType, FFmpegOptionDict] raw_formats = ("rawvideo", *(formats for _, formats in utils.audio_codecs.values())) @@ -81,8 +86,8 @@ def array_to_video_input( rate: int | float | Fraction | None = None, data: Any | None = None, pipe_id: str | None = None, - **opts, -) -> tuple[str, dict]: + **opts: Unpack[FFmpegOptionDict], +) -> tuple[str, FFmpegOptionDict]: """create an stdin input with video stream :param rate: input frame rate in frames/second @@ -105,7 +110,7 @@ def array_to_audio_input( rate: int | None = None, data: Any | None = None, pipe_id: str | None = None, - **opts: dict[str, Any], + **opts: Unpack[FFmpegOptionDict], ): """create an stdin input with audio stream @@ -124,7 +129,7 @@ def array_to_audio_input( ) -def empty(global_options: dict = None) -> FFmpegArgs: +def empty(global_options: FFmpegOptionDict | None = None) -> FFmpegArgs: """create empty ffmpeg arg dict :param global_options: global options, defaults to None @@ -193,7 +198,7 @@ def add_url( args: FFmpegArgs, type: Literal["input", "output"], url: FFmpegUrlType | None, - opts: dict[str, Any] | None = None, + opts: FFmpegOptionDict | None = None, update: bool = False, ) -> tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]: """add new or modify existing url to input or output list @@ -794,9 +799,13 @@ def config_input_fg(expr, args, kwargs): def add_urls( - ffmpeg_args: dict, + ffmpeg_args: FFmpegArgs, url_type: UrlType, - urls: str | tuple[str, dict | None] | Sequence[str | tuple[str, dict | None]], + urls: ( + str + | tuple[str, FFmpegOptionDict] + | Sequence[str | tuple[str, FFmpegOptionDict]] + ), *, update: bool = False, ) -> list[tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]]: @@ -1150,8 +1159,10 @@ def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: def process_url_inputs( args: FFmpegArgs, - urls: list[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]], - inopts_default: dict[str, Any], + urls: list[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + inopts_default: FFmpegOptionDict, no_pipe: bool = False, ) -> list[InputSourceDict]: """analyze and process heterogeneous input url argument @@ -1228,8 +1239,8 @@ def process_url_inputs( def process_raw_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], - streams: Sequence[str] | dict[str, dict[str, Any] | None] | None, - options: dict[str, Any], + streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + options: FFmpegOptionDict, fg_info: dict[str, dict] | None = None, ) -> tuple[list[OutputDestinationDict], dict[str, dict] | None]: """analyze and process piped raw outputs @@ -1315,7 +1326,7 @@ def process_raw_inputs( args: FFmpegArgs, stream_types: Sequence[Literal["a", "v"]], stream_args: Sequence[RawStreamDef], - inopts_default: dict[str, Any], + inopts_default: FFmpegOptionDict, dtypes: list[str] | None = None, shapes: list[tuple[int]] | None = None, ) -> list[InputSourceDict]: @@ -1392,12 +1403,12 @@ def process_url_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], urls: list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] ], - options: dict[str, Any], + options: FFmpegOptionDict, skip_automapping: bool = False, no_pipe: bool = False, -) -> tuple[list[OutputDestinationDict], dict[str, Any] | None]: +) -> tuple[list[OutputDestinationDict], FFmpegOptionDict | None]: """analyze and process url outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -1491,7 +1502,7 @@ def assign_output_url(args: FFmpegArgs, ofile: int, url: str): def retrieve_input_stream_ids( info: InputSourceDict, url: FFmpegUrlType | FilterGraphObject | None, - opts: dict, + opts: FFmpegOptionDict, stream_spec: str | StreamSpecDict | None = None, ) -> list[tuple[int, MediaType]]: """Retrieve ids and media types of streams in an input source @@ -1550,10 +1561,11 @@ def get_spec(info, opts): def init_media_read( urls: list[ - FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict[str, Any] | None] + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] ], - map: Sequence[str] | dict[str, dict[str, Any] | None] | None, - options: dict[str, Any], + map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + options: FFmpegOptionDict, ) -> tuple[FFmpegArgs, list[InputSourceDict], list[OutputDestinationDict]]: """Initialize FFmpeg arguments for media read @@ -1606,7 +1618,7 @@ def init_media_read( def init_media_write( urls: list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] ], stream_types: Sequence[Literal["a", "v"]], stream_args: Sequence[RawStreamDef], @@ -1615,7 +1627,10 @@ def init_media_write( merge_audio_sample_fmt: str | None, merge_audio_outpad: str | None, extra_inputs: ( - Sequence[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]] | None + Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ] + | None ), options: dict[str, Any], dtypes: list[str] | None = None, @@ -1740,13 +1755,21 @@ def init_media_filter( input_types: Sequence[Literal["a", "v"]], input_args: Sequence[RawStreamDef], extra_inputs: ( - Sequence[FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict]] | None + Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ] + | None ), input_dtypes: list[str] | None, input_shapes: list[tuple[int]] | None, - options: dict[str, Any], - output_options: dict[str, dict[str, Any]], -) -> tuple[FFmpegArgs, list[InputSourceDict], list[bool], dict[str | None, dict[str, Any]] | None]: + options: FFmpegOptionDict, + output_options: dict[str, FFmpegOptionDict], +) -> tuple[ + FFmpegArgs, + list[InputSourceDict], + list[bool], + dict[str | None, FFmpegOptionDict] | None, +]: """Prepare FFmpeg arguments for media read :param expr: complex filtergraph definition(s). @@ -1813,7 +1836,7 @@ def init_media_filter( def init_media_filter_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], - output_options: dict[str | None, dict[str, Any]], + output_options: dict[str | None, FFmpegOptionDict], ) -> list[OutputDestinationDict]: """Initialize FFmpeg arguments for media read @@ -1866,16 +1889,12 @@ def init_media_filter_outputs( return output_info -def init_media_transcode( - inputs: Sequence[ - FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, dict[str, Any] | None] - ], - outputs: Sequence[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict[str, Any]] - ], - extra_inputs: Sequence[str | tuple[str, dict]] | None, - extra_outputs: Sequence[str | tuple[str, dict]] | None, - options: dict[str, Any], +def init_media_transcoder( + input_options: Sequence[FFmpegOptionDict], + output_options: Sequence[FFmpegOptionDict], + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, + options: FFmpegOptionDict, ) -> tuple[FFmpegArgs, InputSourceDict, OutputDestinationDict]: """initialize media transcoder diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 0a5fa8f0..ad16987c 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -14,7 +14,7 @@ Unpack, FFmpegUrlType, ) -from .configure import FFmpegOutputUrlComposite, FFmpegInputUrlComposite +from .configure import FFmpegOutputUrlComposite, FFmpegInputUrlComposite, FFmpegOptionDict import contextlib from fractions import Fraction @@ -79,13 +79,13 @@ def _gather_outputs( def read( *urls: * tuple[ - FFmpegInputUrlComposite | tuple[FFmpegUrlType, dict[str, Any] | None] + FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict] ], - map: Sequence[str] | dict[str, dict[str, Any] | None] | None = None, + map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, sp_kwargs: dict | None = None, - **options: Unpack[dict[str, Any]], + **options: Unpack[FFmpegOptionDict], ) -> tuple[dict[str, Fraction | int], dict[str, RawDataBlob]]: """Read video and audio data from multiple media files @@ -157,7 +157,7 @@ def on_exit(rc): def write( urls: ( FFmpegOutputUrlComposite - | list[FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict]] + | list[FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict]] ), stream_types: Sequence[Literal["a", "v"]], *stream_args: * tuple[RawStreamDef, ...], @@ -168,9 +168,9 @@ def write( progress: ProgressCallable | None = None, overwrite: bool | None = None, show_log: bool | None = None, - extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, sp_kwargs: dict | None = None, - **options: Unpack[dict[str, Any]], + **options: Unpack[FFmpegOptionDict], ): """write multiple streams to a url/file @@ -263,12 +263,12 @@ def filter( expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], input_types: Sequence[Literal["a", "v"]], *input_args: * tuple[RawStreamDef, ...], - extra_inputs: Sequence[str | tuple[str, dict]] | None = None, - output_options: dict[str, dict[str, Any]] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + output_options: dict[str, FFmpegOptionDict] | None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, - **options: Unpack[dict[str, Any]], + **options: Unpack[FFmpegOptionDict], ) -> tuple[dict[str, Fraction | int], dict[str, RawDataBlob]]: """write multiple streams to a url/file diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index a5a4f626..2522a818 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -8,6 +8,7 @@ from collections.abc import Sequence from .._typing import Any, ProgressCallable, RawDataBlob, Literal from ..configure import ( + FFmpegOptionDict, FFmpegInputUrlComposite, FFmpegUrlType, MediaType, @@ -32,10 +33,8 @@ class PipedMediaReader: def __init__( self, - *urls: * tuple[ - FFmpegInputUrlComposite | tuple[FFmpegUrlType, dict[str, Any] | None] - ], - map: Sequence[str] | dict[str, dict[str, Any] | None] | None = None, + *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], + map: Sequence[str] | dict[str, FFmpegOptionDict] | None = None, ref_stream: int = 0, blocksize: int | None = None, default_timeout: float | None = None, @@ -43,7 +42,7 @@ def __init__( show_log: bool | None = None, queuesize: int | None = None, sp_kwargs: dict | None = None, - **options: Unpack[dict[str, Any]], + **options: Unpack[FFmpegOptionDict], ): """Read video and audio data from multiple media files @@ -275,23 +274,26 @@ def __init__( self, urls: ( FFmpegOutputUrlComposite - | list[FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, dict]] + | list[ + FFmpegOutputUrlComposite + | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ] ), stream_types: Sequence[Literal["a", "v"]], - *stream_rates_or_opts: * tuple[int | Fraction | dict, ...], + *stream_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], dtypes_in: list[str] | None = None, shapes_in: list[tuple[int]] | None = None, merge_audio_streams: bool | Sequence[int] = False, merge_audio_ar: int | None = None, merge_audio_sample_fmt: str | None = None, merge_audio_outpad: str | None = None, - extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, queuesize: int | None = None, sp_kwargs: dict | None = None, - **options: Unpack[dict[str, Any]], + **options: Unpack[FFmpegOptionDict], ): """Write video and audio data from multiple media streams to one or more files @@ -673,19 +675,19 @@ def __init__( self, expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], input_types: Sequence[Literal["a", "v"]], - *input_rates_or_opts: * tuple[int | Fraction | dict, ...], + *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], input_dtypes: list[str] | None = None, input_shapes: list[tuple[int]] | None = None, - extra_inputs: Sequence[str | tuple[str, dict]] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, ref_output: int = 0, - output_options: dict[str, dict[str, Any]] | None = None, + output_options: dict[str, FFmpegOptionDict] | None = None, default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, blocksize: int | None = None, queuesize: int | None = None, sp_kwargs: dict | None = None, - **options: Unpack[dict[str, Any]], + **options: Unpack[FFmpegOptionDict], ): """Filter audio/video data streams with FFmpeg filtergraphs @@ -1192,10 +1194,10 @@ class PipedMediaTranscoder: def __init__( self, - input_options: Sequence[dict[str, Any]], - output_options: Sequence[dict[str, Any]], - extra_inputs: Sequence[str | tuple[str, dict]] | None = None, - extra_outputs: Sequence[str | tuple[str, dict]] | None = None, + input_options: Sequence[FFmpegOptionDict], + output_options: Sequence[FFmpegOptionDict], + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, *, default_timeout: float | None = None, progress: ProgressCallable | None = None, @@ -1203,7 +1205,7 @@ def __init__( blocksize: int | None = None, queuesize: int | None = None, sp_kwargs: dict = None, - **options: Unpack[dict], + **options: Unpack[FFmpegOptionDict], ): """Encoded media stream transcoder @@ -1233,12 +1235,8 @@ def __init__( sequence will overwrite those specified here. """ - args, self._input_info, self._output_info = configure.init_media_transcode( - [("-", opts) for opts in input_options], - [("-", opts) for opts in output_options], - extra_inputs, - extra_outputs, - options, + args, self._input_info, self._output_info = configure.init_media_transcoder( + input_options, output_options, extra_inputs, extra_outputs, options ) # create logger without assigning the source stream From a48697dbc8e4bd07531146ed349682c778afa230 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 6 Mar 2025 21:02:22 -0600 Subject: [PATCH 259/333] renamed `are_inputs_ready()` to `are_input_pipes_ready()` and added an optional `must_probe` argument --- src/ffmpegio/configure.py | 2 +- src/ffmpegio/utils/__init__.py | 15 +++++++++---- tests/test_utils.py | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 43fd142f..7a4df20c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1819,7 +1819,7 @@ def init_media_filter( raise FFmpegioError("extra_inputs cannot be piped in.") # make sure all inputs are complete - ready = utils.are_inputs_ready(args["inputs"], input_info) + ready = utils.are_input_pipes_ready(args["inputs"], input_info) # add the default output options to output_options dict with None as the key output_options[None] = options diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index fc88864a..92e75ac0 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -832,13 +832,17 @@ def analyze_complex_filtergraphs( return filtergraphs, fg_info -def are_inputs_ready( - inputs: list[tuple[str, dict]], input_info: list[InputSourceDict] +def are_input_pipes_ready( + inputs: list[tuple[str, dict]], + input_info: list[InputSourceDict], + must_probe: bool = False, ) -> list[bool]: """Test if all the input information is provided for raw output initialization :param inputs: url-option pairs of input sources - :param input_info: input source information + :param input_info: input source information + :param must_probe: True to skip required option check and fail if piped in, + defaults to False :return: If i-th element is True, it indicates that the i-th input is ready What it checks @@ -862,7 +866,10 @@ def are_inputs_ready( ( info["src_type"] != "buffer" or "buffer" in info - or all(o in opts for o in required_options[info["media_type"]]) + or ( + not must_probe + and all(o in opts for o in required_options[info["media_type"]]) + ) ) for (_, opts), info in zip(inputs, input_info) ] diff --git a/tests/test_utils.py b/tests/test_utils.py index a962ec77..c893127c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -87,3 +87,42 @@ def test_get_output_stream_id(): utils.get_output_stream_id(info, 1) with pytest.raises(FFmpegioError): utils.get_output_stream_id(info, "in0") + + +@pytest.mark.parametrize( + "inputs,input_info,must_probe,ret", + [ + ([], [], False, []), + ([(None, {})], [{"src_type": "fileobj"}], False, [True]), + ([(None, {})], [{"src_type": "buffer", "buffer": b""}], False, [True]), + ([(None, {})], [{"src_type": "buffer", "buffer": b""}], True, [True]), + ([(None, {})], [{"src_type": "buffer"}], True, [False]), + ( + [(None, {"ac": 1, "sample_fmt": "flt"})], + [{"src_type": "buffer", "media_type": "audio"}], + False, + [True], + ), + ( + [(None, {"ac": 1, "sample_fmt": "flt"})], + [{"src_type": "buffer", "media_type": "audio"}], + True, + [False], + ), + ( + [(None, {})], + [{"src_type": "buffer", "media_type": "audio"}], + False, + [False], + ), + ( + [(None, {"s": (10,10), "pix_fmt": "gray"})], + [{"src_type": "buffer", "media_type": "video"}], + False, + [True], + ), + ], +) +def test_are_input_pipes_ready(inputs, input_info, must_probe, ret): + + assert utils.are_input_pipes_ready(inputs, input_info, must_probe) == ret From e9660ae3d904772f9ca505870d4b4a4fc6f6e2bb Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 6 Mar 2025 21:22:44 -0600 Subject: [PATCH 260/333] `InputSourceDict` added required `encoded` field --- src/ffmpegio/_typing.py | 1 + src/ffmpegio/configure.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index ef5e1839..8c5bc333 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -50,6 +50,7 @@ class InputSourceDict(TypedDict): """input source info""" src_type: FFmpegInputType # True if file path/url + encoded: bool # True if encoded stream, False if raw media stream buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7a4df20c..653a4a33 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1202,21 +1202,25 @@ def process_url_inputs( "input filtergraph must use the `'lavfi'` input format." ) - input_info = {"src_type": "filtergraph"} + input_info = {"src_type": "filtergraph", "encoded": True} elif utils.is_fileobj(url, readable=True): - input_info = {"src_type": "fileobj", "fileobj": url} + input_info = {"src_type": "fileobj", "encoded": True, "fileobj": url} url = None elif utils.is_pipe(url): if no_pipe: raise FFmpegioNoPipeAllowed("No input pipe allowed.") - input_info = {"src_type": "buffer"} + input_info = {"src_type": "buffer", "encoded": True} url = None elif utils.is_url(url): - input_info = {"src_type": "url"} + input_info = {"src_type": "url", "encoded": True} elif isinstance(url, FFConcat): # convert to buffer - input_info = {"src_type": "buffer", "buffer": FFConcat.input} + input_info = { + "src_type": "buffer", + "encoded": True, + "buffer": FFConcat.input, + } url = None else: @@ -1225,7 +1229,7 @@ def process_url_inputs( except TypeError as e: raise TypeError("Given input URL argument is not supported.") from e else: - input_info = {"src_type": "buffer", "buffer": buffer} + input_info = {"src_type": "buffer", "encoded": True, "buffer": buffer} url = None url_opts, input_info_list[i] = (url, opts), input_info @@ -1390,7 +1394,7 @@ def process_raw_inputs( {"f": "rawvideo", f"c:v": "rawvideo", "pix_fmt": pix_fmt, "s": s} ) - info = {"src_type": "buffer", "media_type": media_type} + info = {"src_type": "buffer", "encoded": False, "media_type": media_type} if data is not None: info["buffer"] = data add_url(args, "input", None, opts) From 5832f4eee6f8847921b8254ac4ab468c25164bdb Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 14:45:53 -0600 Subject: [PATCH 261/333] `is_pipe()` - fixed to handle non-str exception (returns False) --- src/ffmpegio/_utils.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 8165eff7..ce4a0acf 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -133,7 +133,10 @@ def is_url(value: Any, *, pipe_ok: bool = False) -> bool: def is_pipe(value: Any) -> bool: """True if FFmpeg pipe protocol string""" - return value == "-" or bool(re.match(r"pipe(\:\d*)?", value)) + try: + return value == "-" or bool(re.match(r"pipe(\:\d*)?", value)) + except: + return False def is_namedpipe( From 626bd1977212f1630b2f6420f3561f67e1e6d094 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 15:00:18 -0600 Subject: [PATCH 262/333] removed `encoded` field from `InputSourceDict` (unused) --- src/ffmpegio/_typing.py | 1 - src/ffmpegio/configure.py | 18 +++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 8c5bc333..ef5e1839 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -50,7 +50,6 @@ class InputSourceDict(TypedDict): """input source info""" src_type: FFmpegInputType # True if file path/url - encoded: bool # True if encoded stream, False if raw media stream buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 653a4a33..7a4df20c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1202,25 +1202,21 @@ def process_url_inputs( "input filtergraph must use the `'lavfi'` input format." ) - input_info = {"src_type": "filtergraph", "encoded": True} + input_info = {"src_type": "filtergraph"} elif utils.is_fileobj(url, readable=True): - input_info = {"src_type": "fileobj", "encoded": True, "fileobj": url} + input_info = {"src_type": "fileobj", "fileobj": url} url = None elif utils.is_pipe(url): if no_pipe: raise FFmpegioNoPipeAllowed("No input pipe allowed.") - input_info = {"src_type": "buffer", "encoded": True} + input_info = {"src_type": "buffer"} url = None elif utils.is_url(url): - input_info = {"src_type": "url", "encoded": True} + input_info = {"src_type": "url"} elif isinstance(url, FFConcat): # convert to buffer - input_info = { - "src_type": "buffer", - "encoded": True, - "buffer": FFConcat.input, - } + input_info = {"src_type": "buffer", "buffer": FFConcat.input} url = None else: @@ -1229,7 +1225,7 @@ def process_url_inputs( except TypeError as e: raise TypeError("Given input URL argument is not supported.") from e else: - input_info = {"src_type": "buffer", "encoded": True, "buffer": buffer} + input_info = {"src_type": "buffer", "buffer": buffer} url = None url_opts, input_info_list[i] = (url, opts), input_info @@ -1394,7 +1390,7 @@ def process_raw_inputs( {"f": "rawvideo", f"c:v": "rawvideo", "pix_fmt": pix_fmt, "s": s} ) - info = {"src_type": "buffer", "encoded": False, "media_type": media_type} + info = {"src_type": "buffer", "media_type": media_type} if data is not None: info["buffer"] = data add_url(args, "input", None, opts) From be67edded01181d7720dce4462748e564894face Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 15:01:13 -0600 Subject: [PATCH 263/333] added `qsize()`, `empty()`, & `full()` methods to `ReaderThread` and `WriterThread` --- src/ffmpegio/threading.py | 48 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 13b9f114..bf895950 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -468,6 +468,31 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: def read_all(self, timeout: float | None = None) -> bytes: return self.read(-1, timeout) + def qsize(self) -> int: + """Return the approximate size of the queue. + + Note, qsize() > 0 doesn't guarantee that a subsequent write() will not block, + nor will qsize() < maxsize guarantee that put() will not block. + """ + return self._queue.qsize() + + def empty(self) -> bool: + """Return True if the queue is empty, False otherwise. + + If empty() returns False it doesn't guarantee that a subsequent call to + read() will not block. + """ + return self._queue.empty() + + def full(self) -> bool: + """Return True if the queue is full, False otherwise. + + If full() returns True it doesn't guarantee that a subsequent call to + read() will not block. + + """ + return self._queue.full() + class WriterThread(Thread): """a thread to write byte data to a writable stream @@ -604,6 +629,29 @@ def flush(self, timeout: float | None = None): if not (self._no_more or self._empty or self._empty_cond.wait(timeout)): raise NotEmpty() + def qsize(self) -> int: + """Return the approximate size of the queue. + + Note, qsize() > 0 doesn't guarantee that a subsequent write() will not block + """ + return self._queue.qsize() + + def empty(self) -> bool: + """Return True if the write queue is empty, False otherwise. + + If empty() returns True it doesn't guarantee that a subsequent call to + write() will not block. + """ + return self._queue.empty() + + def full(self) -> bool: + """Return True if the queue is full, False otherwise. + + If full() returns False it doesn't guarantee that a subsequent call to + write() will not block. + """ + return self._queue.full() + class AviReaderThread(Thread): class InvalidAviStream(FFmpegError): ... From e395a108316caac4156cc792748a511fac3abd99 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 15:03:52 -0600 Subject: [PATCH 264/333] `process_url_inputs()` - requires `fileobj` inputs to be seekable --- src/ffmpegio/configure.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7a4df20c..0ebbd658 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1205,6 +1205,8 @@ def process_url_inputs( input_info = {"src_type": "filtergraph"} elif utils.is_fileobj(url, readable=True): + if not url.seekable(): + raise FFmpegioNoPipeAllowed("Fileobj input must be seekable.") input_info = {"src_type": "fileobj", "fileobj": url} url = None elif utils.is_pipe(url): From e1ff3e7d50aae85ce83425c696a67ae404adec1d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 15:12:46 -0600 Subject: [PATCH 265/333] `init_named_pipes()` - added `blocksize` optional argument --- src/ffmpegio/configure.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 0ebbd658..507fe7f9 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1960,6 +1960,7 @@ def init_named_pipes( input_info: list[InputSourceDict], output_info: list[OutputDestinationDict], update_rate: float | None = None, + blocksize: int | None = None, queue_size: int | None = None, ) -> ExitStack | None: """initialize named pipes for read & write operations with FFmpeg @@ -1967,7 +1968,9 @@ def init_named_pipes( :param args: FFmpeg option arguments (modified) :param input_info: FFmpeg input information, its length matches that of `args['inputs']` :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) - :param update_rate: target rate at which queue transactions will occur + :param update_rate: target rate at which queue transactions will occur for raw data output, + defaults to None (1 video frame or 1024 audio sample at a time) + :param blocksize: encoded data output block size in bytes, defaults to None (2**20 bytes) :returns: a list of indices of the FFmpeg outputs that are raw data streams In addition to the retured list, this function modifies the dicts in its arguements. @@ -2008,7 +2011,7 @@ def init_named_pipes( else: # assume encoded output kws["itemsize"] = 1 - kws["nmin"] = 2**20 + kws["nmin"] = blocksize or 2**16 reader = ReaderThread(pipe, **kws) else: raise FFmpegioError(f"{dst_type=} is an unknown output data type.") From c6a8e91f799e935bd58a53bb481d2d9b6453c3d4 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 18:57:26 -0600 Subject: [PATCH 266/333] consolidated `PipeStreams` functionality by refactoring the common `PipeStreams._PipedFFmpegRunner` class and its mixins refactored `configure.init_media_xxx_outputs()` from `configure.init_media_xxx()` --- src/ffmpegio/configure.py | 189 ++- src/ffmpegio/media.py | 15 +- src/ffmpegio/streams/PipedStreams.py | 1964 ++++++++++---------------- tests/test_pipedstreams.py | 13 +- 4 files changed, 958 insertions(+), 1223 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 507fe7f9..9d96c6be 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -13,6 +13,9 @@ InputSourceDict, OutputDestinationDict, RawStreamDef, + Unpack, + Callable, + RawDataBlob, ) from collections.abc import Sequence @@ -78,6 +81,34 @@ class FFmpegArgs(TypedDict): global_options: dict # FFmpeg global options +InitMediaOutputsCallable = Callable[ + [ + FFmpegArgs, + list[InputSourceDict], + Any, + list[list[RawDataBlob] | bytes], + ], + list[OutputDestinationDict], +] +"""function to finalize the media output initialization + + init_media_xxx_outputs(ffmpeg_args, input_info, output_options) + + Inputs: + + args - partial FFmpeg arguments (to be modified) + input_info - list of input information + output_args - output arguments + deferred_inputs - list of input data + + Outputs: + + output_info - list of output information + + The callback may return True to cancel the FFmpeg execution. +""" + + ################################# ## module functions @@ -1579,7 +1610,11 @@ def init_media_read( :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific input url or '_in' to be applied to all inputs. The url-specific option gets the preference (see :doc:`options` for custom options) - :return: frame/sampling rates and raw data for each requested stream + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return input_ready: Element is True if corresponding input is ready (known dtype and shape) + :return output_info: output stream information, None if outputs not initialized + :return output_options: output options, None if outputs already initialized Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run `ffmpegio.video.read()` for each url. @@ -1612,10 +1647,50 @@ def init_media_read( # analyze and assign inputs input_info = process_url_inputs(args, urls, inopts_default) + # make sure all inputs are complete + ready = utils.are_input_pipes_ready(args["inputs"], input_info, must_probe=True) + + # add the default output options to output_options dict with None as the key + output_options = (map, options) + + if all(ready): + output_info = init_media_read_outputs(args, input_info, output_options) + output_options = None + else: + output_info = None + + return args, input_info, ready, output_info, output_options + + +def init_media_read_outputs( + args: FFmpegArgs, + input_info: list[InputSourceDict], + output_options: tuple[ + Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + FFmpegOptionDict, + ], + deferred_inputs: list[bytes | None] = None, +) -> list[OutputDestinationDict]: + """Initialize FFmpeg arguments for media read + + :param args: partial FFmpeg arguments (to be modified) + :param input_info: list of input information + :param output_options: tuple of mapping assignments and common output options + :param deferred_inputs: deferred (partial) input data, probable to retrieve + necessary stream information + :return output_info: output file information + """ + + # if partial input bytes data given, load it up + if deferred_inputs is not None: + input_info = [ + {**info, "buffer": data} for info, data in zip(input_info, deferred_inputs) + ] + # analyze and assign outputs - output_info, fg_info = process_raw_outputs(args, input_info, map, options) + output_info, _ = process_raw_outputs(args, input_info, *output_options) - return args, input_info, output_info + return output_info def init_media_write( @@ -1637,7 +1712,13 @@ def init_media_write( options: dict[str, Any], dtypes: list[str] | None = None, shapes: list[tuple[int]] | None = None, -) -> tuple[FFmpegArgs, list[InputSourceDict], list[OutputDestinationDict], list[bool]]: +) -> tuple[ + FFmpegArgs, + list[InputSourceDict], + list[OutputDestinationDict] | None, + tuple | None, + list[bool], +]: """write multiple streams to a url/file :param url: output url @@ -1655,10 +1736,11 @@ def init_media_write( of input media streams, defaults to `None` (auto-detect). :param shapes: list of shapes of input samples or frames of input media streams, defaults to `None` (auto-detect). - :return ffmpeg_args: FFmpeg argument dict + :return ffmpeg_args: FFmpeg argument dict (partial) :return input_info: input stream information - :return output_info: output file information - :return not_ready: An elemtn is true if corresponding input is missing data format information + :return input_ready: Element is True if corresponding input is ready (known dtype and shape) + :return output_info: output stream information, None if outputs not initialized + :return output_options: output options, None if outputs already initialized TIPS ---- @@ -1680,14 +1762,72 @@ def init_media_write( # create a new FFmpeg dict args = empty(utils.pop_global_options(options)) - gopts = args["global_options"] # global options dict # analyze and assign inputs input_info = process_raw_inputs( args, stream_types, stream_args, inopts_default, dtypes, shapes ) - # map all input streams to output unless user specifies the mapping + # append extra (not-piped) inputs + if extra_inputs is not None: + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed as e: + raise FFmpegioError("extra_inputs cannot be piped in.") from e + + ready = utils.are_input_pipes_ready(args["inputs"], input_info) + + output_args = ( + urls, + options, + merge_audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad, + ) + + if all(ready): + output_info = init_media_write_outputs( + args, + input_info, + output_args, + ) + output_args = None + else: + output_info = None + + return args, input_info, ready, output_info, output_args + + +def init_media_write_outputs( + args: FFmpegArgs, + input_info: list[InputSourceDict], + output_args: tuple, + deferred_inputs: list[bytes | None] | None = None, +) -> list[OutputDestinationDict]: + """Initialize FFmpeg arguments for media read + + :param args: partial FFmpeg arguments (to be modified) + :param input_info: list of input information + :param output_args: output related init arguments + :param deferred_inputs: buffered raw data blocks, not used + :return output_info: output file information + + `args['inputs']` is expected to have all the necessary options of piped input + (see `PipedStreams.PipedRawInputMixin._write_stream`) + + """ + + ( + urls, + options, + merge_audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad, + ) = output_args + + # if `merge_audio_streams` is non-`None`, append audio-merge filtergraph a_ids = [i for i, info in enumerate(input_info) if info["media_type"] == "audio"] do_merge = bool(merge_audio_streams) and len(a_ids) > 1 if do_merge: @@ -1715,32 +1855,18 @@ def init_media_write( merge_audio_outpad or "aout", ) + gopts = args["global_options"] if "filter_complex" in gopts: # prepare complex filter output gopts["filter_complex"] = utils.as_multi_option( gopts["filter_complex"], (str, FilterGraphObject) - ).append(afilt) + ) + gopts["filter_complex"].append(afilt) else: gopts["filter_complex"] = [afilt] - if extra_inputs is not None: - try: - input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) - except FFmpegioNoPipeAllowed as e: - raise FFmpegioError("extra_inputs cannot be piped in.") - - # make sure all inputs are complete - opt_names = {"audio": ("sample_fmt", "ac"), "video": ("pix_fmt", "s")} - not_ready = [False] * len(input_info) - for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)): - if url is None and info["src_type"] == "buffer": - if not all(o in opts for o in opt_names[info["media_type"]]): - not_ready[i] = True - # analyze and assign outputs - output_info = process_url_outputs( - args, input_info, urls, options, skip_automapping=any(not_ready) - ) + output_info = process_url_outputs(args, input_info, urls, options) # if output is piped, it must have the -f option specified for url, opts in args["outputs"]: @@ -1749,7 +1875,7 @@ def init_media_write( 'all piped encoded output stream must have its format (`"f"`) defined in its option dict' ) - return args, input_info, output_info, not_ready + return output_info def init_media_filter( @@ -1770,6 +1896,7 @@ def init_media_filter( FFmpegArgs, list[InputSourceDict], list[bool], + list[OutputDestinationDict] | None, dict[str | None, FFmpegOptionDict] | None, ]: """Prepare FFmpeg arguments for media read @@ -1839,12 +1966,14 @@ def init_media_filter_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], output_options: dict[str | None, FFmpegOptionDict], + deferred_inputs: list[list[RawDataBlob | None] | bytes] | None = None, ) -> list[OutputDestinationDict]: """Initialize FFmpeg arguments for media read :param args: partial FFmpeg arguments (to be modified) :param input_info: list of input information :param output_options: default and specific output options + :param deferred_inputs: deferred_inputs- list of input data :return output_info: output file information """ @@ -1892,8 +2021,8 @@ def init_media_filter_outputs( def init_media_transcoder( - input_options: Sequence[FFmpegOptionDict], - output_options: Sequence[FFmpegOptionDict], + inputs: Sequence[FFmpegInputOptionTuple], + outputs: Sequence[FFmpegOutputOptionTuple], extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, options: FFmpegOptionDict, diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index ad16987c..4c15cf06 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -115,7 +115,13 @@ def read( """ # initialize FFmpeg argument dict and get input & output information - args, input_info, output_info = configure.init_media_read(urls, map, options) + args, input_info, input_ready, output_info, _ = configure.init_media_read( + urls, map, options + ) + + # if any input buffer is empty, invalid + if not all(input_ready): + raise FFmpegioError("Not all inputs are resolved.") # True if there is unknown datablob info need_stderr = any(info["media_info"] is None for info in output_info) @@ -206,7 +212,7 @@ def write( if not isinstance(urls, list): urls = [urls] - args, input_info, output_info, _ = configure.init_media_write( + args, input_info, input_ready, output_info, _ = configure.init_media_write( urls, stream_types, stream_args, @@ -218,8 +224,9 @@ def write( options, ) - if output_info is None: - raise FFmpegioError("failed to format output...") + # if any input buffer is empty, invalid + if not all(input_ready): + raise FFmpegioError("Invalid input data.") # configure named pipes stack = configure.init_named_pipes(args, input_info, output_info) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 2522a818..8f5f90b6 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -6,23 +6,32 @@ from typing_extensions import Unpack from collections.abc import Sequence -from .._typing import Any, ProgressCallable, RawDataBlob, Literal +from .._typing import ( + ProgressCallable, + RawDataBlob, + Literal, + InputSourceDict, + OutputDestinationDict, +) from ..configure import ( + FFmpegArgs, FFmpegOptionDict, FFmpegInputUrlComposite, FFmpegUrlType, MediaType, FFmpegOutputUrlComposite, + InitMediaOutputsCallable, ) from ..filtergraph.abc import FilterGraphObject from ..configure import OutputDestinationDict +from contextlib import ExitStack import sys from time import time from fractions import Fraction -from .. import configure, ffmpegprocess, plugins, utils -from ..threading import LoggerThread, NotEmpty +from .. import configure, ffmpegprocess, plugins, utils, probe +from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError # fmt:off @@ -30,93 +39,124 @@ # fmt:on -class PipedMediaReader: +class _PipedFFmpegRunner: + """Base class to run FFmpeg and manage its multiple I/O's""" + def __init__( self, - *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], - map: Sequence[str] | dict[str, FFmpegOptionDict] | None = None, - ref_stream: int = 0, - blocksize: int | None = None, + ffmpeg_args: FFmpegArgs, + input_info: list[InputSourceDict], + output_info: list[OutputDestinationDict] | None, + input_ready: True | list[bool] | None, + init_deferred_outputs: InitMediaOutputsCallable | None, + deferred_output_args: list[FFmpegOptionDict | None], + *, default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, queuesize: int | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], + sp_kwargs: dict = None, ): - """Read video and audio data from multiple media files + """Encoded media stream transcoder - :param *urls: URLs of the media files to read or a tuple of the URL and its input option dict. - :param map: FFmpeg map options - :param ref_stream: index of the reference stream to pace read operation, defaults to 0. The - reference stream is guaranteed to have a frame data on every read operation. + :param ffmpeg_args: (Mostly) populated FFmpeg argument dict + :param input_info: FFmpeg output option dicts of all the output pipes. Each dict + must contain the `"f"` option to specify the media format. + :param output_info: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param input_ready: indicates if input is ready (True) or need its first batch of data to + provide necessary information for the outputs + :param init_deferred_outputs: function to initialize the outputs which have been deferred to + configure until the first batch of input data is in + :param deferred_output_args: :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - - Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run - `ffmpegio.video.read()` for each url. - - Specify the streams to return by `map` output option: - - map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. - - For audio streams, if 'sample_fmt' output option is not specified, 's16'. + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. """ - # initialize FFmpeg argument dict and get input & output information - args, self._input_info, self._output_info = configure.init_media_read( - urls, map, {"probesize_in": 32, **options} - ) + self._input_info = input_info + self._output_info = output_info + self._input_ready = input_ready + self._init_deferred_outputs = init_deferred_outputs + self._deferred_output_options = deferred_output_args + self._deferred_data = [] + + if input_ready is None or all(input_ready): + # all good to go + self._input_ready = True # create logger without assigning the source stream self._logger = LoggerThread(None, show_log) # prepare FFmpeg keyword arguments self._args = { - "ffmpeg_args": args, + "ffmpeg_args": ffmpeg_args, "progress": progress, "capture_log": True, "sp_kwargs": sp_kwargs, } # set the default read block size for the referenc stream - info = self._output_info[ref_stream] - if blocksize is None: - blocksize = 1 if info["media_type"] == "video" else 1024 - self._blocksize = blocksize self.default_timeout = default_timeout - self._ref = ref_stream - self._rates = [v["media_info"][2] for v in self._output_info] - self._n0 = [0] * len(self._output_info) # timestamps of the last read sample - self._pipe_kws = { - "queue_size": queuesize, - "update_rate": self._rates[self._ref] / Fraction(blocksize), - } - - hook = plugins.get_hook() - self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} - self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} + self._pipe_kws = {"queue_size": queuesize} + self._proc = None def __enter__(self): - # set up and activate pipes and read/write threads - stack = configure.init_named_pipes( + self.open() + return self + + def open(self): + """start FFmpeg processing + + Note + ---- + + It may flag to defer starting the FFmpeg process if the input streams + are not fully specified and must wait to deduce them from the written + data. + + """ + + if self._input_ready is True: + self._open(False) + + def _init_named_pipes(self) -> ExitStack: + + return configure.init_named_pipes( self._args["ffmpeg_args"], self._input_info, self._output_info, **self._pipe_kws, ) - self._n0 = [0] * len(self._output_info) # timestamps of the last read sample + def _write_deferred_data(self): + pass + + def _open(self, deferred: bool): + + if deferred: + # finalize the output configurations + self._output_info = self._init_deferred_outputs( + self._args["ffmpeg_args"], + self._input_info, + self._deferred_output_options, + self._deferred_data, + ) + + # set up and activate pipes and read/write threads + stack = self._init_named_pipes() # run the FFmpeg try: @@ -131,61 +171,34 @@ def __enter__(self): self._logger.stderr = self._proc.stderr self._logger.start() - # wait until all the reader threads are running - for info in self._output_info: - info["reader"].wait_till_running() + # if any pending data, queue them + if deferred: + self._write_deferred_data() return self - def open(self): - self.__enter__() + def close(self): + """Kill FFmpeg process and close the streams""" - def __exit__(self, *exc_details): + if self._proc is not None and self._proc.poll() is None: + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + self._proc = None + + def __exit__(self, *exc_details) -> bool: try: - if self._proc is not None and self._proc.poll() is None: - # kill the ffmpeg runtime - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - self._proc = None + self.close() + return False except: if not exc_details[0]: exc_details = sys.exc_info() finally: - self._logger.join() - - def close(self): - """Flush and close this stream. This method has no effect if the stream is already - closed. Once the stream is closed, any read operation on the stream will raise - a ValueError. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - - """ - - self.__exit__(None, None, None) - - def specs(self) -> list[str]: - """list of specifiers of the streams""" - - return [v["user_map"] for v in self._output_info] - - def types(self) -> dict[str, MediaType]: - """media type associated with the streams (key)""" - return {v["user_map"]: v["media_type"] for v in self._output_info} - - def rates(self) -> dict[str, int | Fraction]: - """sample or frame rates associated with the streams (key)""" - return {v["user_map"]: v["media_info"][2] for v in self._output_info} - - def dtypes(self) -> dict[str, str]: - """frame/sample data type associated with the streams (key)""" - return {v["user_map"]: v["media_info"][0] for v in self._output_info} - - def shapes(self) -> dict[str, tuple[int]]: - """frame/sample shape associated with the streams (key)""" - return {v["user_map"]: v["media_info"][1] for v in self._output_info} + try: + self._logger.join() + except RuntimeError: + pass @property def closed(self) -> bool: @@ -200,322 +213,253 @@ def lasterror(self) -> FFmpegError: else: return None - def __iter__(self): - return self - - def __next__(self): - F = self.read(self._blocksize, None) - if not any( - len(self._get_bytes[info["media_type"]](obj=f)) - for f, info in zip(F.values(), self._output_info) - ): - raise StopIteration - return F + def readlog(self, n: int) -> str: + """read FFmpeg log lines - def readlog(self, n: int = None) -> str: + :param n: number of lines to read + :return: logged messages + """ if n is not None: self._logger.index(n) with self._logger._newline_mutex: return "\n".join(self._logger.logs or self._logger.logs[:n]) - def read(self, n: int = -1, timeout: float | None = None) -> dict[str, RawDataBlob]: - """Read and return numpy.ndarray with up to n frames/samples. If - the argument is omitted or negative, data is read and returned until - EOF is reached. An empty bytes object is returned if the stream is - already at EOF. - - If the argument is positive, and the underlying raw stream is not - interactive, multiple raw reads may be issued to satisfy the byte - count (unless EOF is reached first). But for interactive raw streams, - at most one raw read will be issued, and a short result does not - imply that EOF is imminent. + def wait(self, timeout: float | None = None) -> int | None: + """close all input pipes and wait for FFmpeg to exit - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait indefinitely + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to + catch this exception and retry the wait. + :return returncode: return subprocess Popen returncode attribute + """ if timeout is None: timeout = self.default_timeout - # compute the number of frames to read per stream - if self._n0 and n > 0: - T = n / self._rates[self._ref] # duration + if self._proc: - n1 = [(T * r) + n0 for r, n0 in zip(self._rates, self._n0)] - nread = [int(n1 - n0) for n0, n1 in zip(self._n0, n1)] - self._n0 = n1 - else: - nread = [n] * len(self._output_info) - self._n0 = None - - data = {} - for info, nr in zip(self._output_info, nread): - - converter = self._converters[info["media_type"]] - dtype, shape, _ = info["media_info"] - data[info["user_map"]] = converter( - b=info["reader"].read(nr, timeout) if nr else b"", - dtype=dtype, - shape=shape, - squeeze=False, - ) + if timeout is not None: + timeout += time() - return data + # write the sentinel to each input queue + for info in self._input_info: + if "writer" in info: + info["writer"].write( + None, None if timeout is None else timeout - time() + ) + + # wait until the FFmpeg finishes the job + try: + self._proc.wait(None if timeout is None else timeout - time()) + except TimeoutError: + raise + else: + rc = self._proc.returncode + if rc is not None: + self._proc = None + else: + rc = None + return rc -class PipedMediaWriter: +class _RawInputMixin: + _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} _array_to_opts = { "video": utils.array_to_video_options, "audio": utils.array_to_audio_options, } - _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} - def __init__( + def __init__(self, **kwargs): + super().__init__(**kwargs) + hook = plugins.get_hook() + self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} + + # input data must be initially buffered + self._deferred_data = [[] for _ in range(len(self._input_info))] + + def _write_deferred_data(self): + for src, info in zip(self._deferred_data, self._input_info): + if "writer" in info and len(src): + writer = info["writer"] + media_type = info["media_type"] + for data in src: + writer.write( + self._get_bytes[media_type](obj=data), self.default_timeout + ) + self._deferred_data = [] + self._input_ready = True + + def _write_stream( self, - urls: ( - FFmpegOutputUrlComposite - | list[ - FFmpegOutputUrlComposite - | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ] - ), - stream_types: Sequence[Literal["a", "v"]], - *stream_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], - dtypes_in: list[str] | None = None, - shapes_in: list[tuple[int]] | None = None, - merge_audio_streams: bool | Sequence[int] = False, - merge_audio_ar: int | None = None, - merge_audio_sample_fmt: str | None = None, - merge_audio_outpad: str | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - default_timeout: float | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - queuesize: int | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], + info: OutputDestinationDict, + stream_id: int, + data: RawDataBlob, + timeout: float | None, ): - """Write video and audio data from multiple media streams to one or more files + """write a raw media data to a specified stream (backend)""" - :param url: output url - :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) - :param stream_rates_or_opts: either sample rate (audio) or frame rate (video) - or a dict of input options. The option dict must - include `'ar'` (audio) or `'r'` (video) to specify - the rate. - :param dtypes_in: list of numpy-style data type strings of input samples - or frames of input media streams, defaults to `None` - (auto-detect). - :param shapes_in: list of shapes of input samples or frames of input media - streams, defaults to `None` (auto-detect). - :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's - (indices of `stream_types`) to combine only specified streams. - :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream - :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - """ + media_type = info["media_type"] + b = self._get_bytes[media_type](obj=data) + if not len(b): + return - if not isinstance(urls, list): - urls = [urls] + if (self._input_ready or self._input_ready[stream_id]) is not True: + # need to collect input data type and shape from the actual data + # before starting the FFmpeg - stream_args = [ - (None, v) if isinstance(v, dict) else (v, None) - for v in stream_rates_or_opts - ] - args, self._input_info, self._output_info, self._deferred_open = ( - configure.init_media_write( - urls, - stream_types, - stream_args, - merge_audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad, - extra_inputs, - {"probesize_in": 32, **options}, - dtypes_in, - shapes_in, - ) - ) + info = self._input_info[stream_id] + opts = self._args["ffmpeg_args"]["inputs"][stream_id][1] - if any(self._deferred_open): - # temporary storage - self._deferred_data = [[] for _ in range(len(self._deferred_open))] - else: - # no need for deferral - self._deferred_open = False - self._deferred_data = None + opts.update(self._array_to_opts[info["media_type"]](data)) - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) + self._deferred_data[stream_id].append(b) + self._input_ready[stream_id] = True - # prepare FFmpeg keyword arguments - self._args = { - "ffmpeg_args": args, - "progress": progress, - "capture_log": True, - "sp_kwargs": sp_kwargs, - } + if all(self._input_ready): + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) - # set the default read block size for the referenc stream - self.default_timeout = default_timeout - self._pipe_kws = {"queue_size": queuesize} + else: - hook = plugins.get_hook() - self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} - self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} + logger.debug("[writer main] writing...") - self._proc = None - self._piped_outputs = None + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (BrokenPipeError, OSError): + self._logger.join_and_raise() - def _open(self, deferred: bool): + def write_stream( + self, stream_id: int, data: RawDataBlob, timeout: float | None = None + ): + """write a raw media data to a specified stream - if deferred: - ffmpeg_args = self._args["ffmpeg_args"] - outputs = ffmpeg_args["outputs"] - if not any("map" in url_opts[1] for url_opts in outputs): - # some output file is missing `map` option - # add all input streams or all complex filter outputs - input_info = self._input_info - map_opts = [*configure.auto_map(ffmpeg_args, input_info, None)] - - # add outputs to FFmpeg arguments - for _, opts in outputs: - if "map" not in opts: - opts["map"] = map_opts + :param stream_id: input stream index or label + :param data: media data blob (depends on the active data conversion plugin) + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue + :return: currently available encoded data (bytes) if returning the encoded + data back to Python - # set up and activate pipes and read/write threads - stack = configure.init_named_pipes( - self._args["ffmpeg_args"], - self._input_info, - self._output_info, - **self._pipe_kws, - ) + Write the given NDArray object, data, and return the number + of bytes written (always equal to the number of data frames/samples, + since if the write fails an OSError will be raised). - # run the FFmpeg - try: - self._proc = ffmpegprocess.Popen( - **self._args, on_exit=lambda _: stack.close() - ) - except: - stack.close() - raise + When in non-blocking mode, a BlockingIOError is raised if the data + needed to be written to the raw stream but it couldn’t accept all + the data without blocking. - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() + The caller may release or mutate data after this method returns, + so the implementation should only access data during the method call. - # if any pending data, queue them - for src, info in zip(self._deferred_data, self._input_info): - if "writer" in info and len(src): - writer = info["writer"] - for data in src: - writer.write(data) - self._deferred_data = [] + """ - self._piped_outputs = [ - info["reader"] - for info in self._output_info - if "reader" in info and info["dst_type"] == "buffer" - ] + # get input stream information + try: + info = self._input_info[stream_id] + except IndexError: + raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") - # wait until all the reader threads are running - # for info in self._output_info: - # if "reader" in info: - # info["reader"].wait_till_running() + if timeout is None: + timeout = self.default_timeout - self._deferred_open = False + self._write_stream(info, stream_id, data, timeout) - return self + def write( + self, + data: Sequence[RawDataBlob] | dict[int, RawDataBlob], + timeout: float | None = None, + ) -> bytes | None: + """write data to all input streams - def __enter__(self): + :param data: media data blob keyed by stream index + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue - if self._deferred_open is False: - self._open(False) + """ - return self + it_data = data.items() if isinstance(data, dict) else enumerate(data) - def open(self): - self.__enter__() + if timeout is None: + timeout = self.default_timeout - def __exit__(self, *exc_details): + if timeout is not None: + timeout += time() - try: - if self._proc is not None and self._proc.poll() is None: - # kill the ffmpeg runtime - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - self._proc = None - except: - if not exc_details[0]: - exc_details = sys.exc_info() - finally: - self._logger.join() + info = self._input_info + for stream_id, stream_data in it_data: + self._write_stream( + info[stream_id], + stream_id, + stream_data, + None if timeout is None else timeout - time(), + ) - def close(self): - """Flush and close this stream. This method has no effect if the stream is already - closed. Once the stream is closed, any read operation on the stream will raise - a ValueError. - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. +class _EncodedInputMixin: - """ + def __init__(self, **kwargs): - self.__exit__(None, None, None) + super().__init__(**kwargs) - def types(self) -> dict[str, MediaType]: - """media type associated with the streams (key)""" - return {v["user_map"]: v["media_type"] for v in self._output_info} + def _write_deferred_data(self): + for data, info in zip(self._deferred_data, self._input_info): + if len(data) and "writer" in info: + info["writer"].write(data, self.default_timeout) + self._deferred_data = [] + self._input_ready = True - def rates(self) -> dict[str, int | Fraction]: - """sample or frame rates associated with the streams (key)""" - return {v["user_map"]: v["media_info"][2] for v in self._output_info} + def _write_encoded_stream( + self, + index: int, + info: OutputDestinationDict, + data: bytes, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" - def dtypes(self) -> dict[str, str]: - """frame/sample data type associated with the streams (key)""" - return {v["user_map"]: v["media_info"][0] for v in self._output_info} + if (self._input_ready or self._input_ready[index]) is not True: + # buffer must be contiguous + data0 = self._deferred_data[index] + if len(data0): + data = data0.append(data) + else: + self._deferred_data[index] = data - def shapes(self) -> dict[str, tuple[int]]: - """frame/sample shape associated with the streams (key)""" - return {v["user_map"]: v["media_info"][1] for v in self._output_info} + # need to be able to probe the input streams before starting the FFmpeg + try: + probe.format_basic(data) + except FFmpegError: + pass # not ready yet + else: + self._input_ready[index] = True - @property - def closed(self) -> bool: - """True if the stream is closed.""" - return self._proc.poll() is not None + if all(self._input_ready): + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) - @property - def lasterror(self) -> FFmpegError: - """Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() else: - return None - def readlog(self, n: int = None) -> str: - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) + try: + info["writer"].write(data, timeout) + except: + raise FFmpegioError("Cannot write to a non-piped input.") - def write( - self, stream_id: int, data: RawDataBlob, timeout: float | None = None - ) -> bytes | None: + def write_encoded_stream( + self, stream_id: int, data: bytes, timeout: float | None = None + ): """write a raw media data to a specified stream - :param stream_id: input stream index - :param data: media data blob (depends on the active data conversion plugin) + :param stream_id: input stream index or label + :param data: media data bytes :param timeout: timeout in seconds or defaults to `None` to use the `default_timeout` property. If `default_timeout` is `None` then the operation will block until all the data is written @@ -523,7 +467,7 @@ def write( :return: currently available encoded data (bytes) if returning the encoded data back to Python - Write the given numpy.ndarray object, data, and return the number + Write the given NDArray object, data, and return the number of bytes written (always equal to the number of data frames/samples, since if the write fails an OSError will be raised). @@ -537,499 +481,263 @@ def write( """ # get input stream information - info = self._input_info[stream_id] - media_type = info["media_type"] - b = getattr(plugins.get_hook(), self._media_bytes[media_type])(obj=data) + try: + info = self._input_info[stream_id] + except IndexError: + raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") - if self._deferred_open is not False: - # need to collect input data type and shape from the actual data - # before starting the FFmpeg - if self._deferred_open[stream_id]: - # first frame of the input stream with missing information - # update the - input_args = self._args["ffmpeg_args"]["inputs"][stream_id] - self._args["ffmpeg_args"]["inputs"][stream_id] = ( - input_args[0], - {**input_args[1], **self._array_to_opts[media_type](data)}, - ) - self._deferred_open[stream_id] = False + if timeout is None: + timeout = self.default_timeout - self._deferred_data[stream_id].append(b) + self._write_encoded_stream(stream_id, info, data, timeout) - if not any(self._deferred_open): - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) + def write_encoded( + self, + data: Sequence[RawDataBlob] | dict[int, RawDataBlob], + timeout: float | None = None, + ) -> bytes | None: + """write data to all input streams - else: + :param data: media byte data keyed by stream index + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue - logger.debug("[writer main] writing...") + """ - if timeout is None: - timeout = self.default_timeout + it_data = data.items() if isinstance(data, dict) else enumerate(data) - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() + if timeout is None: + timeout = self.default_timeout - def flush(self, timeout: float | None = None): - """block until the write buffers are emptied. + if timeout is not None: + timeout += time() - :param timeout: a timeout for blocking in seconds, or fractions - thereof, defaults to None, to wait until empty - :raise `NotEmpty`: if a timeout is set, and the buffer is not emptied in time + info = self._input_info + for stream_id, stream_data in it_data: + self._write_encoded_stream( + stream_id, + info[stream_id], + stream_data, + None if timeout is None else timeout - time(), + ) - ---- - Note - ---- - This function may hang or throw `NotEmpty` when input streams are written - in an unbalanced fashion. The behavior is dictated by how FFmpeg reads - its input data. Use the `timeout` argument to avoid hanging if in doubt. +class _RawOutputMixin: + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(**kwargs) + hook = plugins.get_hook() + self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} + self._get_num = {"video": hook.video_frames, "audio": hook.audio_samples} - """ - for info in self._input_info: - if "writer" in info and info["writer"].is_alive(): - info["writer"].flush(timeout) + # set the default read block size for the reference stream + self._blocksize = blocksize + self._ref = ref_output + self._rates = None + self._n0 = None # timestamps of the last read sample - def wait(self, timeout: float | None = None) -> int | None: - """close the input pipes and wait for FFmpeg to finish + @property + def output_labels(self) -> list[str]: + """FFmpeg/custom labels of output streams""" + return [v["user_map"] for v in self._output_info] - :param timeout: a timeout for blocking in seconds, or fractions - thereof, defaults to None, to wait until empty - :raise `TimeoutExpired`: if a timeout is set, and the process does not - terminate after timeout seconds. It is safe to - catch this exception and retry the wait. - :return returncode: return returncode attribute + @property + def output_types(self) -> dict[str, MediaType]: + """media type associated with the output streams (key)""" + return {v["user_map"]: v["media_type"] for v in self._output_info} - Note that the piped output will remain accessible until `pop_encoded()` is called. - """ + @property + def output_rates(self) -> dict[str, int | Fraction]: + """sample or frame rates associated with the output streams (key)""" + return {v["user_map"]: v["media_info"][2] for v in self._output_info} - if self._proc: + @property + def output_dtypes(self) -> dict[str, str]: + """frame/sample data type associated with the output streams (key)""" + return {v["user_map"]: v["media_info"][0] for v in self._output_info} - # write the sentinel to each input queue - for info in self._input_info: - if "writer" in info: - info["writer"].write(None) + @property + def output_shapes(self) -> dict[str, tuple[int]]: + """frame/sample shape associated with the output streams (key)""" + return {v["user_map"]: v["media_info"][1] for v in self._output_info} - try: - self.flush(timeout) - except NotEmpty as e: - raise TimeoutError() from e + @property + def output_counts(self) -> dict[str, int]: + """number of frames/samples read""" + return {v["user_map"]: n for v, n in zip(self._output_info, self._n0)} - # wait until the FFmpeg finishes the job - try: - rc = self._proc.wait(timeout) - except TimeoutError: - raise - else: - self._proc = None - else: - rc = None - return rc + def _init_named_pipes(self) -> ExitStack: - def pop_encoded(self, pipe_id: int | None = 0) -> bytes | tuple[bytes]: - """retrieve piped encoded bytes + # set the default read block size for the referenc stream + info = self._output_info[self._ref] + if self._blocksize is None: + self._blocksize = 1 if info["media_type"] == "video" else 1024 + self._rates = [v["media_info"][2] for v in self._output_info] + self._n0 = [0] * len(self._output_info) # timestamps of the last read sample + self._pipe_kws = { + **self._pipe_kws, + "update_rate": self._rates[self._ref] / Fraction(self._blocksize), + } - :param pipe_id: index of the output piped, defaults to `None` to return - all piped outputs. Indexing is specific to only piped - outputs, e.g., `pipe_id=0` means the first piped output - regardless of where the first `"pipe"` url was specified - in the `urls` argument of the constructor. - :return: `bytes` object if index specified or a tuple of bytes if all - piped outputs requested. - """ + # set up and activate pipes and read/write threads + return super()._init_named_pipes() - if pipe_id is None: - if not len(self._piped_outputs): - raise FFmpegioError("None of the outputs is piped.") - readers = self._piped_outputs - else: - try: - reader = self._piped_outputs[pipe_id] - except IndexError: - if pipe_id != 0: - raise FFmpegioError( - f"{pipe_id=} is not a valid piped output index." - ) - else: - raise FFmpegioError(f"This writer has no piped output defined.") - else: - readers = [reader] + def _read_stream( + self, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" - data_it = (reader.read_all(timeout=0) for reader in readers) + converter = self._converters[info["media_type"]] + dtype, shape, _ = info["media_info"] - return tuple(data_it) if pipe_id is None else next(data_it) + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=False + ) + # update the frame/sample counter + n = self._get_num[info["media_type"]](obj=data) # actual number read + self._n0[stream_id] += n -class PipedMediaFilter: + return data - _array_to_opts = { - "video": utils.array_to_video_options, - "audio": utils.array_to_audio_options, - } - _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} + def read_stream( + self, stream_id: int | str, n: int, timeout: float | None = None + ) -> RawDataBlob: + """read selected output stream - def __init__( - self, - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], - input_types: Sequence[Literal["a", "v"]], - *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], - input_dtypes: list[str] | None = None, - input_shapes: list[tuple[int]] | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - ref_output: int = 0, - output_options: dict[str, FFmpegOptionDict] | None = None, - default_timeout: float | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Filter audio/video data streams with FFmpeg filtergraphs + :param stream_id: stream index or label + :param n: number of frames/samples to read, defaults to -1 to read as many as available + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is read + from the buffer queue + :return: retrieved data - :param expr: complex filtergraph expression or a list of filtergraphs - :param input_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) - :param input_rates_or_opts: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob - or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. - :param input_dtypes: list of numpy-style data type strings of input samples - or frames of input media streams, defaults to `None` - (auto-detect). - :param input_shapes: list of shapes of input samples or frames of input media - streams, defaults to `None` (auto-detect). - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param ref_output: index or label of the reference stream to pace read operation, defaults to 0. - `PipedMediaFilter.read()` operates around the reference stream. - :param output_options: specific options for keyed filtergraph output pads. - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) - : defaults to `None` to use 1 video frame or 1024 audio frames - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options - will be applied to all input streams unless the option has been already defined in `stream_data` - """ + Effect of mixing `n` and `timeout` + ---------------------------------- - input_args = [ - (None, v) if isinstance(v, dict) else (v, None) for v in input_rates_or_opts - ] + === ========= ========================================================================= + `n` `timeout` Behavior + === ========= ========================================================================= + 0 --- Immediately returns + >0 `None` Wait indefinitely until `n` frames/samples are retrieved + >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes + <0 `None` Wait indefinitely until FFmpeg terminates + <0 `float` Retrieve as many frames/samples until `timeout` seconds passes + === ========= ========================================================================= - ( - args, - self._input_info, - self._input_ready, - self._output_info, - self._deferred_output_options, - ) = configure.init_media_filter( - expr, - input_types, - input_args, - extra_inputs, - input_dtypes, - input_shapes, - {"probesize_in": 32, **options}, - output_options or {}, - ) + """ - if all(self._input_ready): - self._input_ready = True - self._deferred_data = None - else: - # input data must be initially buffered - self._deferred_data = [[] for _ in range(len(self._input_info))] + if timeout is None: + timeout = self.default_timeout - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) + info = self._output_info + stream_id = utils.get_output_stream_id(info, stream_id) + return self._read_stream(info[stream_id], stream_id, n, timeout) - # prepare FFmpeg keyword arguments - self._args = { - "ffmpeg_args": args, - "progress": progress, - "capture_log": True, - "sp_kwargs": sp_kwargs, - } + def read(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob]: + """Read data from all output streams - # set the default read block size for the referenc stream - self.default_timeout = default_timeout - self._blocksize = blocksize - self._ref = ref_output - self._pipe_kws = {"queue_size": queuesize} - self._rates = None - self._n0 = None # timestamps of the last read sample + :param n: number of frames/samples of the reference output stream to read + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is read + from the buffer queue + :return: retrieved data keyed by output streams - hook = plugins.get_hook() - self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} - self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} - self._get_num = {"video": hook.video_frames, "audio": hook.audio_samples} + Read all output streams and return retrieved data up to `n` frames/samples + of the reference output stream. The amount of the data of the other output + streams are calculated to match the time span of the retrieved reference + data. - self._proc = None + The returned `dict` is keyed by the output labels. - def _open(self, deferred: bool): + Effect of mixing `n` and `timeout` + ---------------------------------- - ffmpeg_args = self._args["ffmpeg_args"] + === ========= ========================================================================= + `n` `timeout` Behavior + === ========= ========================================================================= + 0 --- Immediately returns + >0 `None` Wait indefinitely until `n` frames/samples are retrieved + >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes + <0 `None` Wait indefinitely until FFmpeg terminates + <0 `float` Retrieve as many frames/samples until `timeout` seconds passes + === ========= ========================================================================= + """ - if deferred: - self._output_info = configure.init_media_filter_outputs( - ffmpeg_args, self._input_info, self._deferred_output_options - ) + data = {} # output - self._ref = utils.get_output_stream_id(self._output_info, self._ref) + if timeout is None: + timeout = self.default_timeout - # set the default read block size for the referenc stream - info = self._output_info[self._ref] - if self._blocksize is None: - self._blocksize = 1 if info["media_type"] == "video" else 1024 - self._rates = [v["media_info"][2] for v in self._output_info] - self._n0 = [0] * len(self._output_info) # timestamps of the last read sample - self._pipe_kws = { - **self._pipe_kws, - "update_rate": self._rates[self._ref] / Fraction(self._blocksize), - } + if timeout is not None: + timeout += time() - # set up and activate pipes and read/write threads - stack = configure.init_named_pipes( - ffmpeg_args, - self._input_info, - self._output_info, - **self._pipe_kws, - ) + get_timeout = lambda: None if timeout is None else max(timeout - time(), 0) - # run the FFmpeg - try: - self._proc = ffmpegprocess.Popen( - **self._args, on_exit=lambda _: stack.close() - ) - except: - stack.close() - raise + get_all = n < 0 and timeout is None - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() + # read the reference stream + i0 = self._ref + n0 = self._n0[i0] + ref_data = self._read_stream(self._output_info[i0], i0, n, get_timeout()) + if not get_all: + # get the timestamp of the final frame + T = (self._n0[i0] - n0) / self._rates[i0] - # if any pending data, queue them - for src, info in zip(self._deferred_data, self._input_info): - if "writer" in info and len(src): - writer = info["writer"] - for data in src: - writer.write(data) - self._deferred_data = [] - self._input_ready = True + # retrieve all the other streams up to T seconds mark + for i, info in enumerate(self._output_info): + if i != i0: + if not get_all: + n1 = int(T * self._rates[i]) + n = max(n1 - self._n0[i], 0) + stream_data = self._read_stream(info, i, n, get_timeout()) + else: + stream_data = ref_data + data[info["user_map"]] = stream_data - return self + return data - def __enter__(self): - if self._input_ready is True: - self._open(False) +class _EncodedOutputMixin: + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) + hook = plugins.get_hook() - return self + # set the default read block size + self._blocksize = blocksize - def open(self): - """start FFmpeg processing + def _init_named_pipes(self) -> ExitStack: - Note - ---- + # set the default read block size for the referenc stream + self._pipe_kws = {**self._pipe_kws, "blocksize": self._blocksize} - It may flag to defer starting the FFmpeg process if the input streams - are not fully specified and must wait to deduce them from the written - data. - - """ - self.__enter__() - - def __exit__(self, *exc_details): - - try: - if self._proc is not None and self._proc.poll() is None: - # kill the ffmpeg runtime - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - self._proc = None - except: - if not exc_details[0]: - exc_details = sys.exc_info() - finally: - try: - self._logger.join() - except RuntimeError: - pass - - def close(self): - """Kill FFmpeg process and close the streams.""" - - self.__exit__(None, None, None) - - @property - def output_labels(self) -> list[str]: - """FFmpeg/custom labels of output streams""" - return [v["user_map"] for v in self._output_info] - - @property - def output_types(self) -> dict[str, MediaType]: - """media type associated with the output streams (key)""" - return {v["user_map"]: v["media_type"] for v in self._output_info} - - @property - def output_rates(self) -> dict[str, int | Fraction]: - """sample or frame rates associated with the output streams (key)""" - return {v["user_map"]: v["media_info"][2] for v in self._output_info} - - @property - def output_dtypes(self) -> dict[str, str]: - """frame/sample data type associated with the output streams (key)""" - return {v["user_map"]: v["media_info"][0] for v in self._output_info} - - @property - def output_shapes(self) -> dict[str, tuple[int]]: - """frame/sample shape associated with the output streams (key)""" - return {v["user_map"]: v["media_info"][1] for v in self._output_info} - - @property - def output_counts(self) -> dict[str, int]: - """number of frames/samples read""" - return {v["user_map"]: n for v, n in zip(self._output_info, self._n0)} - - @property - def closed(self) -> bool: - """True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self) -> FFmpegError: - """Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def readlog(self, n: int) -> str: - """read FFmpeg log lines - - :param n: number of lines to read - :return: logged messages - """ - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def _write_stream( - self, - info: OutputDestinationDict, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - media_type = info["media_type"] - b = self._get_bytes[media_type](obj=data) - - if self._input_ready is not True: - # need to collect input data type and shape from the actual data - # before starting the FFmpeg - if not self._input_ready[stream_id]: - # first frame of the input stream with missing information - # update the - input_args = self._args["ffmpeg_args"]["inputs"][stream_id] - self._args["ffmpeg_args"]["inputs"][stream_id] = ( - input_args[0], - {**input_args[1], **self._array_to_opts[media_type](data)}, - ) - self._input_ready[stream_id] = True - - self._deferred_data[stream_id].append(b) - - if all(self._input_ready): - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) - - else: - - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() - - def write_stream( - self, stream_id: int, data: RawDataBlob, timeout: float | None = None - ): - """write a raw media data to a specified stream - - :param stream_id: input stream index or label - :param data: media data blob (depends on the active data conversion plugin) - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - :return: currently available encoded data (bytes) if returning the encoded - data back to Python - - Write the given NDArray object, data, and return the number - of bytes written (always equal to the number of data frames/samples, - since if the write fails an OSError will be raised). - - When in non-blocking mode, a BlockingIOError is raised if the data - needed to be written to the raw stream but it couldn’t accept all - the data without blocking. - - The caller may release or mutate data after this method returns, - so the implementation should only access data during the method call. - - """ - - # get input stream information - try: - info = self._input_info[stream_id] - except IndexError: - raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") - - if timeout is None: - timeout = self.default_timeout - - self._write_stream(info, stream_id, data, timeout) + # set up and activate pipes and read/write threads + return super()._init_named_pipes() - def _read_stream( + def _read_encoded_stream( self, info: OutputDestinationDict, - stream_id: int | str, n: int, timeout: float | None = None, - ) -> RawDataBlob: + ) -> bytes: """read selected output stream (shared backend)""" - converter = self._converters[info["media_type"]] - dtype, shape, _ = info["media_info"] - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=False - ) - - # update the frame/sample counter - n = self._get_num[info["media_type"]](obj=data) # actual number read - self._n0[stream_id] += n - - return data + return info["reader"].read(n, timeout) - def read_stream( - self, stream_id: int | str, n: int, timeout: float | None = None - ) -> RawDataBlob: + def read_encoded_stream( + self, stream_id: int, n: int, timeout: float | None = None + ) -> bytes: """read selected output stream :param stream_id: stream index or label - :param n: number of frames/samples to read, defaults to -1 to read as many as available + :param n: number of bytes to read :param timeout: timeout in seconds or defaults to `None` to use the `default_timeout` property. If `default_timeout` is `None` then the operation will block until all the data is read @@ -1043,10 +751,10 @@ def read_stream( `n` `timeout` Behavior === ========= ========================================================================= 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` frames/samples are retrieved - >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes + >0 `None` Wait indefinitely until `n` bytes are retrieved + >0 `float` Retrieve as many bytes up to `n` before `timeout` seconds passes <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as many frames/samples until `timeout` seconds passes + <0 `float` Retrieve as many bytes until `timeout` seconds passes === ========= ========================================================================= """ @@ -1056,69 +764,16 @@ def read_stream( info = self._output_info stream_id = utils.get_output_stream_id(info, stream_id) - return self._read_stream(info[stream_id], stream_id, n, timeout) - - def write( - self, - data: Sequence[RawDataBlob] | dict[int, RawDataBlob], - timeout: float | None = None, - ) -> bytes | None: - """write data to all input streams - - :param data: media data blob keyed by stream index - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - - """ - - it_data = data.items() if isinstance(data, dict) else enumerate(data) - - if timeout is None: - timeout = self.default_timeout - - if timeout is not None: - timeout += time() - - info = self._input_info - for stream_id, stream_data in it_data: - self._write_stream( - info[stream_id], - stream_id, - stream_data, - None if timeout is None else timeout - time(), - ) + return self._read_encoded_stream(info[stream_id], n, timeout) - def read(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob]: - """Read data from all output streams + def readall_encoded(self, timeout: float | None = None) -> dict[str, bytes]: + """Read available data from all output streams - :param n: number of frames/samples of the reference output stream to read :param timeout: timeout in seconds or defaults to `None` to use the `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is read - from the buffer queue + then the operation will block until FFmpeg stops :return: retrieved data keyed by output streams - Read all output streams and return retrieved data up to `n` frames/samples - of the reference output stream. The amount of the data of the other output - streams are calculated to match the time span of the retrieved reference - data. - - The returned `dict` is keyed by the output labels. - - Effect of mixing `n` and `timeout` - ---------------------------------- - - === ========= ========================================================================= - `n` `timeout` Behavior - === ========= ========================================================================= - 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` frames/samples are retrieved - >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes - <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as many frames/samples until `timeout` seconds passes - === ========= ========================================================================= """ data = {} # output @@ -1131,65 +786,281 @@ def read(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob]: get_timeout = lambda: None if timeout is None else max(timeout - time(), 0) - get_all = n < 0 and timeout is None - - # read the reference stream - i0 = self._ref - n0 = self._n0[i0] - ref_data = self._read_stream(self._output_info[i0], i0, n, get_timeout()) - if not get_all: - # get the timestamp of the final frame - T = (self._n0[i0] - n0) / self._rates[i0] - # retrieve all the other streams up to T seconds mark for i, info in enumerate(self._output_info): - if i != i0: - if not get_all: - n1 = int(T * self._rates[i]) - n = max(n1 - self._n0[i], 0) - stream_data = self._read_stream(info, i, n, get_timeout()) - else: - stream_data = ref_data - data[info["user_map"]] = stream_data + data[i] = self._read_encoded_stream(info, -1, get_timeout()) return data - def wait(self, timeout: float | None = None) -> int | None: - """close the input pipes and wait for FFmpeg to exit - :param timeout: a timeout for blocking in seconds, or fractions - thereof, defaults to None, to wait indefinitely - :raise `TimeoutExpired`: if a timeout is set, and the process does not - terminate after timeout seconds. It is safe to - catch this exception and retry the wait. - :return returncode: return returncode attribute +class PipedMediaReader(_EncodedInputMixin, _RawOutputMixin, _PipedFFmpegRunner): + + def __init__( + self, + *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], + map: Sequence[str] | dict[str, FFmpegOptionDict] | None = None, + ref_stream: int = 0, + blocksize: int | None = None, + default_timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + queuesize: int | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Read video and audio data from multiple media files + + :param *urls: URLs of the media files to read or a tuple of the URL and its input option dict. + :param map: FFmpeg map options + :param ref_stream: index of the reference stream to pace read operation, defaults to 0. The + reference stream is guaranteed to have a frame data on every read operation. + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + + Note: To read a single stream from a single source, use `audio.read()`, `video.read()` or `image.read()` + for reducing the overhead + + Specify the streams to return by `map` output option: + + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + + Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output + 'pix_fmt' option is not explicitly set, 'rgb24' is used. + + For audio streams, if 'sample_fmt' output option is not specified, 's16'. """ - if self._proc: + # initialize FFmpeg argument dict and get input & output information + args, input_info, ready, output_info, output_args = configure.init_media_read( + urls, map, {"probesize_in": 32, **options} + ) - if timeout is not None: - timeout += time() + super().__init__( + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=ready, + init_deferred_outputs=configure.init_media_read_outputs, + deferred_output_args=output_args, + ref_output=ref_stream, + blocksize=blocksize, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) - # write the sentinel to each input queue - for info in self._input_info: - if "writer" in info: - info["writer"].write( - None, None if timeout is None else timeout - time() - ) + hook = plugins.get_hook() + self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} + + def __iter__(self): + return self + + def __next__(self): + F = self.read(self._blocksize, self.default_timeout) + if not any( + len(self._get_bytes[info["media_type"]](obj=f)) + for f, info in zip(F.values(), self._output_info) + ): + raise StopIteration + return F - # wait until the FFmpeg finishes the job - try: - rc = self._proc.wait(None if timeout is None else timeout - time()) - except TimeoutError: - raise - else: - self._proc = None - else: - rc = None - return rc +class PipedMediaWriter(_EncodedOutputMixin, _RawInputMixin, _PipedFFmpegRunner): + + def __init__( + self, + urls: ( + FFmpegOutputUrlComposite + | list[ + FFmpegOutputUrlComposite + | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ] + ), + stream_types: Sequence[Literal["a", "v"]], + *stream_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], + dtypes_in: list[str] | None = None, + shapes_in: list[tuple[int]] | None = None, + merge_audio_streams: bool | Sequence[int] = False, + merge_audio_ar: int | None = None, + merge_audio_sample_fmt: str | None = None, + merge_audio_outpad: str | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + default_timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Write video and audio data from multiple media streams to one or more files -class PipedMediaTranscoder: + :param url: output url + :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) + :param stream_rates_or_opts: either sample rate (audio) or frame rate (video) + or a dict of input options. The option dict must + include `'ar'` (audio) or `'r'` (video) to specify + the rate. + :param dtypes_in: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param shapes_in: list of shapes of input samples or frames of input media + streams, defaults to `None` (auto-detect). + :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's + (indices of `stream_types`) to combine only specified streams. + :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream + :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) + : defaults to `None` to use 1 video frame or 1024 audio frames + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + """ + + if not isinstance(urls, list): + urls = [urls] + + stream_args = [ + (None, v) if isinstance(v, dict) else (v, None) + for v in stream_rates_or_opts + ] + args, input_info, input_ready, output_info, output_args = ( + configure.init_media_write( + urls, + stream_types, + stream_args, + merge_audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad, + extra_inputs, + {"probesize_in": 32, **options}, + dtypes_in, + shapes_in, + ) + ) + + super().__init__( + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_write_outputs, + deferred_output_args=output_args, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + blocksize=blocksize, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + +class PipedMediaFilter(_RawOutputMixin, _RawInputMixin, _PipedFFmpegRunner): + + def __init__( + self, + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + input_types: Sequence[Literal["a", "v"]], + *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], + input_dtypes: list[str] | None = None, + input_shapes: list[tuple[int]] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + ref_output: int = 0, + output_options: dict[str, FFmpegOptionDict] | None = None, + default_timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Filter audio/video data streams with FFmpeg filtergraphs + + :param expr: complex filtergraph expression or a list of filtergraphs + :param input_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) + :param input_rates_or_opts: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob + or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param input_dtypes: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param input_shapes: list of shapes of input samples or frames of input media + streams, defaults to `None` (auto-detect). + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param ref_output: index or label of the reference stream to pace read operation, defaults to 0. + `PipedMediaFilter.read()` operates around the reference stream. + :param output_options: specific options for keyed filtergraph output pads. + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) + : defaults to `None` to use 1 video frame or 1024 audio frames + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + """ + + input_args = [ + (None, v) if isinstance(v, dict) else (v, None) for v in input_rates_or_opts + ] + + ( + args, + input_info, + input_ready, + output_info, + deferred_output_args, + ) = configure.init_media_filter( + expr, + input_types, + input_args, + extra_inputs, + input_dtypes, + input_shapes, + {"probesize_in": 32, **options}, + output_options or {}, + ) + + super().__init__( + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_filter_outputs, + deferred_output_args=deferred_output_args, + ref_output=ref_output, + blocksize=blocksize, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + +class PipedMediaTranscoder(_EncodedOutputMixin, _EncodedInputMixin, _PipedFFmpegRunner): """Class to transcode encoded media streams""" def __init__( @@ -1220,8 +1091,7 @@ def __init__( :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) - defaults to `None` to use 1 video frame or 1024 audio frames + :param blocksize: Background reader queue's item size in bytes, defaults to `None` (64 kB) :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults @@ -1235,295 +1105,25 @@ def __init__( sequence will overwrite those specified here. """ - args, self._input_info, self._output_info = configure.init_media_transcoder( - input_options, output_options, extra_inputs, extra_outputs, options + args, input_info, output_info = configure.init_media_transcoder( + [("pipe", opts) for opts in input_options], + [("pipe", opts) for opts in output_options], + extra_inputs, + extra_outputs, + options, ) - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - # prepare FFmpeg keyword arguments - self._args = { - "ffmpeg_args": args, - "progress": progress, - "capture_log": True, - "sp_kwargs": sp_kwargs, - } - - # set the default read block size for the referenc stream - self.default_timeout = default_timeout - self._blocksize = blocksize - self._pipe_kws = {"queue_size": queuesize} - self._proc = None - - def __enter__(self): - - ffmpeg_args = self._args["ffmpeg_args"] - - # set up and activate pipes and read/write threads - stack = configure.init_named_pipes( - ffmpeg_args, self._input_info, self._output_info, **self._pipe_kws + super().__init__( + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=None, + init_deferred_outputs=None, + deferred_output_args=None, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + blocksize=blocksize, + queuesize=queuesize, + sp_kwargs=sp_kwargs, ) - - # run the FFmpeg - try: - self._proc = ffmpegprocess.Popen( - **self._args, on_exit=lambda _: stack.close() - ) - except: - stack.close() - raise - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - return self - - def open(self): - """start FFmpeg processing - - Note - ---- - - It may flag to defer starting the FFmpeg process if the input streams - are not fully specified and must wait to deduce them from the written - data. - - """ - self.__enter__() - - def __exit__(self, *exc_details): - - try: - if self._proc is not None and self._proc.poll() is None: - # kill the ffmpeg runtime - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - self._proc = None - except: - if not exc_details[0]: - exc_details = sys.exc_info() - finally: - try: - self._logger.join() - except RuntimeError: - pass - - def close(self): - """Kill FFmpeg process and close the streams.""" - - self.__exit__(None, None, None) - - @property - def closed(self) -> bool: - """True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self) -> FFmpegError: - """Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def readlog(self, n: int) -> str: - """read FFmpeg log lines - - :param n: number of lines to read - :return: logged messages - """ - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def _write_encoded_stream( - self, - info: OutputDestinationDict, - data: bytes, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - try: - info["writer"].write(data, timeout) - except: - raise FFmpegioError("Cannot write to a non-piped input.") - - def write_encoded_stream( - self, stream_id: int, data: bytes, timeout: float | None = None - ): - """write a raw media data to a specified stream - - :param stream_id: input stream index or label - :param data: media data bytes - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - :return: currently available encoded data (bytes) if returning the encoded - data back to Python - - Write the given NDArray object, data, and return the number - of bytes written (always equal to the number of data frames/samples, - since if the write fails an OSError will be raised). - - When in non-blocking mode, a BlockingIOError is raised if the data - needed to be written to the raw stream but it couldn’t accept all - the data without blocking. - - The caller may release or mutate data after this method returns, - so the implementation should only access data during the method call. - - """ - - # get input stream information - try: - info = self._input_info[stream_id] - except IndexError: - raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") - - if timeout is None: - timeout = self.default_timeout - - self._write_encoded_stream(info, data, timeout) - - def _read_encoded_stream( - self, - info: OutputDestinationDict, - n: int, - timeout: float | None = None, - ) -> bytes: - """read selected output stream (shared backend)""" - - return info["reader"].read(n, timeout) - - def read_encoded_stream( - self, stream_id: int, n: int, timeout: float | None = None - ) -> bytes: - """read selected output stream - - :param stream_id: stream index or label - :param n: number of bytes to read - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is read - from the buffer queue - :return: retrieved data - - Effect of mixing `n` and `timeout` - ---------------------------------- - - === ========= ========================================================================= - `n` `timeout` Behavior - === ========= ========================================================================= - 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` bytes are retrieved - >0 `float` Retrieve as many bytes up to `n` before `timeout` seconds passes - <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as many bytes until `timeout` seconds passes - === ========= ========================================================================= - - """ - - if timeout is None: - timeout = self.default_timeout - - info = self._output_info - stream_id = utils.get_output_stream_id(info, stream_id) - return self._read_encoded_stream(info[stream_id], n, timeout) - - def write_encoded( - self, - data: Sequence[RawDataBlob] | dict[int, RawDataBlob], - timeout: float | None = None, - ) -> bytes | None: - """write data to all input streams - - :param data: media byte data keyed by stream index - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - - """ - - it_data = data.items() if isinstance(data, dict) else enumerate(data) - - if timeout is None: - timeout = self.default_timeout - - if timeout is not None: - timeout += time() - - info = self._input_info - for stream_id, stream_data in it_data: - self._write_encoded_stream( - info[stream_id], - stream_id, - stream_data, - None if timeout is None else timeout - time(), - ) - - def readall_encoded(self, timeout: float | None = None) -> dict[str, bytes]: - """Read available data from all output streams - - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until FFmpeg stops - :return: retrieved data keyed by output streams - - """ - - data = {} # output - - if timeout is None: - timeout = self.default_timeout - - if timeout is not None: - timeout += time() - - get_timeout = lambda: None if timeout is None else max(timeout - time(), 0) - - # retrieve all the other streams up to T seconds mark - for i, info in enumerate(self._output_info): - data[i] = self._read_encoded_stream(info, -1, get_timeout()) - - return data - - def wait(self, timeout: float | None = None) -> int | None: - """close the input pipes and wait for FFmpeg to exit - - :param timeout: a timeout for blocking in seconds, or fractions - thereof, defaults to None, to wait indefinitely - :raise `TimeoutExpired`: if a timeout is set, and the process does not - terminate after timeout seconds. It is safe to - catch this exception and retry the wait. - :return returncode: return returncode attribute - """ - - if self._proc: - - if timeout is not None: - timeout += time() - - # write the sentinel to each input queue - for info in self._input_info: - if "writer" in info: - info["writer"].write( - None, None if timeout is None else timeout - time() - ) - - # wait until the FFmpeg finishes the job - try: - rc = self._proc.wait(None if timeout is None else timeout - time()) - except TimeoutError: - raise - else: - self._proc = None - else: - rc = None - return rc diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index 28ec6c59..d69fc731 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -39,14 +39,13 @@ def test_PipedMediaWriter_audio(): # loglevel="debug", ) as writer: for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): - writer.write(i, frame) - writer.write(i, None) + writer.write_stream(i, frame) # close the input and wait for FFmpeg to finish encoding and terminate writer.wait(10) # read the encoded bytes - b = writer.pop_encoded() + b = writer.readall_encoded() def test_PipedMediaWriter(): @@ -61,13 +60,13 @@ def test_PipedMediaWriter(): ) as writer: for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): if mtype == "v": - writer.write(i, frame[0]) + writer.write_stream(i, frame[0]) else: - writer.write(i, frame) + writer.write_stream(i, frame) writer.wait(10) - b = writer.pop_encoded(0) - assert isinstance(b, bytes) + b = writer.read_encoded_stream(0, -1, 10) + assert isinstance(b, bytes) and len(b) > 0 def test_PipedMediaFilter(): From 655019419047197bce24401dd1189fbdd414c7e3 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 18:58:33 -0600 Subject: [PATCH 267/333] `init_media_write_outputs()` - mark consequential exception --- src/ffmpegio/configure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 9d96c6be..7bea225c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1840,10 +1840,10 @@ def init_media_write_outputs( assert all( i in a_ids and "ar" in inputs[i][1] for i in merge_audio_streams ) - except AssertionError: + except AssertionError as e: raise ValueError( "To merge audio streams their sampling rate must be the same." - ) + ) from e # get FFmpeg input list ffinputs = args["inputs"] From 31f324bb43dc233d1b728b2c38b163bd3f1ea395 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 18:59:20 -0600 Subject: [PATCH 268/333] `init_media_filter()` - check for the validity of `extra_inputs` --- src/ffmpegio/configure.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7bea225c..7797ea4b 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1949,6 +1949,10 @@ def init_media_filter( # make sure all inputs are complete ready = utils.are_input_pipes_ready(args["inputs"], input_info) + if extra_inputs is not None and not all(r for r in ready[len(input_types) :]): + raise FFmpegioError( + "At least one extra input URL is either invalid or their data are not " + ) # add the default output options to output_options dict with None as the key output_options[None] = options From f2208f9eca8ef42c3930287afd77d810aecd0871 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 19:00:08 -0600 Subject: [PATCH 269/333] `init_media_transcoder()` - process all inputs first before outputs --- src/ffmpegio/configure.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7797ea4b..545634db 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -2054,9 +2054,6 @@ def init_media_transcoder( gopts["y"] = None input_info = process_url_inputs(args, inputs, inopts_default) - output_info = process_url_outputs( - args, input_info, outputs, options, skip_automapping=True - ) if extra_inputs is not None: try: @@ -2067,6 +2064,10 @@ def init_media_transcoder( if not len(input_info): raise ValueError("At least one input must be given.") + output_info = process_url_outputs( + args, input_info, outputs, options, skip_automapping=True + ) + if extra_outputs is not None: try: output_info.extend( From 15740c712be71a19dd67e4e4201eccb90555ca28 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 19:00:51 -0600 Subject: [PATCH 270/333] update docs --- src/ffmpegio/configure.py | 4 ++-- src/ffmpegio/media.py | 21 ++++++++++++--------- src/ffmpegio/utils/__init__.py | 10 ++++++++-- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 545634db..c9c7788e 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1204,7 +1204,7 @@ def process_url_inputs( a pipe expression. :param urls: list of input urls/data or a pair of input url and its options :param inopts_default: default input options - :param no_pipe: True to raise exception if output is piped without data buffer, defaults to False + :param no_pipe: True to raise exception if an input is piped without data buffer, defaults to False :return: list of input information """ @@ -2170,7 +2170,7 @@ def init_named_pipes( if "buffer" in info: # data buffer given, feed the data and terminate writer.write(info["buffer"]) - writer.write(None) # close the writer immediately + writer.write(None) # close the writer immediately else: # if no data given, provide the access to the writer info["writer"] = writer diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 4c15cf06..1a17ebd0 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -14,7 +14,11 @@ Unpack, FFmpegUrlType, ) -from .configure import FFmpegOutputUrlComposite, FFmpegInputUrlComposite, FFmpegOptionDict +from .configure import ( + FFmpegOutputUrlComposite, + FFmpegInputUrlComposite, + FFmpegOptionDict, +) import contextlib from fractions import Fraction @@ -78,9 +82,7 @@ def _gather_outputs( def read( - *urls: * tuple[ - FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict] - ], + *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, @@ -163,10 +165,12 @@ def on_exit(rc): def write( urls: ( FFmpegOutputUrlComposite - | list[FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict]] + | list[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ] ), stream_types: Sequence[Literal["a", "v"]], - *stream_args: * tuple[RawStreamDef, ...], + *stream_args: *tuple[RawStreamDef, ...], merge_audio_streams: bool | Sequence[int] = False, merge_audio_ar: int | None = None, merge_audio_sample_fmt: str | None = None, @@ -239,7 +243,7 @@ def write( capture_log=None if show_log else True, sp_kwargs=sp_kwargs, on_exit=lambda _: stack.close(), - overwrite=overwrite + overwrite=overwrite, ) except: stack.close() @@ -269,7 +273,7 @@ def write( def filter( expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], input_types: Sequence[Literal["a", "v"]], - *input_args: * tuple[RawStreamDef, ...], + *input_args: *tuple[RawStreamDef, ...], extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, output_options: dict[str, FFmpegOptionDict] | None = None, show_log: bool | None = None, @@ -303,7 +307,6 @@ def filter( for some outputs as needed. """ - args, input_info, input_ready, output_info, _ = configure.init_media_filter( expr, input_types, diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 92e75ac0..272015b9 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -1,4 +1,4 @@ -""" utility functions for the main modules""" +"""utility functions for the main modules""" from __future__ import annotations @@ -18,7 +18,13 @@ from .._utils import * from ..stream_spec import * from ..errors import FFmpegError, FFmpegioError -from .._typing import Any, MediaType, InputSourceDict, RawDataBlob, OutputDestinationDict +from .._typing import ( + Any, + MediaType, + InputSourceDict, + RawDataBlob, + OutputDestinationDict, +) from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb from ..filtergraph.presets import temp_video_src, temp_audio_src From 55ea7231beb4f37f733141bdc754884ea166ee8e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 20:09:16 -0600 Subject: [PATCH 271/333] refactored `_runner()` from all module functions --- src/ffmpegio/media.py | 143 +++++++++++++++++------------------------- 1 file changed, 59 insertions(+), 84 deletions(-) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 1a17ebd0..1b9606ae 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -7,20 +7,21 @@ from collections.abc import Sequence from ._typing import ( Literal, - Any, RawStreamDef, ProgressCallable, RawDataBlob, Unpack, FFmpegUrlType, + InputSourceDict, + OutputDestinationDict, ) from .configure import ( + FFmpegArgs, FFmpegOutputUrlComposite, FFmpegInputUrlComposite, FFmpegOptionDict, ) -import contextlib from fractions import Fraction from . import ffmpegprocess, utils, configure, FFmpegError, plugins @@ -31,8 +32,58 @@ __all__ = ["read", "write"] +def _runner( + args: FFmpegArgs, + input_info: list[InputSourceDict], + output_info: list[OutputDestinationDict], + show_log: bool | None, + progress: ProgressCallable | None, + sp_kwargs: dict | None, + overwrite: bool | None = None, +) -> ffmpegprocess.Popen: + + # True if there is unknown datablob info + need_stderr = any( + info["dst_type"] == "pipe" and info["media_info"] is None + for info in output_info + ) + + # run FFmpeg + capture_log = True if need_stderr else None if show_log else True + + # configure named pipes + stack = configure.init_named_pipes(args, input_info, output_info) + + def on_exit(rc): + stack.close() + + # run the FFmpeg + try: + proc = ffmpegprocess.Popen( + args, + overwrite=overwrite, + progress=progress, + capture_log=capture_log, + sp_kwargs=sp_kwargs, + on_exit=on_exit, + ) + except: + # if Popen failed to start FFmpeg process, need to call the callback + stack.close() + raise + + # wait for the FFmpeg to finish processing + proc.wait() + + # throw error if failed + if proc.returncode: + raise FFmpegError(proc.stderr, capture_log) + + return proc + + def _gather_outputs( - output_info, proc + output_info: list[OutputDestinationDict], proc: ffmpegprocess.Popen ) -> tuple[dict[str, int | Fraction], dict[str, RawDataBlob]]: rates = {} data = {} @@ -125,38 +176,8 @@ def read( if not all(input_ready): raise FFmpegioError("Not all inputs are resolved.") - # True if there is unknown datablob info - need_stderr = any(info["media_info"] is None for info in output_info) - # run FFmpeg - capture_log = True if need_stderr else None if show_log else True - - # configure named pipes - stack = configure.init_named_pipes(args, input_info, output_info) - - def on_exit(rc): - stack.close() - - # run the FFmpeg - try: - proc = ffmpegprocess.Popen( - args, - progress=progress, - capture_log=capture_log, - sp_kwargs=sp_kwargs, - on_exit=on_exit, - ) - except: - # if Popen failed to start FFmpeg process, need to call the callback - stack.close() - raise - - # wait for the FFmpeg to finish processing - proc.wait() - - # throw error if failed - if proc.returncode: - raise FFmpegError(proc.stderr, capture_log) + proc = _runner(args, input_info, output_info, show_log, progress, sp_kwargs) # gather and return output return _gather_outputs(output_info, proc) @@ -232,34 +253,8 @@ def write( if not all(input_ready): raise FFmpegioError("Invalid input data.") - # configure named pipes - stack = configure.init_named_pipes(args, input_info, output_info) - - # run the FFmpeg - try: - proc = ffmpegprocess.Popen( - args, - progress=progress, - capture_log=None if show_log else True, - sp_kwargs=sp_kwargs, - on_exit=lambda _: stack.close(), - overwrite=overwrite, - ) - except: - stack.close() - raise - - # wait for the FFmpeg to finish processing - proc.wait() - - # throw error if failed - if proc.returncode: - raise FFmpegError(proc.stderr, show_log) - - # wind-down the readers - for info in output_info: - if "reader" in info: - info["reader"].cool_down() + # run FFmpeg + _runner(args, input_info, output_info, show_log, progress, sp_kwargs, overwrite) # gather output data = {} @@ -324,28 +319,8 @@ def filter( "Data type and shape of some inputs could not be determined." ) - # configure named pipes - stack = configure.init_named_pipes(args, input_info, output_info) - - # run the FFmpeg - try: - proc = ffmpegprocess.Popen( - args, - progress=progress, - capture_log=None if show_log else True, - sp_kwargs=sp_kwargs, - on_exit=lambda _: stack.close(), - ) - except: - stack.close() - raise - - # wait for the FFmpeg to finish processing - proc.wait() - - # throw error if failed - if proc.returncode: - raise FFmpegError(proc.stderr, show_log) + # run FFmpeg + proc = _runner(args, input_info, output_info, show_log, progress, sp_kwargs) # gather and return output return _gather_outputs(output_info, proc) From 2e293453d6f7862005721a27244e105ddeb76b76 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 22:05:42 -0600 Subject: [PATCH 272/333] moved `FFmpegInputUrlComposite` & `FFmpegOutputUrlComposite` from `configure` to `utils` --- src/ffmpegio/configure.py | 7 ++----- src/ffmpegio/utils/__init__.py | 7 +++++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index c9c7788e..51684376 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -6,9 +6,7 @@ Any, MediaType, FFmpegUrlType, - Union, TypedDict, - IO, Buffer, InputSourceDict, OutputDestinationDict, @@ -18,6 +16,8 @@ RawDataBlob, ) from collections.abc import Sequence +from .utils import FFmpegInputUrlComposite, FFmpegOutputUrlComposite + from fractions import Fraction import re, logging @@ -57,9 +57,6 @@ UrlType = Literal["input", "output"] -FFmpegInputUrlComposite = Union[FFmpegUrlType, FFConcat, FilterGraphObject, IO, Buffer] -FFmpegOutputUrlComposite = Union[FFmpegUrlType, IO] - FFmpegOptionDict = dict[str, Any] """FFmpeg options with their values keyed by the option names without preceding dash. For option flags (e.g., -y) without any value, use `None` or its alias `ffmpegio.FLAG`""" diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 272015b9..506640ef 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -24,10 +24,17 @@ InputSourceDict, RawDataBlob, OutputDestinationDict, + FFmpegUrlType, + IO, + Buffer, ) from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb from ..filtergraph.presets import temp_video_src, temp_audio_src +from .concat import FFConcat + +FFmpegInputUrlComposite = FFmpegUrlType | FFConcat | FilterGraphObject | IO | Buffer +FFmpegOutputUrlComposite = FFmpegUrlType | IO # TODO: auto-detect endianness # import sys From d3035ca85ed63e0d2aa244d822d5d08ac5dbc732 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 22:05:50 -0600 Subject: [PATCH 273/333] added `is_valid_input_url()` and `is_valid_output_url()` --- src/ffmpegio/utils/__init__.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 506640ef..97cd9212 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -912,3 +912,32 @@ def get_output_stream_id( ) return stream + + +def is_valid_input_url(url: FFmpegInputUrlComposite) -> bool: # get the option dict + + # check url (must be url and not fileobj) + valid = isinstance(url, (str, FilterGraphObject, FFConcat)) + if not valid: + valid = is_fileobj(url, readable=True) + + if not valid: + try: + memoryview(url) + except TypeError as e: + pass + else: + valid = True + + return valid + + +def is_valid_output_url(url: FFmpegOutputUrlComposite) -> bool: + + valid = isinstance(url, str) + + # check url (must be url and not fileobj) + if not valid: + valid = is_fileobj(url, writable=True) + + return valid From 12c86b33399bcf1adcf724efd6e49a945be079e2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 22:06:08 -0600 Subject: [PATCH 274/333] `process_url_outputs()` - fixed fileobj check --- src/ffmpegio/configure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 51684376..1f398503 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1473,7 +1473,7 @@ def process_url_outputs( opts = {**options} # check url (must be url and not fileobj) - if utils.is_fileobj(url, readable=True): + if utils.is_fileobj(url, writable=True): output_info = {"dst_type": "fileobj", "fileobj": url} url = None elif utils.is_pipe(url): From fe7e829df2ca42e4e869339d5d9ea02e65daeba3 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 22:07:21 -0600 Subject: [PATCH 275/333] transcoding `-y` option only mandatory for a piped operation --- src/ffmpegio/configure.py | 2 -- src/ffmpegio/streams/PipedStreams.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 1f398503..82d765b3 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -2047,8 +2047,6 @@ def init_media_transcoder( # create a new FFmpeg dict args = empty(utils.pop_global_options(options)) - gopts = args["global_options"] # global options dict - gopts["y"] = None input_info = process_url_inputs(args, inputs, inopts_default) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 8f5f90b6..5afe0b68 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -1110,7 +1110,7 @@ def __init__( [("pipe", opts) for opts in output_options], extra_inputs, extra_outputs, - options, + {"y": None, **options}, ) super().__init__( From 4fddbe00b416356599a45954cfa6a682419f287e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 22:17:34 -0600 Subject: [PATCH 276/333] - `transcode()` to form its FFmpeg arguments by `init_media_transcoder()` - added type hints --- src/ffmpegio/transcode.py | 143 +++++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 63 deletions(-) diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index 192fd386..ce8e83c6 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -1,53 +1,74 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger("ffmpegio") + +from ._typing import Sequence, ProgressCallable, Unpack +from .configure import ( + FFmpegOutputUrlComposite, + FFmpegInputUrlComposite, + FFmpegOptionDict, + FFmpegInputOptionTuple, + FFmpegOutputOptionTuple, +) + + from . import ffmpegprocess as fp, configure, utils, FFmpegError from .path import check_version -from .errors import scan_stderr __all__ = ["transcode"] def transcode( - inputs, - outputs, - progress=None, - overwrite=None, - show_log=None, - two_pass=False, - pass1_omits=None, - pass1_extras=None, - sp_kwargs=None, - **options, -): + inputs: ( + FFmpegInputUrlComposite + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + outputs: ( + FFmpegOutputUrlComposite + | Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + ), + *, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + two_pass: bool = False, + pass1_omits: ( + Sequence[str] | Sequence[Sequence[str]] | dict[int, Sequence[str]] | None + ) = None, + pass1_extras: ( + FFmpegOptionDict + | Sequence[FFmpegOptionDict] + | dict[int, FFmpegOptionDict] + | None + ) = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> bytes | None: """Transcode media files to another format/encoding :param inputs: url/path of the input media file or a sequence of tuples, each containing an input url and its options dict - :type inputs: str or a list of str or a sequence of (str,dict) :param outputs: url/path of the output media file or a sequence of tuples, each containing an output url and its options dict - :type outputs: str or sequence of (str, dict) :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param two_pass: True to encode in 2-pass :param pass1_omits: list of output arguments to ignore in pass 1, defaults to None (removes 'c:a' or 'acodec'). For multiple outputs, specify use list of the list of arguments, matching the length of outputs, for per-output omission. - :type pass1_omits: seq(str), or seq(seq(str)) optional :param pass1_extras: list of additional output arguments to include in pass 1, defaults to None (add 'an' if `pass1_omits` also None) - :type pass1_extras: dict(int:dict(str)), optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options. For output and global options, use FFmpeg + :param **options: FFmpeg options. For output and global options, use FFmpeg option names as is. For input options, append "_in" to the option name. For example, r_in=2000 to force the input frame rate to 2000 frames/s (see :doc:`options`). @@ -57,54 +78,50 @@ def transcode( options, and the url-specific duplicate options in the ``inputs`` or ``outputs`` sequence will overwrite those specified here. - :type \\**options: dict, optional :returns: if any of the outputs is stdout, returns output bytes - :rtype: bytes | None """ - # split input and global options from options - input_options = utils.pop_extra_options(options, "_in") - global_options = utils.pop_global_options(options) - - def format_arg(arg, defopts): - def test(a, is_list): - try: - assert len(a) == 2 - assert isinstance(a[1], dict) - return (a[0], {**defopts, **a[1]}) - except: - if is_list: - return (a, defopts) - raise - - # special case: a list of inputs w/out options - if type(arg) == list: - return [test(a, True) for a in arg] - - # attempt to map url-options pairs - try: - return [test(a, False) for a in arg] - except: - return [(arg, defopts)] - - inputs = format_arg(inputs, input_options) - outputs = format_arg(outputs, options) - - # initialize FFmpeg argument dict - args = configure.empty(global_options) - - for url, opts in inputs: - input_url, stdin, input = configure.check_url(url, False, opts.get("f", None)) - configure.add_url(args, "input", input_url, opts) - - for url, opts in outputs: - output_url, stdout, _ = configure.check_url(url, True) - i, _ = configure.add_url(args, "output", output_url, opts) - - # convert basic VF options to vf option + if utils.is_valid_input_url(inputs): + inputs = [inputs] + if utils.is_valid_output_url(outputs): + outputs = [outputs] + + args, input_info, output_info = configure.init_media_transcoder( + inputs, outputs, None, None, options + ) + + # check number of pipes + nb_inpipes = sum(info["src_type"] == "buffer" for info in input_info) + nb_outpipes = sum(info["dst_type"] == "buffer" for info in output_info) + + # if 0 or 1 buffered input and 0 or 1 buffered output, just use stdin/stdout + simple_mode = nb_inpipes < 2 and nb_outpipes < 2 + + if not simple_mode: + raise NotImplementedError( + "transcoding with multiple input or output pipes is not yet implemented." + ) + + # convert basic VF options to vf option + for i in range(len(output_info)): configure.build_basic_vf(args, None, i) + stdin = stdout = input = None + if nb_inpipes: + inputs = args["inputs"] + for i, info in enumerate(input_info): + inputs[i] = ("pipe:0", inputs[i][1]) + if "buffer" in info: + input = info["buffer"] + else: + stdin = fp.PIPE + if nb_outpipes: + outputs = args["outputs"] + for i, info in enumerate(output_info): + outputs[i] = ("pipe:1", outputs[i][1]) + stdout = fp.PIPE + kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( { From e016acf4abbbf3339c227dddb986b4bdf9111b3a Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 8 Mar 2025 22:21:12 -0600 Subject: [PATCH 277/333] moved `open()` to `_open.py` --- src/ffmpegio/__init__.py | 276 +-------------------------------------- src/ffmpegio/_open.py | 274 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 272 deletions(-) create mode 100644 src/ffmpegio/_open.py diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 301ff078..42413003 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -29,7 +29,6 @@ """ import logging -from typing import Optional, Tuple logger = logging.getLogger("ffmpegio") logger.addHandler(logging.NullHandler()) @@ -47,6 +46,7 @@ use = plugins.use + def __getattr__(name): if name == "ffmpeg_ver": return path.FFMPEG_VER @@ -60,17 +60,18 @@ def __getattr__(name): from .filtergraph import Graph as FilterGraph from . import devices, ffmpegprocess, caps, probe, audio, image, video, media from .transcode import transcode -from . import streams as _streams from .utils.parser import FLAG +from ._open import open # 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", "FFmpegioError", "FilterGraph", "FFConcat", "use"] + "open", "ffmpegprocess", "FFmpegError", "FFmpegioError", "FilterGraph", "FFConcat", "use", "FLAG"] # fmt:on __version__ = "0.11.1" @@ -81,272 +82,3 @@ def __getattr__(name): is_ready = path.found ffmpeg = path.ffmpeg ffprobe = path.ffprobe - - -def open( - url_fg: str, - mode: str, - rate_in: Optional[float] = None, - shape_in: Optional[Tuple[int, ...]] = None, - dtype_in: Optional[str] = None, - rate: Optional[float] = None, - shape: Optional[Tuple[int, ...]] = None, - **kwds, -): - """Open a multimedia file/stream for read/write - - :param url_fg: URL of the media source/destination for file read/write or filtergraph definition - for filter operation. - :type url_fg: str or seq(str) - :param mode: specifies the mode in which the FFmpeg is used, see below - :type mode: str - :param rate_in: (write and filter only, required) input frame rate (video) or sampling rate - (audio), defaults to None - :type rate_in: Fraction, float, int, optional - :param shape_in: (write and filter only) input video frame size (height x width [x ncomponents]), - or audio sample size (channels,), defaults to None - :type shape_in: seq of int, optional - :param dtype_in: (write and filter only) input data type, defaults to None - :type dtype_in: str, optional - :param rate: (filter only, required) output frame rate (video write) or sample rate (audio - write), defaults to None - :type rate: Fraction, float, int, optional - :param dtype: (read and filter specific) output data type, defaults to None - :type dtype: str, optional - :param shape: (read and filter specific) output video frame size (height x width [x ncomponents]), - or audio sample size (channels,), defaults to None - :type shape: seq of int, optional - :param show_log: True to echo the ffmpeg log to stdout, default to False - :type show_log: bool, optional - :param progress: progress callback function (see :ref:`quick-callback`) - :type progress: Callable, optional - :param blocksize: (read and filter only) Number of frames to read by `read()` method, default to None (auto) - :type blocksize: int, optional - :param extra_inputs: (write only) List of additional (non-pipe) inputs to pass onto FFmpeg. Each - input is defined by a tuple of its url or a dict of input options, default to None - :type extra_inputs: List[Tuple[str,dict]], optional - :param default_timeout: (filter only) default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional - :param sp_kwargs: Keyword arguments for FFmpeg process (see :py:class:`ffmpegio.ffmpegprocess.Popen`), default to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :returns: ffmpegio stream object - - Start FFmpeg and open I/O link to it to perform read/write/filter operation and return - a corresponding stream object. If the file cannot be opened, an error is raised. - See :ref:`quick-streamio` for more examples of how to use this function. - - Just like built-in `open()`, it is good practice to use the with keyword when dealing with - ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are - properly closed after ffmpeg terminates, even if an exception is raised at some point. - Using with is also much shorter than writing equivalent try-finally blocks. - - :Examples: - - Open an MP4 file and process all the frames:: - - with ffmpegio.open('video_source.mp4', 'rv') as f: - frame = f.read() - while frame: - # process the captured frame data - frame = f.read() - - Read an audio stream of MP4 file and write it to a FLAC file as samples - are decoded:: - - with ffmpegio.open('video_source.mp4','ra') as rd: - fs = rd.sample_rate - with ffmpegio.open('video_dst.flac','wa',rate_in=fs) as wr: - frame = rd.read() - while frame: - wr.write(frame) - frame = rd.read() - - :Additional Notes: - - `url_fg` can be a string specifying either the pathname (absolute or relative to the current - working directory) of the media target (file or streaming media) to be opened or a string describing - the filtergraph to be implemented. Its interpretation depends on the `mode` argument. - - `mode` is an optional string that specifies the mode in which the FFmpeg is opened. - - ==== =================================================== - Mode Description - ==== =================================================== - 'r' read from url - 'w' write to url - 'f' filter data defined by fg - 'v' operate on video stream, 'vv' if multi-video reader - 'a' operate on audio stream, 'aa' if multi-audio reader - ==== =================================================== - - `rate` and `rate_in`: Video frame rates shall be given in frames/second and - may be given as a number, string, or `fractions.Fraction`. Audio sample rate in - samples/second (per channel) and shall be given as an integer or string. - - Optional `shape` or `shape_in` for video defines the video frame size and - number of components with a 2 or 3 element sequence: `(width, height[, ncomp])`. - The number of components and other optional `dtype` (or `dtype_in`) implicitly - define the pixel format (FFmpeg pix_fmt option): - - ===== ===== ========= =================================== - ncomp dtype pix_fmt Description - ===== ===== ========= =================================== - 1 \|u8 gray grayscale - 1 1: - raise ValueError( - f"Too many streams specified: {mode}. A {'write' if write else 'filter'} stream can only process one stream at a time." - ) - - if write: - if is_fg: - ValueError("Cannot write to a filtergraph.") - if rate_in is None: - raise ValueError( - "Missing required argument: rate_in. A write stream must specify the rate of the input media stream." - ) - if rate is not None: - raise ValueError( - "Invalid argument for a write stream: rate. To change rate, use FFmpeg 'r' argument for video stream or 'ar' argument for audio stream." - ) - if shape is not None: - raise ValueError( - "Invalid argument for a read stream: shape. To change shape, use FFmpeg 's' argument for video frame or 'ac' for the number of audio channels." - ) - else: # if filter - vars = [] - if rate_in is None: - vars.append("rate_in") - if rate is None: - vars.append("rate") - if len(vars): - vars = ", ".join(vars) - raise ValueError( - f"Missing required arguments: {vars}. A filter stream must specify the rates of both the input and output media streams." - ) - - try: - StreamClass = ( - { - 0: { - 0: _streams.SimpleAudioReader, - 1: _streams.SimpleAudioWriter, - 2: _streams.SimpleAudioFilter, - }, - 1: { - 0: _streams.SimpleVideoReader, - 1: _streams.SimpleVideoWriter, - 2: _streams.SimpleVideoFilter, - }, - }[video][write + 2 * filter] - if audio + video == 1 - else _streams.AviMediaReader - ) - except: - raise ValueError(f"Invalid/unsupported FFmpeg streaming mode: {mode}.") - - if len(url_fg) > 1 and not StreamClass.multi_read: - raise ValueError(f'Multi-input streaming is not supported in "{mode}" mode') - - # add other info to the arguments - args = (*url_fg,) if read else (*url_fg, rate_in) - if not read: - for k, v in ( - ("dtype_in", dtype_in), - ("shape_in", shape_in), - ("rate", rate), - ("shape", shape), - ): - if v is not None: - kwds[k] = v - - # instantiate the streaming object - return StreamClass(*args, **kwds) diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py new file mode 100644 index 00000000..fc00ec9e --- /dev/null +++ b/src/ffmpegio/_open.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from typing import Optional, Tuple +from . import streams as _streams + +from .filtergraph import Graph as FilterGraph + +def open( + url_fg: str, + mode: str, + rate_in: Optional[float] = None, + shape_in: Optional[Tuple[int, ...]] = None, + dtype_in: Optional[str] = None, + rate: Optional[float] = None, + shape: Optional[Tuple[int, ...]] = None, + **kwds, +): + """Open a multimedia file/stream for read/write + + :param url_fg: URL of the media source/destination for file read/write or filtergraph definition + for filter operation. + :type url_fg: str or seq(str) + :param mode: specifies the mode in which the FFmpeg is used, see below + :type mode: str + :param rate_in: (write and filter only, required) input frame rate (video) or sampling rate + (audio), defaults to None + :type rate_in: Fraction, float, int, optional + :param shape_in: (write and filter only) input video frame size (height x width [x ncomponents]), + or audio sample size (channels,), defaults to None + :type shape_in: seq of int, optional + :param dtype_in: (write and filter only) input data type, defaults to None + :type dtype_in: str, optional + :param rate: (filter only, required) output frame rate (video write) or sample rate (audio + write), defaults to None + :type rate: Fraction, float, int, optional + :param dtype: (read and filter specific) output data type, defaults to None + :type dtype: str, optional + :param shape: (read and filter specific) output video frame size (height x width [x ncomponents]), + or audio sample size (channels,), defaults to None + :type shape: seq of int, optional + :param show_log: True to echo the ffmpeg log to stdout, default to False + :type show_log: bool, optional + :param progress: progress callback function (see :ref:`quick-callback`) + :type progress: Callable, optional + :param blocksize: (read and filter only) Number of frames to read by `read()` method, default to None (auto) + :type blocksize: int, optional + :param extra_inputs: (write only) List of additional (non-pipe) inputs to pass onto FFmpeg. Each + input is defined by a tuple of its url or a dict of input options, default to None + :type extra_inputs: List[Tuple[str,dict]], optional + :param default_timeout: (filter only) default filter timeout in seconds, defaults to None (10 ms) + :type default_timeout: float, optional + :param sp_kwargs: Keyword arguments for FFmpeg process (see :py:class:`ffmpegio.ffmpegprocess.Popen`), default to None + :type sp_kwargs: dict, optional + :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :type \\**options: dict, optional + :returns: ffmpegio stream object + + Start FFmpeg and open I/O link to it to perform read/write/filter operation and return + a corresponding stream object. If the file cannot be opened, an error is raised. + See :ref:`quick-streamio` for more examples of how to use this function. + + Just like built-in `open()`, it is good practice to use the with keyword when dealing with + ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are + properly closed after ffmpeg terminates, even if an exception is raised at some point. + Using with is also much shorter than writing equivalent try-finally blocks. + + :Examples: + + Open an MP4 file and process all the frames:: + + with ffmpegio.open('video_source.mp4', 'rv') as f: + frame = f.read() + while frame: + # process the captured frame data + frame = f.read() + + Read an audio stream of MP4 file and write it to a FLAC file as samples + are decoded:: + + with ffmpegio.open('video_source.mp4','ra') as rd: + fs = rd.sample_rate + with ffmpegio.open('video_dst.flac','wa',rate_in=fs) as wr: + frame = rd.read() + while frame: + wr.write(frame) + frame = rd.read() + + :Additional Notes: + + `url_fg` can be a string specifying either the pathname (absolute or relative to the current + working directory) of the media target (file or streaming media) to be opened or a string describing + the filtergraph to be implemented. Its interpretation depends on the `mode` argument. + + `mode` is an optional string that specifies the mode in which the FFmpeg is opened. + + ==== =================================================== + Mode Description + ==== =================================================== + 'r' read from url + 'w' write to url + 'f' filter data defined by fg + 'v' operate on video stream, 'vv' if multi-video reader + 'a' operate on audio stream, 'aa' if multi-audio reader + ==== =================================================== + + `rate` and `rate_in`: Video frame rates shall be given in frames/second and + may be given as a number, string, or `fractions.Fraction`. Audio sample rate in + samples/second (per channel) and shall be given as an integer or string. + + Optional `shape` or `shape_in` for video defines the video frame size and + number of components with a 2 or 3 element sequence: `(width, height[, ncomp])`. + The number of components and other optional `dtype` (or `dtype_in`) implicitly + define the pixel format (FFmpeg pix_fmt option): + + ===== ===== ========= =================================== + ncomp dtype pix_fmt Description + ===== ===== ========= =================================== + 1 \|u8 gray grayscale + 1 1: + raise ValueError( + f"Too many streams specified: {mode}. A {'write' if write else 'filter'} stream can only process one stream at a time." + ) + + if write: + if is_fg: + ValueError("Cannot write to a filtergraph.") + if rate_in is None: + raise ValueError( + "Missing required argument: rate_in. A write stream must specify the rate of the input media stream." + ) + if rate is not None: + raise ValueError( + "Invalid argument for a write stream: rate. To change rate, use FFmpeg 'r' argument for video stream or 'ar' argument for audio stream." + ) + if shape is not None: + raise ValueError( + "Invalid argument for a read stream: shape. To change shape, use FFmpeg 's' argument for video frame or 'ac' for the number of audio channels." + ) + else: # if filter + vars = [] + if rate_in is None: + vars.append("rate_in") + if rate is None: + vars.append("rate") + if len(vars): + vars = ", ".join(vars) + raise ValueError( + f"Missing required arguments: {vars}. A filter stream must specify the rates of both the input and output media streams." + ) + + try: + StreamClass = ( + { + 0: { + 0: _streams.SimpleAudioReader, + 1: _streams.SimpleAudioWriter, + 2: _streams.SimpleAudioFilter, + }, + 1: { + 0: _streams.SimpleVideoReader, + 1: _streams.SimpleVideoWriter, + 2: _streams.SimpleVideoFilter, + }, + }[video][write + 2 * filter] + if audio + video == 1 + else _streams.AviMediaReader + ) + except: + raise ValueError(f"Invalid/unsupported FFmpeg streaming mode: {mode}.") + + if len(url_fg) > 1 and not StreamClass.multi_read: + raise ValueError(f'Multi-input streaming is not supported in "{mode}" mode') + + # add other info to the arguments + args = (*url_fg,) if read else (*url_fg, rate_in) + if not read: + for k, v in ( + ("dtype_in", dtype_in), + ("shape_in", shape_in), + ("rate", rate), + ("shape", shape), + ): + if v is not None: + kwds[k] = v + + # instantiate the streaming object + return StreamClass(*args, **kwds) From 3c989fd8be5802ca0231bfcfeb9bb5d1f4a633ae Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 9 Mar 2025 15:24:05 -0500 Subject: [PATCH 278/333] doc update --- src/ffmpegio/_typing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index ef5e1839..f7884dba 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -40,7 +40,7 @@ FFmpegMediaType = Literal["video", "audio", "subtitle", "data", "attachments"] -FFmpegUrlType = Union[str, Path, ParseResult] +FFmpegUrlType = str | Path | ParseResult FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] FFmpegOutputType = Literal["url", "fileobj", "buffer"] From 27cd58e6b84c15b386bd70278bed32196dfea108 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 9 Mar 2025 15:24:20 -0500 Subject: [PATCH 279/333] changed keyword argument orders --- src/ffmpegio/streams/PipedStreams.py | 39 +++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 5afe0b68..bae2cbc0 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -800,11 +800,11 @@ def __init__( *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], map: Sequence[str] | dict[str, FFmpegOptionDict] | None = None, ref_stream: int = 0, - blocksize: int | None = None, - default_timeout: float | None = None, - progress: ProgressCallable | None = None, show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, queuesize: int | None = None, + default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): @@ -814,11 +814,15 @@ def __init__( :param map: FFmpeg map options :param ref_stream: index of the reference stream to pace read operation, defaults to 0. The reference stream is guaranteed to have a frame data on every read operation. - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. + :param progress: progress callback function, defaults to None + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific input url or '_in' to be applied to all inputs. The url-specific option gets the preference (see :doc:`options` for custom options) @@ -893,11 +897,11 @@ def __init__( merge_audio_sample_fmt: str | None = None, merge_audio_outpad: str | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - default_timeout: float | None = None, - progress: ProgressCallable | None = None, show_log: bool | None = None, + progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, + default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): @@ -920,14 +924,13 @@ def __init__( :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) - : defaults to `None` to use 1 video frame or 1024 audio frames + :param progress: progress callback function, defaults to None + :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific @@ -986,11 +989,11 @@ def __init__( extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, ref_output: int = 0, output_options: dict[str, FFmpegOptionDict] | None = None, - default_timeout: float | None = None, - progress: ProgressCallable | None = None, show_log: bool | None = None, + progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, + default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): @@ -1010,12 +1013,12 @@ def __init__( :param ref_output: index or label of the reference stream to pace read operation, defaults to 0. `PipedMediaFilter.read()` operates around the reference stream. :param output_options: specific options for keyed filtergraph output pads. - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param progress: progress callback function, defaults to None :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) : defaults to `None` to use 1 video frame or 1024 audio frames :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options @@ -1070,11 +1073,11 @@ def __init__( extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, *, - default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, blocksize: int | None = None, queuesize: int | None = None, + default_timeout: float | None = None, sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], ): @@ -1088,11 +1091,11 @@ def __init__( string or a pair of a url string and an option dict. :param extra_outputs: list of additional output destinations, defaults to None. Each destination may be url string or a pair of a url string and an option dict. - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param blocksize: Background reader queue's item size in bytes, defaults to `None` (64 kB) + :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None From 9377c7f534762a03649d3e30a776d8a1f4fa5911 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Mar 2025 20:11:48 -0500 Subject: [PATCH 280/333] moved `FFmpegOptionDict` to `_typing` --- src/ffmpegio/_typing.py | 6 +++++- src/ffmpegio/configure.py | 5 +---- src/ffmpegio/media.py | 2 +- src/ffmpegio/streams/PipedStreams.py | 2 +- src/ffmpegio/transcode.py | 3 +-- src/ffmpegio/utils/__init__.py | 9 +++++++-- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index f7884dba..80c8c9e6 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -17,7 +17,11 @@ # from typing_extensions import * -RawDataBlob = Any # depends on raw data reader plugin +FFmpegOptionDict = dict[str, Any] +"""FFmpeg options with their values keyed by the option names without preceding dash. +For option flags (e.g., -y) without any value, use `None` or its alias `ffmpegio.FLAG`""" + + RawStreamDef = ( tuple[int | float | Fraction, RawDataBlob] diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 82d765b3..7cefa73a 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -14,6 +14,7 @@ Unpack, Callable, RawDataBlob, + FFmpegOptionDict, ) from collections.abc import Sequence from .utils import FFmpegInputUrlComposite, FFmpegOutputUrlComposite @@ -57,10 +58,6 @@ UrlType = Literal["input", "output"] -FFmpegOptionDict = dict[str, Any] -"""FFmpeg options with their values keyed by the option names without preceding dash. -For option flags (e.g., -y) without any value, use `None` or its alias `ffmpegio.FLAG`""" - FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, FFmpegOptionDict] FFmpegOutputOptionTuple = tuple[FFmpegUrlType, FFmpegOptionDict] diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 1b9606ae..370e46d6 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -14,12 +14,12 @@ FFmpegUrlType, InputSourceDict, OutputDestinationDict, + FFmpegOptionDict, ) from .configure import ( FFmpegArgs, FFmpegOutputUrlComposite, FFmpegInputUrlComposite, - FFmpegOptionDict, ) from fractions import Fraction diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index bae2cbc0..62cd24b2 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -12,10 +12,10 @@ Literal, InputSourceDict, OutputDestinationDict, + FFmpegOptionDict, ) from ..configure import ( FFmpegArgs, - FFmpegOptionDict, FFmpegInputUrlComposite, FFmpegUrlType, MediaType, diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index ce8e83c6..7de1ed90 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -4,11 +4,10 @@ logger = logging.getLogger("ffmpegio") -from ._typing import Sequence, ProgressCallable, Unpack +from ._typing import Sequence, ProgressCallable, Unpack, FFmpegOptionDict from .configure import ( FFmpegOutputUrlComposite, FFmpegInputUrlComposite, - FFmpegOptionDict, FFmpegInputOptionTuple, FFmpegOutputOptionTuple, ) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 97cd9212..a819ca67 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -27,6 +27,7 @@ FFmpegUrlType, IO, Buffer, + FFmpegOptionDict, ) from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb @@ -497,7 +498,9 @@ def pop_global_options(options: dict[str, Any]) -> dict[str, Any]: return {k: options.pop(k) for k in [k for k in options.keys() if k in all_gopts]} -def array_to_audio_options(data: RawDataBlob | None) -> dict: +def array_to_audio_options( + data: RawDataBlob | None, +) -> tuple[FFmpegOptionDict, tuple[tuple[int], str]]: """create an input option dict for the given raw audio data blob :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) :returns: dict of audio options @@ -511,7 +514,9 @@ def array_to_audio_options(data: RawDataBlob | None) -> dict: return {"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt} -def array_to_video_options(data: RawDataBlob | None = None) -> dict: +def array_to_video_options( + data: RawDataBlob | None = None, +) -> tuple[FFmpegOptionDict, tuple[tuple[int], str]]: """create an input option dict for the given raw video data blob :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) From 57ee350c3ca88975c00ad4a7951af8c94366c256 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Mar 2025 21:14:43 -0500 Subject: [PATCH 281/333] `process_raw_outputs()` - fixed to handle critical options given in `options` --- src/ffmpegio/configure.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7cefa73a..89dcd236 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1299,13 +1299,22 @@ def process_raw_outputs( stream_info: dict[str, OutputDestinationDict] if streams is None or len(streams) == 0: stream_info = auto_map(args, input_info, fg_info) + stream_maps = {st: options for st in stream_info} else: + # add outputs to FFmpeg arguments + get_opts = isinstance(streams, dict) + # analyze for custom labels user_maps = {} stream_maps = {} - for k, v in ( - streams.items() if isinstance(streams, dict) else ((s, {}) for s in streams) - ): + for k, v in streams.items() if get_opts else ((s, None) for s in streams): + + if isinstance(k, tuple): + k = ":".join(str(s) for s in k) + + # add default options (if given) + v = {**options} if v is None else {**options, **v} + if "map" in v: st_map = v["map"] if not isinstance(st_map, str): @@ -1325,16 +1334,8 @@ def process_raw_outputs( stream_info = resolve_raw_output_streams(args, input_info, fg_info, user_maps) # add outputs to FFmpeg arguments - get_opts = isinstance(streams, dict) for spec, info in stream_info.items(): - if isinstance(spec, tuple): - spec = ":".join((str(s) for s in spec)) - - opts = ( - {**options, **streams[info["user_map"]], "map": spec} - if get_opts - else {**options, "map": spec} - ) + opts = {**stream_maps[spec], "map": spec} add_url(args, "output", None, opts) # finalize each output streams and identify the output formats From 7c2f5b5a0d1c9dd9f8a27d4a6f66739fd9d2bbd0 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Mar 2025 21:18:17 -0500 Subject: [PATCH 282/333] `InputSourceDict` added `data_info` field --- src/ffmpegio/_typing.py | 1 + src/ffmpegio/configure.py | 27 +++++++++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 80c8c9e6..b5f1ec34 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -57,6 +57,7 @@ class InputSourceDict(TypedDict): buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe + data_info: NotRequired[RawStreamInfo] writer: NotRequired[WriterThread] # pipe diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 89dcd236..2fc32b9b 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1395,30 +1395,41 @@ def process_raw_inputs( ) opts = {**inopts_default, **opts} - + more_opts = None + data_info = None if mtype == "a": # audio media_type = "audio" if data is not None: - opts.update(utils.array_to_audio_options(data)) + more_opts, data_info = utils.array_to_audio_options(data) data = plugins.get_hook().audio_bytes(obj=data) elif dtypes and shapes: + data_info = (shapes[i], dtypes[i]) sample_fmt, ac = utils.guess_audio_format(dtypes[i], shapes[i]) acodec, f = utils.get_audio_codec(sample_fmt) - opts.update({"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f}) + more_opts = {"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f} else: # video media_type = "video" if data is not None: - opts.update(utils.array_to_video_options(data)) + more_opts, data_info = utils.array_to_video_options(data) data = plugins.get_hook().video_bytes(obj=data) elif dtypes and shapes: - pix_fmt, s = utils.guess_video_format(shapes[i], dtypes[i]) - opts.update( - {"f": "rawvideo", f"c:v": "rawvideo", "pix_fmt": pix_fmt, "s": s} - ) + data_info = shapes[i], dtypes[i] + pix_fmt, s = utils.guess_video_format(*data_info) + more_opts = { + "f": "rawvideo", + f"c:v": "rawvideo", + "pix_fmt": pix_fmt, + "s": s, + } + if more_opts is not None: + opts.update(more_opts) info = {"src_type": "buffer", "media_type": media_type} + if data_info is not None: + info["data_info"] = data_info + if data is not None: info["buffer"] = data add_url(args, "input", None, opts) From 103383fd1ac3452f82fa66745bd0eceee44f9f91 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 13 Mar 2025 21:20:33 -0500 Subject: [PATCH 283/333] `init_media_filter()` - simplified `output_options` handling --- src/ffmpegio/configure.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 2fc32b9b..f7a3fdc4 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1961,8 +1961,7 @@ def init_media_filter( ) # add the default output options to output_options dict with None as the key - output_options[None] = options - + output_options = (output_options, options) if all(ready): output_info = init_media_filter_outputs(args, input_info, output_options) output_options = None @@ -1975,7 +1974,7 @@ def init_media_filter( def init_media_filter_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], - output_options: dict[str | None, FFmpegOptionDict], + output_options: tuple[dict[str, FFmpegOptionDict], FFmpegOptionDict], deferred_inputs: list[list[RawDataBlob | None] | bytes] | None = None, ) -> list[OutputDestinationDict]: """Initialize FFmpeg arguments for media read @@ -1994,8 +1993,8 @@ def init_media_filter_outputs( gopts["filter_complex"], args["inputs"], input_info ) - # get default output options - default_opts = output_options.pop(None, {}) + # separate specific and default output options + (output_options, default_opts) = output_options # adjust output_options out_maps = {} From 2b5f93d5085d2e03f026021ddc10f650ba62128a Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 11:57:28 -0500 Subject: [PATCH 284/333] `process_url_inputs()` - fixed handling of `FFConcat` object --- src/ffmpegio/configure.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index f7a3fdc4..e637666f 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1242,10 +1242,18 @@ def process_url_inputs( elif utils.is_url(url): input_info = {"src_type": "url"} elif isinstance(url, FFConcat): - # convert to buffer - input_info = {"src_type": "buffer", "buffer": FFConcat.input} - url = None - + #TODO - generalize this to handle an arbitrary Muxer class + opts["f"] = "concat" + url0 = url.url + if url0 in ("-", "unset"): + input_info = { + "src_type": "buffer", + "buffer": url.compose().getvalue().encode(), + } + url = None + else: + input_info = {"src_type": "url"} + url = url0 else: try: buffer = memoryview(url) From c7837680f8bd92d7b29a91751380ad4bc67db0bd Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 12:04:39 -0500 Subject: [PATCH 285/333] `guess_audio_format()` swapped input argument order --- src/ffmpegio/configure.py | 2 +- src/ffmpegio/streams/SimpleStreams.py | 4 ++-- src/ffmpegio/utils/__init__.py | 10 ++++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e637666f..e3e86b65 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1413,7 +1413,7 @@ def process_raw_inputs( elif dtypes and shapes: data_info = (shapes[i], dtypes[i]) - sample_fmt, ac = utils.guess_audio_format(dtypes[i], shapes[i]) + sample_fmt, ac = utils.guess_audio_format(shapes[i], dtypes[i]) acodec, f = utils.get_audio_codec(sample_fmt) more_opts = {"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f} diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index b2b8f13d..a1ff3da2 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -593,7 +593,7 @@ def _finalize(self, ffmpeg_args): if not ready and (self.dtype_in is not None or self.shape_in is not None): inopts = ffmpeg_args["inputs"][0][1] inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.dtype_in, self.shape_in + self.shape_in, self.dtype_in ) ready = True @@ -610,7 +610,7 @@ def _finalize_with_data(self, data): inopts = self._cfg["ffmpeg_args"]["inputs"][0][1] inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.dtype_in, self.shape_in + self.shape_in, self.dtype_in ) inopts["c:a"], inopts["f"] = utils.get_audio_codec(inopts["sample_fmt"]) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index a819ca67..dd0d0404 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -270,18 +270,16 @@ def get_audio_format(fmt: str, ac: int | None = None) -> str | tuple[str, tuple[ raise ValueError(f"Unsupported or unknown sample_fmt ({fmt}) specified.") -def guess_audio_format( - dtype: str, shape: Sequence[int] | None = None -) -> tuple[int, str]: +def guess_audio_format(shape: Sequence[int], dtype: str) -> tuple[int, str]: """get audio format - :param dtype: sample data type :param shape: sample data shape + :param dtype: sample data type :return: tuple of # of channels and sample_fmt ``` X = np.ones((1000,2),np.int16) - sample_fmt, ac = guess_audio_format(X.dtype, X.shape) + sample_fmt, ac = guess_audio_format(X.shape, X.dtype) # => sample_fmt='s16', ac=2 """ @@ -509,7 +507,7 @@ def array_to_audio_options( shape, dtype = plugins.get_hook().audio_info(obj=data) if shape is None: return {} - sample_fmt, ac = guess_audio_format(dtype, shape) + sample_fmt, ac = guess_audio_format(shape, dtype) codec, f = get_audio_codec(sample_fmt) return {"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt} From 41afe02834a7a4bcd671f03c9a904aada6f211de Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 12:05:28 -0500 Subject: [PATCH 286/333] `FFConcat.script` - cannot depend on a file object --- src/ffmpegio/utils/concat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/utils/concat.py b/src/ffmpegio/utils/concat.py index 3a7b9d79..50394df6 100644 --- a/src/ffmpegio/utils/concat.py +++ b/src/ffmpegio/utils/concat.py @@ -494,7 +494,7 @@ def url(self): @property def script(self): """:str: composed concat listing script""" - return (self._temp_file or self.compose()).getvalue() + return self.compose().getvalue() @property def input(self): From aacb5b38886b3bda015cea484398d363522923af Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 15:38:52 -0500 Subject: [PATCH 287/333] added `DTypeString`, `ShapeTuple`, and `RawStreamInfo` custom types --- src/ffmpegio/_open.py | 14 +++++---- src/ffmpegio/_typing.py | 44 ++++++++++++++++++++++++--- src/ffmpegio/_utils.py | 5 +-- src/ffmpegio/configure.py | 25 ++++++++------- src/ffmpegio/plugins/hookspecs.py | 15 ++++----- src/ffmpegio/plugins/rawdata_bytes.py | 14 +++++---- src/ffmpegio/plugins/rawdata_mpl.py | 4 +-- src/ffmpegio/plugins/rawdata_numpy.py | 10 +++--- src/ffmpegio/streams/PipedStreams.py | 14 +++++---- src/ffmpegio/utils/__init__.py | 18 ++++++----- 10 files changed, 107 insertions(+), 56 deletions(-) diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index fc00ec9e..8c77016e 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import Optional, Tuple +from ._typing import DTypeString, ShapeTuple + +from fractions import Fraction from . import streams as _streams from .filtergraph import Graph as FilterGraph @@ -8,11 +10,11 @@ def open( url_fg: str, mode: str, - rate_in: Optional[float] = None, - shape_in: Optional[Tuple[int, ...]] = None, - dtype_in: Optional[str] = None, - rate: Optional[float] = None, - shape: Optional[Tuple[int, ...]] = None, + rate_in: Optional[int | Fraction] = None, + shape_in: Optional[ShapeTuple] = None, + dtype_in: Optional[DTypeString] = None, + rate: Optional[int | Fraction] = None, + shape: Optional[ShapeTuple] = None, **kwds, ): """Open a multimedia file/stream for read/write diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index b5f1ec34..f5aad14a 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -21,13 +21,49 @@ """FFmpeg options with their values keyed by the option names without preceding dash. For option flags (e.g., -y) without any value, use `None` or its alias `ffmpegio.FLAG`""" +RawDataBlob = Any +"""any object to represent raw binary data supported by a data I/O plugin.""" + +DTypeString = LiteralString +"""Numpy array interface protocol typestr string + +The string format consists of 3 parts: a character describing the byteorder of the data +(`'<'`: little-endian, `'>'`: big-endian, `'|'`: not-relevant), a character code giving +the basic type of the array, and an integer providing the number of bytes the type uses. + +Three basic type character codes are relevant to `ffmpegio` package: + +===== ================ +code description +===== ================ +`'i'` Integer +`'u'` Unsigned integer +`'f'` Floating point +===== ================ + +See https://numpy.org/doc/stable/reference/arrays.interface.html for Numpy's +official documentation. +""" + +ShapeTuple = tuple[int, ...] +"""Tuple whose elements are the array size in each dimension. Each entry is an integer (a Python int).""" RawStreamDef = ( - tuple[int | float | Fraction, RawDataBlob] - | tuple[RawDataBlob | None, dict[str, Any]] + tuple[int | Fraction, RawDataBlob] | tuple[RawDataBlob | None, FFmpegOptionDict] ) +"""2-element tuple to define a raw stream data + + It comes in two forms: rate-data or data-option. The rate-data form specifies + a pair of the frame rate (video) or sampling rate (audio) and the data blob. + The data-option form specifies the data blob and its FFmpeg options. Note + that a data-option tuple is only valid if its option dict contains the rate + field: `r` for video or `ar` for audio. + +""" +RawStreamInfoTuple = tuple[DTypeString, ShapeTuple, int | Fraction] +"""3-element tuple (rate, shape, dtype) to characterize raw data stream""" ProgressCallable = Callable[[dict[str, Any], bool], bool] """FFmpeg progress callback function @@ -57,7 +93,7 @@ class InputSourceDict(TypedDict): buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe - data_info: NotRequired[RawStreamInfo] + data_info: NotRequired[RawStreamInfoTuple] writer: NotRequired[WriterThread] # pipe @@ -70,7 +106,7 @@ class OutputDestinationDict(TypedDict): input_file_id: NotRequired[int] input_stream_id: NotRequired[int] linklabel: NotRequired[str] - media_info: NotRequired[dict[str, Any]] + media_info: NotRequired[RawStreamInfoTuple] pipe: NotRequired[NPopen] reader: NotRequired[ReaderThread] itemsize: NotRequired[int] diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index ce4a0acf..6f5092cb 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Sequence +from ._typing import DTypeString, ShapeTuple from io import IOBase from pathlib import Path @@ -88,7 +89,7 @@ def as_multi_option( ) -def dtype_itemsize(dtype: str) -> int: +def dtype_itemsize(dtype: DTypeString) -> int: """get the byte size of each dtype sample :param dtype: numpy-style data type string @@ -97,7 +98,7 @@ def dtype_itemsize(dtype: str) -> int: return int(dtype[-1]) -def get_samplesize(shape: int, dtype: str) -> int: +def get_samplesize(shape: ShapeTuple, dtype: DTypeString) -> int: """get the byte size of each video frame or audio sample :param shape: sample shape diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e3e86b65..e7c81d12 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -7,6 +7,9 @@ MediaType, FFmpegUrlType, TypedDict, + DTypeString, + ShapeTuple, + RawStreamInfoTuple, Buffer, InputSourceDict, OutputDestinationDict, @@ -108,8 +111,8 @@ class FFmpegArgs(TypedDict): def array_to_video_input( - rate: int | float | Fraction | None = None, - data: Any | None = None, + rate: int | Fraction | None = None, + data: RawDataBlob | None = None, pipe_id: str | None = None, **opts: Unpack[FFmpegOptionDict], ) -> tuple[str, FFmpegOptionDict]: @@ -133,7 +136,7 @@ def array_to_video_input( def array_to_audio_input( rate: int | None = None, - data: Any | None = None, + data: RawDataBlob | None = None, pipe_id: str | None = None, **opts: Unpack[FFmpegOptionDict], ): @@ -307,7 +310,7 @@ def finalize_video_read_opts( ofile: int = 0, input_info: list[InputSourceDict] = [], fg_info: dict[str, dict] | None = None, -) -> tuple[str, tuple[int, int, int] | None, Fraction | None]: +) -> RawStreamInfoTuple: """finalize raw video read output options :param args: FFmpeg arguments (will be modified) @@ -480,7 +483,7 @@ def finalize_audio_read_opts( ofile: int = 0, input_info: list[InputSourceDict] = [], fg_info: dict[str, dict] | None = None, -) -> tuple[str, tuple[int] | None, int | None]: +) -> RawStreamInfoTuple: """finalize a raw output audio stream :param args: FFmpeg arguments. The option dict in args['outputs'][ofile][1] may be modified. @@ -1363,8 +1366,8 @@ def process_raw_inputs( stream_types: Sequence[Literal["a", "v"]], stream_args: Sequence[RawStreamDef], inopts_default: FFmpegOptionDict, - dtypes: list[str] | None = None, - shapes: list[tuple[int]] | None = None, + dtypes: list[DTypeString] | None = None, + shapes: list[ShapeTuple] | None = None, ) -> list[InputSourceDict]: input_info: list[InputSourceDict] = [] @@ -1724,8 +1727,8 @@ def init_media_write( | None ), options: dict[str, Any], - dtypes: list[str] | None = None, - shapes: list[tuple[int]] | None = None, + dtypes: list[DTypeString] | None = None, + shapes: list[ShapeTuple] | None = None, ) -> tuple[ FFmpegArgs, list[InputSourceDict], @@ -1902,8 +1905,8 @@ def init_media_filter( ] | None ), - input_dtypes: list[str] | None, - input_shapes: list[tuple[int]] | None, + input_dtypes: list[DTypeString] | None, + input_shapes: list[ShapeTuple] | None, options: FFmpegOptionDict, output_options: dict[str, FFmpegOptionDict], ) -> tuple[ diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index 32c9fa2c..e809fdec 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -1,7 +1,8 @@ from __future__ import annotations import pluggy -from typing import Callable, Tuple +from typing import Callable +from .._typing import DTypeString, ShapeTuple hookspec = pluggy.HookspecMarker("ffmpegio") @@ -12,7 +13,7 @@ def finder() -> Tuple[str, str]: @hookspec(firstresult=True) -def video_info(obj: object) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: object) -> tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: object containing video frame data with arbitrary number of frames @@ -22,7 +23,7 @@ def video_info(obj: object) -> Tuple[Tuple[int, int, int], str]: @hookspec(firstresult=True) -def audio_info(obj: object) -> Tuple[int, str]: +def audio_info(obj: object) -> tuple[ShapeTuple, DTypeString]: """get audio sample info :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples @@ -67,7 +68,7 @@ def audio_samples(obj: object) -> int: @hookspec(firstresult=True) def bytes_to_video( - b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool ) -> object: """convert bytes to rawvideo object @@ -80,7 +81,7 @@ def bytes_to_video( @hookspec(firstresult=True) -def bytes_to_audio(b: bytes, dtype: str, shape: Tuple[int], squeeze: bool) -> object: +def bytes_to_audio(b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool) -> object: """convert bytes to rawaudio object :param b: byte data of arbitrary number of video frames @@ -92,7 +93,7 @@ def bytes_to_audio(b: bytes, dtype: str, shape: Tuple[int], squeeze: bool) -> ob @hookspec -def device_source_api() -> Tuple[str, dict[str, Callable]]: +def device_source_api() -> tuple[str, dict[str, Callable]]: """return a source name and its set of interface functions keyword/signature Descriptions @@ -106,7 +107,7 @@ def device_source_api() -> Tuple[str, dict[str, Callable]]: @hookspec -def device_sink_api() -> Tuple[str, dict[str, Callable]]: +def device_sink_api() -> tuple[str, dict[str, Callable]]: """return a sink name and its set of interface functions keyword/signature Descriptions diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index c8ad3d0f..fa129dcb 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -4,6 +4,8 @@ from pluggy import HookimplMarker from typing import Tuple, TypedDict +from .._typing import DTypeString, ShapeTuple + __all__ = [ "BytesRawDataBlob", "video_info", @@ -25,15 +27,15 @@ class BytesRawDataBlob(TypedDict): buffer: bytes """data buffer""" - dtype: str + dtype: DTypeString """numpy-style data type string""" - shape: Tuple[int, int, int] + shape: ShapeTuple """data shape""" @hookimpl -def video_info(obj: BytesRawDataBlob) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: BytesRawDataBlob) -> Tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: dict containing video frame data with arbitrary number of frames @@ -48,7 +50,7 @@ def video_info(obj: BytesRawDataBlob) -> Tuple[Tuple[int, int, int], str]: @hookimpl -def audio_info(obj: BytesRawDataBlob) -> Tuple[int, str]: +def audio_info(obj: BytesRawDataBlob) -> Tuple[ShapeTuple, DTypeString]: """get audio sample info :param obj: dict containing audio data (with interleaving channels) with arbitrary number of samples @@ -119,7 +121,7 @@ def audio_samples(obj: BytesRawDataBlob) -> int: @hookimpl def bytes_to_video( - b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool ) -> BytesRawDataBlob: """convert bytes to rawvideo object @@ -144,7 +146,7 @@ def bytes_to_video( @hookimpl def bytes_to_audio( - b: bytes, dtype: str, shape: Tuple[int], squeeze: bool + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool ) -> BytesRawDataBlob: """convert bytes to rawaudio object diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py index ee80e2c9..ae2cc0cb 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -2,7 +2,7 @@ import matplotlib as Figure from pluggy import HookimplMarker -from typing import Tuple +from .._typing import DTypeString, ShapeTuple import io __all__ = ["video_info", "video_bytes"] @@ -11,7 +11,7 @@ @hookimpl -def video_info(obj: Figure) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: Figure) -> tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: matplotlib Figure object diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index 94f243e9..8ac292fc 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -3,7 +3,7 @@ from __future__ import annotations import numpy as np from pluggy import HookimplMarker -from typing import Tuple +from .._typing import DTypeString, ShapeTuple from numpy.typing import ArrayLike @@ -22,7 +22,7 @@ @hookimpl -def video_info(obj: ArrayLike) -> Tuple[Tuple[int, int, int], str]: +def video_info(obj: ArrayLike) -> tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: video frame data with arbitrary number of frames @@ -36,7 +36,7 @@ def video_info(obj: ArrayLike) -> Tuple[Tuple[int, int, int], str]: @hookimpl -def audio_info(obj: ArrayLike) -> Tuple[int, str]: +def audio_info(obj: ArrayLike) -> tuple[ShapeTuple, DTypeString]: """get audio sample info :param obj: column-wise audio data with arbitrary number of samples @@ -107,7 +107,7 @@ def audio_bytes(obj: ArrayLike) -> memoryview: @hookimpl def bytes_to_video( - b: bytes, dtype: str, shape: Tuple[int, int, int], squeeze: bool + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool ) -> ArrayLike: """convert bytes to rawvideo NumPy array @@ -126,7 +126,7 @@ def bytes_to_video( @hookimpl -def bytes_to_audio(b: bytes, dtype: str, shape: Tuple[int], squeeze: bool) -> ArrayLike: +def bytes_to_audio(b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool) -> ArrayLike: """convert bytes to rawaudio NumPy array :param b: byte data of arbitrary number of video frames diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 62cd24b2..37c933d9 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -7,6 +7,8 @@ from typing_extensions import Unpack from collections.abc import Sequence from .._typing import ( + DTypeString, + ShapeTuple, ProgressCallable, RawDataBlob, Literal, @@ -553,12 +555,12 @@ def output_rates(self) -> dict[str, int | Fraction]: return {v["user_map"]: v["media_info"][2] for v in self._output_info} @property - def output_dtypes(self) -> dict[str, str]: + def output_dtypes(self) -> dict[str, DTypeString]: """frame/sample data type associated with the output streams (key)""" return {v["user_map"]: v["media_info"][0] for v in self._output_info} @property - def output_shapes(self) -> dict[str, tuple[int]]: + def output_shapes(self) -> dict[str, ShapeTuple]: """frame/sample shape associated with the output streams (key)""" return {v["user_map"]: v["media_info"][1] for v in self._output_info} @@ -890,8 +892,8 @@ def __init__( ), stream_types: Sequence[Literal["a", "v"]], *stream_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], - dtypes_in: list[str] | None = None, - shapes_in: list[tuple[int]] | None = None, + dtypes_in: list[DTypeString] | None = None, + shapes_in: list[ShapeTuple] | None = None, merge_audio_streams: bool | Sequence[int] = False, merge_audio_ar: int | None = None, merge_audio_sample_fmt: str | None = None, @@ -984,8 +986,8 @@ def __init__( expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], input_types: Sequence[Literal["a", "v"]], *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], - input_dtypes: list[str] | None = None, - input_shapes: list[tuple[int]] | None = None, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, ref_output: int = 0, output_options: dict[str, FFmpegOptionDict] | None = None, diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index dd0d0404..9df73def 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -28,6 +28,8 @@ IO, Buffer, FFmpegOptionDict, + ShapeTuple, + DTypeString, ) from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb @@ -44,7 +46,7 @@ def get_pixel_config( input_pix_fmt: str, pix_fmt: str | None = None -) -> tuple[str, int, str, bool]: +) -> tuple[str, int, DTypeString, bool]: """get best pixel configuration to read video data in specified pixel format :param input_pix_fmt: input pixel format @@ -154,7 +156,7 @@ def get_pixel_format(fmt: str) -> tuple[str, int]: def get_video_format( fmt: str, s: tuple[int, int] | str -) -> tuple[str, tuple[int, int, int]]: +) -> tuple[DTypeString, ShapeTuple]: """get pixel data type and frame array (height,width,ncomp) :param fmt: ffmpeg pix_fmt or data type string @@ -167,7 +169,7 @@ def get_video_format( def guess_video_format( - shape: Sequence[int, int, int], dtype: str + shape: ShapeTuple, dtype: DTypeString ) -> tuple[tuple[int, int], str]: """get video format @@ -248,7 +250,9 @@ def get_audio_codec(fmt: str) -> tuple[str, str]: raise ValueError(f"{fmt} is not a valid raw audio sample_fmt") -def get_audio_format(fmt: str, ac: int | None = None) -> str | tuple[str, tuple[int]]: +def get_audio_format( + fmt: str, ac: int | None = None +) -> str | tuple[DTypeString, ShapeTuple]: """get audio sample data format :param fmt: ffmpeg sample_fmt or data type string @@ -270,7 +274,7 @@ def get_audio_format(fmt: str, ac: int | None = None) -> str | tuple[str, tuple[ raise ValueError(f"Unsupported or unknown sample_fmt ({fmt}) specified.") -def guess_audio_format(shape: Sequence[int], dtype: str) -> tuple[int, str]: +def guess_audio_format(shape: ShapeTuple, dtype: DTypeString) -> tuple[int, str]: """get audio format :param shape: sample data shape @@ -498,7 +502,7 @@ def pop_global_options(options: dict[str, Any]) -> dict[str, Any]: def array_to_audio_options( data: RawDataBlob | None, -) -> tuple[FFmpegOptionDict, tuple[tuple[int], str]]: +) -> tuple[FFmpegOptionDict, tuple[ShapeTuple, DTypeString]]: """create an input option dict for the given raw audio data blob :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) :returns: dict of audio options @@ -514,7 +518,7 @@ def array_to_audio_options( def array_to_video_options( data: RawDataBlob | None = None, -) -> tuple[FFmpegOptionDict, tuple[tuple[int], str]]: +) -> tuple[FFmpegOptionDict, tuple[ShapeTuple, DTypeString]]: """create an input option dict for the given raw video data blob :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) From 4ddc86756a3fdc6fdf00115adb0618af6f75b186 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 15:42:00 -0500 Subject: [PATCH 288/333] `InputSourceDict['data_info']` and `OutputSourceDict['media_info']` fields renamed to `'raw_info'` --- src/ffmpegio/_typing.py | 4 ++-- src/ffmpegio/configure.py | 26 +++++++++++++------------- src/ffmpegio/media.py | 8 ++++---- src/ffmpegio/streams/PipedStreams.py | 10 +++++----- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index f5aad14a..f96d45f6 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -93,7 +93,7 @@ class InputSourceDict(TypedDict): buffer: NotRequired[bytes] # index of the source index fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe - data_info: NotRequired[RawStreamInfoTuple] + raw_info: NotRequired[RawStreamInfoTuple] writer: NotRequired[WriterThread] # pipe @@ -106,7 +106,7 @@ class OutputDestinationDict(TypedDict): input_file_id: NotRequired[int] input_stream_id: NotRequired[int] linklabel: NotRequired[str] - media_info: NotRequired[RawStreamInfoTuple] + raw_info: NotRequired[RawStreamInfoTuple] pipe: NotRequired[NPopen] reader: NotRequired[ReaderThread] itemsize: NotRequired[int] diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index e7c81d12..a06c27a9 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1351,8 +1351,8 @@ def process_raw_outputs( # finalize each output streams and identify the output formats for i, (_, info) in enumerate(stream_info.items()): - # append media_info key to the output info dict - info["media_info"] = ( + # append raw_info key to the output info dict + info["raw_info"] = ( finalize_audio_read_opts if info["media_type"] == "audio" else finalize_video_read_opts @@ -1407,15 +1407,15 @@ def process_raw_inputs( opts = {**inopts_default, **opts} more_opts = None - data_info = None + raw_info = None if mtype == "a": # audio media_type = "audio" if data is not None: - more_opts, data_info = utils.array_to_audio_options(data) + more_opts, raw_info = utils.array_to_audio_options(data) data = plugins.get_hook().audio_bytes(obj=data) elif dtypes and shapes: - data_info = (shapes[i], dtypes[i]) + raw_info = (shapes[i], dtypes[i]) sample_fmt, ac = utils.guess_audio_format(shapes[i], dtypes[i]) acodec, f = utils.get_audio_codec(sample_fmt) more_opts = {"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f} @@ -1423,11 +1423,11 @@ def process_raw_inputs( else: # video media_type = "video" if data is not None: - more_opts, data_info = utils.array_to_video_options(data) + more_opts, raw_info = utils.array_to_video_options(data) data = plugins.get_hook().video_bytes(obj=data) elif dtypes and shapes: - data_info = shapes[i], dtypes[i] - pix_fmt, s = utils.guess_video_format(*data_info) + raw_info = shapes[i], dtypes[i] + pix_fmt, s = utils.guess_video_format(*raw_info) more_opts = { "f": "rawvideo", f"c:v": "rawvideo", @@ -1438,9 +1438,9 @@ def process_raw_inputs( opts.update(more_opts) info = {"src_type": "buffer", "media_type": media_type} - if data_info is not None: - info["data_info"] = data_info - + if raw_info is not None: + info["raw_info"] = raw_info + if data is not None: info["buffer"] = data add_url(args, "input", None, opts) @@ -2151,8 +2151,8 @@ def init_named_pipes( reader = CopyFileObjThread(info["fileobj"], pipe) elif dst_type == "buffer": kws = {**wr_kws} - if "media_info" in info: - dtype, shape, rate = info["media_info"] + if "raw_info" in info: + dtype, shape, rate = info["raw_info"] kws["itemsize"] = utils.get_samplesize(shape, dtype) if update_rate is not None: kws["nmin"] = round(rate / update_rate) or 1 diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 370e46d6..b8e358a8 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -44,7 +44,7 @@ def _runner( # True if there is unknown datablob info need_stderr = any( - info["dst_type"] == "pipe" and info["media_info"] is None + info["dst_type"] == "pipe" and info["raw_info"] is None for info in output_info ) @@ -92,14 +92,14 @@ def _gather_outputs( b = info["reader"].read_all() # get datablob info from stderr if needed - missing = any(v is None for v in info["media_info"]) + missing = any(v is None for v in info["raw_info"]) if missing: logger.warning('Retrieving stream "%s" information from FFmpeg log.', spec) new_info = extract_output_stream(proc.stderr, i) if info["media_type"] == "video": - dtype, shape, rate = info["media_info"] + dtype, shape, rate = info["raw_info"] if missing: if dtype is None: @@ -114,7 +114,7 @@ def _gather_outputs( b=b, dtype=dtype, shape=shape, squeeze=False ) else: # 'audio' - dtype, shape, rate = info["media_info"] + dtype, shape, rate = info["raw_info"] if missing: if dtype is None: sample_fmt = new_info["sample_fmt"] diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 37c933d9..2cbf57b9 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -552,17 +552,17 @@ def output_types(self) -> dict[str, MediaType]: @property def output_rates(self) -> dict[str, int | Fraction]: """sample or frame rates associated with the output streams (key)""" - return {v["user_map"]: v["media_info"][2] for v in self._output_info} + return {v["user_map"]: v["raw_info"][2] for v in self._output_info} @property def output_dtypes(self) -> dict[str, DTypeString]: """frame/sample data type associated with the output streams (key)""" - return {v["user_map"]: v["media_info"][0] for v in self._output_info} + return {v["user_map"]: v["raw_info"][1] for v in self._output_info} @property def output_shapes(self) -> dict[str, ShapeTuple]: """frame/sample shape associated with the output streams (key)""" - return {v["user_map"]: v["media_info"][1] for v in self._output_info} + return {v["user_map"]: v["raw_info"][0] for v in self._output_info} @property def output_counts(self) -> dict[str, int]: @@ -575,7 +575,7 @@ def _init_named_pipes(self) -> ExitStack: info = self._output_info[self._ref] if self._blocksize is None: self._blocksize = 1 if info["media_type"] == "video" else 1024 - self._rates = [v["media_info"][2] for v in self._output_info] + self._rates = [v["raw_info"][2] for v in self._output_info] self._n0 = [0] * len(self._output_info) # timestamps of the last read sample self._pipe_kws = { **self._pipe_kws, @@ -595,7 +595,7 @@ def _read_stream( """read selected output stream (shared backend)""" converter = self._converters[info["media_type"]] - dtype, shape, _ = info["media_info"] + dtype, shape, _ = info["raw_info"] data = converter( b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=False From 4f54c4a6174ccc9c5597dd4279c941b08c6aac7d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:24:13 -0500 Subject: [PATCH 289/333] `ReaderThread.read()` - fixed timeout handling --- src/ffmpegio/threading.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index bf895950..f9e57428 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -436,7 +436,7 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: # loop till enough data are collected while read_all or m > 0: tout = timeout and max(timeout - time(), 0) - block = not self._running.is_set() and tout + block = self._running.is_set() and tout != 0 try: b = self._queue.get(block, tout) @@ -446,7 +446,9 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: mr = len(b) m -= mr mread += mr - assert mr and (read_all or tout > 0) # no more read time left + assert mr and ( + read_all or tout is None or tout > 0 + ) # no more read time left except (Empty, AssertionError): break From 95bd3034505ee601a3715cf47c2b18cf38b0f122 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:24:58 -0500 Subject: [PATCH 290/333] `WriterThread.run()` - fixed to check stdin state before closing --- src/ffmpegio/threading.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index f9e57428..512ab322 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -579,10 +579,11 @@ def run(self): self._no_more = True # close the pipe/stream - if self.pipe is None: - self.stdin.close() - else: + if self.pipe is not None: self.pipe.close() + elif not self.stdin.closed: + print(f'{self.stdin.closed=}') + self.stdin.close() # completely flush the queue # check if queue has any remaining items From 8ec777ccd8fcd49095f1ea9b8f29ce69d50b0dae Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:25:26 -0500 Subject: [PATCH 291/333] `WriteThread.write()` - fixed timeout handling --- src/ffmpegio/threading.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 512ab322..af1bafa2 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -617,7 +617,7 @@ def write(self, data, timeout=None): if data is None: self._no_more = True - self._queue.put(data, timeout) + self._queue.put(data, timeout != 0, timeout) self._empty = False def flush(self, timeout: float | None = None): From c44b604d851831bab801371a399d3bd69f65d018 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:27:03 -0500 Subject: [PATCH 292/333] `temp_video_src()` - support unknown `pix_fmt` case (omit format filter) --- src/ffmpegio/filtergraph/presets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index f669f7ba..a5049af6 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -152,7 +152,8 @@ def temp_video_src(r: int | Fraction, pix_fmt: str, s: tuple[int, int]) -> fgb.C :param s: frame shape (width x height) :return: a chain of color and format filters """ - return fgb.color(s=f"{s[0]}x{s[1]}", r=r) + fgb.format(pix_fmts=pix_fmt) + fg = fgb.color(s=f"{s[0]}x{s[1]}", r=r) + return fg if pix_fmt == "unknown" else (fg + fgb.format(pix_fmts=pix_fmt)) def temp_audio_src(ar: int, sample_fmt: str, ac: int) -> fgb.Chain: From d41915689197f6714235500f3978cee349424fb2 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:40:35 -0500 Subject: [PATCH 293/333] `array_to_audio_options()` & `array_to_video_options()` to return a tuple of dtype and shape as a second item --- src/ffmpegio/configure.py | 4 ++-- src/ffmpegio/utils/__init__.py | 23 ++++++++++++++--------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index a06c27a9..db1538bf 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -130,7 +130,7 @@ def array_to_video_input( return ( pipe_id or "-", - {**utils.array_to_video_options(data), f"r": rate, **opts}, + {**utils.array_to_video_options(data)[0], f"r": rate, **opts}, ) @@ -153,7 +153,7 @@ def array_to_audio_input( return ( pipe_id or "-", - {**utils.array_to_audio_options(data), f"ar": rate, **opts}, + {**utils.array_to_audio_options(data)[0], f"ar": rate, **opts}, ) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 9df73def..5813910c 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -508,12 +508,12 @@ def array_to_audio_options( :returns: dict of audio options """ - shape, dtype = plugins.get_hook().audio_info(obj=data) + shape, dtype = info = plugins.get_hook().audio_info(obj=data) if shape is None: - return {} + return ({}, info) sample_fmt, ac = guess_audio_format(shape, dtype) codec, f = get_audio_codec(sample_fmt) - return {"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt} + return ({"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt}, info) def array_to_video_options( @@ -522,17 +522,22 @@ def array_to_video_options( """create an input option dict for the given raw video data blob :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) - :return: option dict + :return : option dict """ - s, pix_fmt = guess_video_format(*plugins.get_hook().video_info(obj=data)) + shape, dtype = info = plugins.get_hook().video_info(obj=data) + if shape is None: + return ({}, info) + s, pix_fmt = guess_video_format(shape, dtype) return ( - {"f": "rawvideo", f"c:v": "rawvideo"} - if s is None - else {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} + ( + {"f": "rawvideo", f"c:v": "rawvideo"} + if s is None + else {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} + ), + info, ) - def set_sp_kwargs_stdin( url: str | None, info: InputSourceDict, sp_kwargs: dict = {} ) -> tuple[str, dict | None, Callable]: From fc8668429bb1495c87f58d2f5f2659e26a9dbeb8 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:42:17 -0500 Subject: [PATCH 294/333] `finalize_video_read_opts()` - check for invalid `pix_fmt` --- src/ffmpegio/configure.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index db1538bf..6d2f44a7 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -377,6 +377,12 @@ def finalize_video_read_opts( # pixel format must be specified if pix_fmt is None: + + if pix_fmt_in == "unknown": + raise FFmpegioError( + "input pixel format unknown. Please specify output pix_fmt (to be autoset)" + ) + # deduce output pixel format from the input pixel format try: outopts["pix_fmt"], ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) From 104517b5a966e2dad03b04860aa08845a2892345 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:43:21 -0500 Subject: [PATCH 295/333] `finalize_audio_read_opts()` fixed prioritizing output option for filtergraph outputs as well --- src/ffmpegio/configure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 6d2f44a7..2c45919c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -525,7 +525,7 @@ def finalize_audio_read_opts( raise FFmpegioError( f"Complex filtergraph or the specified {linklabel=} do not exist." ) - opt_vals = [info["ar"], info["sample_fmt"], info["ac"]] + inopt_vals = [info["ar"], info["sample_fmt"], info["ac"]] else: ifile = outmap_fields["input_file_id"] @@ -546,7 +546,7 @@ def finalize_audio_read_opts( "0", af, {"f": "lavfi"}, {"src_type": "filtergraph"} ) - opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] + opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] # assign the values to individual variables ar, sample_fmt, ac = opt_vals From c5af7cfc982a7b1263ec7f3b3ce980505db20837 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:44:24 -0500 Subject: [PATCH 296/333] refactored `configure.update_raw_input()` --- src/ffmpegio/configure.py | 32 ++++++++++++++++++++++++++++ src/ffmpegio/streams/PipedStreams.py | 7 +++--- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 2c45919c..355266ba 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1455,6 +1455,38 @@ def process_raw_inputs( return input_info +def update_raw_input( + args: FFmpegArgs, + input_info: list[InputSourceDict], + stream_id: int, + data: RawDataBlob, +): + """update raw input stream from the data blob + + :param args: FFmpeg arguments to be modified + :param input_info: FFmpeg input information + :param stream_id: index of the input stream to be updated + :param data: input data blob + + * updates `args['inputs'][stream_id][1]` dict + * updates `raw_info` field of ``input_info[stream_id]` dict + + """ + + opts = args["inputs"][stream_id][1] + info = input_info[stream_id] + is_audio = info["media_type"] == "audio" + rate = opts["ar" if is_audio else "r"] + more_opts, raw_info = ( + utils.array_to_audio_options(data) + if is_audio + else utils.array_to_video_options(data) + ) + + opts.update(more_opts) + info["raw_info"] = (*raw_info[::-1], rate) # dtype, shape, rate + + def process_url_outputs( args: FFmpegArgs, input_info: list[InputSourceDict], diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 2cbf57b9..0b0da8f2 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -312,10 +312,9 @@ def _write_stream( # need to collect input data type and shape from the actual data # before starting the FFmpeg - info = self._input_info[stream_id] - opts = self._args["ffmpeg_args"]["inputs"][stream_id][1] - - opts.update(self._array_to_opts[info["media_type"]](data)) + configure.update_raw_input( + self._args["ffmpeg_args"], self._input_info, stream_id, data + ) self._deferred_data[stream_id].append(b) self._input_ready[stream_id] = True From 0be9dfde0981610b56c9aac9947112e6925ca357 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 20:46:08 -0500 Subject: [PATCH 297/333] `init_media_write_outputs()` - check for providing both `merge_audio_streams` and `extra_inputs` (not currently supported) --- src/ffmpegio/configure.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 355266ba..5f67572e 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1883,8 +1883,13 @@ def init_media_write_outputs( ) = output_args # if `merge_audio_streams` is non-`None`, append audio-merge filtergraph - a_ids = [i for i, info in enumerate(input_info) if info["media_type"] == "audio"] - do_merge = bool(merge_audio_streams) and len(a_ids) > 1 + do_merge = bool(merge_audio_streams) + if do_merge: + try: + a_ids = [i for i, info in enumerate(input_info) if info["media_type"] == "audio"] + except KeyError as e: + raise NotImplementedError('audio merging mode is not currently implemented. Please use the `complex_filtergraph=ffmpegio.filtergraph.presets.merge_audio(...)` to assign a custom filtergraph.') + do_merge = len(a_ids) > 1 if do_merge: if merge_audio_streams is True: # if True, convert to stream indices of audio inputs From 1cbfa006b225a6138d0c0e17e3318942d9d08d24 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 15 Mar 2025 21:01:45 -0500 Subject: [PATCH 298/333] added `configure.assign_std_pipes()` refactored from `transcode()` --- src/ffmpegio/configure.py | 80 +++++++++++++++++++++++++++++++++++++++ src/ffmpegio/transcode.py | 17 ++------- 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 5f67572e..ff6fb433 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -2240,3 +2240,83 @@ def init_named_pipes( args["global_options"]["y"] = None return stack if len(input_info) or len(output_info) else None + + +def assign_std_pipes( + args: FFmpegArgs, + input_info: list[InputSourceDict], + output_info: list[OutputDestinationDict], + use_sp_run: bool = False, +) -> tuple[int | IO | None, int | IO | None, bytes | None]: + """initialize named pipes for read & write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param input_info: list of input information + :param output_info: list of output information + :param use_sp_run: True to set `stdin` output to `None` even if input + data is given (so it's compatible with `subprocess.run()`) + :returns stdin: stdin argument of subsequent ffmpegprocess.Popen call + :returns stdout: stdout argument of subsequent ffmpegprocess.Popen call + :returns input: input argument of subsequent ffmpegprocess.Popen call + + In addition to the retured list, this function modifies the dicts in its arguements. + + - The pipe names are assigned to the URLs of FFmpeg input and output (`args['inputs'][][0]` + and `args['outputs'][][0]`) + - The reader threads for FFmpeg outputs that are written to buffers (i.e., + `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` + so the reader object can be used to retrieve the data. + + + if any output is a piped, overwrite flag (-y) is automatically inserted + """ + + # configure output pipes + use_stdin = use_stdout = False + stdin = stdout = pinput = None + for i, (output, info) in enumerate(zip(args["outputs"], output_info)): + if output[0] is None or utils.is_pipe(output[0]): + if use_stdout: + raise FFmpegioError( + "More than 1 pipe to output found. Cannot use standard pipes." + ) + use_stdout = True + assign_output_url(args, i, "pipe:1") + + dst_type = info["dst_type"] + if dst_type == "fileobj": + stdout = info["fileobj"] + elif dst_type == "buffer": + stdout = fp.PIPE + else: + raise FFmpegioError(f"{dst_type=} is an unknown output data type.") + + # configure input pipes (if needed) + for i, (input, info) in enumerate(zip(args["inputs"], input_info)): + if input[0] is None or utils.is_pipe(input[0]): + if use_stdin: + raise FFmpegioError( + "More than 1 pipe to input found. Cannot use standard pipes." + ) + use_stdin = True + assign_input_url(args, i, "pipe:0") + src_type = info["src_type"] + if src_type == "fileobj": + stdin = info["fileobj"] + elif src_type == "buffer": + if "buffer" in info: + pinput = info["buffer"] + if not use_sp_run: + stdin = fp.PIPE + else: + stdin = fp.PIPE + else: + raise FFmpegioError(f"{src_type=} is an unknown input data type.") + + if use_stdout: + # if any output is piped, must run in the overwrite mode + args["global_options"].pop("n", None) + args["global_options"]["y"] = None + + return stdin, stdout, pinput + diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index 7de1ed90..a4ef4d0b 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -106,20 +106,9 @@ def transcode( for i in range(len(output_info)): configure.build_basic_vf(args, None, i) - stdin = stdout = input = None - if nb_inpipes: - inputs = args["inputs"] - for i, info in enumerate(input_info): - inputs[i] = ("pipe:0", inputs[i][1]) - if "buffer" in info: - input = info["buffer"] - else: - stdin = fp.PIPE - if nb_outpipes: - outputs = args["outputs"] - for i, info in enumerate(output_info): - outputs[i] = ("pipe:1", outputs[i][1]) - stdout = fp.PIPE + stdin, stdout, input = configure.assign_std_pipes( + args, input_info, output_info, use_sp_run=True + ) kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( From 4e46de956303c9df5821da57724446ec6d3572b4 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 17 Mar 2025 07:53:15 -0500 Subject: [PATCH 299/333] doc/typehint/format updates --- docsrc/options.rst | 2 +- src/ffmpegio/_typing.py | 49 ++++++++++++++++++++++++++-- src/ffmpegio/_utils.py | 4 +-- src/ffmpegio/configure.py | 34 ++++++++++--------- src/ffmpegio/filtergraph/presets.py | 3 +- src/ffmpegio/streams/PipedStreams.py | 2 +- src/ffmpegio/threading.py | 7 ++-- src/ffmpegio/utils/__init__.py | 19 +++++++---- 8 files changed, 87 insertions(+), 33 deletions(-) diff --git a/docsrc/options.rst b/docsrc/options.rst index ed85e732..f44e39da 100644 --- a/docsrc/options.rst +++ b/docsrc/options.rst @@ -92,7 +92,7 @@ ncomp dtype pix_fmt Description 4 Sequence[Any]: +) -> Sequence[Any] | None: """Put value in a list if it is not already a sequence :param value: value to be put in a list @@ -94,7 +94,7 @@ def dtype_itemsize(dtype: DTypeString) -> int: :param dtype: numpy-style data type string :return: number of bytes per audio sample/video pixel - """ + """ return int(dtype[-1]) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index ff6fb433..b1c41d55 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,21 +1,26 @@ from __future__ import annotations -from ._typing import ( +from typing_extensions import ( + IO, Literal, get_args, + LiteralString, Any, - MediaType, - FFmpegUrlType, TypedDict, + Unpack, + Callable, +) + +from ._typing import ( DTypeString, ShapeTuple, RawStreamInfoTuple, Buffer, + MediaType, + FFmpegUrlType, InputSourceDict, OutputDestinationDict, RawStreamDef, - Unpack, - Callable, RawDataBlob, FFmpegOptionDict, ) @@ -33,6 +38,7 @@ from namedpipe import NPopen from contextlib import ExitStack +from . import ffmpegprocess as fp from . import utils, probe, plugins from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject @@ -62,8 +68,10 @@ UrlType = Literal["input", "output"] -FFmpegInputOptionTuple = tuple[FFmpegUrlType | FilterGraphObject, FFmpegOptionDict] -FFmpegOutputOptionTuple = tuple[FFmpegUrlType, FFmpegOptionDict] +FFmpegInputOptionTuple = tuple[ + FFmpegUrlType | IO | Buffer | FilterGraphObject | FFConcat, FFmpegOptionDict +] +FFmpegOutputOptionTuple = tuple[FFmpegUrlType | IO, FFmpegOptionDict] raw_formats = ("rawvideo", *(formats for _, formats in utils.audio_codecs.values())) @@ -672,13 +680,11 @@ def get_video_array_format(ffmpeg_args, type, file_id=0): return shape, dtype -def move_global_options(args): +def move_global_options(args: FFmpegArgs) -> FFmpegArgs: """move global options from the output options dicts :param args: FFmpeg arguments - :type args: dict :returns: FFmpeg arguments (the same object as the input) - :rtype: dict """ from .caps import options @@ -702,12 +708,10 @@ def move_global_options(args): return args -def clear_loglevel(args): +def clear_loglevel(args: FFmpegArgs): """clear global loglevel option :param args: FFmpeg argument dict - :type args: dict - """ try: @@ -1251,7 +1255,7 @@ def process_url_inputs( elif utils.is_url(url): input_info = {"src_type": "url"} elif isinstance(url, FFConcat): - #TODO - generalize this to handle an arbitrary Muxer class + # TODO - generalize this to handle an arbitrary Muxer class opts["f"] = "concat" url0 = url.url if url0 in ("-", "unset"): @@ -1731,7 +1735,7 @@ def init_media_read_outputs( :param args: partial FFmpeg arguments (to be modified) :param input_info: list of input information :param output_options: tuple of mapping assignments and common output options - :param deferred_inputs: deferred (partial) input data, probable to retrieve + :param deferred_inputs: deferred (partial) input data, probable to retrieve necessary stream information :return output_info: output file information """ diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index a5049af6..ae9d0a7c 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -1,5 +1,4 @@ -"""ffmpegio.filtergraph.presets Module - a collection of preset filtergraph generators -""" +"""ffmpegio.filtergraph.presets Module - a collection of preset filtergraph generators""" from __future__ import annotations diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 0b0da8f2..bcd7d9b5 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -14,7 +14,7 @@ Literal, InputSourceDict, OutputDestinationDict, - FFmpegOptionDict, + FFmpegOptionDict, ) from ..configure import ( FFmpegArgs, diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index af1bafa2..10c64a27 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -1,5 +1,4 @@ -"""collection of thread classes for handling FFmpeg streams -""" +"""collection of thread classes for handling FFmpeg streams""" from __future__ import annotations @@ -32,6 +31,7 @@ class NotEmpty(Exception): "Exception raised by WriterThread.flush(timeout) if timedout." + pass @@ -582,7 +582,6 @@ def run(self): if self.pipe is not None: self.pipe.close() elif not self.stdin.closed: - print(f'{self.stdin.closed=}') self.stdin.close() # completely flush the queue @@ -988,7 +987,7 @@ def run(self): try: copyfileobj(src, dst, self.length) except: - #TODO - test the behavior when FFmpeg is prematurely terminated + # TODO - test the behavior when FFmpeg is prematurely terminated logger.warning("CopyFileObjThread runner failed to complete the job.") if self.auto_close: src.close() diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 5813910c..dba06696 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -538,6 +538,7 @@ def array_to_video_options( info, ) + def set_sp_kwargs_stdin( url: str | None, info: InputSourceDict, sp_kwargs: dict = {} ) -> tuple[str, dict | None, Callable]: @@ -613,8 +614,8 @@ def analyze_input_stream( fields: list[str], stream: str, media_type: MediaType, - input_url: str | None, - input_opts: dict, + input_url: FFmpegUrlType | None, + input_opts: FFmpegOptionDict, input_info: InputSourceDict, ) -> list: """analyze a stream and return requested field values @@ -651,7 +652,10 @@ def video_fields_to_options(pix_fmt, width, height, r1, r2): def analyze_video_stream( - stream_specifier: str, inurl: str, inopts: dict, input_info: InputSourceDict + stream_specifier: str, + inurl: FFmpegUrlType, + inopts: FFmpegOptionDict, + input_info: InputSourceDict, ) -> tuple[int | Fraction | None, str | None, tuple[int, int] | None]: """analyze video stream core attributes @@ -682,7 +686,10 @@ def analyze_video_stream( def analyze_audio_stream( - stream_specifier: str, inurl: str, inopts: dict, input_info: InputSourceDict + stream_specifier: str, + inurl: FFmpegUrlType, + inopts: FFmpegOptionDict, + input_info: InputSourceDict, ) -> tuple[int | None, str | None, int | None]: """analyze input audio stream @@ -720,7 +727,7 @@ def analyze_audio_stream( def analyze_complex_filtergraphs( filtergraphs: list[FilterGraphObject | str], - inputs: list[tuple[str | None, dict]], + inputs: list[tuple[FFmpegUrlType | None, FFmpegOptionDict]], inputs_info: list[InputSourceDict], ) -> tuple[list[FilterGraphObject], dict[str, dict]]: """analyze filtergraphs and return requested field values @@ -858,7 +865,7 @@ def analyze_complex_filtergraphs( def are_input_pipes_ready( - inputs: list[tuple[str, dict]], + inputs: list[tuple[FFmpegUrlType, FFmpegOptionDict]], input_info: list[InputSourceDict], must_probe: bool = False, ) -> list[bool]: From b85d0f9790d0fd795cec1e6bcd7b87a6fc51b8dd Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 17 Mar 2025 07:54:33 -0500 Subject: [PATCH 300/333] `init_media_write_outputs()` - fixed `merge_audio_streams` clause --- src/ffmpegio/configure.py | 81 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index b1c41d55..deb456ca 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1890,9 +1890,13 @@ def init_media_write_outputs( do_merge = bool(merge_audio_streams) if do_merge: try: - a_ids = [i for i, info in enumerate(input_info) if info["media_type"] == "audio"] + a_ids = [ + i for i, info in enumerate(input_info) if info["media_type"] == "audio" + ] except KeyError as e: - raise NotImplementedError('audio merging mode is not currently implemented. Please use the `complex_filtergraph=ffmpegio.filtergraph.presets.merge_audio(...)` to assign a custom filtergraph.') + raise NotImplementedError( + "audio merging mode is not currently implemented. Please use the `complex_filtergraph=ffmpegio.filtergraph.presets.merge_audio(...)` to assign a custom filtergraph." + ) do_merge = len(a_ids) > 1 if do_merge: if merge_audio_streams is True: @@ -2324,3 +2328,76 @@ def assign_std_pipes( return stdin, stdout, pinput + +def init_std_pipes( + stdin: IO | None, + stdout: IO | None, + input_info: list[InputSourceDict], + output_info: list[OutputDestinationDict], + update_rate: float | None = None, + blocksize: int | None = None, + queue_size: int | None = None, +) -> ExitStack | None: + """initialize named pipes for read & write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param input_info: FFmpeg input information, its length matches that of `args['inputs']` + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) + :param update_rate: target rate at which queue transactions will occur for raw data output, + defaults to None (1 video frame or 1024 audio sample at a time) + :param blocksize: encoded data output block size in bytes, defaults to None (2**20 bytes) + :returns: a list of indices of the FFmpeg outputs that are raw data streams + + In addition to the retured list, this function modifies the dicts in its arguements. + + - The pipe names are assigned to the URLs of FFmpeg input and output (`args['inputs'][][0]` + and `args['outputs'][][0]`) + - The reader threads for FFmpeg outputs that are written to buffers (i.e., + `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` + so the reader object can be used to retrieve the data. + + + if any output is a piped, overwrite flag (-y) is automatically inserted + """ + + stack = ExitStack() + in_use = False + wr_kws = {"queuesize": queue_size} if queue_size else {} + + # configure output pipes + for info in output_info: + dst_type = info["dst_type"] + if dst_type == "buffer": + kws = {**wr_kws} + if "raw_info" in info: + dtype, shape, rate = info["raw_info"] + kws["itemsize"] = utils.get_samplesize(shape, dtype) + if update_rate is not None: + kws["nmin"] = round(rate / update_rate) or 1 + else: + # assume encoded output + kws["itemsize"] = 1 + kws["nmin"] = blocksize or 2**16 + info["reader"] = reader = ReaderThread(stdout, **kws) + stack.enter_context(reader) # starts thread & wait for pipe connection + in_use = True + break + + # configure input pipes (if needed) + for info in input_info: + src_type = info["src_type"] + if src_type == "buffer": + writer = WriterThread(stdin, **wr_kws) + # starts thread & wait for pipe connection + stack.enter_context(writer) + in_use = True + if "buffer" in info: + # data buffer given, feed the data and terminate + writer.write(info["buffer"]) + writer.write(None) # close the writer immediately + else: + # if no data given, provide the access to the writer + info["writer"] = writer + break + + return stack if in_use else None From 8d5776ebbe709455695e858cc10f6ac09dd4e59d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 17 Mar 2025 08:09:58 -0500 Subject: [PATCH 301/333] changed `dtype(s)_in` and `shape(s)_in` arguments to `input_dtype(s)` and `input_shape(s)` --- src/ffmpegio/_open.py | 4 +-- src/ffmpegio/media.py | 12 ++++---- src/ffmpegio/streams/PipedStreams.py | 18 ++++++------ src/ffmpegio/streams/SimpleStreams.py | 40 +++++++++++++-------------- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index 8c77016e..26709191 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -264,8 +264,8 @@ def open( args = (*url_fg,) if read else (*url_fg, rate_in) if not read: for k, v in ( - ("dtype_in", dtype_in), - ("shape_in", shape_in), + ("input_dtype", dtype_in), + ("input_shape", shape_in), ("rate", rate), ("shape", shape), ): diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index b8e358a8..91a9a59b 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -135,8 +135,8 @@ def _gather_outputs( def read( *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None = None, - progress: ProgressCallable | None = None, show_log: bool | None = None, + progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> tuple[dict[str, Fraction | int], dict[str, RawDataBlob]]: @@ -196,10 +196,10 @@ def write( merge_audio_ar: int | None = None, merge_audio_sample_fmt: str | None = None, merge_audio_outpad: str | None = None, - progress: ProgressCallable | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, overwrite: bool | None = None, show_log: bool | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): @@ -209,15 +209,15 @@ def write( :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) :param stream_args: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's (indices of `stream_types`) to combine only specified streams. :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream - :param progress: progress callback function, defaults to None :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. + :param progress: progress callback function, defaults to None :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index bcd7d9b5..619fc957 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -890,9 +890,9 @@ def __init__( ] ), stream_types: Sequence[Literal["a", "v"]], - *stream_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], - dtypes_in: list[DTypeString] | None = None, - shapes_in: list[ShapeTuple] | None = None, + *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, merge_audio_streams: bool | Sequence[int] = False, merge_audio_ar: int | None = None, merge_audio_sample_fmt: str | None = None, @@ -910,14 +910,14 @@ def __init__( :param url: output url :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) - :param stream_rates_or_opts: either sample rate (audio) or frame rate (video) + :param input_rates_or_opts: either sample rate (audio) or frame rate (video) or a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. - :param dtypes_in: list of numpy-style data type strings of input samples + :param input_dtypes: list of numpy-style data type strings of input samples or frames of input media streams, defaults to `None` (auto-detect). - :param shapes_in: list of shapes of input samples or frames of input media + :param input_shapes: list of shapes of input samples or frames of input media streams, defaults to `None` (auto-detect). :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's (indices of `stream_types`) to combine only specified streams. @@ -944,7 +944,7 @@ def __init__( stream_args = [ (None, v) if isinstance(v, dict) else (v, None) - for v in stream_rates_or_opts + for v in input_rates_or_opts ] args, input_info, input_ready, output_info, output_args = ( configure.init_media_write( @@ -957,8 +957,8 @@ def __init__( merge_audio_outpad, extra_inputs, {"probesize_in": 32, **options}, - dtypes_in, - shapes_in, + input_dtypes, + input_shapes, ) ) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index a1ff3da2..b3657ff7 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -336,8 +336,8 @@ def __init__( self, viewer, url, - shape_in=None, - dtype_in=None, + input_shape=None, + input_dtype=None, show_log=None, progress=None, overwrite=None, @@ -347,8 +347,8 @@ def __init__( ) -> None: self._proc = None self._viewer = viewer - self.dtype_in = dtype_in - self.shape_in = shape_in + self.input_dtype = input_dtype + self.input_shape = input_shape # get url/file stream url, stdout, _ = configure.check_url(url, True) @@ -483,8 +483,8 @@ def __init__( self, url, rate_in, - shape_in=None, - dtype_in=None, + input_shape=None, + input_dtype=None, show_log=None, progress=None, overwrite=None, @@ -499,8 +499,8 @@ def __init__( super().__init__( plugins.get_hook().video_bytes, url, - shape_in, - dtype_in, + input_shape, + input_dtype, show_log, progress, overwrite, @@ -515,8 +515,8 @@ def _finalize(self, ffmpeg_args) -> None: ready = "s" in inopts and "pix_fmt" in inopts - if not (ready or (self.dtype_in is None or self.shape_in is None)): - s, pix_fmt = utils.guess_video_format((self.shape_in, self.dtype_in)) + if not (ready or (self.input_dtype is None or self.input_shape is None)): + s, pix_fmt = utils.guess_video_format((self.input_shape, self.input_dtype)) if "s" not in inopts: inopts["s"] = s if "pix_fmt" not in inopts: @@ -545,8 +545,8 @@ def _finalize_with_data(self, data): if "pix_fmt" not in inopts: inopts["pix_fmt"] = pix_fmt - self.shape_in = shape - self.dtype_in = dtype + self.input_shape = shape + self.input_dtype = dtype class SimpleAudioWriter(SimpleWriterBase): @@ -559,8 +559,8 @@ def __init__( self, url, rate_in, - shape_in=None, - dtype_in=None, + input_shape=None, + input_dtype=None, show_log=None, progress=None, overwrite=None, @@ -575,8 +575,8 @@ def __init__( super().__init__( plugins.get_hook().audio_bytes, url, - shape_in, - dtype_in, + input_shape, + input_dtype, show_log, progress, overwrite, @@ -590,10 +590,10 @@ def _finalize(self, ffmpeg_args): inopts = ffmpeg_args["inputs"][0][1] ready = "sample_fmt" in inopts and "ac" in inopts - if not ready and (self.dtype_in is not None or self.shape_in is not None): + if not ready and (self.input_dtype is not None or self.input_shape is not None): inopts = ffmpeg_args["inputs"][0][1] inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.shape_in, self.dtype_in + self.input_shape, self.input_dtype ) ready = True @@ -606,11 +606,11 @@ def _finalize(self, ffmpeg_args): return ready def _finalize_with_data(self, data): - self.shape_in, self.dtype_in = plugins.get_hook().audio_info(obj=data) + self.input_shape, self.input_dtype = plugins.get_hook().audio_info(obj=data) inopts = self._cfg["ffmpeg_args"]["inputs"][0][1] inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.shape_in, self.dtype_in + self.input_shape, self.input_dtype ) inopts["c:a"], inopts["f"] = utils.get_audio_codec(inopts["sample_fmt"]) From 5dc3787f722f6712ba0ad6931716a495d04dfdb5 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 17 Mar 2025 08:12:38 -0500 Subject: [PATCH 302/333] shifted around constructor arguments --- src/ffmpegio/streams/PipedStreams.py | 7 +++++-- src/ffmpegio/streams/SimpleStreams.py | 20 +++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 619fc957..6ba5364e 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -893,11 +893,12 @@ def __init__( *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], input_dtypes: list[DTypeString] | None = None, input_shapes: list[ShapeTuple] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, merge_audio_streams: bool | Sequence[int] = False, merge_audio_ar: int | None = None, merge_audio_sample_fmt: str | None = None, merge_audio_outpad: str | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool | None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, @@ -919,6 +920,8 @@ def __init__( (auto-detect). :param input_shapes: list of shapes of input samples or frames of input media streams, defaults to `None` (auto-detect). + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's (indices of `stream_types`) to combine only specified streams. :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream @@ -1071,9 +1074,9 @@ def __init__( self, input_options: Sequence[FFmpegOptionDict], output_options: Sequence[FFmpegOptionDict], + *, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - *, progress: ProgressCallable | None = None, show_log: bool | None = None, blocksize: int | None = None, diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index b3657ff7..86f38b87 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -214,7 +214,14 @@ class SimpleVideoReader(SimpleReaderBase): multi_write = False def __init__( - self, url, show_log=None, progress=None, blocksize=1, sp_kwargs=None, **options + self, + url, + *, + show_log=None, + progress=None, + blocksize=1, + sp_kwargs=None, + **options, ): hook = plugins.get_hook() super().__init__( @@ -277,6 +284,7 @@ class SimpleAudioReader(SimpleReaderBase): def __init__( self, url, + *, show_log=None, progress=None, blocksize=None, @@ -483,12 +491,13 @@ def __init__( self, url, rate_in, + *, input_shape=None, input_dtype=None, + extra_inputs=None, + overwrite=None, show_log=None, progress=None, - overwrite=None, - extra_inputs=None, sp_kwargs=None, **options, ): @@ -559,12 +568,13 @@ def __init__( self, url, rate_in, + *, input_shape=None, input_dtype=None, + extra_inputs=None, + overwrite=None, show_log=None, progress=None, - overwrite=None, - extra_inputs=None, sp_kwargs=None, **options, ): From 72ea655fa604ec602bf951ee88382503047955ae Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 17 Mar 2025 08:13:08 -0500 Subject: [PATCH 303/333] `PipedMediaWriter` added forgotten `overwrite` argument --- src/ffmpegio/streams/PipedStreams.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 6ba5364e..1d8e6cba 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -926,8 +926,7 @@ def __init__( (indices of `stream_types`) to combine only specified streams. :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. + :param overwrite: True to overwrite existing files, defaults to None (auto-set) :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. @@ -945,6 +944,14 @@ def __init__( if not isinstance(urls, list): urls = [urls] + options = {"probesize_in": 32, **options} + if overwrite: + if "n" in options: + raise FFmpegioError( + "cannot specify both `overwrite=True` and `n=ff.FLAG`." + ) + options["y"] = None + stream_args = [ (None, v) if isinstance(v, dict) else (v, None) for v in input_rates_or_opts From 66e7892796df276df18319b3d97407efc091f812 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 17 Mar 2025 08:18:22 -0500 Subject: [PATCH 304/333] test simple stream classes directly --- tests/test_simplestreams.py | 42 +++++++++++++------------------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py index 3d5a2075..fa68432e 100644 --- a/tests/test_simplestreams.py +++ b/tests/test_simplestreams.py @@ -14,8 +14,8 @@ def test_read_video(): w = 420 h = 360 - with ffmpegio.open( - url, "rv", vf="transpose", pix_fmt="gray", s=(w, h), show_log=True + with streams.SimpleVideoReader( + url, vf="transpose", pix_fmt="gray", s=(w, h), show_log=True ) as f: F = f.read(10) print(f.rate) @@ -41,7 +41,7 @@ def test_read_write_video(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with ffmpegio.open(out_url, "wv", rate_in=fs) as f: + with streams.SimpleVideoWriter(out_url, rate_in=fs) as f: f.write(F0) f.write(F1) @@ -52,7 +52,7 @@ def test_read_audio(caplog): fs, x = ffmpegio.audio.read(url) bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) - with ffmpegio.open(url, "ra", show_log=True, blocksize=1024 ** 2) as f: + with streams.SimpleAudioReader(url, show_log=True, blocksize=1024**2) as f: # x = f.read(1024) # assert x['shape'] == (1024, f.ac) blks = [blk["buffer"] for blk in f] @@ -64,8 +64,8 @@ def test_read_audio(caplog): t0 = n0 / fs t1 = n1 / fs - with ffmpegio.open( - url, "ra", ss_in=t0, to_in=t1, show_log=True, blocksize=1024 ** 2 + with streams.SimpleAudioReader( + url, ss_in=t0, to_in=t1, show_log=True, blocksize=1024**2 ) as f: blks, shapes = zip(*[(blk["buffer"], blk["shape"][0]) for blk in f]) log = f.readlog() @@ -85,7 +85,7 @@ def test_read_audio(caplog): def test_read_write_audio(): outext = ".flac" - with ffmpegio.open(url, "ra") as f: + with streams.SimpleAudioReader(url) as f: F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) fs = f.rate shape = f.shape @@ -96,7 +96,7 @@ def test_read_write_audio(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with ffmpegio.open(out_url, "wa", rate_in=fs, show_log=True) as f: + with streams.SimpleAudioWriter(out_url, rate_in=fs, show_log=True) as f: f.write({**out, "buffer": F[: 100 * bps]}) f.write({**out, "buffer": F[100 * bps :]}) @@ -106,8 +106,8 @@ def test_video_filter(): fps = 10 # fractions.Fraction(60000,1001) - with ffmpegio.open(url, "rv", blocksize=30, t=30) as src, ffmpegio.open( - "scale=200:100", "fv", rate_in=src.rate, rate=fps, show_log=True + with streams.SimpleVideoReader(url, blocksize=30, t=30) as src, streams.SimpleVideoFilter( + "scale=200:100", rate_in=src.rate, rate=fps, show_log=True ) as f: def process(i, frames): @@ -170,23 +170,17 @@ def test_write_extra_inputs(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with ffmpegio.open( - out_url, - "wv", - rate_in=fs, - extra_inputs=[url_aud], - map=["0:v", "1:a"], - show_log=True, + with streams.SimpleVideoWriter( + out_url, fs, extra_inputs=[url_aud], map=["0:v", "1:a"], show_log=True ) as f: f.write(F) info = ffmpegio.probe.streams_basic(out_url) assert len(info) == 2 - with ffmpegio.open( + with streams.SimpleVideoWriter( out_url, - "wv", - rate_in=fs, + fs, extra_inputs=[("anoisesrc", {"f": "lavfi"})], map=["0:v", "1:a"], shortest=None, @@ -197,11 +191,3 @@ def test_write_extra_inputs(): info = ffmpegio.probe.streams_basic(out_url) assert len(info) == 2 - - -if __name__ == "__main__": - print("starting test") - logging.debug("logging check") - test_video_filter() - - # python tests\test_simplestreams.py From 2e148907e75a5d95c3d723685446ef4a8f984d30 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 17 Mar 2025 08:20:35 -0500 Subject: [PATCH 305/333] added `StdStreams` classes --- src/ffmpegio/streams/StdStreams.py | 1133 ++++++++++++++++++++++++++++ src/ffmpegio/streams/__init__.py | 18 +- tests/test_stdstreams.py | 188 +++++ 3 files changed, 1338 insertions(+), 1 deletion(-) create mode 100644 src/ffmpegio/streams/StdStreams.py create mode 100644 tests/test_stdstreams.py diff --git a/src/ffmpegio/streams/StdStreams.py b/src/ffmpegio/streams/StdStreams.py new file mode 100644 index 00000000..190ff7b9 --- /dev/null +++ b/src/ffmpegio/streams/StdStreams.py @@ -0,0 +1,1133 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger("ffmpegio") + +from typing_extensions import Unpack +from collections.abc import Sequence +from .._typing import ( + DTypeString, + ShapeTuple, + ProgressCallable, + RawDataBlob, + FFmpegOptionDict, + InputSourceDict, + OutputDestinationDict, +) +from ..configure import ( + FFmpegArgs, + MediaType, + InitMediaOutputsCallable, +) +from ..filtergraph.abc import FilterGraphObject +from ..configure import OutputDestinationDict +from contextlib import ExitStack + +import sys +from time import time +from fractions import Fraction +from math import prod + +from .. import configure, ffmpegprocess, plugins, utils, probe +from ..threading import LoggerThread +from ..errors import FFmpegError, FFmpegioError + +# fmt:off +__all__ = ["StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", + "StdVideoDecoder", "StdVideoEncoder", "StdVideoFilter", "StdMediaTranscoder"] +# fmt:on + + +class _StdFFmpegRunner: + """Base class to run FFmpeg and manage its multiple I/O's""" + + def __init__( + self, + *, + get_num, + ffmpeg_args: FFmpegArgs, + input_info: list[InputSourceDict], + output_info: list[OutputDestinationDict] | None, + input_ready: bool, + init_deferred_outputs: InitMediaOutputsCallable | None, + deferred_output_args: list[FFmpegOptionDict | None], + default_timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + queuesize: int | None = None, + sp_kwargs: dict = None, + ): + """Encoded media stream transcoder + + :param ffmpeg_args: (Mostly) populated FFmpeg argument dict + :param input_info: FFmpeg output option dicts of all the output pipes. Each dict + must contain the `"f"` option to specify the media format. + :param output_info: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param input_ready: indicates if input is ready (True) or need its first batch of data to + provide necessary information for the outputs + :param init_deferred_outputs: function to initialize the outputs which have been deferred to + configure until the first batch of input data is in + :param deferred_output_args: + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + """ + + self._get_num = get_num + self._input_info = input_info + self._output_info = output_info + self._input_ready = input_ready + self._init_deferred_outputs = init_deferred_outputs + self._deferred_output_options = deferred_output_args + self._deferred_data = [] + + # all good to go + self._input_ready = all(input_ready) + + # create logger without assigning the source stream + self._logger = LoggerThread(None, show_log) + + # prepare FFmpeg keyword arguments + self._args = { + "ffmpeg_args": ffmpeg_args, + "progress": progress, + "capture_log": True, + "sp_kwargs": {**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, + } + + # set the default read block size for the referenc stream + self.default_timeout = default_timeout + self._pipe_kws = {"queue_size": queuesize} + self._proc = None + self._stack = None + + def __enter__(self): + + self.open() + return self + + def open(self): + """start FFmpeg processing + + Note + ---- + + It may flag to defer starting the FFmpeg process if the input streams + are not fully specified and must wait to deduce them from the written + data. + + """ + + if self._input_ready is True: + self._open(False) + + def _init_std_pipes(self) -> ExitStack: + + return configure.init_std_pipes( + self._proc.stdin, + self._proc.stdout, + self._input_info, + self._output_info, + **self._pipe_kws, + ) + + def _write_deferred_data(self): + pass + + def _close_io(self, _): + if self._stack: + self._stack.close() + self._stack = None + + def _open(self, deferred: bool): + + if deferred: + # finalize the output configurations + self._output_info = self._init_deferred_outputs( + self._args["ffmpeg_args"], + self._input_info, + self._deferred_output_options, + [self._deferred_data], + ) + + # get std pipes + stdin, stdout, input = configure.assign_std_pipes( + self._args["ffmpeg_args"], self._input_info, self._output_info + ) + + # run the FFmpeg + self._proc = ffmpegprocess.Popen( + **self._args, + stdin=stdin, + stdout=stdout, + input=input, + on_exit=self._close_io, + ) + + # set up and activate pipes and read/write threads + self._stack = self._init_std_pipes() + + # set the log source and start the logger + self._logger.stderr = self._proc.stderr + self._logger.start() + + # if any pending data, queue them + if deferred: + self._write_deferred_data() + + return self + + def close(self): + """Kill FFmpeg process and close the streams""" + + if self._proc is not None and self._proc.poll() is None: + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + self._proc = None + + def __exit__(self, *exc_details) -> bool: + try: + self.close() + return False + except: + if not exc_details[0]: + exc_details = sys.exc_info() + finally: + try: + self._logger.join() + except RuntimeError: + pass + + @property + def closed(self) -> bool: + """True if the stream is closed.""" + return self._proc.poll() is not None + + @property + def lasterror(self) -> FFmpegError: + """Last error FFmpeg posted""" + if self._proc.poll(): + return self._logger.Exception() + else: + return None + + def readlog(self, n: int | None = None) -> str: + """read FFmpeg log lines + + :param n: number of lines to read + :return: logged messages + """ + if n is not None: + self._logger.index(n) + with self._logger._newline_mutex: + return "\n".join(self._logger.logs or self._logger.logs[:n]) + + def wait(self, timeout: float | None = None) -> int | None: + """close all input pipes and wait for FFmpeg to exit + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait indefinitely + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to + catch this exception and retry the wait. + :return returncode: return subprocess Popen returncode attribute + """ + + if timeout is None: + timeout = self.default_timeout + + if self._proc: + + if timeout is not None: + timeout += time() + + # write the sentinel to each input queue + for info in self._input_info: + if "writer" in info: + info["writer"].write( + None, None if timeout is None else timeout - time() + ) + + # wait until the FFmpeg finishes the job + try: + self._proc.wait(None if timeout is None else timeout - time()) + except TimeoutError: + raise + else: + rc = self._proc.returncode + if rc is not None: + self._proc = None + else: + rc = None + return rc + + +from collections.abc import Callable + + +class _RawInputBaseMixin: + + _get_num: Callable + _input_info: InputSourceDict + default_timeout: float | None + + def __init__(self, get_bytes, array_to_opts, **kwargs): + super().__init__(**kwargs) + self._get_bytes = get_bytes + self._array_to_opts = array_to_opts + + # input data must be initially buffered + self._deferred_data = [] + self._nin = 0 + + def _write_deferred_data(self): + info = self._input_info[0] + writer = info["writer"] + for data in self._deferred_data: + writer.write(data, self.default_timeout) + self._deferred_data = None + self._input_ready = True + + @property + def input_count(self) -> int: + """number of input frames/samples written""" + return self._nin + + @property + def input_rate(self) -> int | Fraction: + """input sample or frame rates""" + return self._input_info[0]["raw_info"][2] + + @property + def input_dtype(self) -> DTypeString: + """input frame/sample data type""" + return self._input_info[0]["raw_info"][0] + + @property + def input_shape(self) -> ShapeTuple: + """input frame/sample shape""" + return self._input_info[0]["raw_info"][1] + + @property + def input_samplesize(self) -> int: + """input sample/pixel count per frame""" + return prod(self._input_info[0]["raw_info"][1]) + + def write(self, data: RawDataBlob, timeout: float | None = None): + """write a raw media data + + :param data: audio data blob (depends on the active data conversion plugin) + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue + + + Write the given NDArray object, data, and return the number + of bytes written (always equal to the number of data frames/samples, + since if the write fails an OSError will be raised). + + When in non-blocking mode, a BlockingIOError is raised if the data + needed to be written to the raw stream but it couldn’t accept all + the data without blocking. + + The caller may release or mutate data after this method returns, + so the implementation should only access data during the method call. + """ + + b = self._get_bytes(obj=data) + self._nin += self._get_num(obj=data) + if not len(b): + return + + if data is None: + raise TypeError("data cannot be None") + + if self._input_ready: + logger.debug("[writer main] writing...") + try: + self._input_info[0]["writer"].write(b, timeout) + except (BrokenPipeError, OSError): + self._logger.join_and_raise() + else: + # need to collect input data type and shape from the actual data + # before starting the FFmpeg + + configure.update_raw_input( + self._args["ffmpeg_args"], self._input_info, 0, data + ) + self._deferred_data.append(b) + self._input_ready = True + + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) + + +class _AudioInputMixin(_RawInputBaseMixin): + + def __init__(self, **kwargs): + super().__init__( + plugins.get_hook().audio_bytes, utils.array_to_audio_options, **kwargs + ) + + +class _VideoInputMixin(_RawInputBaseMixin): + + def __init__(self, **kwargs): + super().__init__( + plugins.get_hook().video_bytes, utils.array_to_video_options, **kwargs + ) + + +class _EncodedInputMixin: + + def __init__(self, **kwargs): + + super().__init__(**kwargs) + + def _write_deferred_data(self): + data = self._deferred_data + info = self._input_info[0] + if len(data) and "writer" in info: + info["writer"].write(data, self.default_timeout) + self._deferred_data = None + self._input_ready = True + + def write_encoded(self, data: bytes, timeout: float | None = None): + """write encoded media data to stdout + + :param data: encoded data byte sequence + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is written + to the buffer queue + """ + + info = self._input_info[0] + + if self._input_ready: + + try: + info["writer"].write(data, timeout) + except: + raise FFmpegioError("Cannot write to a non-piped input.") + + else: + # buffer must be contiguous + data0 = self._deferred_data + if len(data0): + data = data0.append(data) + else: + self._deferred_data = data + + # need to be able to probe the input streams before starting the FFmpeg + try: + probe.format_basic(data) + except FFmpegError: + pass # not ready yet + else: + self._input_ready = True + + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) + + +class _RawOutputBaseMixin: + def __init__(self, converter, blocksize, **kwargs): + super().__init__(**kwargs) + self._converter = converter + + # set the default read block size for the reference stream + self._blocksize = blocksize + self._n0 = None # timestamps of the last read sample + + @property + def output_label(self) -> str: + """FFmpeg/custom label of output stream""" + return self._output_info[0]["user_map"] + + @property + def output_type(self) -> MediaType: + """output media type""" + return self._output_info[0]["media_type"] + + @property + def output_rate(self) -> int | Fraction: + """output sample or frame rates""" + return self._output_info[0]["raw_info"][2] + + @property + def output_dtype(self) -> DTypeString: + """output frame/sample data type""" + return self._output_info[0]["raw_info"][0] + + @property + def output_shape(self) -> ShapeTuple: + """output frame/sample shape""" + return self._output_info[0]["raw_info"][1] + + @property + def output_samplesize(self) -> int: + """output sample/pixel count per frame""" + return prod(self._output_info[0]["raw_info"][1]) + + @property + def output_count(self) -> int: + """number of frames/samples read""" + return self._n0 + + def _init_std_pipes(self) -> ExitStack: + + # set the default read block size for the referenc stream + info = self._output_info[0] + if self._blocksize is None: + self._blocksize = 1 if info["media_type"] == "video" else 1024 + self._n0 = 0 + self._pipe_kws = {**self._pipe_kws} + + # set up and activate pipes and read/write threads + return super()._init_std_pipes() + + def read(self, n: int, timeout: float | None = None) -> RawDataBlob: + """read output stream + + :param n: number of frames/samples to read. Set -1 to read as many as available. + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is read + from the buffer queue + :return: retrieved data + + Effect of mixing `n` and `timeout` + ---------------------------------- + + === ========= ========================================================================= + `n` `timeout` Behavior + === ========= ========================================================================= + 0 --- Immediately returns + >0 `None` Wait indefinitely until `n` frames/samples are retrieved + >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes + <0 `None` Wait indefinitely until FFmpeg terminates + <0 `float` Retrieve as many frames/samples until `timeout` seconds passes + === ========= ========================================================================= + + """ + + info = self._output_info[0] + converter = self._converter + dtype, shape, _ = info["raw_info"] + + if timeout is None: + timeout = self.default_timeout + + b = info["reader"].read(n, timeout) + data = converter(b=b, dtype=dtype, shape=shape, squeeze=False) + + # update the frame/sample counter + n = self._get_num(obj=data) # actual number read + self._n0 += n + + return data + + +class _AudioOutputMixin(_RawOutputBaseMixin): + + def __init__(self, **kwargs): + super().__init__(plugins.get_hook().bytes_to_audio, **kwargs) + + +class _VideoOutputMixin(_RawOutputBaseMixin): + + def __init__(self, **kwargs): + super().__init__(plugins.get_hook().bytes_to_video, **kwargs) + + +class _EncodedOutputMixin: + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) + + # set the default read block size + self._blocksize = blocksize + + def _init_std_pipes(self) -> ExitStack: + + # set the default read block size for the referenc stream + self._pipe_kws = {**self._pipe_kws, "blocksize": self._blocksize} + + # set up and activate pipes and read/write threads + return super()._init_std_pipes() + + def read_encoded(self, n: int, timeout: float | None = None) -> bytes: + """read encoded data from stdout + + :param n: number of bytes to read, set it to -1 to read as many as available + :param n: number of frames/samples to read. Set -1 to read as many as available. + :param timeout: timeout in seconds or defaults to `None` to use the + `default_timeout` property. If `default_timeout` is `None` + then the operation will block until all the data is read + from the buffer queue + :return: retrieved byte sequence + + Effect of mixing `n` and `timeout` + ---------------------------------- + + === ========= ========================================================================= + `n` `timeout` Behavior + === ========= ========================================================================= + 0 --- Immediately returns + >0 `None` Wait indefinitely until `n` bytes are retrieved + >0 `float` Retrieve as much data up to `n` bytes before `timeout` seconds passes + <0 `None` Wait indefinitely until FFmpeg terminates + <0 `float` Retrieve as much data until `timeout` seconds passes + === ========= ========================================================================= + """ + + return self._output_info[0]["reader"].read(n, timeout) + + +class StdAudioDecoder(_EncodedInputMixin, _AudioOutputMixin, _StdFFmpegRunner): + + def __init__( + self, + *, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Decode audio data from media data stream over std pipes + + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param progress: progress callback function, defaults to None + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + """ + + # initialize FFmpeg argument dict and get input & output information + map = options.pop("map", "0:a:0") + args, input_info, ready, output_info, output_args = configure.init_media_read( + ["pipe"], [map], {"probesize_in": 32, **options} + ) + + super().__init__( + get_num=plugins.get_hook().audio_samples, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=ready, + init_deferred_outputs=configure.init_media_read_outputs, + deferred_output_args=output_args, + blocksize=blocksize, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + self._get_bytes = plugins.get_hook().audio_bytes + + def __iter__(self): + return self + + def __next__(self): + F = self.read(self._blocksize, self.default_timeout) + if not len(self._get_bytes(obj=F)): + raise StopIteration + return F + + +class StdVideoDecoder(_EncodedInputMixin, _VideoOutputMixin, _StdFFmpegRunner): + + def __init__( + self, + *, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Read audio data from encoded media data stream + + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param progress: progress callback function, defaults to None + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + """ + + # initialize FFmpeg argument dict and get input & output information + map = options.pop("map", "0:V:0") + args, input_info, ready, output_info, output_args = configure.init_media_read( + ["pipe"], [map], {"probesize_in": 32, **options} + ) + + super().__init__( + get_num=plugins.get_hook().video_frames, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=ready, + init_deferred_outputs=configure.init_media_read_outputs, + deferred_output_args=output_args, + blocksize=blocksize, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + self._get_bytes = plugins.get_hook().video_bytes + + def __iter__(self): + return self + + def __next__(self): + F = self.read(self._blocksize, self.default_timeout) + if not len(self._get_bytes(obj=F)): + raise StopIteration + return F + + +class StdAudioEncoder(_EncodedOutputMixin, _AudioInputMixin, _StdFFmpegRunner): + + def __init__( + self, + input_rate: int, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Write video and audio data from multiple media streams to one or more files + + :param rate: input sample rate + :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). + :param input_shape: shapes of input samples or frames streams, defaults to `None` (auto-detect). + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param progress: progress callback function, defaults to None + :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + """ + + args, input_info, input_ready, output_info, output_args = ( + configure.init_media_write( + ["pipe"], + "a", + [(input_rate, None)], + False, + None, + None, + None, + extra_inputs, + {"probesize_in": 32, **options}, + input_dtype, + input_shape, + ) + ) + + super().__init__( + get_num=plugins.get_hook().audio_samples, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_write_outputs, + deferred_output_args=output_args, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + blocksize=blocksize, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + +class StdVideoEncoder(_EncodedOutputMixin, _VideoInputMixin, _StdFFmpegRunner): + + def __init__( + self, + input_rate: int, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Write video and audio data from multiple media streams to one or more files + + :param rate: input frame rate + :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). + :param input_shape: list of shapes of input samples or frames, defaults to `None` (auto-detect). + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. + :param progress: progress callback function, defaults to None + :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + """ + + args, input_info, input_ready, output_info, output_args = ( + configure.init_media_write( + ["pipe"], + "v", + [(input_rate, None)], + False, + None, + None, + None, + extra_inputs, + {"probesize_in": 32, **options}, + input_dtype and [input_dtype], + input_shape and [input_shape], + ) + ) + + super().__init__( + get_num=plugins.get_hook().video_frames, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_write_outputs, + deferred_output_args=output_args, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + blocksize=blocksize, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + +class StdAudioFilter(_AudioOutputMixin, _AudioInputMixin, _StdFFmpegRunner): + + def __init__( + self, + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + input_rate: int, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Filter audio/video data streams with FFmpeg filtergraphs + + :param expr: filtergraph expression or a list of filtergraphs + :param input_rate: input sampling rate. + :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). + :param input_shape: shapes of input samples or frames, defaults to `None` (auto-detect). + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param progress: progress callback function, defaults to None + :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) + : defaults to `None` to use 1 video frame or 1024 audio frames + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + """ + + ( + args, + input_info, + input_ready, + output_info, + deferred_output_args, + ) = configure.init_media_filter( + expr, + "a", + [(input_rate, None)], + None, + input_dtype and [input_dtype], + input_shape and [input_shape], + {"probesize_in": 32, **options}, + {}, + ) + + super().__init__( + get_num=plugins.get_hook().audio_samples, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_filter_outputs, + deferred_output_args=deferred_output_args, + blocksize=blocksize, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + def filter( + self, data: RawDataBlob, timeout: float | None = None + ) -> RawDataBlob | None: + """Run filter operation + + :param data: input data block + :type data: numpy.ndarray + :param timeout: timeout for the operation in seconds, defaults to None + :type timeout: float, optional + :return: output data block + :rtype: numpy.ndarray + + The input `data` array is expected to have the datatype specified by + Filter class' `input_dtype` property and the array shape to match Filter + class' `input_shape` property or with an additional dimension prepended. + + .. important:: + If `timeout = None`, the read operation is non-blocking. There is at + least 32-frame latency is imposed by FFmpeg, so the initial few frames + will not produce any output. + + """ + + timeout = timeout or self.default_timeout + if timeout: + timeout += time() + + self.write(data, timeout and timeout - time()) + return self.read(self._get_num(obj=data), (timeout and timeout - time()) or 0) + + +class StdVideoFilter(_VideoOutputMixin, _VideoInputMixin, _StdFFmpegRunner): + + def __init__( + self, + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + input_rate: int | Fraction, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], + ): + """Filter audio/video data streams with FFmpeg filtergraphs + + :param expr: complex filtergraph expression or a list of filtergraphs + :param input_rate: frame rate + :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). + :param input_shape: shapes of input samples or frames , defaults to `None` (auto-detect). + :param output_options: specific options for keyed filtergraph output pads. + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param progress: progress callback function, defaults to None + :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) + : defaults to `None` to use 1 video frame or 1024 audio frames + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call + used to run the FFmpeg, defaults to None + :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + """ + + ( + args, + input_info, + input_ready, + output_info, + deferred_output_args, + ) = configure.init_media_filter( + expr, + "v", + [(input_rate, None)], + None, + input_dtype and [input_dtype], + input_shape and [input_shape], + {"probesize_in": 32, **options}, + {}, + ) + + super().__init__( + get_num=plugins.get_hook().video_frames, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_filter_outputs, + deferred_output_args=deferred_output_args, + blocksize=blocksize, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) + + def filter( + self, data: RawDataBlob, timeout: float | None = None + ) -> RawDataBlob | None: + """Run filter operation + + :param data: input data block + :type data: numpy.ndarray + :param timeout: timeout for the operation in seconds, defaults to None + :type timeout: float, optional + :return: output data block + :rtype: numpy.ndarray + + The input `data` array is expected to have the datatype specified by + Filter class' `input_dtype` property and the array shape to match Filter + class' `input_shape` property or with an additional dimension prepended. + + .. important:: + If `timeout = None`, the read operation is non-blocking. There is at + least 32-frame latency is imposed by FFmpeg, so the initial few frames + will not produce any output. + + """ + + timeout = timeout or self.default_timeout + if timeout: + timeout += time() + + self.write(data, timeout and timeout - time()) + return self.read(self._get_num(obj=data), (timeout and timeout - time()) or 0) + + +class StdMediaTranscoder(_EncodedOutputMixin, _EncodedInputMixin, _StdFFmpegRunner): + """Class to transcode one media stream to another via std pipes""" + + def __init__( + self, + *, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + blocksize: int | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict = None, + **options: Unpack[FFmpegOptionDict], + ): + """Encoded media stream transcoder + + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param extra_outputs: list of additional output destinations, defaults to None. Each destination + may be url string or a pair of a url string and an option dict. + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks + :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) + :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + """ + + args, input_info, output_info = configure.init_media_transcoder( + [("pipe", {})], + [("pipe", {})], + extra_inputs, + extra_outputs, + {"y": None, **options}, + ) + + super().__init__( + get_num=None, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + input_ready=True, + init_deferred_outputs=None, + deferred_output_args=None, + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + blocksize=blocksize, + queuesize=queuesize, + sp_kwargs=sp_kwargs, + ) diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index f17e285f..de5065fe 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -6,7 +6,21 @@ SimpleVideoFilter, SimpleAudioFilter, ) -from .PipedStreams import PipedMediaReader, PipedMediaWriter, PipedMediaFilter, PipedMediaTranscoder +from .StdStreams import ( + StdAudioDecoder, + StdAudioEncoder, + StdAudioFilter, + StdVideoDecoder, + StdVideoEncoder, + StdVideoFilter, + StdMediaTranscoder, +) +from .PipedStreams import ( + PipedMediaReader, + PipedMediaWriter, + PipedMediaFilter, + PipedMediaTranscoder, +) from .AviStreams import AviMediaReader # TODO multi-stream write @@ -15,6 +29,8 @@ # fmt: off __all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", + "StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", + "StdVideoDecoder", "StdVideoEncoder", "StdVideoFilter", "StdMediaTranscoder", "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder", "AviMediaReader"] # fmt: on diff --git a/tests/test_stdstreams.py b/tests/test_stdstreams.py new file mode 100644 index 00000000..b41006b5 --- /dev/null +++ b/tests/test_stdstreams.py @@ -0,0 +1,188 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import ffmpegio as ff +import tempfile, re +from os import path +from ffmpegio import streams, utils + +url = "tests/assets/testmulti-1m.mp4" +outext = ".mp4" + + +def test_read_video(): + w = 420 + h = 360 + b = ff.transcode(url, "-", f="matroska", c="copy", to=1) + with ( + streams.StdVideoDecoder( + vf="transpose", pix_fmt="gray", s=(w, h), show_log=True + ) as f, + ): + f.write_encoded(b) + F = f.read(10) + print(f.output_rate) + assert f.output_shape == (h, w, 1) + assert f.output_samplesize == w * h + assert F["shape"] == (10, h, w, 1) + assert F["dtype"] == f.output_dtype + + +def test_read_write_video(): + fs, F = ff.video.read(url, t=1) + bps = utils.get_samplesize(F["shape"][-3:], F["dtype"]) + F0 = { + "buffer": F["buffer"][:bps], + "shape": (1, *F["shape"][1:]), + "dtype": F["dtype"], + } + F1 = { + "buffer": F["buffer"][bps:], + "shape": (F["shape"][0] - 1, *F["shape"][1:]), + "dtype": F["dtype"], + } + + with streams.StdVideoEncoder(fs, f="matroska", show_log=True) as f: + f.write(F0) + f.write(F1) + f.wait() + b = f.read_encoded(-1) + + +def test_read_audio(caplog): + # caplog.set_level(logging.DEBUG) + + b = ff.transcode(url, "-", f="matroska", c="copy", vn=None) + fs, x = ff.audio.read(b, to=10, show_log=True, sample_fmt="flt") + bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) + with streams.StdAudioDecoder(show_log=True, blocksize=1024**2, to=10) as f: + f.write_encoded(b) + # x = f.read(1024) + # assert x['shape'] == (1024, f.ac) + blks = [blk["buffer"] for blk in f] + x1 = b"".join(blks) + assert x["buffer"] == x1 + + +def test_read_write_audio(): + outext = ".flac" + b = ff.transcode(url, "-", f="matroska", c="copy", vn=None) + + with streams.StdAudioDecoder(show_log=True, to=10) as f: + f.write_encoded(b) + F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) + fs = f.output_rate + shape = f.output_shape + dtype = f.output_dtype + bps = f.output_samplesize + + out = {"dtype": dtype, "shape": shape} + + with streams.StdAudioEncoder(fs, show_log=True, f="matroska") as f: + f.write({**out, "buffer": F[: 100 * bps]}) + f.write({**out, "buffer": F[100 * bps :]}) + + +def test_video_filter(): + url = "tests/assets/testvideo-1m.mp4" + + fps = 10 # fractions.Fraction(60000,1001) + + with ( + streams.SimpleVideoReader(url, blocksize=30, t=30) as src, + streams.StdVideoFilter("scale=200:100", src.rate, r=fps, show_log=True) as f, + ): + + def process(i, frames): + print( + f"{i} - output {frames['shape'][0]} frames ({f.input_count},{f.output_count})" + ) + + for i, frames in enumerate(src): + process(i, f.filter(frames)) + assert f.input_rate == src.rate + assert f.output_rate == fps + f.wait() + process("end", f.read(-1)) + + +def test_audio_filter(): + url = "tests/assets/testaudio-1m.mp3" + + sps = 4000 # fractions.Fraction(60000,1001) + + with ( + streams.SimpleAudioReader(url, blocksize=1024 * 8, t=10, ar=32000) as src, + streams.StdAudioFilter("lowpass", src.rate, ar=sps, show_log=True) as f, + ): + samples = src.read(src.blocksize) + + def process(i, samples): + if len(samples): + print( + f"{i} - output {samples['shape'][0]} samples ({f.input_count, f.output_count})" + ) + + try: + process(-1, f.filter(samples)) + except TimeoutError: + pass + + for i, samples in enumerate(src): + try: + process(i, f.filter(samples)) + except TimeoutError: + pass + assert f.input_rate == src.rate + assert f.output_rate == sps + f.wait() + process("end", f.read(-1)) + + +def test_write_extra_inputs(): + url_aud = "tests/assets/testaudio-1m.mp3" + + fs, F = ff.video.read(url, t=1) + F = { + "buffer": F["buffer"], + "shape": F["shape"], + "dtype": F["dtype"], + } + + with streams.StdVideoEncoder( + fs, + extra_inputs=[url_aud], + f="matroska", + map=["0:v", "1:a"], + show_log=True, + ) as f: + f.write(F) + f.wait() + b = f.read_encoded(-1) + + info = ff.probe.streams_basic(b) + assert len(info) == 2 + + with streams.StdVideoEncoder( + fs, + extra_inputs=[("anoisesrc", {"f": "lavfi"})], + f="matroska", + map=["0:v", "1:a"], + shortest=None, + show_log=True, + ) as f: + f.write(F) + f.wait() + b = f.read_encoded(-1) + + info = ff.probe.streams_basic(b) + assert len(info) == 2 + + +if __name__ == "__main__": + print("starting test") + logging.debug("logging check") + test_video_filter() + + # python tests\test_simplestreams.py From 188d13667d0c3ff646135b7bb5f72d141470b761 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 20:47:15 -0600 Subject: [PATCH 306/333] ran ruff formatter & check-fix --- docsrc/conf.py | 2 +- docsrc/examples/audio_create.py | 4 +- docsrc/examples/plotting.ipynb | 31 +++-- docsrc/examples/video_create.py | 3 +- src/ffmpegio/__init__.py | 2 +- src/ffmpegio/_open.py | 11 +- src/ffmpegio/_utils.py | 5 +- src/ffmpegio/analyze.py | 4 +- src/ffmpegio/audio.py | 3 +- src/ffmpegio/caps.py | 30 +++-- src/ffmpegio/configure.py | 30 ++--- src/ffmpegio/devices.py | 9 +- src/ffmpegio/errors.py | 4 +- src/ffmpegio/ffmpegprocess.py | 24 ++-- src/ffmpegio/filtergraph/Chain.py | 8 +- src/ffmpegio/filtergraph/Filter.py | 14 +-- src/ffmpegio/filtergraph/Graph.py | 30 +++-- src/ffmpegio/filtergraph/GraphLinks.py | 20 ++-- src/ffmpegio/filtergraph/__init__.py | 1 + src/ffmpegio/filtergraph/abc.py | 6 +- src/ffmpegio/filtergraph/build.py | 5 +- src/ffmpegio/filtergraph/convert.py | 4 +- src/ffmpegio/filtergraph/exceptions.py | 4 +- src/ffmpegio/filtergraph/presets.py | 2 - src/ffmpegio/filtergraph/utils.py | 11 +- src/ffmpegio/image.py | 2 +- src/ffmpegio/media.py | 3 +- src/ffmpegio/path.py | 3 +- src/ffmpegio/plugins/__init__.py | 5 +- src/ffmpegio/plugins/devices/dshow.py | 14 +-- src/ffmpegio/plugins/finder_win32.py | 6 +- src/ffmpegio/plugins/hookspecs.py | 10 +- src/ffmpegio/plugins/rawdata_bytes.py | 4 +- src/ffmpegio/plugins/rawdata_numpy.py | 8 +- src/ffmpegio/probe.py | 5 +- src/ffmpegio/stream_spec.py | 5 +- src/ffmpegio/streams/AviStreams.py | 2 +- src/ffmpegio/streams/PipedStreams.py | 11 +- src/ffmpegio/streams/SimpleStreams.py | 23 +++- src/ffmpegio/streams/StdStreams.py | 14 --- src/ffmpegio/threading.py | 12 +- src/ffmpegio/typing.py | 4 +- src/ffmpegio/utils/__init__.py | 19 ++- src/ffmpegio/utils/avi.py | 7 +- src/ffmpegio/utils/concat.py | 12 +- src/ffmpegio/utils/parser.py | 8 +- tests/_create_assets.py | 2 - tests/test_analyze.py | 4 +- tests/test_audio.py | 11 +- tests/test_avistreams.py | 21 ++-- tests/test_caps.py | 12 +- tests/test_devices.py | 3 +- tests/test_devices_dshow.py | 5 +- tests/test_errors.py | 8 +- tests/test_ffmpegprocess.py | 3 +- tests/test_filtergraph.py | 159 ++++++++++++++++++++----- tests/test_filtergraph_abc.py | 14 ++- tests/test_filtergraph_build.py | 130 +++++++++++++++----- tests/test_filtergraph_chain.py | 150 +++++++++++++++++++---- tests/test_filtergraph_fglinks.py | 16 ++- tests/test_filtergraph_filter.py | 84 +++++++++++-- tests/test_filtergraph_presets.py | 1 - tests/test_image.py | 9 +- tests/test_media.py | 8 +- tests/test_path.py | 15 ++- tests/test_pipedstreams.py | 3 - tests/test_plugins.py | 1 + tests/test_probe.py | 12 +- tests/test_simplestreams.py | 13 +- tests/test_stdstreams.py | 2 - tests/test_stream_spec.py | 2 +- tests/test_threading.py | 5 +- tests/test_transcode.py | 9 +- tests/test_utils.py | 2 +- tests/test_utils_concat.py | 8 +- tests/test_utils_filter.py | 6 +- tests/test_utils_log.py | 3 - tests/test_utils_parser.py | 17 ++- tests/test_video.py | 11 +- 79 files changed, 779 insertions(+), 414 deletions(-) diff --git a/docsrc/conf.py b/docsrc/conf.py index 294b1ac6..3ba4541e 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -42,7 +42,7 @@ ] # Looks for objects in external projects -autodoc_typehints = 'description' +autodoc_typehints = "description" # autodoc_type_aliases = {'AgentAssignment': 'AgentAssignment'} # Add any paths that contain templates here, relative to this directory. diff --git a/docsrc/examples/audio_create.py b/docsrc/examples/audio_create.py index 914a5fab..cbf8829c 100644 --- a/docsrc/examples/audio_create.py +++ b/docsrc/examples/audio_create.py @@ -21,6 +21,6 @@ x = audio.create("sine", f=220, b=4, d=5, r=fs) -t = np.arange(x.shape[0],dtype=float)*fs +t = np.arange(x.shape[0], dtype=float) * fs plt.plot(t, x) -plt.show() \ No newline at end of file +plt.show() diff --git a/docsrc/examples/plotting.ipynb b/docsrc/examples/plotting.ipynb index d4cdfa69..0532945c 100644 --- a/docsrc/examples/plotting.ipynb +++ b/docsrc/examples/plotting.ipynb @@ -51,8 +51,8 @@ "metadata": {}, "outputs": [], "source": [ - "sns.set() # Use seaborn's default style to make attractive graphs\n", - "plt.rcParams['figure.dpi'] = 100 # Show nicely large images in this notebook" + "sns.set() # Use seaborn's default style to make attractive graphs\n", + "plt.rcParams[\"figure.dpi\"] = 100 # Show nicely large images in this notebook" ] }, { @@ -89,7 +89,7 @@ "plt.xlim([snd.xmin, snd.xmax])\n", "plt.xlabel(\"time [s]\")\n", "plt.ylabel(\"amplitude\")\n", - "plt.show() # or plt.savefig(\"sound.png\"), or plt.savefig(\"sound.pdf\")" + "plt.show() # or plt.savefig(\"sound.png\"), or plt.savefig(\"sound.pdf\")" ] }, { @@ -138,13 +138,14 @@ "def draw_spectrogram(spectrogram, dynamic_range=70):\n", " X, Y = spectrogram.x_grid(), spectrogram.y_grid()\n", " sg_db = 10 * np.log10(spectrogram.values)\n", - " plt.pcolormesh(X, Y, sg_db, vmin=sg_db.max() - dynamic_range, cmap='afmhot')\n", + " plt.pcolormesh(X, Y, sg_db, vmin=sg_db.max() - dynamic_range, cmap=\"afmhot\")\n", " plt.ylim([spectrogram.ymin, spectrogram.ymax])\n", " plt.xlabel(\"time [s]\")\n", " plt.ylabel(\"frequency [Hz]\")\n", "\n", + "\n", "def draw_intensity(intensity):\n", - " plt.plot(intensity.xs(), intensity.values.T, linewidth=3, color='w')\n", + " plt.plot(intensity.xs(), intensity.values.T, linewidth=3, color=\"w\")\n", " plt.plot(intensity.xs(), intensity.values.T, linewidth=1)\n", " plt.grid(False)\n", " plt.ylim(0)\n", @@ -190,10 +191,10 @@ "def draw_pitch(pitch):\n", " # Extract selected pitch contour, and\n", " # replace unvoiced samples by NaN to not plot\n", - " pitch_values = pitch.selected_array['frequency']\n", - " pitch_values[pitch_values==0] = np.nan\n", - " plt.plot(pitch.xs(), pitch_values, 'o', markersize=5, color='w')\n", - " plt.plot(pitch.xs(), pitch_values, 'o', markersize=2)\n", + " pitch_values = pitch.selected_array[\"frequency\"]\n", + " pitch_values[pitch_values == 0] = np.nan\n", + " plt.plot(pitch.xs(), pitch_values, \"o\", markersize=5, color=\"w\")\n", + " plt.plot(pitch.xs(), pitch_values, \"o\", markersize=2)\n", " plt.grid(False)\n", " plt.ylim(0, pitch.ceiling)\n", " plt.ylabel(\"fundamental frequency [Hz]\")" @@ -217,7 +218,9 @@ "# If desired, pre-emphasize the sound fragment before calculating the spectrogram\n", "pre_emphasized_snd = snd.copy()\n", "pre_emphasized_snd.pre_emphasize()\n", - "spectrogram = pre_emphasized_snd.to_spectrogram(window_length=0.03, maximum_frequency=8000)" + "spectrogram = pre_emphasized_snd.to_spectrogram(\n", + " window_length=0.03, maximum_frequency=8000\n", + ")" ] }, { @@ -249,8 +252,9 @@ "source": [ "import pandas as pd\n", "\n", + "\n", "def facet_util(data, **kwargs):\n", - " digit, speaker_id = data[['digit', 'speaker_id']].iloc[0]\n", + " digit, speaker_id = data[[\"digit\", \"speaker_id\"]].iloc[0]\n", " sound = parselmouth.Sound(\"audio/{}_{}.wav\".format(digit, speaker_id))\n", " draw_spectrogram(sound.to_spectrogram())\n", " plt.twinx()\n", @@ -260,13 +264,14 @@ " plt.ylabel(\"\")\n", " plt.yticks([])\n", "\n", + "\n", "results = pd.read_csv(\"other/digit_list.csv\")\n", "\n", - "grid = sns.FacetGrid(results, row='speaker_id', col='digit')\n", + "grid = sns.FacetGrid(results, row=\"speaker_id\", col=\"digit\")\n", "grid.map_dataframe(facet_util)\n", "grid.set_titles(col_template=\"{col_name}\", row_template=\"{row_name}\")\n", "grid.set_axis_labels(\"time [s]\", \"frequency [Hz]\")\n", - "grid.set(facecolor='white', xlim=(0, None))\n", + "grid.set(facecolor=\"white\", xlim=(0, None))\n", "plt.show()" ] } diff --git a/docsrc/examples/video_create.py b/docsrc/examples/video_create.py index 57f05bfb..ac2392f8 100644 --- a/docsrc/examples/video_create.py +++ b/docsrc/examples/video_create.py @@ -10,6 +10,7 @@ im = plt.imshow(A[0, ...]) i = 0 + # initialization function: plot the background of each frame def init(): im.set_data(A[i, ...]) @@ -26,4 +27,4 @@ def animate(i): fig, animate, frames=A.shape[0], init_func=init, interval=1000 / 25, blit=True ) -plt.show() \ No newline at end of file +plt.show() diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 42413003..b122a910 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -58,7 +58,7 @@ def __getattr__(name): from .errors import FFmpegError, FFmpegioError from .utils.concat import FFConcat from .filtergraph import Graph as FilterGraph -from . import devices, ffmpegprocess, caps, probe, audio, image, video, media +from . import devices, caps, probe, audio, image, video, media from .transcode import transcode from .utils.parser import FLAG from ._open import open diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index 26709191..7b81e03f 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -7,6 +7,7 @@ from .filtergraph import Graph as FilterGraph + def open( url_fg: str, mode: str, @@ -24,7 +25,7 @@ def open( :type url_fg: str or seq(str) :param mode: specifies the mode in which the FFmpeg is used, see below :type mode: str - :param rate_in: (write and filter only, required) input frame rate (video) or sampling rate + :param rate_in: (write and filter only, required) input frame rate (video) or sampling rate (audio), defaults to None :type rate_in: Fraction, float, int, optional :param shape_in: (write and filter only) input video frame size (height x width [x ncomponents]), @@ -61,11 +62,11 @@ def open( a corresponding stream object. If the file cannot be opened, an error is raised. See :ref:`quick-streamio` for more examples of how to use this function. - Just like built-in `open()`, it is good practice to use the with keyword when dealing with - ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are - properly closed after ffmpeg terminates, even if an exception is raised at some point. + Just like built-in `open()`, it is good practice to use the with keyword when dealing with + ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are + properly closed after ffmpeg terminates, even if an exception is raised at some point. Using with is also much shorter than writing equivalent try-finally blocks. - + :Examples: Open an MP4 file and process all the frames:: diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index b62b7b46..c7d0d8bb 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -48,13 +48,13 @@ def strict_zip(): if items: i = len(items) plural = " " if i == 1 else "s 1-" - msg = f"zip() argument {i+1} is shorter than argument{plural}{i}" + 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}" + msg = f"zip() argument {i + 1} is longer than argument{plural}{i}" raise ValueError(msg) return strict_zip() @@ -242,7 +242,6 @@ def unescape(txt: str) -> str: in_quote = True while i0 < n: - if in_quote: # find the end quote i1 = txt.find("'", i0) diff --git a/src/ffmpegio/analyze.py b/src/ffmpegio/analyze.py index 8a3dea8d..3ae16b43 100644 --- a/src/ffmpegio/analyze.py +++ b/src/ffmpegio/analyze.py @@ -1,6 +1,4 @@ -"""media analysis tools module - -""" +"""media analysis tools module""" from __future__ import annotations from collections import namedtuple diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 9efa1508..170dca6e 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -1,5 +1,4 @@ -"""Audio Read/Write Module -""" +"""Audio Read/Write Module""" import warnings from . import ffmpegprocess, utils, configure, FFmpegError, plugins, analyze diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index 976aa778..f25432db 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -4,7 +4,9 @@ logger = logging.getLogger("ffmpegio") -import re, fractions, subprocess as sp +import re +import fractions +import subprocess as sp from collections import namedtuple from fractions import Fraction from functools import partial @@ -188,13 +190,17 @@ def filters(type=None): num_inputs=( 0 if intype == "none" - else len(match[5]) if intype != "dynamic" else None + else len(match[5]) + if intype != "dynamic" + else None ), output=outtype, num_outputs=( 0 if outtype == "none" - else len(match[6]) if outtype != "dynamic" else None + else len(match[6]) + if outtype != "dynamic" + else None ), timeline_support=match[1] == "T", slice_threading=match[2] == "S", @@ -421,7 +427,7 @@ def devices(type=None): try: key = {"source": "can_demux", "sink": "can_mux"}[type] except: - raise ValueError(f'type must be either "source" or "sink"') + raise ValueError('type must be either "source" or "sink"') return {k: v for k, v in devs.items() if v[key]} return devs @@ -472,7 +478,7 @@ def _getFormats(type, doCan): data = {} for match in _formatRegexp.finditer(stdout): for format in match[3].split(","): - if not (format in data): + if format not in data: data[format] = {"description": match[4]} if doCan: data[format]["can_demux"] = match[1] == "D" @@ -684,7 +690,7 @@ def demuxer_info(name): options=m[4], ) - if not "demuxer" in _cache: + if "demuxer" not in _cache: _cache["demuxer"] = {} _cache["demuxer"][name] = data return data @@ -733,7 +739,7 @@ def muxer_info(name): "subtitle_codecs": m[7].split(",") if m[7] else [], "options": m[8], } - if not "muxer" in _cache: + if "muxer" not in _cache: _cache["muxer"] = {} _cache["muxer"][name] = data return data @@ -838,7 +844,7 @@ def resolveFs(s): "options": m[11], } - if not "muxer" in _cache: + if "muxer" not in _cache: _cache["muxer"] = {} _cache["muxer"][name] = data return data @@ -923,7 +929,9 @@ def _get_filter_option(str, name): else ( partial(_conv_func, float) if type in ("float", "double") - else partial(_conv_func, Fraction) if type == "rational" else (lambda s: s) + else partial(_conv_func, Fraction) + if type == "rational" + else (lambda s: s) ) ) @@ -1109,7 +1117,7 @@ def filter_info(name): timeline, ) - if not "filter" in _cache: + if "filter" not in _cache: _cache["filter"] = {} _cache["filter"][name] = data return data @@ -1153,7 +1161,7 @@ def bsfilter_info(name): "supported_codecs": m[2].split(" ") if m[2] else [], "options": m[3], } - if not "filter" in _cache: + if "filter" not in _cache: _cache["filter"] = {} _cache["filter"][name] = data return data diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index deb456ca..ff41e7d4 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -4,7 +4,6 @@ IO, Literal, get_args, - LiteralString, Any, TypedDict, Unpack, @@ -29,7 +28,8 @@ from fractions import Fraction -import re, logging +import re +import logging logger = logging.getLogger("ffmpegio") @@ -138,7 +138,7 @@ def array_to_video_input( return ( pipe_id or "-", - {**utils.array_to_video_options(data)[0], f"r": rate, **opts}, + {**utils.array_to_video_options(data)[0], "r": rate, **opts}, ) @@ -161,7 +161,7 @@ def array_to_audio_input( return ( pipe_id or "-", - {**utils.array_to_audio_options(data)[0], f"ar": rate, **opts}, + {**utils.array_to_audio_options(data)[0], "ar": rate, **opts}, ) @@ -366,7 +366,6 @@ def finalize_video_read_opts( # directly from the input url (if not forced via input options) if has_simple_filter: - # create a source chain with matching spec and attach it to the af graph vf = temp_video_src(*inopt_vals) + outopts.get( "filter:v", outopts.get("vf", None) @@ -385,7 +384,6 @@ def finalize_video_read_opts( # pixel format must be specified if pix_fmt is None: - if pix_fmt_in == "unknown": raise FFmpegioError( "input pixel format unknown. Please specify output pix_fmt (to be autoset)" @@ -546,7 +544,6 @@ def finalize_audio_read_opts( # if a simple filter is present, use the stream specs of its output if "af" in outopts or "filter:a" in outopts: - # create a source chain with matching specs and attach it to the af graph af = temp_audio_src(*inopt_vals) af = af + outopts.get("filter:a", outopts.get("af", None)) @@ -766,7 +763,7 @@ def finalize_avi_read_opts(args): # add audio codec for k in utils.find_stream_options(options, "sample_fmt"): - options[f"c:a" + k[10:]] = utils.get_audio_codec(options[k])[0] + options["c:a" + k[10:]] = utils.get_audio_codec(options[k])[0] return ya8 > 0 @@ -794,7 +791,7 @@ def config_input_fg(expr, args, kwargs): # multi-filter input filtergraph, cannot take arguments if len(args): raise FFmpegioError( - f"filtergraph input expresion cannot take ordered options." + "filtergraph input expresion cannot take ordered options." ) return expr, dopt, kwargs @@ -935,7 +932,6 @@ def add_filtergraph( args["outputs"][ofile] = (args["outputs"][ofile][0], {"map": map}) else: if append_map and "map" in outopts: - existing_map = outopts["map"] # remove merged streams from output map & append the output stream of the filter @@ -1329,7 +1325,6 @@ def process_raw_outputs( user_maps = {} stream_maps = {} for k, v in streams.items() if get_opts else ((s, None) for s in streams): - if isinstance(k, tuple): k = ":".join(str(s) for s in k) @@ -1382,7 +1377,6 @@ def process_raw_inputs( input_info: list[InputSourceDict] = [] for i, (mtype, arg) in enumerate(zip(stream_types, stream_args)): - try: a1, a2 = arg if isinstance(a1, (int, float, Fraction)): @@ -1403,7 +1397,7 @@ def process_raw_inputs( elif "r" in opts: mtype = "v" else: - raise FFmpegioError(f"unknown input stream media type") + raise FFmpegioError("unknown input stream media type") data, opts = a1, a2 except FFmpegioError: raise @@ -1440,7 +1434,7 @@ def process_raw_inputs( pix_fmt, s = utils.guess_video_format(*raw_info) more_opts = { "f": "rawvideo", - f"c:v": "rawvideo", + "c:v": "rawvideo", "pix_fmt": pix_fmt, "s": s, } @@ -1558,7 +1552,6 @@ def process_url_outputs( missing_map = True if missing_map and not skip_automapping: - # some output file is missing `map` option # add all input streams or all complex filter outputs map_opts = [*auto_map(args, input_info, None)] @@ -1893,7 +1886,7 @@ def init_media_write_outputs( a_ids = [ i for i, info in enumerate(input_info) if info["media_type"] == "audio" ] - except KeyError as e: + except KeyError: raise NotImplementedError( "audio merging mode is not currently implemented. Please use the `complex_filtergraph=ffmpegio.filtergraph.presets.merge_audio(...)` to assign a custom filtergraph." ) @@ -2012,7 +2005,7 @@ def init_media_filter( if extra_inputs is not None: try: input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) - except FFmpegioNoPipeAllowed as e: + except FFmpegioNoPipeAllowed: raise FFmpegioError("extra_inputs cannot be piped in.") # make sure all inputs are complete @@ -2123,7 +2116,7 @@ def init_media_transcoder( if extra_inputs is not None: try: input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) - except FFmpegioNoPipeAllowed as e: + except FFmpegioNoPipeAllowed: raise FFmpegioError("extra_inputs cannot be piped in.") if not len(input_info): @@ -2190,7 +2183,6 @@ def init_named_pipes( has_pipeout = False for i, (output, info) in enumerate(zip(args["outputs"], output_info)): if output[0] is None: - has_pipeout = True # if fileobj or buffer output, use pipe diff --git a/src/ffmpegio/devices.py b/src/ffmpegio/devices.py index dbac80ae..8936dcd5 100644 --- a/src/ffmpegio/devices.py +++ b/src/ffmpegio/devices.py @@ -1,6 +1,6 @@ """I/O Device Enumeration Module -This module allows input and output hardware devices to be enumerated in the same fashion as the +This module allows input and output hardware devices to be enumerated in the same fashion as the streams of media containers. For example, instead of specifying DirectShow hardware by ``` @@ -15,6 +15,7 @@ """ + import logging logger = logging.getLogger("ffmpegio") @@ -64,7 +65,7 @@ def get_devices(dev_type): src_spans = [ [m[1], *m.span()] - for m in re.finditer(fr"Auto-detected {dev_type} for (.+?):\n", out.stdout) + for m in re.finditer(rf"Auto-detected {dev_type} for (.+?):\n", out.stdout) ] for i in range(len(src_spans) - 1): src_spans[i][1] = src_spans[i][2] @@ -249,7 +250,7 @@ def list_source_options(device, enum): try: list_options = dev["list_options"] except: - raise ValueError(f"No options to list") + raise ValueError("No options to list") return list_options(dev["list"][enum]) @@ -268,7 +269,7 @@ def list_sink_options(device, enum): try: list_options = info["list_options"] except: - raise ValueError(f"No options to list") + raise ValueError("No options to list") return list_options("sink", enum) diff --git a/src/ffmpegio/errors.py b/src/ffmpegio/errors.py index c1469985..35e84ed0 100644 --- a/src/ffmpegio/errors.py +++ b/src/ffmpegio/errors.py @@ -5,9 +5,11 @@ class FFmpegioError(Exception): pass + class FFmpegioNoPipeAllowed(FFmpegioError): pass + ERROR_MESSAGES = ( # cmdutils.c::parse_optgroup() r"Option %s (%s) cannot be applied to %s %s", @@ -285,7 +287,7 @@ 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'): + elif msg0.endswith("No such file or directory"): msg = msg0 return msg diff --git a/src/ffmpegio/ffmpegprocess.py b/src/ffmpegio/ffmpegprocess.py index 0e08d642..dc88305c 100644 --- a/src/ffmpegio/ffmpegprocess.py +++ b/src/ffmpegio/ffmpegprocess.py @@ -3,14 +3,14 @@ spawn FFmpeg processes, connect to their input/output/error pipes, and obtain their return codes. -To read/write a media file, `run_simple()` is the fast and simple solution. Use +To read/write a media file, `run_simple()` is the fast and simple solution. Use more complex `run()` if FFmpeg progress callback Main API ======== -run(...): Runs a FFmpeg command, waits for it to complete, then returns a +run(...): Runs a FFmpeg command, waits for it to complete, then returns a CompletedProcess instance. -Popen(...): A subclass of subprocess.Popen to manage FFmpeg subprocess. +Popen(...): A subclass of subprocess.Popen to manage FFmpeg subprocess. Constants --------- @@ -207,9 +207,9 @@ def monitor_process(proc, on_exit=None): for fcn in on_exit: try: fcn(returncode) - except Exception as e: + except Exception: pass - #TODO - need to re-raise these exceptions? + # TODO - need to re-raise these exceptions? logger.debug("[monitor] executed all on_exit callbacks") @@ -273,8 +273,15 @@ def __init__( if k in ( # fmt: off - "executable", "close_fds", "shell", "niversal_newlines", "pass_fds", - "encoding", "errors", "text", "pipesize", + "executable", + "close_fds", + "shell", + "niversal_newlines", + "pass_fds", + "encoding", + "errors", + "text", + "pipesize", # fmt: on ) ) @@ -381,7 +388,7 @@ def send_signal(self, sig: int = None, kill_monitor: bool = False): Without any argument, `send_signal()` will perform control-C to initiate soft-terminate FFmpeg. FFmpeg may output additional frames before exits. - Note: Setting `kill_monitor=True` will block the caller thread until the + Note: Setting `kill_monitor=True` will block the caller thread until the FFmpeg terminates. """ @@ -598,7 +605,6 @@ def mod_pass2_outopts(url, opts): ret = run(pass1_args, **other_run_kwargs) if not ret.returncode: - if stdin is not None: stdin.seek(pos) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index f0befc04..ed03f070 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -80,10 +80,10 @@ def compose( def __repr__(self): type_ = type(self) return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(self))}> - FFmpeg expression: \"{self.compose(True,True)}\" + 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()))} + 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]): @@ -104,7 +104,7 @@ def get_num_chains(self) -> int: def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain, defaults to None to get the total number + :param chain: id of the chain, defaults to None to get the total number of filters across all chains """ diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 42dc5945..3a0e67c2 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -152,9 +152,7 @@ def compose( """ return ( - fgb.Graph(self).compose( - show_unconnected_inputs, show_unconnected_outputs - ) + fgb.Graph(self).compose(show_unconnected_inputs, show_unconnected_outputs) if show_unconnected_inputs or show_unconnected_outputs else filter_utils.compose_filter(*self) ) @@ -162,7 +160,7 @@ def compose( def __repr__(self): type_ = type(self) return f"""<{type_.__module__}.{type_.__qualname__} object at {hex(id(self))}> - FFmpeg expression: \"{self.compose(True,True)}\" + FFmpeg expression: \"{self.compose(True, True)}\" Number of inputs: {self.get_num_inputs()} Number of outputs: {self.get_num_outputs()} """ @@ -206,7 +204,9 @@ def get_pad_media_type( port = ( "inputs" if "inputs".startswith(port) - else "outputs" if "outputs".startswith(port) else None + else "outputs" + if "outputs".startswith(port) + else None ) assert port is not None except: @@ -433,7 +433,7 @@ def _channelsplit(): channels = self.get_option_value("channels") return len( re.split( - rf"\s*\+\s*", + r"\s*\+\s*", layouts()["layouts"][layout] if channels == "all" else channels, ) ) @@ -494,7 +494,7 @@ def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: def get_num_filters(self, chain: int | None = None) -> int: """get the number of filters of the specfied chain - :param chain: id of the chain, defaults to None to get the total number + :param chain: id of the chain, defaults to None to get the total number of filters across all chains """ diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index d53ade64..335d2206 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -275,7 +275,7 @@ def compose( for j, (index, _, _) in enumerate( self.iter_output_pads(unlabeled_only=True) ): - unc_pads[f"{label}{i+j+1}"] = (None, index) + unc_pads[f"{label}{i + j + 1}"] = (None, index) links = {**fg._links, **unc_pads} if i >= 0 or j >= 0 else fg._links @@ -306,11 +306,14 @@ def __repr__(self): for j, (i0, i1) in enumerate(zip(pos[:-1], pos[1:])) ] if self.sws_flags: - chain_list = [f"{[' ']*(len(prefix)+3+nzeros)}{expr[:pos[0]]}", *chain_list] + chain_list = [ + f"{[' '] * (len(prefix) + 3 + nzeros)}{expr[: pos[0]]}", + *chain_list, + ] if len(chain_list) > 12: chain_list = [ chain_list[:-4], - f"{[' ']*(len(prefix)+3+nzeros)}{expr[:pos[0]]}", + f"{[' '] * (len(prefix) + 3 + nzeros)}{expr[: pos[0]]}", chain_list[-3:], ] chain_list = "\n".join(chain_list) @@ -319,8 +322,8 @@ def __repr__(self): FFmpeg expression: \"{str(self)}\" Number of chains: {len(self)} {chain_list} - Available input pads ({self.get_num_inputs()}): {', '.join((str(id[0]) for id in self.iter_input_pads()))} - Available output pads: ({self.get_num_outputs()}): {', '.join((str(id[0]) for id in self.iter_output_pads()))} + Available input pads ({self.get_num_inputs()}): {", ".join((str(id[0]) for id in self.iter_input_pads()))} + Available output pads: ({self.get_num_outputs()}): {", ".join((str(id[0]) for id in self.iter_output_pads()))} """ def __setitem__(self, key, value): @@ -339,7 +342,7 @@ def __getitem__(self, key): return UserList.__getitem__(self, key) except (IndexError, StopIteration) as e: raise e - except Exception as e: + except Exception: try: assert len(key) == 2 and all((isinstance(k, int) for k in key)) return UserList.__getitem__(self, key[0])[key[1]] @@ -462,7 +465,6 @@ def _iter_pads( ioff = chain for i, c in enumerate(chains): - j = (len(c) + filter) if filter is not None and filter < 0 else filter for pidx, f, other_pidx in iter_filter_pad( @@ -587,7 +589,13 @@ def iter_output_pads( yield v def get_num_inputs(self, chainable_only=False): - return len(list(self.iter_input_pads(exclude_stream_specs=True, chainable_only=chainable_only))) + return len( + list( + self.iter_input_pads( + exclude_stream_specs=True, chainable_only=chainable_only + ) + ) + ) def get_num_outputs(self, chainable_only=False): return len(list(self.iter_output_pads(chainable_only=chainable_only))) @@ -801,7 +809,7 @@ def remove_label(self, label: str, inpad: PAD_INDEX | None = None): """remove an input/output label :param label: linkn label - :param inpad: specify input pad if multiple pads receives the same input + :param inpad: specify input pad if multiple pads receives the same input stream, defaults to `None` to delete all input pads. """ @@ -890,14 +898,13 @@ def _stack( return Graph(other) if isinstance(other, Graph): - fg = Graph(self) if other.sws_flags is not None: if fg.sws_flags is None or replace_sws_flags is True: fg.sws_flags = deepcopy(other.sws_flags) elif replace_sws_flags is None: raise Graph.Error( - f"sws_flags are defined on both FilterGraphs. Specify replace_sws_flags option to True or False to avoid this error." + "sws_flags are defined on both FilterGraphs. Specify replace_sws_flags option to True or False to avoid this error." ) try: @@ -980,7 +987,6 @@ def _connect( # 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 diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 2ee0a5d3..b8b17b4f 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -31,7 +31,6 @@ class GraphLinks: ... class GraphLinks(UserDict): - class Error(FFmpegioError): pass @@ -84,7 +83,7 @@ def validate_pad_idx(id: PAD_INDEX | None, none_ok: bool = True): if id is None: if none_ok: return - raise GraphLinks.Error(f"pad index cannot be None") + raise GraphLinks.Error("pad index cannot be None") if not ( isinstance(id, (tuple)) @@ -102,7 +101,7 @@ def validate_pad_idx_pair(ids: PAD_PAIR): assert len(ids) == 2 except: raise GraphLinks.Error( - f"Link value must be a 2-element tuple with inpad and outpad pad ids" + "Link value must be a 2-element tuple with inpad and outpad pad ids" ) (inpad, outpad) = ids @@ -110,12 +109,12 @@ def validate_pad_idx_pair(ids: PAD_PAIR): inpad_is_none = inpad is None if inpad_is_none and outpad is None: - raise GraphLinks.Error(f"Both input and output pads cannot be None.") + raise GraphLinks.Error("Both input and output pads cannot be None.") i = -1 for i, d in enumerate(GraphLinks.iter_inpad_ids(inpad, True)): if d is None and not inpad_is_none: - raise GraphLinks.Error(f"multi-id input label item cannot be None.") + raise GraphLinks.Error("multi-id input label item cannot be None.") GraphLinks.validate_pad_idx(d) @staticmethod @@ -137,7 +136,6 @@ def validate(data: dict[str | int, PAD_PAIR]): # validate each link for label, pads in data.items(): - if ( not is_map_option(label, allow_missing_file_id=True) and pads[0] is not None @@ -593,7 +591,7 @@ def are_linked( ) else: if inpad is None and outpad is None: - raise ValueError(f"At least one of inpad or outpad must be specified.") + raise ValueError("At least one of inpad or outpad must be specified.") # check internal links first it_links = self.iter_links() @@ -794,8 +792,8 @@ def update( if not isinstance(other, GraphLinks) and validate: try: assert isinstance(other, Mapping) - except Exception as e: - raise GraphLinks.Error(f"Other must be a dict-like mapping object") + except Exception: + raise GraphLinks.Error("Other must be a dict-like mapping object") self.validate(other) # set aside labels @@ -871,8 +869,8 @@ def adjust_filters(self, chain_id: int, pos: int, len: int): :param len: number of chains to be inserted (if positive) or removed (if negative) """ - select = ( - lambda pid: pid[0] == chain_id and pid[1] >= pos + select = lambda pid: ( + pid[0] == chain_id and pid[1] >= pos ) # select all chains at or above pos adjust = lambda pid: (pid[0], pid[1] + len, pid[2]) self._modify_pad_ids(select, adjust) diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index f293cffb..530c180e 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -169,6 +169,7 @@ def func(*args, filter_id=None, **kwargs): return func + # TODO # def validate_input_filtergraph(fg): diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index e49e52bd..a71bd969 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -15,7 +15,6 @@ class FilterGraphObject(ABC): - def get_num_pads(self, input: bool) -> int: """get the number of available pads at input or output @@ -351,7 +350,7 @@ def remove_label(self, label: str, inpad: PAD_INDEX | None = None): """remove an input/output label :param label: linkn label - :param inpad: specify input pad if multiple pads receives the same input + :param inpad: specify input pad if multiple pads receives the same input stream, defaults to `None` to delete all input pads. """ @@ -740,7 +739,6 @@ def parse_other(other): # if output is a list if isinstance(other, list): - if len(other) == 0: raise ValueError("At least one `other` filtergraph must be specified.") @@ -794,7 +792,6 @@ def parse_other(other): # if output is a list if isinstance(other, list): - if len(other) == 0: raise ValueError("At least one `other` filtergraph must be specified.") @@ -975,7 +972,6 @@ def resolve_pad_indices( ] if resolve_omitted: - # assign unknown pad indices in the order of the following ranking: # indices ranking # - int, int, int = 3*6 = 18 diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 468403b1..165f93e1 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -1,6 +1,5 @@ from __future__ import annotations -from itertools import islice from copy import copy from .typing import PAD_INDEX, JOIN_HOW, Literal, get_args @@ -216,7 +215,6 @@ def join( raise if how in ("all", "chainable") or nright != nleft: - left_pads = [out[0] for out in left.iter_output_pads(**iter_kws)] right_pads = [out[0] for out in right.iter_input_pads(**iter_kws)] @@ -299,7 +297,6 @@ def analyze_fgobj(obj): right_objs_labels, attach_right = analyze_fgobj(right) if not (attach_left or attach_right): - if not len(right_objs_labels): return left_objs_labels if not len(left_objs_labels): @@ -415,7 +412,7 @@ def stack( return fgb.as_filtergraph_object(fgs[0], copy=True) fg = fgb.as_filtergraph(fgs[0], copy=not inplace) - + if n == 1: return fg diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py index d2d379e2..0ad3230c 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -125,7 +125,9 @@ def as_filtergraph_object( 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]) + else fgb.Filter(specs[0][0]) + if len(specs[0]) == 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 index d787bf37..fce625be 100644 --- a/src/ffmpegio/filtergraph/exceptions.py +++ b/src/ffmpegio/filtergraph/exceptions.py @@ -43,5 +43,5 @@ class FiltergraphPadNotFoundError(FFmpegioError): # ) # super().__init__(f"cannot find {type} pad at {target}") -class FiltergrapDuplicatehPadFoundError(FFmpegioError): - ... + +class FiltergrapDuplicatehPadFoundError(FFmpegioError): ... diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index ae9d0a7c..fc48dcbc 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -4,9 +4,7 @@ from .._typing import TYPE_CHECKING, Any, Sequence, Literal from ..stream_spec import StreamSpecDict -from .abc import FilterGraphObject -from functools import reduce from fractions import Fraction from .. import filtergraph as fgb diff --git a/src/ffmpegio/filtergraph/utils.py b/src/ffmpegio/filtergraph/utils.py index de46224e..3a11c9bd 100644 --- a/src/ffmpegio/filtergraph/utils.py +++ b/src/ffmpegio/filtergraph/utils.py @@ -1,5 +1,6 @@ from fractions import Fraction -import re, itertools +import re +import itertools from collections.abc import Sequence # Filter string parser/composer @@ -228,7 +229,9 @@ def add_pad(label, output, *padspec): padspecs = sig[output] if padspecs is None: sig[output] = padspec - elif not output and sig[1] is None: # new input label with the same name as existing input label + elif ( + not output and sig[1] is None + ): # new input label with the same name as existing input label if isinstance(sig[output][0], int): # second matching input label sig[output] = [padspecs, padspec] @@ -237,7 +240,7 @@ def add_pad(label, output, *padspec): padspecs.append(padspec) else: raise ValueError( - f'Filter graph specifies multiple \'{label}\' {"output" if output else "input"} pads.' + f"Filter graph specifies multiple '{label}' {'output' if output else 'input'} pads." ) def parse_labels(expr, i, output, *cidfid): @@ -300,7 +303,6 @@ def parse_labels(expr, i, output, *cidfid): i = j else: - # add new filter to the chain fc.append(parse_filter(fs)) @@ -468,7 +470,6 @@ def assign_link(d, label, cid, fid, pid): labels = set() # collection of all the labels if links is not None and len(links): - # log all named link labels labels = {k for k in links.keys() if isinstance(k, str)} diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 534bf94c..0f3799d6 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -165,7 +165,7 @@ def write( show_log=None, extra_inputs=None, sp_kwargs=None, - **options + **options, ): """Write a NumPy array to an image file. diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 91a9a59b..db17d7db 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -44,8 +44,7 @@ def _runner( # True if there is unknown datablob info need_stderr = any( - info["dst_type"] == "pipe" and info["raw_info"] is None - for info in output_info + info["dst_type"] == "pipe" and info["raw_info"] is None for info in output_info ) # run FFmpeg diff --git a/src/ffmpegio/path.py b/src/ffmpegio/path.py index 329f43f1..fd49586a 100644 --- a/src/ffmpegio/path.py +++ b/src/ffmpegio/path.py @@ -1,7 +1,8 @@ from os import path as _path, name as _os_name, devnull from shutil import which from subprocess import run, DEVNULL, PIPE, STDOUT -import re, shlex +import re +import shlex from packaging.version import Version import logging diff --git a/src/ffmpegio/plugins/__init__.py b/src/ffmpegio/plugins/__init__.py index 28f14c07..b6793540 100644 --- a/src/ffmpegio/plugins/__init__.py +++ b/src/ffmpegio/plugins/__init__.py @@ -8,7 +8,8 @@ from typing import Literal, Any from importlib import import_module -import re, os +import re +import os import pluggy @@ -62,9 +63,11 @@ def unregister(name: str) -> Any | None: """ 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) diff --git a/src/ffmpegio/plugins/devices/dshow.py b/src/ffmpegio/plugins/devices/dshow.py index 6ea704c5..cd4e347b 100644 --- a/src/ffmpegio/plugins/devices/dshow.py +++ b/src/ffmpegio/plugins/devices/dshow.py @@ -1,4 +1,4 @@ -""" DirectShow device""" +"""DirectShow device""" from subprocess import PIPE from ffmpegio import path @@ -79,7 +79,7 @@ def __call__(self, t, m): def _resolve(infos): # TODO Verify if multiple videos/audios allowed (more than 1 each) - return ":".join([f'{dev["media_type"]}={dev["name"]}' for dev in infos]) + return ":".join([f"{dev['media_type']}={dev['name']}" for dev in infos]) def _list_options(dev): @@ -88,7 +88,7 @@ def _list_options(dev): is_video = dev["media_type"] == "video" - url = f'{dev["media_type"]}={dev["name"]}' + url = f"{dev['media_type']}={dev['name']}" logs = path.ffmpeg( [ "-hide_banner", @@ -106,12 +106,12 @@ def _list_options(dev): ).stderr # read header - m = re.match(rf"\[(.+?)\] DirectShow .+? device options \(from .+? devices\)", logs) + m = re.match(r"\[(.+?)\] DirectShow .+? device options \(from .+? devices\)", logs) sign = re.escape(m[1]) i0 = m.end() m = re.search( - rf"Error opening input: Immediate exit requested\n", logs + r"Error opening input: Immediate exit requested\n", logs ) or re.search(rf"{re.escape(url)}: Immediate exit requested\n", logs) i1 = m.start() if m else len(logs) @@ -119,8 +119,8 @@ def _list_options(dev): re_video = re.compile( rf"\[{sign}\] (?:unknown compression type 0x([0-9A-F]+?)|vcodec=(.+?)|pixel_format=(.+?))" - + rf" min s=(\d+)x(\d+) fps=([\d.]+) max s=(\d+)x(\d+) fps=([\d.]+)" - + rf"(?: \((.+?), (.+?)/(.+?)/(.+?)(?:, (.+?))?\))?\n" + + r" min s=(\d+)x(\d+) fps=([\d.]+) max s=(\d+)x(\d+) fps=([\d.]+)" + + r"(?: \((.+?), (.+?)/(.+?)/(.+?)(?:, (.+?))?\))?\n" ) re_audio = re.compile( diff --git a/src/ffmpegio/plugins/finder_win32.py b/src/ffmpegio/plugins/finder_win32.py index c680862b..47465f70 100644 --- a/src/ffmpegio/plugins/finder_win32.py +++ b/src/ffmpegio/plugins/finder_win32.py @@ -1,8 +1,10 @@ -import os, shutil +import os +import shutil from pluggy import HookimplMarker hookimpl = HookimplMarker("ffmpegio") + @hookimpl def finder(): """Set path to FFmpeg bin directory @@ -81,4 +83,4 @@ def search(cmd): return (ffmpeg_path or ffprobe_path) and (ffmpeg_path, ffprobe_path) except: - return None \ No newline at end of file + return None diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index e809fdec..2186ef52 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -17,7 +17,7 @@ def video_info(obj: object) -> tuple[ShapeTuple, DTypeString]: """get video frame info :param obj: object containing video frame data with arbitrary number of frames - :return shape: shape (height,width,components) + :return shape: shape (height,width,components) :return dtype: data type in numpy dtype str expression """ @@ -27,7 +27,7 @@ def audio_info(obj: object) -> tuple[ShapeTuple, DTypeString]: """get audio sample info :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples - :return ac: number of channels + :return ac: number of channels :return dtype: sample data type in numpy dtype str expression """ @@ -49,6 +49,7 @@ def audio_bytes(obj: object) -> memoryview: :return: packed bytes of audio samples """ + @hookspec(firstresult=True) def video_frames(obj: object) -> int: """get number of video frames in obj @@ -66,6 +67,7 @@ def audio_samples(obj: object) -> int: :return: number of samples in obj """ + @hookspec(firstresult=True) def bytes_to_video( b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool @@ -81,7 +83,9 @@ def bytes_to_video( @hookspec(firstresult=True) -def bytes_to_audio(b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool) -> object: +def bytes_to_audio( + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool +) -> object: """convert bytes to rawaudio object :param b: byte data of arbitrary number of video frames diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index fa129dcb..32e1161e 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -138,7 +138,7 @@ def bytes_to_video( return { "buffer": b, "dtype": dtype, - "shape": tuple(((i for i in sh if i != 1))) if squeeze else sh, + "shape": tuple((i for i in sh if i != 1)) if squeeze else sh, } except: return None @@ -163,7 +163,7 @@ def bytes_to_audio( return { "buffer": b, "dtype": dtype, - "shape": tuple(((i for i in sh if i != 1))) if squeeze else sh, + "shape": tuple((i for i in sh if i != 1)) if squeeze else sh, } except: return None diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index 8ac292fc..9317a69c 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -86,7 +86,7 @@ def video_bytes(obj: ArrayLike) -> memoryview: """ try: - return np.ascontiguousarray(obj).view('b') + return np.ascontiguousarray(obj).view("b") except: return None @@ -100,7 +100,7 @@ def audio_bytes(obj: ArrayLike) -> memoryview: """ try: - return np.ascontiguousarray(obj).view('b') + return np.ascontiguousarray(obj).view("b") except: return None @@ -126,7 +126,9 @@ def bytes_to_video( @hookimpl -def bytes_to_audio(b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool) -> ArrayLike: +def bytes_to_audio( + b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool +) -> ArrayLike: """convert bytes to rawaudio NumPy array :param b: byte data of arbitrary number of video frames diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index a79f58b3..9f85ec55 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -6,7 +6,8 @@ from typing_extensions import Buffer, IO from io import IOBase -import json, re +import json +import re from fractions import Fraction from functools import lru_cache @@ -41,7 +42,6 @@ def try_conv(v): try: return float(v) except ValueError: - # convert ratio to fraction ':' -> '/' if v = _re_ratio.sub(r"\1/\2", v) @@ -838,7 +838,6 @@ def frames( pass if accurate_time and has_time: - time_bases = {d["index"]: Fraction(d["time_base"]) for d in res["streams"]} if not pick_entries: diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 256476d5..822149eb 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -117,7 +117,6 @@ def parse_stream_spec(spec: str | int) -> StreamSpecDict: """ if isinstance(spec, str): - out: StreamSpecDict = {} spec_parts = spec.split(":") nspecs = len(spec_parts) @@ -130,7 +129,9 @@ def get_int(s, name): ( 10 if s[0] != "0" and len(s) > 1 - else 16 if s.startswith("0x") or s.startswith("0X") else 8 + else 16 + if s.startswith("0x") or s.startswith("0X") + else 8 ), ) assert v >= 0 diff --git a/src/ffmpegio/streams/AviStreams.py b/src/ffmpegio/streams/AviStreams.py index f10b5aed..dbe5b577 100644 --- a/src/ffmpegio/streams/AviStreams.py +++ b/src/ffmpegio/streams/AviStreams.py @@ -57,7 +57,7 @@ def __init__( show_log=None, queuesize=0, sp_kwargs=None, - **options + **options, ): self.ref_stream = ref_stream diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 1d8e6cba..3af7ebec 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -241,7 +241,6 @@ def wait(self, timeout: float | None = None) -> int | None: timeout = self.default_timeout if self._proc: - if timeout is not None: timeout += time() @@ -267,7 +266,6 @@ def wait(self, timeout: float | None = None) -> int | None: class _RawInputMixin: - _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} _array_to_opts = { "video": utils.array_to_video_options, @@ -325,7 +323,6 @@ def _write_stream( self._open(True) else: - logger.debug("[writer main] writing...") try: @@ -405,7 +402,6 @@ def write( class _EncodedInputMixin: - def __init__(self, **kwargs): super().__init__(**kwargs) @@ -448,7 +444,6 @@ def _write_encoded_stream( self._open(True) else: - try: info["writer"].write(data, timeout) except: @@ -795,7 +790,6 @@ def readall_encoded(self, timeout: float | None = None) -> dict[str, bytes]: class PipedMediaReader(_EncodedInputMixin, _RawOutputMixin, _PipedFFmpegRunner): - def __init__( self, *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], @@ -879,7 +873,6 @@ def __next__(self): class PipedMediaWriter(_EncodedOutputMixin, _RawInputMixin, _PipedFFmpegRunner): - def __init__( self, urls: ( @@ -953,8 +946,7 @@ def __init__( options["y"] = None stream_args = [ - (None, v) if isinstance(v, dict) else (v, None) - for v in input_rates_or_opts + (None, v) if isinstance(v, dict) else (v, None) for v in input_rates_or_opts ] args, input_info, input_ready, output_info, output_args = ( configure.init_media_write( @@ -989,7 +981,6 @@ def __init__( class PipedMediaFilter(_RawOutputMixin, _RawInputMixin, _PipedFFmpegRunner): - def __init__( self, expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 86f38b87..54935819 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -735,7 +735,9 @@ def __init__( if "filter_complex" in glopts: # prepare complex filter output - FFmpegioError("To use complex filtergraph (i.e., the `filter_complex` global option), use the PipedFilter class instead.") + FFmpegioError( + "To use complex filtergraph (i.e., the `filter_complex` global option), use the PipedFilter class instead." + ) try: not_ready, self.shape_in, self.dtype_in = self._set_options( @@ -823,7 +825,7 @@ def _get_output_info(self, timeout): ) except TimeoutError as e: raise e - except Exception as e: + except Exception: if self._proc.poll() is None: raise self._logger.Exception else: @@ -1057,9 +1059,20 @@ class SimpleVideoFilter(SimpleFilterBase): def __init__( # fmt:off - self, expr, rate_in, shape_in=None, dtype_in=None, rate=None, shape=None, dtype=None, - blocksize=None, default_timeout=None, progress=None, show_log=None, sp_kwargs=None, -**options, + self, + expr, + rate_in, + shape_in=None, + dtype_in=None, + rate=None, + shape=None, + dtype=None, + blocksize=None, + default_timeout=None, + progress=None, + show_log=None, + sp_kwargs=None, + **options, # fmt:on ) -> None: hook = plugins.get_hook() diff --git a/src/ffmpegio/streams/StdStreams.py b/src/ffmpegio/streams/StdStreams.py index 190ff7b9..b78feca4 100644 --- a/src/ffmpegio/streams/StdStreams.py +++ b/src/ffmpegio/streams/StdStreams.py @@ -252,7 +252,6 @@ def wait(self, timeout: float | None = None) -> int | None: timeout = self.default_timeout if self._proc: - if timeout is not None: timeout += time() @@ -281,7 +280,6 @@ def wait(self, timeout: float | None = None) -> int | None: class _RawInputBaseMixin: - _get_num: Callable _input_info: InputSourceDict default_timeout: float | None @@ -380,7 +378,6 @@ def write(self, data: RawDataBlob, timeout: float | None = None): class _AudioInputMixin(_RawInputBaseMixin): - def __init__(self, **kwargs): super().__init__( plugins.get_hook().audio_bytes, utils.array_to_audio_options, **kwargs @@ -388,7 +385,6 @@ def __init__(self, **kwargs): class _VideoInputMixin(_RawInputBaseMixin): - def __init__(self, **kwargs): super().__init__( plugins.get_hook().video_bytes, utils.array_to_video_options, **kwargs @@ -396,7 +392,6 @@ def __init__(self, **kwargs): class _EncodedInputMixin: - def __init__(self, **kwargs): super().__init__(**kwargs) @@ -422,7 +417,6 @@ def write_encoded(self, data: bytes, timeout: float | None = None): info = self._input_info[0] if self._input_ready: - try: info["writer"].write(data, timeout) except: @@ -548,13 +542,11 @@ def read(self, n: int, timeout: float | None = None) -> RawDataBlob: class _AudioOutputMixin(_RawOutputBaseMixin): - def __init__(self, **kwargs): super().__init__(plugins.get_hook().bytes_to_audio, **kwargs) class _VideoOutputMixin(_RawOutputBaseMixin): - def __init__(self, **kwargs): super().__init__(plugins.get_hook().bytes_to_video, **kwargs) @@ -603,7 +595,6 @@ def read_encoded(self, n: int, timeout: float | None = None) -> bytes: class StdAudioDecoder(_EncodedInputMixin, _AudioOutputMixin, _StdFFmpegRunner): - def __init__( self, *, @@ -666,7 +657,6 @@ def __next__(self): class StdVideoDecoder(_EncodedInputMixin, _VideoOutputMixin, _StdFFmpegRunner): - def __init__( self, *, @@ -729,7 +719,6 @@ def __next__(self): class StdAudioEncoder(_EncodedOutputMixin, _AudioInputMixin, _StdFFmpegRunner): - def __init__( self, input_rate: int, @@ -800,7 +789,6 @@ def __init__( class StdVideoEncoder(_EncodedOutputMixin, _VideoInputMixin, _StdFFmpegRunner): - def __init__( self, input_rate: int, @@ -871,7 +859,6 @@ def __init__( class StdAudioFilter(_AudioOutputMixin, _AudioInputMixin, _StdFFmpegRunner): - def __init__( self, expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], @@ -970,7 +957,6 @@ def filter( class StdVideoFilter(_VideoOutputMixin, _VideoInputMixin, _StdFFmpegRunner): - def __init__( self, expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 10c64a27..bc3a550a 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -5,7 +5,8 @@ from typing import BinaryIO from copy import deepcopy -import re, os +import re +import os from threading import Thread, Condition, Lock, Event from io import TextIOBase, TextIOWrapper from time import sleep, time @@ -259,7 +260,7 @@ def output_stream(self, file_id=0, stream_id=0, block=True, timeout=None): raise e except TimeoutError: raise TimeoutError("Specified output stream not found") - except Exception as e: + except Exception: raise ValueError("Specified output stream not found") with self._newline_mutex: @@ -557,7 +558,7 @@ def run(self): queue.task_done() if data is None: - logger.info(f"writer thread: received a sentinel to stop the writer") + logger.info("writer thread: received a sentinel to stop the writer") break else: logger.info(f"writer thread: received {len(data)} bytes to write") @@ -571,7 +572,7 @@ def run(self): logger.info(f"writer thread exception: {e}") break if not nwritten and stream.closed: # just in case - logger.info(f"writer thread: somethin' else happened") + logger.info("writer thread: somethin' else happened") break # set flag to prevent any more writes @@ -602,11 +603,10 @@ def run(self): self._empty = True self._empty_cond.notify_all() - logger.info(f"writer thread exiting") + logger.info("writer thread exiting") def write(self, data, timeout=None): with self._empty_cond: - if self._no_more: if data is None: return diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py index 03db86e2..684274cb 100644 --- a/src/ffmpegio/typing.py +++ b/src/ffmpegio/typing.py @@ -1,12 +1,10 @@ """type hint definition for external use""" + from __future__ import annotations from typing import * from typing_extensions import * from ._typing import * -from .filtergraph.abc import FilterGraphObject -from .stream_spec import StreamSpecDict, StreamSpecStreamType -from .configure import FFmpegArgs, FFmpegUrlType diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index dba06696..3fa20530 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -189,7 +189,7 @@ def guess_video_format( ndim = len(shape) if ndim < 2 or ndim > 4: raise ValueError( - f"invalid video data dimension: data shape must be must be 2d, 3d or 4d" + "invalid video data dimension: data shape must be must be 2d, 3d or 4d" ) has_comp = ndim != 2 and (ndim != 3 or shape[-1] < 5) @@ -291,7 +291,7 @@ def guess_audio_format(shape: ShapeTuple, dtype: DTypeString) -> tuple[int, str] ndim = len(shape) if ndim < 1 or ndim > 2: raise ValueError( - f"invalid audio data dimension: data shape must be must be 1d or 2d" + "invalid audio data dimension: data shape must be must be 1d or 2d" ) try: @@ -513,7 +513,7 @@ def array_to_audio_options( return ({}, info) sample_fmt, ac = guess_audio_format(shape, dtype) codec, f = get_audio_codec(sample_fmt) - return ({"f": f, f"c:a": codec, f"ac": ac, f"sample_fmt": sample_fmt}, info) + return ({"f": f, "c:a": codec, "ac": ac, "sample_fmt": sample_fmt}, info) def array_to_video_options( @@ -531,9 +531,9 @@ def array_to_video_options( s, pix_fmt = guess_video_format(shape, dtype) return ( ( - {"f": "rawvideo", f"c:v": "rawvideo"} + {"f": "rawvideo", "c:v": "rawvideo"} if s is None - else {"f": "rawvideo", f"c:v": "rawvideo", f"s": s, f"pix_fmt": pix_fmt} + else {"f": "rawvideo", "c:v": "rawvideo", "s": s, "pix_fmt": pix_fmt} ), info, ) @@ -773,7 +773,6 @@ def analyze_complex_filtergraphs( for i, (padidx, filt, _) in enumerate( fg.iter_input_pads(full_pad_index=True, exclude_stream_specs=False) ): - label = fg.get_label(inpad=padidx) media_type = filt.get_pad_media_type("input", padidx[-1]) @@ -782,7 +781,7 @@ def analyze_complex_filtergraphs( sspec = None if i > 0: raise FFmpegioError( - f"All the input pads of a filtergraph with more than one inputs must have them labeled." + "All the input pads of a filtergraph with more than one inputs must have them labeled." ) else: map_option = parse_map_option(label) @@ -803,7 +802,7 @@ def analyze_complex_filtergraphs( ) ) else: - raise FFmpegioError(f"unknown media type of a filter") + raise FFmpegioError("unknown media type of a filter") sources.append((src, (0, len(src) - 1, 0), padidx)) @@ -927,7 +926,7 @@ def get_output_stream_id( ) elif stream < 0 or stream >= len(output_info): raise FFmpegioError( - f'"{stream=}") is not a valid output index (0-{len(output_info)-1})' + f'"{stream=}") is not a valid output index (0-{len(output_info) - 1})' ) return stream @@ -943,7 +942,7 @@ def is_valid_input_url(url: FFmpegInputUrlComposite) -> bool: # get the option if not valid: try: memoryview(url) - except TypeError as e: + except TypeError: pass else: valid = True diff --git a/src/ffmpegio/utils/avi.py b/src/ffmpegio/utils/avi.py index 3d9485e9..88d0e778 100644 --- a/src/ffmpegio/utils/avi.py +++ b/src/ffmpegio/utils/avi.py @@ -1,5 +1,6 @@ from io import SEEK_CUR -import fractions, re +import fractions +import re from struct import Struct from collections import namedtuple from itertools import accumulate @@ -339,12 +340,12 @@ def read_header(f, pix_fmt=None): # read the RIFF header id, datasize, chunksize, list_type = read_chunk_header(f) if id != "RIFF" or list_type != "AVI ": - raise RuntimeError(f"File stream is not AVI") + raise RuntimeError("File stream is not AVI") # read the hdrl chunk id, datasize, chunksize, list_type = read_chunk_header(f) if id != "LIST" and list_type != "hdrl": - raise RuntimeError(f"AVI is missing header chunk") + raise RuntimeError("AVI is missing header chunk") b = f.read(datasize) if chunksize > datasize: _seek(f, 1) diff --git a/src/ffmpegio/utils/concat.py b/src/ffmpegio/utils/concat.py index 50394df6..4b9ef5c2 100644 --- a/src/ffmpegio/utils/concat.py +++ b/src/ffmpegio/utils/concat.py @@ -1,8 +1,8 @@ -"""FFConcat class to build/use ffconcat list file for concat demuxer -""" +"""FFConcat class to build/use ffconcat list file for concat demuxer""" from glob import glob -import io, re +import io +import re import os from tempfile import NamedTemporaryFile from functools import partial @@ -163,7 +163,7 @@ def lines(self): lines = [ f"file {escape(self.path)}\n", *( - f"{k} {getattr(self,k)}\n" + f"{k} {getattr(self, k)}\n" for k in ("duration", "inpoint", "outpoint") if getattr(self, k) is not None ), @@ -226,7 +226,7 @@ def lines(self): ) if self.extradata is not None: lines.append( - f"stream_extradata {self.extradata if isinstance(self.extradata,str) else memoryview(self.extradata).hex()}\n" + f"stream_extradata {self.extradata if isinstance(self.extradata, str) else memoryview(self.extradata).hex()}\n" ) return lines @@ -552,7 +552,7 @@ def as_filter(self, v=1, a=0, file_offset=0): n = len(self.files) nst = v + a in_labels = "".join( - (f"[{i+file_offset}:{j}]" for j in range(nst) for i in range(n)) + (f"[{i + file_offset}:{j}]" for j in range(nst) for i in range(n)) ) fg = f"{in_labels}concat=n={n}:v={v}:a={a}" diff --git a/src/ffmpegio/utils/parser.py b/src/ffmpegio/utils/parser.py index b26f1203..7c1a2e14 100644 --- a/src/ffmpegio/utils/parser.py +++ b/src/ffmpegio/utils/parser.py @@ -1,4 +1,6 @@ -import re, os, shlex +import re +import os +import shlex from collections import abc from ..filtergraph import Graph, Chain, Filter @@ -215,7 +217,9 @@ def outputs2args(outputs): args.append( str(url) if url is not None - else "/dev/null" if os.name != "nt" else "NUL" + else "/dev/null" + if os.name != "nt" + else "NUL" ) return args diff --git a/tests/_create_assets.py b/tests/_create_assets.py index 6ef56021..3afe668d 100644 --- a/tests/_create_assets.py +++ b/tests/_create_assets.py @@ -2,8 +2,6 @@ from os import path from pprint import pprint -from ffmpegio import image -from matplotlib import pyplot as plt command_list = ( { diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 11b2d024..5a8f5145 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -1,7 +1,6 @@ from pprint import pprint from ffmpegio import analyze, path as ffmpeg_path -import tempfile, re, logging -from os import path +import logging import pytest logging.basicConfig(level=logging.DEBUG) @@ -64,7 +63,6 @@ def test_astats(): if __name__ == "__main__": import logging - from matplotlib import pyplot as plt import ffmpegio logging.basicConfig(level=logging.DEBUG) diff --git a/tests/test_audio.py b/tests/test_audio.py index c3f4c47c..89cace56 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,5 +1,7 @@ from ffmpegio import audio, probe, FilterGraph -import tempfile, re, logging +import tempfile +import re +import logging from os import path import pytest @@ -73,7 +75,6 @@ def test_read_write(): fs, x = audio.read(url) with tempfile.TemporaryDirectory() as tmpdirname: - print(probe.audio_streams_basic(url)) out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) print(out_url) @@ -104,7 +105,9 @@ def test_filter(): ], ) - output_rate, output = audio.filter(expr, input_rate, input, show_log=True, loglevel ='verbose') + output_rate, output = audio.filter( + expr, input_rate, input, show_log=True, loglevel="verbose" + ) assert output_rate == 22050 assert output["shape"] == (22050, 2) assert output["dtype"] == input["dtype"] @@ -125,7 +128,7 @@ def test_filter(): output_rate, output = audio.filter(expr, input_rate, input) assert output_rate == 44100 assert output["shape"] == (44100, 2) - assert output["dtype"] == '") - path.check_version("5.0","<=") - path.check_version("5.0",">=") + path.check_version("5.0", "==") + path.check_version("5.0", "!=") + path.check_version("5.0", "<") + path.check_version("5.0", ">") + path.check_version("5.0", "<=") + path.check_version("5.0", ">=") + if __name__ == "__main__": test_find() diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index d69fc731..070d31f6 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -2,9 +2,6 @@ logging.basicConfig(level=logging.DEBUG) -from os import path -import pytest -from tempfile import TemporaryDirectory import ffmpegio as ff from ffmpegio import streams diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 24598353..b2d2460c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -28,6 +28,7 @@ def test_rawdata_bytes(): assert hook.audio_info(obj=data) == (shape, dtype) assert hook.audio_bytes(obj=data) == b + def test_use(): import numpy as np diff --git a/tests/test_probe.py b/tests/test_probe.py index 24bc10cf..44cd0534 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -1,9 +1,7 @@ import logging logging.basicConfig(level=logging.DEBUG) -import pytest -from ffmpegio import ffmpeg_info import ffmpegio.probe as probe # print( @@ -29,15 +27,15 @@ def test_url_types(): out1 = probe.query(f) f.seek(0) del out1["filename"] - if 'bit_rate' not in out1: - del out['bit_rate'] - if 'size' not in out1: - del out['size'] + if "bit_rate" not in out1: + del out["bit_rate"] + if "size" not in out1: + del out["size"] assert out1 == out # piping in byte content of the file yields a few other differences probe.query(f.read()) - + def test_all(): url = "tests/assets/testmulti-1m.mp4" diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py index fa68432e..fed020a9 100644 --- a/tests/test_simplestreams.py +++ b/tests/test_simplestreams.py @@ -3,7 +3,8 @@ logging.basicConfig(level=logging.DEBUG) import ffmpegio -import tempfile, re +import tempfile +import re from os import path from ffmpegio import streams, utils @@ -106,9 +107,12 @@ def test_video_filter(): fps = 10 # fractions.Fraction(60000,1001) - with streams.SimpleVideoReader(url, blocksize=30, t=30) as src, streams.SimpleVideoFilter( - "scale=200:100", rate_in=src.rate, rate=fps, show_log=True - ) as f: + with ( + streams.SimpleVideoReader(url, blocksize=30, t=30) as src, + streams.SimpleVideoFilter( + "scale=200:100", rate_in=src.rate, rate=fps, show_log=True + ) as f, + ): def process(i, frames): print(f"{i} - output {frames['shape'][0]} frames ({f.nin},{f.nout})") @@ -126,7 +130,6 @@ def test_audio_filter(): sps = 4000 # fractions.Fraction(60000,1001) with streams.SimpleAudioReader(url, blocksize=1024 * 8, t=10, ar=32000) as src: - samples = src.read(src.blocksize) with streams.SimpleAudioFilter( diff --git a/tests/test_stdstreams.py b/tests/test_stdstreams.py index b41006b5..5b27f7de 100644 --- a/tests/test_stdstreams.py +++ b/tests/test_stdstreams.py @@ -3,8 +3,6 @@ logging.basicConfig(level=logging.DEBUG) import ffmpegio as ff -import tempfile, re -from os import path from ffmpegio import streams, utils url = "tests/assets/testmulti-1m.mp4" diff --git a/tests/test_stream_spec.py b/tests/test_stream_spec.py index 847e488c..6721d2ba 100644 --- a/tests/test_stream_spec.py +++ b/tests/test_stream_spec.py @@ -75,4 +75,4 @@ def test_stream_spec(): ], ) def test_parse_map_option(map, input_file_id, ret): - assert ret==utils.parse_map_option(map, input_file_id=input_file_id) + assert ret == utils.parse_map_option(map, input_file_id=input_file_id) diff --git a/tests/test_threading.py b/tests/test_threading.py index b944a8e5..8a64d1fa 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -1,5 +1,4 @@ from ffmpegio import threading -from ffmpegio import ffmpegprocess from ffmpegio.ffmpegprocess import Popen from tempfile import TemporaryDirectory from os import path @@ -33,13 +32,11 @@ def test_copyfileobj(): open(path.join(tmpdir, "out.mp3"), "w+b") as fdst, threading.CopyFileObjThread(fsrc, fdst) as copier, ): - copier.join() fsrc.seek(0) data = fsrc.read() fdst.seek(0) data_out = fdst.read() - - assert data == data_out + assert data == data_out diff --git a/tests/test_transcode.py b/tests/test_transcode.py index 1a5622bb..fe4cf710 100644 --- a/tests/test_transcode.py +++ b/tests/test_transcode.py @@ -1,5 +1,6 @@ -from ffmpegio import transcode, probe, FFmpegError, FilterGraph -import tempfile, re +from ffmpegio import transcode, FFmpegError, FilterGraph +import tempfile +import re from os import path import pytest @@ -66,7 +67,7 @@ def test_transcode_2pass(): show_log=True, two_pass=True, t=1, - **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"} + **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}, ) transcode( @@ -78,7 +79,7 @@ def test_transcode_2pass(): pass1_extras={"an": None}, overwrite=True, t=1, - **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"} + **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}, ) diff --git a/tests/test_utils.py b/tests/test_utils.py index c893127c..aa669e5a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -116,7 +116,7 @@ def test_get_output_stream_id(): [False], ), ( - [(None, {"s": (10,10), "pix_fmt": "gray"})], + [(None, {"s": (10, 10), "pix_fmt": "gray"})], [{"src_type": "buffer", "media_type": "video"}], False, [True], diff --git a/tests/test_utils_concat.py b/tests/test_utils_concat.py index 35f258ab..9fdc7bdd 100644 --- a/tests/test_utils_concat.py +++ b/tests/test_utils_concat.py @@ -3,7 +3,8 @@ from ffmpegio.configure import check_url from ffmpegio.transcode import transcode from ffmpegio.ffmpegprocess import run -import pytest, tempfile +import pytest +import tempfile from os import path @@ -48,8 +49,8 @@ def test_stream_item(): "stream\n", f"exact_stream_id {id}\n", f"stream_codec {codec}\n", - f"stream_meta encoder libx264\n", - f"stream_meta crf 20\n", + "stream_meta encoder libx264\n", + "stream_meta crf 20\n", f"stream_extradata {extradata.hex()}\n", ] @@ -98,7 +99,6 @@ def test_transcode(): url = "tests/assets/testaudio-1m.mp3" with tempfile.TemporaryDirectory() as tmpdirname: - in_url = path.join(tmpdirname, "input.wav") transcode(url, in_url) diff --git a/tests/test_utils_filter.py b/tests/test_utils_filter.py index 4bf3a4e9..2a2c76e5 100644 --- a/tests/test_utils_filter.py +++ b/tests/test_utils_filter.py @@ -1,5 +1,4 @@ import logging -from ffmpegio.caps import filters logging.basicConfig(level=logging.INFO) @@ -57,8 +56,10 @@ def test_compose_filter(): f = r"select='eq(pict_type\,I)'" print(filter_utils.compose_filter("select", "eq(pict_type,I)")) - f = r"drawtext=fontfile=/usr/share/fonts/truetype/DroidSans.ttf: timecode='09\:57\:00\:00': r=25: \ + f = ( + r"drawtext=fontfile=/usr/share/fonts/truetype/DroidSans.ttf: timecode='09\:57\:00\:00': r=25: \ x=(w-tw)/2: y=h-(2*lh): fontcolor=white: box=1: boxcolor=0x00000000@1" + ) print( filter_utils.compose_filter( @@ -166,5 +167,6 @@ def test_compose_graph(): ) ) + if __name__ == "__main__": from pprint import pprint diff --git a/tests/test_utils_log.py b/tests/test_utils_log.py index 9341381f..5ce9df5e 100644 --- a/tests/test_utils_log.py +++ b/tests/test_utils_log.py @@ -1,5 +1,3 @@ -import io -import time from ffmpegio.utils import log from ffmpegio.ffmpegprocess import run from tempfile import TemporaryDirectory @@ -26,5 +24,4 @@ def test_log_completed(): if __name__ == "__main__": - pass diff --git a/tests/test_utils_parser.py b/tests/test_utils_parser.py index 0000195f..77d0d854 100644 --- a/tests/test_utils_parser.py +++ b/tests/test_utils_parser.py @@ -50,16 +50,13 @@ def test_parse_options(): def test_compose(): - assert ( - parser.compose( - { - "global_options": None, - "inputs": [("input.avi", {})], - "outputs": [("output.avi", {"b:v": "64k", "bufsize": "64k"})], - } - ) - == ["-i", "input.avi", "-b:v", "64k", "-bufsize", "64k", "output.avi"] - ) + assert parser.compose( + { + "global_options": None, + "inputs": [("input.avi", {})], + "outputs": [("output.avi", {"b:v": "64k", "bufsize": "64k"})], + } + ) == ["-i", "input.avi", "-b:v", "64k", "-bufsize", "64k", "output.avi"] assert parser.compose( { diff --git a/tests/test_video.py b/tests/test_video.py index 11b12b65..49acfa64 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1,5 +1,6 @@ from ffmpegio import video, probe, utils -import tempfile, re +import tempfile +import re from os import path @@ -105,22 +106,23 @@ def test_two_pass_write(): show_log=True, ) + def test_write_basic_filter(): url = "tests/assets/ffmpeg-logo.png" _, B = video.read(url) print(B) - B['buffer'] = B['buffer']*30 - B['shape'] = (30, *B['shape'][1:]) + B["buffer"] = B["buffer"] * 30 + B["shape"] = (30, *B["shape"][1:]) with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, "output.mp4") video.write(out_url, 30, B, pix_fmt="yuv420p", show_log=True) + if __name__ == "__main__": # test_create() - import ffmpegio # ffmpeg -y -i input -c:v libx264 -b:v 2600k -pass 1 -an -f null /dev/null && \ # ffmpeg -i input -c:v libx264 -b:v 2600k -pass 2 -c:a aac -b:a 128k output.mp4 @@ -128,7 +130,6 @@ def test_write_basic_filter(): logging.basicConfig(level=logging.DEBUG) - # B = video.read(url, show_log=True, s=(100, -2)) # print(B["shape"]) From 33d2832ef29f0c6c980b4fc13a57a3889d58e597 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:25:53 -0600 Subject: [PATCH 307/333] formatting and updated docstrings --- src/ffmpegio/analyze.py | 19 ++-- src/ffmpegio/caps.py | 9 +- src/ffmpegio/devices.py | 11 ++- src/ffmpegio/ffmpegprocess.py | 22 +++-- src/ffmpegio/filtergraph/Chain.py | 15 +-- src/ffmpegio/filtergraph/Filter.py | 11 +-- src/ffmpegio/filtergraph/Graph.py | 20 ++-- src/ffmpegio/filtergraph/GraphLinks.py | 15 +-- src/ffmpegio/filtergraph/__init__.py | 30 +++--- src/ffmpegio/filtergraph/abc.py | 8 +- src/ffmpegio/filtergraph/build.py | 7 +- src/ffmpegio/filtergraph/convert.py | 3 +- src/ffmpegio/filtergraph/presets.py | 7 +- src/ffmpegio/filtergraph/typing.py | 2 +- src/ffmpegio/filtergraph/utils.py | 12 +-- src/ffmpegio/path.py | 18 ++-- src/ffmpegio/plugins/__init__.py | 16 ++-- src/ffmpegio/plugins/devices/dshow.py | 12 ++- src/ffmpegio/plugins/finder_ffdl.py | 4 +- src/ffmpegio/plugins/finder_static.py | 1 + src/ffmpegio/plugins/finder_syspath.py | 6 +- src/ffmpegio/plugins/finder_win32.py | 3 + src/ffmpegio/plugins/hookspecs.py | 14 ++- src/ffmpegio/plugins/rawdata_bytes.py | 9 +- src/ffmpegio/plugins/rawdata_mpl.py | 8 +- src/ffmpegio/plugins/rawdata_numpy.py | 8 +- src/ffmpegio/probe.py | 25 ++--- src/ffmpegio/stream_spec.py | 14 +-- src/ffmpegio/threading.py | 24 ++--- src/ffmpegio/transcode.py | 72 +++++++-------- src/ffmpegio/utils/concat.py | 14 +-- src/ffmpegio/utils/log.py | 16 ++-- src/ffmpegio/utils/parser.py | 6 +- tests/test_configure.py | 3 +- tests/test_filtergraph_abc.py | 123 +------------------------ tests/test_image.py | 7 +- tests/test_plugins.py | 2 +- tests/test_utils.py | 4 +- tests/test_utils_concat.py | 13 +-- 39 files changed, 270 insertions(+), 343 deletions(-) diff --git a/src/ffmpegio/analyze.py b/src/ffmpegio/analyze.py index 3ae16b43..9aacd2c6 100644 --- a/src/ffmpegio/analyze.py +++ b/src/ffmpegio/analyze.py @@ -1,22 +1,23 @@ """media analysis tools module""" from __future__ import annotations -from collections import namedtuple -from abc import ABC + import logging +from abc import ABC +from collections import namedtuple logger = logging.getLogger("ffmpegio") -from . import configure -from .filtergraph import Graph, Filter, Chain, as_filtergraph -from .filtergraph.utils import compose_filter -from .errors import FFmpegError -from .path import devnull -from . import ffmpegprocess as fp import re from json import loads +from typing import Any, List, NamedTuple, Optional, Tuple -from typing import Any, Tuple, NamedTuple, List, Optional +from . import configure +from . import ffmpegprocess as fp +from .errors import FFmpegError +from .filtergraph import Chain, Filter, Graph, as_filtergraph +from .filtergraph.utils import compose_filter +from .path import devnull try: from typing import Literal diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index f25432db..92559de9 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -1,18 +1,17 @@ # TODO add function to guess media type given extension +import fractions import logging - -logger = logging.getLogger("ffmpegio") - import re -import fractions import subprocess as sp from collections import namedtuple from fractions import Fraction from functools import partial -from .path import ffmpeg as _ffmpeg from .errors import FFmpegError +from .path import ffmpeg as _ffmpeg + +logger = logging.getLogger("ffmpegio") # fmt:off __all__ = ["options", "filters", "codecs", "coders", "formats", "devices", diff --git a/src/ffmpegio/devices.py b/src/ffmpegio/devices.py index 8936dcd5..e5968703 100644 --- a/src/ffmpegio/devices.py +++ b/src/ffmpegio/devices.py @@ -16,14 +16,17 @@ """ -import logging +from __future__ import annotations -logger = logging.getLogger("ffmpegio") +import logging +import re +from subprocess import DEVNULL, PIPE from ffmpegio.path import ffmpeg -from subprocess import PIPE, DEVNULL + from . import plugins -import re + +logger = logging.getLogger("ffmpegio") SOURCES = {} SINKS = {} diff --git a/src/ffmpegio/ffmpegprocess.py b/src/ffmpegio/ffmpegprocess.py index dc88305c..d99c25a4 100644 --- a/src/ffmpegio/ffmpegprocess.py +++ b/src/ffmpegio/ffmpegprocess.py @@ -19,21 +19,25 @@ """ -from collections import abc -from os import path, name as os_name -from threading import Thread +from __future__ import annotations + +import logging +import signal import subprocess as sp +from collections import abc from copy import deepcopy +from os import name as os_name +from os import path from tempfile import TemporaryDirectory -import logging -import signal +from threading import Thread + +from .configure import move_global_options +from .path import DEVNULL, PIPE, devnull, ffmpeg +from .threading import ProgressMonitorThread +from .utils.parser import FLAG, compose, parse logger = logging.getLogger("ffmpegio") -from .utils.parser import parse, compose, FLAG -from .threading import ProgressMonitorThread -from .configure import move_global_options -from .path import ffmpeg, DEVNULL, PIPE, devnull __all__ = ["versions", "run", "Popen", "FLAG", "PIPE", "DEVNULL", "devnull"] diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index ed03f070..eccc587e 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -2,15 +2,12 @@ from collections import UserList from collections.abc import Callable, Generator, Sequence - from itertools import chain -from . import utils as filter_utils from .. import filtergraph as fgb - -from .typing import PAD_INDEX, Literal +from . import utils as filter_utils from .exceptions import * - +from .typing import PAD_INDEX, Literal __all__ = ["Chain"] @@ -86,7 +83,9 @@ def __repr__(self): Output pads: ({self.get_num_outputs()}): {", ".join((str(id) for id, *_ in self.iter_output_pads()))} """ - def __getitem__(self, key: int | slice | tuple[int | slice, int | slice]): + def __getitem__( + self, key: int | slice | tuple[int | slice, int | slice] + ) -> fgb.Filter: if not isinstance(key, (int, slice)): i, key = key if i != 0: @@ -122,7 +121,9 @@ def is_last_filter(self, filter_id: int) -> bool: """Returns True if the given id is the last filter of the chain""" return filter_id == len(self) - 1 - def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: """normalize pad index. Returns three-element pad index with non-negative indices. diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 3a0e67c2..6ea48be1 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -1,18 +1,17 @@ from __future__ import annotations -from collections.abc import Generator, Sequence import re +from collections.abc import Generator, Sequence from functools import partial from itertools import chain -from ..caps import filters as list_filters, filter_info, layouts, FilterInfo -from . import utils as filter_utils - from .. import filtergraph as fgb +from ..caps import FilterInfo, filter_info, layouts +from ..caps import filters as list_filters from ..stream_spec import parse_stream_spec - -from .typing import PAD_INDEX, Literal +from . import utils as filter_utils from .exceptions import * +from .typing import PAD_INDEX, Literal __all__ = ["Filter"] diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 335d2206..8f455508 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -1,24 +1,20 @@ from __future__ import annotations +import os from collections import UserList -from collections.abc import Generator, Callable, Sequence - +from collections.abc import Callable, Generator, Sequence from contextlib import contextmanager -from itertools import chain from copy import deepcopy +from itertools import chain from math import floor, log10 -import os from tempfile import NamedTemporaryFile -from . import utils as filter_utils - -from ..stream_spec import is_map_option from .. import filtergraph as fgb - -from .typing import PAD_INDEX, Literal +from ..stream_spec import is_map_option +from . import utils as filter_utils from .exceptions import * from .GraphLinks import GraphLinks - +from .typing import PAD_INDEX, Literal __all__ = ["Graph"] @@ -937,8 +933,8 @@ def _connect( """combine another filtergraph object and make downstream connections (worker) :param right: other filtergraph - :param fwd_links: a list of tuples, pairing self's output pad and right's ipnut pad - :param bwd_links: a list of tuples, pairing right's output pad and self's ipnut pad + :param fwd_links: a list of tuples, pairing self's output pad and right's input pad + :param bwd_links: a list of tuples, pairing right's output pad and self's input pad :param chain_siso: True to chain the single-input single-output connection, default: True :param replace_sws_flags: True to use `right` sws_flags if present, False to drop `right` sws_flags, diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index b8b17b4f..ec640f62 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -2,11 +2,10 @@ import re from collections import UserDict -from collections.abc import Generator, Mapping, Sequence, Callable +from collections.abc import Callable, Generator, Mapping, Sequence - -from ..stream_spec import is_map_option from ..errors import FFmpegioError +from ..stream_spec import is_map_option from .typing import PAD_INDEX, PAD_PAIR, Literal """ @@ -27,9 +26,6 @@ """ -class GraphLinks: ... - - class GraphLinks(UserDict): class Error(FFmpegioError): pass @@ -618,7 +614,12 @@ def are_linked( def chain_has_link( self, chain_id: int, check_input: bool = True, check_output: bool = True ) -> bool: - """True if there is any link/label defined on the chain specified by its id""" + """True if there is any link/label defined on the chain specified by its id + + :param chain_id: index of the chain under test + :param check_input: True to check all the input pads, defaults to True + :param check_output: _description_, defaults to True + """ for inpads, outpad in self.values(): if check_output and outpad and outpad[0] == chain_id: return True diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index 530c180e..20978dde 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """ffmpegio.filtergraph module - FFmpeg filtergraph classes Arithmetic Filtergraph Construction @@ -9,7 +7,7 @@ :widths: 15 10 30 :header-rows: 1 - --------------------------------- ------------------------------------------------------------ + ------------------------------ ------------------------------------------------------------ Operation Description Related Methods ------------------------------ ------------------------------------------------------------ `+` operator Chaining/join operator, supports scalar expansion @@ -27,7 +25,7 @@ `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 @@ -48,12 +46,12 @@ `(_,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 >> 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 @@ -63,16 +61,17 @@ Filter Pad Labeling =================== -`str >> Filter/Chain/Graph` and `Filter/Chain/Graph >> str` operations can be used to set input -and output labels, respectively. The labels must be specified in square brackets as in the same -manner as FFmpeg filtergraph specification. +`str >> Filter/Chain/Graph` and `Filter/Chain/Graph >> str` operations can be +used to set input and output labels, respectively. The labels must be specified +in square brackets as in the same manner as FFmpeg filtergraph specification. .. code-block::python fg = '[in]' >> Filter('scale',0.5,-1) >> '[out]' -The brackets are required to distinguish labels from str expressions of filter, chain, and graph. -For example, the following expression chains `scale` and `setsar` filters: +The brackets are required to distinguish labels from str expressions of filter, +chain, and graph. For example, the following expression chains `scale` and +`setsar` filters: .. code-block::python @@ -93,19 +92,18 @@ --------------------- ----------------------------------------------------------------------- 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 + 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 __future__ import annotations from .. import path from ..caps import filters as list_filters from . import abc -from .Filter import Filter +from .build import attach, concatenate, connect, join, stack from .Chain import Chain -from .Graph import Graph -from .build import connect, join, attach, stack, concatenate from .convert import ( as_filter, as_filterchain, @@ -115,6 +113,8 @@ atleast_filterchain, ) from .exceptions import FiltergraphInvalidIndex, FiltergraphPadNotFoundError +from .Filter import Filter +from .Graph import Graph # chain | filter | pad diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index a71bd969..cf0f17dd 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -3,13 +3,11 @@ from abc import ABC, abstractmethod from collections.abc import Generator, Sequence -from .typing import PAD_INDEX, JOIN_HOW, Literal -from .exceptions import * - from .. import filtergraph as fgb - from .._utils import zip # pre-py310 compatibility - +from .exceptions import * +from .GraphLinks import GraphLinks +from .typing import JOIN_HOW, PAD_INDEX, Literal __all__ = ["FilterGraphObject"] diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 165f93e1..ca45fd8f 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -2,12 +2,11 @@ from copy import copy -from .typing import PAD_INDEX, JOIN_HOW, Literal, get_args - -from .exceptions import FiltergraphInvalidExpression, FFmpegioError from .. import filtergraph as fgb - from .._utils import zip # pre-py310 compatibility +from .exceptions import FFmpegioError, FiltergraphInvalidExpression +from .GraphLinks import GraphLinks +from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args __all__ = ["connect", "join", "attach", "stack", "concatenate"] diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py index 0ad3230c..5a31139d 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -1,9 +1,8 @@ from __future__ import annotations +from .. import filtergraph as fgb from . import utils as filter_utils - from .exceptions import FiltergraphConversionError, FiltergraphInvalidExpression -from .. import filtergraph as fgb def as_filter( diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index fc48dcbc..110ccc50 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -2,16 +2,15 @@ from __future__ import annotations -from .._typing import TYPE_CHECKING, Any, Sequence, Literal -from ..stream_spec import StreamSpecDict - from fractions import Fraction from .. import filtergraph as fgb +from .._typing import TYPE_CHECKING, Any, Literal, Sequence +from ..stream_spec import StreamSpecDict if TYPE_CHECKING: - from .Graph import Graph from .Chain import Chain + from .Graph import Graph def remove_video_alpha( diff --git a/src/ffmpegio/filtergraph/typing.py b/src/ffmpegio/filtergraph/typing.py index 301664c7..66d5121b 100644 --- a/src/ffmpegio/filtergraph/typing.py +++ b/src/ffmpegio/filtergraph/typing.py @@ -1,8 +1,8 @@ from __future__ import annotations from typing import * -from typing_extensions import * +from typing_extensions import * PAD_INDEX = Union[ Tuple[Union[int, None], Union[int, None], int], diff --git a/src/ffmpegio/filtergraph/utils.py b/src/ffmpegio/filtergraph/utils.py index 3a11c9bd..eaa6e7c9 100644 --- a/src/ffmpegio/filtergraph/utils.py +++ b/src/ffmpegio/filtergraph/utils.py @@ -1,7 +1,9 @@ -from fractions import Fraction -import re +from __future__ import annotations + import itertools +import re from collections.abc import Sequence +from fractions import Fraction # Filter string parser/composer # For FilterGraph class, see ../filtergraph.py @@ -85,13 +87,11 @@ def get_kw(arg): return args -def compose_filter_args(*args): +def compose_filter_args(*args: tuple[str, ...]) -> str: """compose once-escaped filter argument string - :param *args: list of argument strings; last element may be a dict of key-value pairs - :type *args: list of str + dict + :param args: list of argument strings; last element may be a dict of key-value pairs :return: filter argument string - :rtype: str """ def finalize_option_value(value): diff --git a/src/ffmpegio/path.py b/src/ffmpegio/path.py index fd49586a..04c8d122 100644 --- a/src/ffmpegio/path.py +++ b/src/ffmpegio/path.py @@ -1,15 +1,21 @@ -from os import path as _path, name as _os_name, devnull -from shutil import which -from subprocess import run, DEVNULL, PIPE, STDOUT +from __future__ import annotations + +import logging import re import shlex +from os import devnull +from os import name as _os_name +from os import path as _path +from shutil import which +from subprocess import DEVNULL, PIPE, STDOUT, run + from packaging.version import Version -import logging + +from . import plugins +from .errors import FFmpegioError logger = logging.getLogger("ffmpegio") -from .errors import FFmpegioError -from . import plugins # fmt:off __all__ = [ diff --git a/src/ffmpegio/plugins/__init__.py b/src/ffmpegio/plugins/__init__.py index b6793540..8bccb7f1 100644 --- a/src/ffmpegio/plugins/__init__.py +++ b/src/ffmpegio/plugins/__init__.py @@ -1,20 +1,18 @@ 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 import os -import pluggy +import re +from importlib import import_module +from typing import Any, Literal +import pluggy from . import hookspecs +logger = logging.getLogger(__name__) +logger.addHandler(logging.NullHandler()) + __all__ = ["initialize", "get_hook"] pm = pluggy.PluginManager("ffmpegio") diff --git a/src/ffmpegio/plugins/devices/dshow.py b/src/ffmpegio/plugins/devices/dshow.py index cd4e347b..419efba3 100644 --- a/src/ffmpegio/plugins/devices/dshow.py +++ b/src/ffmpegio/plugins/devices/dshow.py @@ -1,11 +1,15 @@ """DirectShow device""" -from subprocess import PIPE -from ffmpegio import path +from __future__ import annotations + +import logging import re -from pluggy import HookimplMarker +from subprocess import PIPE + from packaging.version import Version -import logging +from pluggy import HookimplMarker + +from ffmpegio import path logger = logging.getLogger("ffmpegio") diff --git a/src/ffmpegio/plugins/finder_ffdl.py b/src/ffmpegio/plugins/finder_ffdl.py index e754cf38..63f5ebf7 100644 --- a/src/ffmpegio/plugins/finder_ffdl.py +++ b/src/ffmpegio/plugins/finder_ffdl.py @@ -1,9 +1,9 @@ """ffmpegio plugin to find ffmpeg and ffprobe installed by ffmpeg-downloader (ffdl) package""" import logging -from pluggy import HookimplMarker import ffmpeg_downloader as ffdl +from pluggy import HookimplMarker hookimpl = HookimplMarker("ffmpegio") @@ -17,7 +17,7 @@ def finder(): ffmpeg_path = ffdl.ffmpeg_path if ffmpeg_path is None: - logging.warning( + logging.info( """FFmpeg binaries not found in the ffmpegio-downloader's install directory. To install, run the following in the terminal: ffdl install diff --git a/src/ffmpegio/plugins/finder_static.py b/src/ffmpegio/plugins/finder_static.py index c5b186f1..f5ce7ca5 100644 --- a/src/ffmpegio/plugins/finder_static.py +++ b/src/ffmpegio/plugins/finder_static.py @@ -1,6 +1,7 @@ """ffmpegio plugin to find ffmpeg and ffprobe installed by static-ffmpeg package""" import logging + from pluggy import HookimplMarker from static_ffmpeg import run diff --git a/src/ffmpegio/plugins/finder_syspath.py b/src/ffmpegio/plugins/finder_syspath.py index df47bc4a..d479f02d 100644 --- a/src/ffmpegio/plugins/finder_syspath.py +++ b/src/ffmpegio/plugins/finder_syspath.py @@ -1,11 +1,12 @@ """ffmpegio plugin to find ffmpeg and ffprobe on system path""" +from __future__ import annotations + import logging +from shutil import which from pluggy import HookimplMarker -from shutil import which - hookimpl = HookimplMarker("ffmpegio") __all__ = ["finder"] @@ -16,6 +17,7 @@ def finder(): """find ffmpeg and ffprobe executables""" if which("ffmpeg") and which("ffprobe"): + logging.info("found ffmpeg and ffprobe on the system path") return "ffmpeg", "ffprobe" logging.warning("""FFmpeg and FFprobe binaries not found in the system path.""") diff --git a/src/ffmpegio/plugins/finder_win32.py b/src/ffmpegio/plugins/finder_win32.py index 47465f70..81cac8b3 100644 --- a/src/ffmpegio/plugins/finder_win32.py +++ b/src/ffmpegio/plugins/finder_win32.py @@ -1,5 +1,8 @@ +from __future__ import annotations + import os import shutil + from pluggy import HookimplMarker hookimpl = HookimplMarker("ffmpegio") diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index 2186ef52..f52689d7 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -1,15 +1,18 @@ from __future__ import annotations -import pluggy from typing import Callable + +import pluggy + from .._typing import DTypeString, ShapeTuple hookspec = pluggy.HookspecMarker("ffmpegio") @hookspec(firstresult=True) -def finder() -> Tuple[str, str]: +def finder() -> tuple[str, str]: """find ffmpeg and ffprobe executable""" + ... @hookspec(firstresult=True) @@ -20,6 +23,7 @@ def video_info(obj: object) -> tuple[ShapeTuple, DTypeString]: :return shape: shape (height,width,components) :return dtype: data type in numpy dtype str expression """ + ... @hookspec(firstresult=True) @@ -30,6 +34,7 @@ def audio_info(obj: object) -> tuple[ShapeTuple, DTypeString]: :return ac: number of channels :return dtype: sample data type in numpy dtype str expression """ + ... @hookspec(firstresult=True) @@ -39,6 +44,7 @@ def video_bytes(obj: object) -> memoryview: :param obj: object containing video frame data with arbitrary number of frames :return: packed bytes of video frames """ + ... @hookspec(firstresult=True) @@ -48,6 +54,7 @@ def audio_bytes(obj: object) -> memoryview: :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples :return: packed bytes of audio samples """ + ... @hookspec(firstresult=True) @@ -57,6 +64,7 @@ def video_frames(obj: object) -> int: :param obj: object containing video frame data with arbitrary number of frames :return: number of video frames in obj """ + ... @hookspec(firstresult=True) @@ -66,6 +74,7 @@ def audio_samples(obj: object) -> int: :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples :return: number of samples in obj """ + ... @hookspec(firstresult=True) @@ -108,6 +117,7 @@ def device_source_api() -> tuple[str, dict[str, Callable]]: Partial definition is OK """ + ... @hookspec diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index 32e1161e..79178382 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -1,10 +1,11 @@ from __future__ import annotations -from .._utils import get_samplesize -from pluggy import HookimplMarker from typing import Tuple, TypedDict +from pluggy import HookimplMarker + from .._typing import DTypeString, ShapeTuple +from .._utils import get_samplesize __all__ = [ "BytesRawDataBlob", @@ -97,6 +98,8 @@ def video_frames(obj: BytesRawDataBlob) -> int: :param obj: object containing video frame data with arbitrary number of frames :return: number of video frames in obj + + Note: if blob is squeezed, the returned value may not be accurate. """ try: @@ -111,6 +114,8 @@ def audio_samples(obj: BytesRawDataBlob) -> int: :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples :return: number of samples in obj + + Note: assumes a blob of audio samples always consists of more one time sample. """ try: diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py index ae2cc0cb..de2e3d00 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -1,9 +1,13 @@ """ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" +from __future__ import annotations + +import io + import matplotlib as Figure from pluggy import HookimplMarker -from .._typing import DTypeString, ShapeTuple -import io + +from .._typing import DTypeString, Literal, ShapeTuple __all__ = ["video_info", "video_bytes"] diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index 9317a69c..2e350d94 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -1,11 +1,12 @@ """ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" from __future__ import annotations + import numpy as np +from numpy.typing import ArrayLike from pluggy import HookimplMarker -from .._typing import DTypeString, ShapeTuple -from numpy.typing import ArrayLike +from .._typing import DTypeString, ShapeTuple hookimpl = HookimplMarker("ffmpegio") @@ -55,6 +56,7 @@ def video_frames(obj: ArrayLike) -> int: :param obj: object containing video frame data with arbitrary number of frames :return: number of video frames in obj + Note: if blob is squeezed, the returned value may not be accurate. """ try: @@ -69,6 +71,8 @@ def audio_samples(obj: ArrayLike) -> int: :param obj: object containing audio data (with interleaving channels) with arbitrary number of samples :return: number of samples in obj + + Note: assumes a blob of audio samples always consists of more one time sample. """ try: diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index 9f85ec55..a8c9993a 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -1,23 +1,24 @@ from __future__ import annotations -from typing import BinaryIO, Any, Literal, Union, Tuple, Dict -from numbers import Number -from collections.abc import Sequence -from typing_extensions import Buffer, IO -from io import IOBase - import json +import logging import re +from collections.abc import Sequence from fractions import Fraction from functools import lru_cache +from io import IOBase +from numbers import Number +from typing import Any, BinaryIO, Literal, Union -import logging +from typing_extensions import IO, Buffer + +from .errors import FFmpegError +from .path import PIPE, ffprobe +from .stream_spec import StreamSpecDict +from .stream_spec import stream_spec as compose_stream_spec logger = logging.getLogger("ffmpegio") -from .path import ffprobe, PIPE -from .errors import FFmpegError -from .stream_spec import StreamSpecDict, stream_spec as compose_stream_spec # fmt:off __all__ = ['full_details', 'format_basic', 'streams_basic', @@ -75,8 +76,8 @@ def _compose_entries(entries: dict[str, bool | Sequence[str]]) -> str: str, int, float, - Tuple[Union[str, float], Union[str, int, float]], - Dict[Literal["start", "start_offset", "end"], Union[str, float]], + tuple[str | float, str | int | float], + dict[Literal["start", "start_offset", "end"], str | float], ] """ Union type to specify the FFprobe read_intervals option diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 822149eb..7e53fce1 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -6,19 +6,19 @@ from __future__ import annotations +import re + from ._typing import ( - get_args, - Literal, - TypedDict, - Union, - Tuple, FFmpegMediaType, + Literal, MediaType, NotRequired, + Tuple, + TypedDict, + Union, + get_args, ) -import re - StreamSpecStreamType = Literal["v", "a", "s", "d", "t", "V"] # libavformat/avformat.c:match_stream_specifier() diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index bc3a550a..8e442c35 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -2,27 +2,27 @@ from __future__ import annotations -from typing import BinaryIO - -from copy import deepcopy -import re +import logging import os -from threading import Thread, Condition, Lock, Event +import re +from copy import deepcopy from io import TextIOBase, TextIOWrapper -from time import sleep, time -from tempfile import TemporaryDirectory -from queue import Empty, Full, Queue from math import ceil +from queue import Empty, Full, Queue from shutil import copyfileobj -import logging +from tempfile import TemporaryDirectory +from threading import Condition, Event, Lock, Thread +from time import sleep, time +from typing import BinaryIO from namedpipe import NPopen -logger = logging.getLogger("ffmpegio") - +from .errors import FFmpegError from .utils.avi import AviReader from .utils.log import extract_output_stream as _extract_output_stream -from .errors import FFmpegError + +logger = logging.getLogger("ffmpegio") + # fmt:off __all__ = ['AviReader', 'FFmpegError', 'ThreadNotActive', 'ProgressMonitorThread', diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index a4ef4d0b..c8d31ebb 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -2,20 +2,19 @@ import logging -logger = logging.getLogger("ffmpegio") - -from ._typing import Sequence, ProgressCallable, Unpack, FFmpegOptionDict +from . import FFmpegError, configure, utils +from . import ffmpegprocess as fp +from ._typing import FFmpegOptionDict, ProgressCallable, Sequence, Unpack from .configure import ( - FFmpegOutputUrlComposite, - FFmpegInputUrlComposite, FFmpegInputOptionTuple, + FFmpegInputUrlComposite, FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, ) - - -from . import ffmpegprocess as fp, configure, utils, FFmpegError from .path import check_version +logger = logging.getLogger("ffmpegio") + __all__ = ["transcode"] @@ -47,36 +46,35 @@ def transcode( ) -> bytes | None: """Transcode media files to another format/encoding - :param inputs: url/path of the input media file or a sequence of tuples, each - containing an input url and its options dict - :param outputs: url/path of the output media file or a sequence of tuples, each - containing an output url and its options dict - :param progress: progress callback function, defaults to None - :param overwrite: True to overwrite if output url exists, defaults to None - (auto-select) - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. + :param inputs: url/path of the input media file or a sequence of tuples, + each containing an input url and its options dict + :param outputs: url/path of the output media file or a sequence of tuples, + each containing an output url and its options dict + :param progress: progress callback function, defaults to ``None`` + :param overwrite: True to overwrite if output url exists, defaults to auto- + select + :param show_log: True to show FFmpeg log messages on the console, defaults + to ``None`` (no show/capture) Ignored if stream format must be retrieved + automatically. :param two_pass: True to encode in 2-pass - :param pass1_omits: list of output arguments to ignore in pass 1, defaults to - None (removes 'c:a' or 'acodec'). For multiple outputs, - specify use list of the list of arguments, matching the - length of outputs, for per-output omission. - :param pass1_extras: list of additional output arguments to include in pass 1, - defaults to None (add 'an' if `pass1_omits` also None) - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :param **options: FFmpeg options. For output and global options, use FFmpeg - option names as is. For input options, append "_in" to the - option name. For example, r_in=2000 to force the input frame - rate to 2000 frames/s (see :doc:`options`). - - If multiple inputs or outputs are specified, these input - or output options specified here are treated as common - options, and the url-specific duplicate options in the - ``inputs`` or ``outputs`` sequence will overwrite those - specified here. + :param pass1_omits: list of output arguments to ignore in pass 1, defaults + to ``None`` (removes ``'c:a'`` or ``'acodec'``). For multiple outputs, + specify use list of the list of arguments, matching the length of + outputs, for per-output omission. + :param pass1_extras: list of additional output arguments to include in pass + 1, defaults to ``None`` (add 'an' if ``pass1_omits`` also ``None``) + :param sp_kwargs: dictionary with keywords passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults + to None + :param options: FFmpeg options. For output and global options, use FFmpeg + option names as is. For input options, append ``"_in"`` to the option + name. For example, ``r_in=2000`` to force the input frame rate to 2000 + frames/s (see :doc:`options`). + + If multiple inputs or outputs are specified, these input or output + options specified here are treated as common options, and the url- + specific duplicate options in the ``inputs`` or ``outputs`` sequence + will overwrite those specified here. :returns: if any of the outputs is stdout, returns output bytes """ diff --git a/src/ffmpegio/utils/concat.py b/src/ffmpegio/utils/concat.py index 4b9ef5c2..2f649603 100644 --- a/src/ffmpegio/utils/concat.py +++ b/src/ffmpegio/utils/concat.py @@ -1,17 +1,19 @@ """FFConcat class to build/use ffconcat list file for concat demuxer""" -from glob import glob +from __future__ import annotations + import io -import re +import logging import os -from tempfile import NamedTemporaryFile +import re from functools import partial -import logging - -logger = logging.getLogger("ffmpegio") +from glob import glob +from tempfile import NamedTemporaryFile from .._utils import escape, unescape +logger = logging.getLogger("ffmpegio") + # https://trac.ffmpeg.org/wiki/Concatenate # https://ffmpeg.org/ffmpeg-formats.html#concat diff --git a/src/ffmpegio/utils/log.py b/src/ffmpegio/utils/log.py index b254e3cb..d18d5a38 100644 --- a/src/ffmpegio/utils/log.py +++ b/src/ffmpegio/utils/log.py @@ -1,8 +1,10 @@ import re from fractions import Fraction -from . import layout_to_channels +from .. import utils +from .._typing import RawStreamInfoTuple, Sequence from ..caps import sample_fmts +from . import layout_to_channels _re_audio = re.compile(r"(?:(\d+) Hz, )?(.+)") @@ -111,19 +113,19 @@ def parse_log_video_stream(info): ) -def extract_output_stream(logs, file_id=0, stream_id=0, hint=None): +def extract_output_stream( + logs: str | Sequence[str], + file_id: int = 0, + stream_id: int = 0, + hint: int | None = None, +) -> dict: """extract output stream info from the log lines :param logs: lines of FFmpeg log messages - :type logs: seq(str) :param file_id: output file id, defaults to 0 - :type file_id: int, optional :param stream_id: output stream id, defaults to 0 - :type stream_id: int, optional :param hint: starting log line index to search, defaults to None - :type hint: int, optional :return: stream information - :rtype: dict """ if isinstance(logs, str): logs = re.split(r"[\n\r]+", logs) diff --git a/src/ffmpegio/utils/parser.py b/src/ffmpegio/utils/parser.py index 7c1a2e14..65c59b0d 100644 --- a/src/ffmpegio/utils/parser.py +++ b/src/ffmpegio/utils/parser.py @@ -1,10 +1,12 @@ -import re +from __future__ import annotations + import os +import re import shlex from collections import abc -from ..filtergraph import Graph, Chain, Filter from .. import devices +from ..filtergraph import Chain, Filter, Graph __all__ = ["parse", "compose", "FLAG"] diff --git a/tests/test_configure.py b/tests/test_configure.py index b6004bd1..70160ddf 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1,6 +1,7 @@ -import pytest from pprint import pprint +import pytest + from ffmpegio import configure from ffmpegio import filtergraph as fgb from ffmpegio.utils import analyze_complex_filtergraphs diff --git a/tests/test_filtergraph_abc.py b/tests/test_filtergraph_abc.py index 66025819..e4a77136 100644 --- a/tests/test_filtergraph_abc.py +++ b/tests/test_filtergraph_abc.py @@ -1,6 +1,7 @@ -from ffmpegio import filtergraph as fgb import pytest +from ffmpegio import filtergraph as fgb + # def get_num_pads(self, input: bool) -> int: # def get_num_inputs(self) -> int: @@ -131,123 +132,3 @@ def test_resolve_pad_index( ) == 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_image.py b/tests/test_image.py index f2ed3f37..5c59693b 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,9 +1,10 @@ -import pytest -from ffmpegio import image, probe, transcode -import tempfile import re +import tempfile from os import path +import pytest + +from ffmpegio import image, probe, transcode outext = ".png" diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b2d2460c..ca16cbd0 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,5 +1,5 @@ -from ffmpegio.utils import prod from ffmpegio import plugins +from ffmpegio.utils import prod def test_rawdata_bytes(): diff --git a/tests/test_utils.py b/tests/test_utils.py index aa669e5a..6f1e1a2c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,9 @@ import math -from ffmpegio import utils, FFmpegioError + import pytest +from ffmpegio import FFmpegioError, utils + def test_string_escaping(): raw = "Crime d'Amour" diff --git a/tests/test_utils_concat.py b/tests/test_utils_concat.py index 9fdc7bdd..f82db1b9 100644 --- a/tests/test_utils_concat.py +++ b/tests/test_utils_concat.py @@ -1,12 +1,13 @@ -from ffmpegio.utils.concat import FFConcat -from ffmpegio.utils import escape -from ffmpegio.configure import check_url -from ffmpegio.transcode import transcode -from ffmpegio.ffmpegprocess import run -import pytest import tempfile from os import path +import pytest + +from ffmpegio.ffmpegprocess import run +from ffmpegio.transcode import transcode +from ffmpegio.utils import escape +from ffmpegio.utils.concat import FFConcat + def test_file_item(): filepath = "test.mp4" From 90189dbe4800705f9c254900db110485ba489160 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:35:41 -0600 Subject: [PATCH 308/333] caps - fixed filter help parsing --- src/ffmpegio/caps.py | 86 ++++++++++++++++++++++++++++---------------- tests/test_caps.py | 7 ++++ 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index 92559de9..ff815e0c 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -31,7 +31,7 @@ ) # g _formatRegexp = re.compile(r"([D ]) *([E ]) +(\S+) +(.*)") # g _filterRegexp = re.compile( - r"([T.])([S.])([C.])\s+(\S+)\s+(A+|V+|N|\|)->(A+|V+|N|\|)\s+(.*)" + r"([T.])([S.])([C.])?\s+(\S+)\s+(A+|V+|N|\|)->(A+|V+|N|\|)\s+(.*)" ) # g _cache = dict() @@ -883,18 +883,27 @@ def _conv_func(type, s): return s -def _get_filter_option_constant(str): +def _get_filter_option_constant( + str: str, is_flag: bool = False +) -> tuple[str, str] | tuple[tuple[str, int], str]: + # from libavutil/opts.c opt_list() with flags AV_OPT_FLAG_FILTERING_PARAM and AV_OPT_TYPE_CONST + m = re.match( - r" ([^ \n]+) {1,16}(?:([^ ]+) {1,12}| {13})" - r"[.E][.D][.F][.V][.A][.S][.X][.R][.B][.T][.P]" - r"(?: (.+))?\n?", + r" (.+?)[.E][.D][.F][.V][.A][.S][.X][.R][.B][.T][.P](?: (.+))?", str, ) - return m[1], (m[2] and int(m[2]), m[3] or "") + desc = m[2] or "" + + if is_flag: + return m[1].strip(), desc + else: + name, intval = m[1].rsplit(maxsplit=1) + return (name, int(intval)), desc def _get_filter_option(str, name): - # libavutil/opt.c/opt_list + # from libavutil/opts.c opt_list() with flags AV_OPT_FLAG_FILTERING_PARAM + lines = str.splitlines() # first line is the main option definition @@ -908,7 +917,7 @@ def _get_filter_option(str, name): f"_get_filter_option(): invalid option line found for {name} filter. Likely deprecated:\n{lines[0]}" ) return None - name, type, *flags = m0.groups() + name, otype, *flags = m0.groups() m1 = re.search(r"( \(from \S+? to \S+?\))*(?: \(default (.+)\))?$", lines[0]) ranges_str, default = m1.groups() @@ -916,20 +925,20 @@ def _get_filter_option(str, name): help = lines[0][m0.end() + 1 : m1.start()] if default: - if type == "string": + if otype == "string": # remove quotes default = default[1:-1] - elif type == "boolean": + elif otype == "boolean": default = {"true": True, "false": False}.get(default, default) conv = ( partial(_conv_func, int) - if type in ("int", "int64", "uint64") + if otype in ("int", "int64", "uint64") else ( partial(_conv_func, float) - if type in ("float", "double") + if otype in ("float", "double") else partial(_conv_func, Fraction) - if type == "rational" + if otype == "rational" else (lambda s: s) ) ) @@ -943,30 +952,43 @@ def _get_filter_option(str, name): ] ) - constants = [_get_filter_option_constant(l) for l in lines[1:] if l] - - if len(constants): - # combines aliases - def chk_is_alias(i, o): - other = constants[i] - return other[1] == o[1] - - has_alias = [chk_is_alias(i, o) for i, o in enumerate(constants[1:])] - has_alias.append(False) - for i, has in enumerate(has_alias): - k, v = constants[i] - constants[i] = (k, (constants[i + 1][0] if has else None, *v)) - - has_alias.insert(0, False) - constants = [o for o, isa in zip(constants, has_alias[:-1]) if not isa] + constants = [ + _get_filter_option_constant(l, otype == "flags") for l in lines[1:] if l + ] + + if not len(constants): + cdict = None + elif otype == "int": + # add int values as constant entries + cdict = {} + for (k, kint), v in constants: + cdict[k] = v + cdict[kint] = v + else: + cdict = dict(constants) + + # if len(constants): + # # combines aliases + # def chk_is_alias(i, o): + # other = constants[i] + # return other[1] == o[1] + + # has_alias = [chk_is_alias(i, o) for i, o in enumerate(constants[1:])] + # has_alias.append(False) + # for i, has in enumerate(has_alias): + # k, v = constants[i] + # constants[i] = (k, (constants[i + 1][0] if has else None, *v)) + + # has_alias.insert(0, False) + # constants = [o for o, isa in zip(constants, has_alias[:-1]) if not isa] return FilterOption( name, [], - type, + otype, help, ranges, - dict(constants), + cdict, conv(default), *(fl != "." for fl in flags), ) @@ -1054,6 +1076,8 @@ def filter_info(name): return data blocks = re.split(r"\n(?! |\n|$)", stdout) + if blocks[-1].startswith("Exiting with exit code"): + blocks = blocks[:-1] m = re.match( r"Filter (\S+)\s*?\n" diff --git a/tests/test_caps.py b/tests/test_caps.py index c058e8b0..30545d6c 100644 --- a/tests/test_caps.py +++ b/tests/test_caps.py @@ -50,6 +50,13 @@ def test_options(): pprint(caps.options("video", True)) pprint(caps.options("per-file")) +def test_filters(): + for f in caps.filters(): + print(f) + pprint(caps.filter_info(f)) + +if __name__ == '__main__': + caps.filter_info('aresample') if __name__ == "__main__": caps.encoder_info("mpeg1video") From b400090d327b1700c9e806f7cccee3698e0bb533 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:36:36 -0600 Subject: [PATCH 309/333] caps - throws FFmpegError when regexp match fails --- src/ffmpegio/caps.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index ff815e0c..f45c519b 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -682,6 +682,9 @@ def demuxer_info(name): stdout, ) + if m is None: + raise FFmpegError(stdout) + data = dict( names=m[1].split(","), long_name=m[2], @@ -728,6 +731,9 @@ def muxer_info(name): stdout, ) + if m is None: + raise FFmpegError(stdout) + data = { "names": m[1].split(","), "long_name": m[2], @@ -820,6 +826,9 @@ def _getCodecInfo(name, encoder): stdout, ) + if m is None: + raise FFmpegError(stdout) + def resolveFs(s): m = re.match(r"(\d+)\/(\d+)", s) return fractions.Fraction(int(m[1]), int(m[2])) @@ -1089,6 +1098,10 @@ def filter_info(name): r"([\s\S]*)", blocks[0], ) + + if m is None: + raise FFmpegError(blocks[0]) + name = m[1] desc = m[2] threading = ["slice"] if m[3] else [] @@ -1177,7 +1190,7 @@ def bsfilter_info(name): ) if stdout.startswith("Unknown"): - raise Exception(stdout) + raise FFmpegError(stdout) data = { "name": m[1], From acf1dab323b4e540954707e36e001865c62cdb41 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:40:28 -0600 Subject: [PATCH 310/333] formatting --- src/ffmpegio/_utils.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index c7d0d8bb..3e711e24 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -2,15 +2,15 @@ from __future__ import annotations -from typing import Any, Sequence -from ._typing import DTypeString, ShapeTuple - +import re +import urllib.parse from io import IOBase from pathlib import Path +from typing import Any, Sequence + from namedpipe import NPopen -import urllib.parse -import re +from ._typing import DTypeString, ShapeTuple try: from math import prod @@ -109,8 +109,8 @@ def get_samplesize(shape: ShapeTuple, dtype: DTypeString) -> int: def deprecate_core(): - from importlib import metadata import warnings + from importlib import metadata try: metadata.version("ffmpegio-core") From 90f9cbfc3f66980d2af690fc6b90166dd63f8b02 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:41:39 -0600 Subject: [PATCH 311/333] path - check_version() is aware of 'nightly' builds --- src/ffmpegio/path.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/ffmpegio/path.py b/src/ffmpegio/path.py index 04c8d122..f9e1b68a 100644 --- a/src/ffmpegio/path.py +++ b/src/ffmpegio/path.py @@ -235,7 +235,7 @@ def versions(): def check_version(ver, cond=None): - """check FFmpeg version + """check FFmpeg version against the given version for the specified condition :param ver: desired version string :type ver: str @@ -243,7 +243,35 @@ def check_version(ver, cond=None): :type cond: "==", "!=", "<", "<=", ">", ">=", optional :return: True if condition is met :rtype: bool + + Note "nightly" builds are assumed to be the latest. """ + + ver_nightly = ver == "nightly" + + # ffmpeg version is a nightly (assumed the latest) + if FFMPEG_VER == "nightly": + return { + "==": ver_nightly, + "!=": not ver_nightly, + "<": False, + "<=": ver_nightly, + ">": not ver_nightly, + ">=": True, + }[cond or ">="] + + # ffmpeg version is a release compared to nightly + if ver_nightly: + return { + "==": False, + "!=": True, + "<": True, + "<=": True, + ">": False, + ">=": False, + }[cond or ">="] + + # both are releases return { "==": FFMPEG_VER.__eq__, "!=": FFMPEG_VER.__ne__, From 1285f435df7e15f91271af8145dd32026fa3c251 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:46:59 -0600 Subject: [PATCH 312/333] plugins - added is_empty() to accompany video_bytes() or audio_bytes() --- src/ffmpegio/plugins/hookspecs.py | 10 ++++++++++ src/ffmpegio/plugins/rawdata_bytes.py | 9 +++++++++ src/ffmpegio/plugins/rawdata_mpl.py | 9 +++++++++ src/ffmpegio/plugins/rawdata_numpy.py | 9 +++++++++ 4 files changed, 37 insertions(+) diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index f52689d7..08c8e25a 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -132,3 +132,13 @@ def device_sink_api() -> tuple[str, dict[str, Callable]]: Partial definition is OK """ + ... + + +@hookspec(firstresult=True) +def is_empty(obj: object) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + ... diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index 79178382..e375dc10 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -172,3 +172,12 @@ def bytes_to_audio( } except: return None + + +@hookimpl +def is_empty(obj: BytesRawDataBlob) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + return not bool(obj["buffer"]) diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py index de2e3d00..6183fc4d 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -43,3 +43,12 @@ def video_bytes(obj: Figure) -> memoryview: return io_buf.getvalue() except: None + + +@hookimpl +def is_empty(obj: Figure) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + return False diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index 2e350d94..c5b008ff 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -147,3 +147,12 @@ def bytes_to_audio( return x.squeeze() if squeeze else x except: return None + + +@hookimpl +def is_empty(obj: bytes) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + return not bool(obj) From 3f44346255309eedf6031c2dfd2bf72ace086484 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:48:08 -0600 Subject: [PATCH 313/333] plugins - fixed data blob dimension handlings --- src/ffmpegio/plugins/rawdata_bytes.py | 32 ++++++++++++++++++++++----- src/ffmpegio/plugins/rawdata_numpy.py | 28 ++++++++++++++++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index e375dc10..ff96f8f9 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -45,10 +45,21 @@ def video_info(obj: BytesRawDataBlob) -> Tuple[ShapeTuple, DTypeString]: """ try: - return obj["shape"][-3:], obj["dtype"] + shape = obj["shape"] + dtype = obj["dtype"] except: return None + ndim = len(shape) + if ndim == 2: + shape = (*shape, 1) + elif ndim == 3 and shape[-1] > 4: + shape = (*shape[1:], 1) + else: + shape = shape[-3:] + + return shape, dtype + @hookimpl def audio_info(obj: BytesRawDataBlob) -> Tuple[ShapeTuple, DTypeString]: @@ -103,10 +114,19 @@ def video_frames(obj: BytesRawDataBlob) -> int: """ try: - return obj["shape"][0] - except: + shape = obj["shape"] + except KeyError: return None + ndim = len(shape) + if ndim > 3: + return shape[0] + elif ndim < 3: + return 1 + else: + # ndim==3, single frame if the last dim is likely number of components (1-4) + return shape[0] if shape[-1] > 4 else 1 + @hookimpl def audio_samples(obj: BytesRawDataBlob) -> int: @@ -119,9 +139,11 @@ def audio_samples(obj: BytesRawDataBlob) -> int: """ try: - return obj["shape"][0] - except: + shape = obj["shape"] + except KeyError: return None + else: + return shape[0] @hookimpl diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index c5b008ff..9aef7876 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -31,7 +31,14 @@ def video_info(obj: ArrayLike) -> tuple[ShapeTuple, DTypeString]: :return dtype: data type in numpy dtype str expression """ try: - return obj.shape[-3:] if obj.ndim != 2 else [*obj.shape, 1], obj.dtype.str + a = np.asarray(obj) + if a.ndim == 2: + shape = (*a.shape, 1) + elif a.ndim == 3 and a.shape[-1] > 4: + shape = (*a.shape[1:], 1) + else: + shape = a.shape[-3:] + return shape, a.dtype.str except: return None @@ -45,7 +52,8 @@ def audio_info(obj: ArrayLike) -> tuple[ShapeTuple, DTypeString]: :return dtype: sample data type in numpy dtype str expression """ try: - return obj.shape[-1:] if obj.ndim > 1 else [1], obj.dtype.str + a = np.asarray(obj) + return a.shape[-1:] if a.ndim > 1 else [1], a.dtype.str except: return None @@ -60,7 +68,15 @@ def video_frames(obj: ArrayLike) -> int: """ try: - return obj.shape[0] + a = np.asarray(obj) + shape = a.shape + ndim = a.ndim + if ndim > 3: + return shape[0] + elif ndim < 3: + return 1 + else: + return shape[0] if shape[-1] > 4 else 1 except: return None @@ -76,7 +92,7 @@ def audio_samples(obj: ArrayLike) -> int: """ try: - return obj.shape[0] + return np.asarray(obj).shape[0] except: return None @@ -90,7 +106,7 @@ def video_bytes(obj: ArrayLike) -> memoryview: """ try: - return np.ascontiguousarray(obj).view("b") + return np.ascontiguousarray(obj).reshape(-1).view("b") except: return None @@ -104,7 +120,7 @@ def audio_bytes(obj: ArrayLike) -> memoryview: """ try: - return np.ascontiguousarray(obj).view("b") + return np.ascontiguousarray(obj).reshape(-1).view("b") except: return None From 58f825012b25be6b9104cfb82212a58f66325494 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:49:00 -0600 Subject: [PATCH 314/333] plugins.rawdata_mpl - added video_frames() to accompany video_bytes() --- src/ffmpegio/plugins/rawdata_mpl.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py index 6183fc4d..47d948e5 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -52,3 +52,15 @@ def is_empty(obj: Figure) -> bool: :param obj: object containing media data """ return False + + +@hookimpl +def video_frames(obj: Figure) -> Literal[1]: + """get number of video frames in obj (always 1) + + :param obj: object containing video frame data with arbitrary number of frames + :return: number of video frames in obj + + """ + + return 1 From 15379801aeab973e25eb2f64ee2e9a8367cb337e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:50:11 -0600 Subject: [PATCH 315/333] fixed to specify explicitly the read_bytes mode --- tests/test_plugins.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index ca16cbd0..c2cd4795 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -3,6 +3,9 @@ def test_rawdata_bytes(): + + plugins.use("read_bytes") + hook = plugins.get_hook() dtype = "|u1" From b9b5a79a1ddd84d8d0d9ed50b26ffefad9412415 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 22:56:06 -0600 Subject: [PATCH 316/333] errors - added FFmpegioInsufficientInputData exception --- src/ffmpegio/errors.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/ffmpegio/errors.py b/src/ffmpegio/errors.py index 35e84ed0..eec7f427 100644 --- a/src/ffmpegio/errors.py +++ b/src/ffmpegio/errors.py @@ -6,6 +6,10 @@ class FFmpegioError(Exception): pass +class FFmpegioInsufficientInputData(FFmpegioError): + pass + + class FFmpegioNoPipeAllowed(FFmpegioError): pass From d428803649125eb6cfb4690078b34097aaeb7f8c Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 23:02:47 -0600 Subject: [PATCH 317/333] threading - updated docstrings & logging --- src/ffmpegio/threading.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 8e442c35..2c014653 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -204,7 +204,21 @@ def run(self): self.newline.notify_all() logger.debug("[logger] exiting") - def index(self, prefix, start=None, block=True, timeout=None): + def index( + self, + prefix: str, + start: int = 0, + block: bool = True, + timeout: float | None = None, + ) -> int | None: + """Return an index of the first log line which starts with the specified prefix + + :param prefix: look for log lines starting with this string + :param start: log line index to start searching, defaults to 0 + :param block: True to block until the specified log line appears, default is True + :param timeout: blocking timeout, defaults to None (wait indefinitely) + :return: index of the matching line of the LoggerThread.logs or None if none found + """ start = int(start or 0) with self.newline: logs = self.logs[start:] if start else self.logs @@ -409,7 +423,7 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: :param n: number of samples/frames to read, if non-positive, read all (until the pipe is broken), defaults to -1 - :param timeout: timeout in seconds, defaults to None + :param timeout: timeout in seconds, defaults to wait indefinitely :return: n*itemsize bytes """ @@ -555,24 +569,25 @@ def run(self): self._empty = True self._empty_cond.notify_all() data = queue.get() + logger.debug("WriterThread getting data from the queue") queue.task_done() if data is None: - logger.info("writer thread: received a sentinel to stop the writer") + logger.debug("WriterThread: received a sentinel to stop the writer") break else: - logger.info(f"writer thread: received {len(data)} bytes to write") + logger.debug("WriterThread: writing %d bytes", len(data)) try: nwritten = 0 nwritten = stream.write(data) - logger.info(f"writer thread: written {nwritten} written") + logger.debug("WriterThread: written %d written", nwritten) except Exception as e: # stdout stream closed/FFmpeg terminated, end the thread as well - logger.info(f"writer thread exception: {e}") + logger.debug("WriterThread exception: %s", e) break if not nwritten and stream.closed: # just in case - logger.info("writer thread: somethin' else happened") + logger.debug("WriterThread: somethin' else happened") break # set flag to prevent any more writes @@ -603,7 +618,7 @@ def run(self): self._empty = True self._empty_cond.notify_all() - logger.info("writer thread exiting") + logger.info("WriterThread exiting") def write(self, data, timeout=None): with self._empty_cond: From baf36c314c96f00469a4d1a9daef1605b0600410 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 23:03:44 -0600 Subject: [PATCH 318/333] removed dependency on utils.check_url() --- tests/test_utils_concat.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_utils_concat.py b/tests/test_utils_concat.py index f82db1b9..5e9a87b8 100644 --- a/tests/test_utils_concat.py +++ b/tests/test_utils_concat.py @@ -90,9 +90,12 @@ def test_concat_demux(): def test_url_check(): concat = FFConcat("file vid1.mp4\nfile vid2.mp4\n", pipe_url="-") - url, _, input = check_url(concat, nodata=False) + + url = concat + data = url.input + assert url == concat - assert input == concat.input + assert data == concat.input def test_transcode(): From 562d3c1189a82b838433ec56ac3bb26f35db5548 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 23:06:32 -0600 Subject: [PATCH 319/333] LoggerThread - explicitly catch StopIteration exceptions --- src/ffmpegio/threading.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 2c014653..09bbc99d 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -228,7 +228,7 @@ def index( next((i for i, log in enumerate(logs) if log.startswith(prefix))) + start ) - except: + except StopIteration: if not self.is_alive(): raise ThreadNotActive("LoggerThread is not running") @@ -262,7 +262,7 @@ def index( ) + start ) - except: + except StopIteration: # still no match, update the starting position start = len(self.logs) From c19ffd0d7092fa8cb0e5f8210b9647de2b6cca71 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 23:10:03 -0600 Subject: [PATCH 320/333] WriterThread: - added global timeout (constructor argument) - added closed() method - fixed runner flushing loop stop condition --- src/ffmpegio/threading.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 09bbc99d..4be452d9 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -516,21 +516,26 @@ class WriterThread(Thread): :param stdin: stream to write data to :param queuesize: depth of a queue for inter-thread data transfer, defaults to None - :param bufsize: maximum number of bytes to write at once, defaults to None (1048576 bytes) + :param timeout: maximum number of bytes to write at once, defaults to None (1048576 bytes) """ - def __init__(self, stdin_or_pipe: BinaryIO | NPopen, queuesize: int | None = None): + def __init__( + self, + stdin_or_pipe: BinaryIO | NPopen, + queuesize: int | None = None, + timeout: float | None = None, + ): super().__init__() is_pipe = isinstance(stdin_or_pipe, NPopen) self.pipe = stdin_or_pipe if is_pipe else None self.stdin = None if is_pipe else stdin_or_pipe #:writable stream: data sink - self._queue = Queue(queuesize or 0) # inter-thread data I/O + self._queue = Queue(16 if queuesize is None else queuesize) self._empty_cond = Condition() self._empty = True self._no_more = False # true if sentinel has been written to the queue + self._timeout = float(timeout) if timeout else None def join(self, timeout: float | None = None): - if self.stdin is None: # pipe not yet connected, open it to release the runner with open(self.pipe.path, "rb"): @@ -541,7 +546,11 @@ def join(self, timeout: float | None = None): self._queue.put(None) # if queue is full, - super().join(timeout) + super().join(timeout or self._timeout) + + def closed(self) -> bool: + """``True`` if thread no longer accepts data to write""" + return self._no_more def __enter__(self): self.start() @@ -552,7 +561,6 @@ def __exit__(self, *_): return False def run(self): - if self.stdin is None: self.stdin = self.pipe.wait() @@ -561,6 +569,7 @@ def run(self): while True: # get next data block + logger.debug("WriterThread getting data to the queue") try: data = queue.get_nowait() except Empty: @@ -607,10 +616,8 @@ def run(self): try: queue.get_nowait() except Empty: - break - else: - # queue was empty not_empty = False + break # if queue was not empty, notify its empty state now if not_empty: @@ -631,7 +638,7 @@ def write(self, data, timeout=None): if data is None: self._no_more = True - self._queue.put(data, timeout != 0, timeout) + self._queue.put(data, timeout != 0, timeout or self._timeout) self._empty = False def flush(self, timeout: float | None = None): @@ -643,7 +650,11 @@ def flush(self, timeout: float | None = None): """ with self._empty_cond: - if not (self._no_more or self._empty or self._empty_cond.wait(timeout)): + if not ( + self._no_more + or self._empty + or self._empty_cond.wait(timeout or self._timeout) + ): raise NotEmpty() def qsize(self) -> int: From 261f68f6c126828bcd8757f399253149bcc7b467 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 23:14:13 -0600 Subject: [PATCH 321/333] ReaderThread changes: - added global timeout (xtor argument) - fixed join()'s blocking logic - improved runner loop's blocking logic - fixed read() logic to retrieve n frames - added read_nowait() method --- src/ffmpegio/threading.py | 170 +++++++++++++++++++++++++++++--------- 1 file changed, 130 insertions(+), 40 deletions(-) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 4be452d9..24362f27 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -5,9 +5,7 @@ import logging import os import re -from copy import deepcopy from io import TextIOBase, TextIOWrapper -from math import ceil from queue import Empty, Full, Queue from shutil import copyfileobj from tempfile import TemporaryDirectory @@ -18,15 +16,14 @@ from namedpipe import NPopen from .errors import FFmpegError -from .utils.avi import AviReader from .utils.log import extract_output_stream as _extract_output_stream logger = logging.getLogger("ffmpegio") # fmt:off -__all__ = ['AviReader', 'FFmpegError', 'ThreadNotActive', 'ProgressMonitorThread', - 'LoggerThread', 'ReaderThread', 'WriterThread', 'AviReaderThread', 'Empty', 'Full'] +__all__ = ['FFmpegError', 'ThreadNotActive', 'ProgressMonitorThread', + 'LoggerThread', 'ReaderThread', 'WriterThread', 'Empty', 'Full'] # fmt:on @@ -309,6 +306,7 @@ def __init__( queuesize: int | None = None, itemsize: int | None = None, retry_delay: float | None = None, + timeout: float | None = None, ): super().__init__() is_pipe = isinstance(stdout_or_pipe, NPopen) @@ -316,11 +314,15 @@ def __init__( self.stdout = None if is_pipe else stdout_or_pipe #:readable stream self.nmin = nmin #:positive int: expected minimum number of read()'s n arg (not enforced) self.itemsize = itemsize or 2**20 #:int: number of bytes per time sample - self._queue = Queue(queuesize or 0) # inter-thread data I/O - self._carryover = None # extra data that was not previously read by user + self._queue = Queue(16 if queuesize is None else queuesize) + self._carryover: bytes | None = ( + None #:bytes: extra data that was not previously read by user + ) self._halt = Event() + self._cooling = Event() self._running = Event() - self._retry_delay = 0.001 if retry_delay is None else retry_delay + self._retry_delay = 0.01 if retry_delay is None else retry_delay + self._timeout = float(timeout) if timeout else None def start(self): if self.itemsize is None: @@ -332,9 +334,11 @@ def start(self): def cool_down(self): # stop enqueue read samples - self._halt.set() + self._cooling.set() def join(self, timeout=None): + if timeout is None: + timeout = self._timeout if self.pipe is None: self.stdout.close() @@ -345,15 +349,19 @@ def join(self, timeout=None): ... self.pipe.close() + # set flag to terminate the thread loop + self._cooling.set() self._halt.set() - if self._queue.full(): - if timeout: - self._queue.not_full.wait(timeout) - if self._queue.full(): - return - else: - with self._queue.mutex: - self._queue.queue.clear() + + # if queue is full, the thread loop is likely stuck. + is_full = self._queue.full() + if is_full and timeout: + # if timeout is set, wait to see if dequeued by another thread + self._queue.not_full.wait(timeout) + is_full = self._queue.full() + + if is_full: + raise Full("Cannot join reader thread because the queue is full.") # if queue is full, super().join(timeout) @@ -362,7 +370,7 @@ def is_running(self): return self._running.is_set() def wait_till_running(self, timeout: float | None = None) -> bool: - return self._running.wait(timeout) + return self._running.wait(timeout or self._timeout) def __enter__(self): self.start() @@ -387,33 +395,51 @@ def run(self): logger.debug("starting to read") self._running.set() - while not self._halt.is_set(): + while not self._cooling.is_set(): try: data = stream.read(blocksize) - logger.debug("read %d bytes", len(data)) + # logger.debug("read %d bytes", len(data)) except: # stdout stream closed/FFmpeg terminated, end the thread as well data = None # print(f"reader thread: read {len(data)} bytes") - if not data: - if stream.closed: # just in case - logger.info("ReaderThread no data, stream is closed, exiting") - self._halt.set() - break - else: - # pause a bit then try again - sleep(self._retry_delay) - continue + if data: + logger.debug("ReaderThread putting data into the queue") + while not self._cooling.is_set(): + try: + queue.put(data, timeout=0.01) + break + except Full: + if self._cooling.is_set(): + break - if not self._halt.is_set(): # True until self.cooloff - queue.put(data) - # print(f"reader thread: queued samples") + logger.debug("ReaderThread data in the reader queue") - logger.debug("stopping to read") + elif stream.closed: # just in case + logger.info("ReaderThread no data, stream is closed, exiting") + self._cooling.set() + self._halt.set() + break + else: + # pause a bit then try again + # logger.info("ReaderThread no data, reader thread pausing") + sleep(self._retry_delay) + logger.debug("stopping to read") logger.info("ReaderThread sending the sentinel") - queue.put(None) # sentinel for eos + while not self._halt.is_set(): + try: + queue.put(None, timeout=0.01) + break + except Full: + if self._halt.is_set(): + break + + # cooling loop (no queuing, flush all read) + logger.info("ReaderThread enters cool-down mode") + while not self._halt.is_set(): + stream.read(blocksize) logger.info("ReaderThread exiting") self._running.clear() @@ -434,6 +460,8 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: read_all = n < 0 # wait till matching line is read by the thread + if timeout is None: + timeout = self._timeout if timeout is not None: timeout = time() + timeout @@ -451,11 +479,17 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: # loop till enough data are collected while read_all or m > 0: tout = timeout and max(timeout - time(), 0) - block = self._running.is_set() and tout != 0 - + block = self.is_alive() and timeout is None try: - b = self._queue.get(block, tout) - assert b is not None # encountered sentinel + b = self._queue.get(block, tout or 0.01) + except Empty: + if not block: + break + else: + if b is None: + # encountered sentinel + break + self._queue.task_done() arrays.append(b) mr = len(b) @@ -464,8 +498,6 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: assert mr and ( read_all or tout is None or tout > 0 ) # no more read time left - except (Empty, AssertionError): - break # combine all the data and return requested amount all_data = b"".join(arrays) @@ -485,6 +517,64 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: def read_all(self, timeout: float | None = None) -> bytes: return self.read(-1, timeout) + def read_nowait(self, n: int = -1) -> bytes: + """read at most n samples + + :param n: number of samples/frames to read, if non-positive, read all + in the queue, defaults to -1 + :return: <= n*itemsize bytes + """ + + # no sample requested, return empty bytes object + if n == 0: + return b"" + + read_all = n < 0 + + # wait till matching line is read by the thread + arrays = [] + m = n * self.itemsize # bytes needed + mread = 0 # bytes read + + # grab any leftover data from previous read + if self._carryover: + mread = len(self._carryover) + arrays = [self._carryover] + m -= mread + self._carryover = None + + # loop till enough data are collected + while read_all or m > 0: + try: + b = self._queue.get_nowait() + self._queue.task_done() + if b is None: + # sentinel + break + mr = len(b) + assert mr > 0 # just in case + + arrays.append(b) + m -= mr + mread += mr + except Empty: + break + + # combine all the data and return requested amount + all_data = b"".join(arrays) + + nread = mread // self.itemsize # number of frames read + if n >= 0: + nread = min(nread, n) # adjust to number of frames needed + + mbytes = nread * self.itemsize # number of bytes needed + + # update carryover buffer + self._carryover = all_data[mbytes:] if mbytes < mread else None + + # return retrieved bytes array + return all_data[:mbytes] + def qsize(self) -> int: """Return the approximate size of the queue. From 6b48bc06afd01b0d6faf86c2d1d4866465b6c378 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 23:38:03 -0600 Subject: [PATCH 322/333] Rebuilt FFmpeg configurator and streaming classes - batch audio/image/video read/writer/filter functions fully support complex filtergraphs with multiple urls - media (multiple stream batch IO) module added write() and filter() functions - new open() function with additional operation modes: decode, encode, and transcode (and the arrow mode specifier). Also, supports arbitrary number of inputs and outputs - new streaming classes (PipedFFmpegRunner, SISOFFmpegFilter, and StdFFmpegRunner) which are based on BaseFFmpegRunner - configuring the ffmpeg options are now centralized to the 4 initializer functions in the configure module: init_media_read(), init_media_write(), init_media_filter(), and init_media_transcode() - improved automatic input detection: input streams are now probed instead of depending on ffmpeg log message - backend functions in configure & utils are heavily revamped - purged AVI readers and supporting backends --- src/ffmpegio/__init__.py | 26 +- src/ffmpegio/_open.py | 277 --- src/ffmpegio/_typing.py | 367 ++- src/ffmpegio/audio.py | 434 ++-- src/ffmpegio/configure.py | 3186 +++++++++++-------------- src/ffmpegio/image.py | 357 ++- src/ffmpegio/media.py | 298 +-- src/ffmpegio/std_runners.py | 148 ++ src/ffmpegio/streams/AviStreams.py | 238 -- src/ffmpegio/streams/PipedStreams.py | 1134 --------- src/ffmpegio/streams/SimpleStreams.py | 1301 ---------- src/ffmpegio/streams/StdStreams.py | 1119 --------- src/ffmpegio/streams/__init__.py | 55 +- src/ffmpegio/streams/open.py | 1480 ++++++++++++ src/ffmpegio/streams/runners.py | 2053 ++++++++++++++++ src/ffmpegio/threading.py | 285 --- src/ffmpegio/transcode.py | 26 +- src/ffmpegio/utils/__init__.py | 656 +++-- src/ffmpegio/utils/avi.py | 645 ----- src/ffmpegio/video.py | 395 +-- tests/test_audio.py | 75 +- tests/test_avistreams.py | 87 - tests/test_configure.py | 205 +- tests/test_ffmpegprocess.py | 11 +- tests/test_image.py | 84 +- tests/test_media.py | 68 +- tests/test_open.py | 267 ++- tests/test_pipedstreams.py | 124 - tests/test_simplestreams.py | 196 -- tests/test_stdstreams.py | 186 -- tests/test_streams_piped.py | 221 ++ tests/test_streams_simple.py | 160 ++ tests/test_transcode.py | 16 - tests/test_utils.py | 90 +- tests/test_utils_avi.py | 136 -- tests/test_video.py | 15 - 36 files changed, 7545 insertions(+), 8876 deletions(-) delete mode 100644 src/ffmpegio/_open.py create mode 100644 src/ffmpegio/std_runners.py delete mode 100644 src/ffmpegio/streams/AviStreams.py delete mode 100644 src/ffmpegio/streams/PipedStreams.py delete mode 100644 src/ffmpegio/streams/SimpleStreams.py delete mode 100644 src/ffmpegio/streams/StdStreams.py create mode 100644 src/ffmpegio/streams/open.py create mode 100644 src/ffmpegio/streams/runners.py delete mode 100644 src/ffmpegio/utils/avi.py delete mode 100644 tests/test_avistreams.py delete mode 100644 tests/test_pipedstreams.py delete mode 100644 tests/test_simplestreams.py delete mode 100644 tests/test_stdstreams.py create mode 100644 tests/test_streams_piped.py create mode 100644 tests/test_streams_simple.py delete mode 100644 tests/test_utils_avi.py diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index b122a910..f75e91bf 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -53,25 +53,35 @@ def __getattr__(name): raise AttributeError(f"module '{__name__}' has no attribute '{name}'") -from . import ffmpegprocess +from . import ( + audio, + caps, + devices, + ffmpegprocess, + image, + media, + probe, + stream_spec, + streams, + video, +) +# check if ffmpegio-core is installed, if it is warn its deprecation +from ._utils import deprecate_core from .errors import FFmpegError, FFmpegioError -from .utils.concat import FFConcat from .filtergraph import Graph as FilterGraph -from . import devices, caps, probe, audio, image, video, media +from .streams.open import open from .transcode import transcode +from .utils.concat import FFConcat from .utils.parser import FLAG -from ._open import open - -# 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", "FFmpegioError", "FilterGraph", "FFConcat", "use", "FLAG"] + "open", "streams", "ffmpegprocess", "FFmpegError", "FFmpegioError", "FilterGraph", + "FFConcat", "use", "FLAG", "stream_spec"] # fmt:on __version__ = "0.11.1" diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py deleted file mode 100644 index 7b81e03f..00000000 --- a/src/ffmpegio/_open.py +++ /dev/null @@ -1,277 +0,0 @@ -from __future__ import annotations - -from ._typing import DTypeString, ShapeTuple - -from fractions import Fraction -from . import streams as _streams - -from .filtergraph import Graph as FilterGraph - - -def open( - url_fg: str, - mode: str, - rate_in: Optional[int | Fraction] = None, - shape_in: Optional[ShapeTuple] = None, - dtype_in: Optional[DTypeString] = None, - rate: Optional[int | Fraction] = None, - shape: Optional[ShapeTuple] = None, - **kwds, -): - """Open a multimedia file/stream for read/write - - :param url_fg: URL of the media source/destination for file read/write or filtergraph definition - for filter operation. - :type url_fg: str or seq(str) - :param mode: specifies the mode in which the FFmpeg is used, see below - :type mode: str - :param rate_in: (write and filter only, required) input frame rate (video) or sampling rate - (audio), defaults to None - :type rate_in: Fraction, float, int, optional - :param shape_in: (write and filter only) input video frame size (height x width [x ncomponents]), - or audio sample size (channels,), defaults to None - :type shape_in: seq of int, optional - :param dtype_in: (write and filter only) input data type, defaults to None - :type dtype_in: str, optional - :param rate: (filter only, required) output frame rate (video write) or sample rate (audio - write), defaults to None - :type rate: Fraction, float, int, optional - :param dtype: (read and filter specific) output data type, defaults to None - :type dtype: str, optional - :param shape: (read and filter specific) output video frame size (height x width [x ncomponents]), - or audio sample size (channels,), defaults to None - :type shape: seq of int, optional - :param show_log: True to echo the ffmpeg log to stdout, default to False - :type show_log: bool, optional - :param progress: progress callback function (see :ref:`quick-callback`) - :type progress: Callable, optional - :param blocksize: (read and filter only) Number of frames to read by `read()` method, default to None (auto) - :type blocksize: int, optional - :param extra_inputs: (write only) List of additional (non-pipe) inputs to pass onto FFmpeg. Each - input is defined by a tuple of its url or a dict of input options, default to None - :type extra_inputs: List[Tuple[str,dict]], optional - :param default_timeout: (filter only) default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional - :param sp_kwargs: Keyword arguments for FFmpeg process (see :py:class:`ffmpegio.ffmpegprocess.Popen`), default to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :returns: ffmpegio stream object - - Start FFmpeg and open I/O link to it to perform read/write/filter operation and return - a corresponding stream object. If the file cannot be opened, an error is raised. - See :ref:`quick-streamio` for more examples of how to use this function. - - Just like built-in `open()`, it is good practice to use the with keyword when dealing with - ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are - properly closed after ffmpeg terminates, even if an exception is raised at some point. - Using with is also much shorter than writing equivalent try-finally blocks. - - :Examples: - - Open an MP4 file and process all the frames:: - - with ffmpegio.open('video_source.mp4', 'rv') as f: - frame = f.read() - while frame: - # process the captured frame data - frame = f.read() - - Read an audio stream of MP4 file and write it to a FLAC file as samples - are decoded:: - - with ffmpegio.open('video_source.mp4','ra') as rd: - fs = rd.sample_rate - with ffmpegio.open('video_dst.flac','wa',rate_in=fs) as wr: - frame = rd.read() - while frame: - wr.write(frame) - frame = rd.read() - - :Additional Notes: - - `url_fg` can be a string specifying either the pathname (absolute or relative to the current - working directory) of the media target (file or streaming media) to be opened or a string describing - the filtergraph to be implemented. Its interpretation depends on the `mode` argument. - - `mode` is an optional string that specifies the mode in which the FFmpeg is opened. - - ==== =================================================== - Mode Description - ==== =================================================== - 'r' read from url - 'w' write to url - 'f' filter data defined by fg - 'v' operate on video stream, 'vv' if multi-video reader - 'a' operate on audio stream, 'aa' if multi-audio reader - ==== =================================================== - - `rate` and `rate_in`: Video frame rates shall be given in frames/second and - may be given as a number, string, or `fractions.Fraction`. Audio sample rate in - samples/second (per channel) and shall be given as an integer or string. - - Optional `shape` or `shape_in` for video defines the video frame size and - number of components with a 2 or 3 element sequence: `(width, height[, ncomp])`. - The number of components and other optional `dtype` (or `dtype_in`) implicitly - define the pixel format (FFmpeg pix_fmt option): - - ===== ===== ========= =================================== - ncomp dtype pix_fmt Description - ===== ===== ========= =================================== - 1 \|u8 gray grayscale - 1 1: - raise ValueError( - f"Too many streams specified: {mode}. A {'write' if write else 'filter'} stream can only process one stream at a time." - ) - - if write: - if is_fg: - ValueError("Cannot write to a filtergraph.") - if rate_in is None: - raise ValueError( - "Missing required argument: rate_in. A write stream must specify the rate of the input media stream." - ) - if rate is not None: - raise ValueError( - "Invalid argument for a write stream: rate. To change rate, use FFmpeg 'r' argument for video stream or 'ar' argument for audio stream." - ) - if shape is not None: - raise ValueError( - "Invalid argument for a read stream: shape. To change shape, use FFmpeg 's' argument for video frame or 'ac' for the number of audio channels." - ) - else: # if filter - vars = [] - if rate_in is None: - vars.append("rate_in") - if rate is None: - vars.append("rate") - if len(vars): - vars = ", ".join(vars) - raise ValueError( - f"Missing required arguments: {vars}. A filter stream must specify the rates of both the input and output media streams." - ) - - try: - StreamClass = ( - { - 0: { - 0: _streams.SimpleAudioReader, - 1: _streams.SimpleAudioWriter, - 2: _streams.SimpleAudioFilter, - }, - 1: { - 0: _streams.SimpleVideoReader, - 1: _streams.SimpleVideoWriter, - 2: _streams.SimpleVideoFilter, - }, - }[video][write + 2 * filter] - if audio + video == 1 - else _streams.AviMediaReader - ) - except: - raise ValueError(f"Invalid/unsupported FFmpeg streaming mode: {mode}.") - - if len(url_fg) > 1 and not StreamClass.multi_read: - raise ValueError(f'Multi-input streaming is not supported in "{mode}" mode') - - # add other info to the arguments - args = (*url_fg,) if read else (*url_fg, rate_in) - if not read: - for k, v in ( - ("input_dtype", dtype_in), - ("input_shape", shape_in), - ("rate", rate), - ("shape", shape), - ): - if v is not None: - kwds[k] = v - - # instantiate the streaming object - return StreamClass(*args, **kwds) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 897a9b37..c9cafdf4 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -2,17 +2,14 @@ from __future__ import annotations -from typing import * -from typing_extensions import * - from fractions import Fraction -from pathlib import Path -from urllib.parse import ParseResult as UrlParseResult +from typing_extensions import * if TYPE_CHECKING: from namedpipe import NPopen - from .threading import WriterThread, ReaderThread + + from .threading import CopyFileObjThread # from typing_extensions import * @@ -49,9 +46,7 @@ """Tuple whose elements are the array size in each dimension. Each entry is an integer (a Python int).""" -RawStreamDef = ( - tuple[int | Fraction, RawDataBlob] | tuple[RawDataBlob | None, FFmpegOptionDict] -) +RawStreamDef = tuple[int | Fraction, RawDataBlob] | tuple[RawDataBlob, FFmpegOptionDict] """2-element tuple to define a raw stream data It comes in two forms: rate-data or data-option. The rate-data form specifies @@ -63,7 +58,7 @@ """ RawStreamInfoTuple = tuple[DTypeString, ShapeTuple, int | Fraction] -"""3-element tuple (rate, shape, dtype) to characterize raw data stream""" +"""3-element tuple (dtype, shape, rate) to characterize raw data stream""" ProgressCallable = Callable[[dict[str, Any], bool], bool] """FFmpeg progress callback function @@ -101,12 +96,12 @@ =============== ================================================================ """ -FFmpegUrlType = str | Path | UrlParseResult -"""input and output file/stream urls +FFmpegUrlType = str +"""input and output file/stream urls (str or a stringifiable object) """ FFmpegInputType = Literal["url", "filtergraph", "buffer", "fileobj"] -"""mechanisms to feed input data to FFmpeg +"""mechanisms to feed encoded input data to FFmpeg input pipe =============== ================================================================ value description @@ -119,7 +114,7 @@ """ FFmpegOutputType = Literal["url", "fileobj", "buffer"] -"""mechanisms to extract output data from FFmpeg +"""mechanisms to extract encoded output data from FFmpeg output pipe =============== ============================================================================ value description @@ -130,29 +125,341 @@ =============== ============================================================================ """ +################## +# Plugin protocols +################## + + +class GetInfoCallable(Protocol): + """Plugin function prototype to get information of a raw data blob object + + A plugin may implement this prototype with `audio_info()` for audio stream or + `video_info()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return shape: tuple of `int`s of the raw data shape + :return dtype: numpy dtype string of a video/image pixel or an audio sample + """ + + def __call__(self, *, obj: object) -> tuple[ShapeTuple, DTypeString]: ... + + +class ToBytesCallable(Protocol): + """Plugin function prototype to convert raw data blob object to a byte buffer + + A plugin may implement this prototype with `audio_bytes()` for audio stream or + `video_bytes()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return: a FFmpeg raw media stream compatible bytes + """ + + def __call__(self, *, obj: object) -> memoryview: ... + + +class CountDataCallable(Protocol): + """Plugin function prototype to count a number of video frames/audio samples + + A plugin may implement this prototype with `audio_samples()` for audio stream or + `video_frames()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return: number of video frames or of audio samples + """ + + def __call__(self, *, obj: object) -> int: ... + + +class FromBytesCallable(Protocol): + """Plugin function prototype to convert FFmpeg output bytes to raw data blob + + A plugin may implement this prototype with `bytes_to_audio()` for audio stream or + `bytes_to_video()` for video stream. + + :param b: FFmpeg output of raw audio/video/image frames + :param dtype: numpy dtype string of pixel/sample data format + :param shape: tuple of the dimension of one video frame or one audio sample. + Audio: (channels,), Video: (height, width, components) + :param squeeze: True to remove all dimensions with length 1 + :return: Plugin-specific raw data blob object + """ + + def __call__( + self, b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool + ) -> object: ... + + +class IsEmptyCallable(Protocol): + """Plugin function prototype to check if data blob contains no data + + A plugin may implement this prototype with `audio_samples()` for audio stream or + `video_frames()` for video/image stream. + + :param obj: Plugin-specific raw data blob object + :return: True if the blob contains no data + """ + + def __call__(self, *, obj: object) -> bool: ... + + +###### + + +class RawInputInfoDict(TypedDict): + """raw input media stream information + + =============== ================================================================ + key description + =============== ================================================================ + `'src_type'` always `'buffer'` + `'media_type'` media stream identifier: `'audio'` or '`video'` + `'raw_info'` tuple of (rate, shape, dtype) + `'item_size` size of each frame/sample in bytes + `'data2bytes'` conversion function + `'data_is_empty'` function to check empty data frame + `'data_count'` function to count number of frames/samples in a blob + `'buffer'` (optional) known media data blobs to be input (typically for + a batch operation) + `'pipe'` (optional) named pipe assigned to this data stream + `'writer'` (optional) writer thread assigned to this data stream + =============== ================================================================ + """ + + src_type: Literal["buffer"] + """True if file path/url""" + media_type: MediaType + """media type if input pipe""" + raw_info: RawStreamInfoTuple + """tuple of (rate, shape, dtype)""" + item_size: int + """size of each frame/sample in bytes""" + data2bytes: ToBytesCallable + """converts a Python data blob to raw media bytes""" + data_is_empty: IsEmptyCallable + """returns True if the data blob is empty""" + data_count: CountDataCallable + """returns number of frames in the data blob""" + buffer: NotRequired[object] + """stores data blob (typically for batch operation)""" + + +class UrlEncodedInputInfoDict(TypedDict): + """url/filtergraph encoded input source info""" + + src_type: Literal["url", "filtergraph"] + """input data is from a url/file or from an input filtergraph""" + -class InputSourceDict(TypedDict): - """input source info""" +class PipedEncodedInputInfoDict(TypedDict): + """piped encoded input source info""" - src_type: FFmpegInputType # True if file path/url + src_type: Literal["buffer"] buffer: NotRequired[bytes] # index of the source index - fileobj: NotRequired[IO] # file object - media_type: NotRequired[MediaType] # media type if input pipe - raw_info: NotRequired[RawStreamInfoTuple] - writer: NotRequired[WriterThread] # pipe -class OutputDestinationDict(TypedDict): - """output source info""" +class FileObjEncodedInputInfoDict(TypedDict): + """fileobj encoded input info""" - dst_type: FFmpegOutputType # True if file path/url - user_map: str | None # user specified map option - media_type: MediaType | None # + src_type: Literal["fileobj"] + fileobj: IO # file object + + +EncodedInputInfoDict = ( + UrlEncodedInputInfoDict | PipedEncodedInputInfoDict | FileObjEncodedInputInfoDict +) +"""encoded input container stream information + +=============== ================================================================ +key description +=============== ================================================================ +`'src_type'` `'url'`, `'filtergraph'`, `'buffer'`, or `'fileobj'` +`'buffer'` (optional for `src_type = 'buffer') known media data bytes to be + input (typically for a batch operation) +=============== ================================================================ +""" + +InputInfoDict = RawInputInfoDict | EncodedInputInfoDict + + +class PipeWriter(Protocol): + def write(self, data: bytes | None): ... + def join(self): ... + def closed(self) -> bool: ... + + +class PipeReader(Protocol): + def read(self, n: int = -1) -> bytes: ... + def join(self): ... + def cool_down(self): ... + + +class InputPipeInfoDict(TypedDict): + """ + ========== ========================================== + `'pipe'` named pipe assigned to this data stream + `'writer'` writer thread assigned to this data stream + ========== ========================================== + """ + + pipe: NPopen | Literal["stdin"] + """named pipe assigned to this data stream""" + writer: PipeWriter + """writer thread assigned to this data stream""" + + +################################################## + + +class RawDirectOutputInfoDict(TypedDict): + """raw output media stream info + + =================== ================================================================ + key description + =================== ================================================================ + `'dst_type'` `'buffer'` + `'media_type'` media stream identifier: `'audio'` or '`video'` + `'raw_info'` tuple of (dtype, shape, rate) + `'item_size` size of each frame/sample in bytes + `'bytes2data'` function to convert bytes to raw data blob + `'data_is_empty'` function to check empty data frame + `'data_count'` function to count number of frames/samples in a blob + `'user_map'` user specified FFmpeg map option of this stream + `'squeeze'` True to squeeze output shape (remove all length-1 dims) + `'input_file_id'` input file id + `'input_stream_id'` input stream id + =================== ================================================================ + """ + + dst_type: Literal["buffer"] # True if file path/url + media_type: MediaType # + raw_info: RawStreamInfoTuple + bytes2data: FromBytesCallable + item_size: int + data_is_empty: IsEmptyCallable + data_count: CountDataCallable + user_map: str # user specified map option + squeeze: bool input_file_id: NotRequired[int] input_stream_id: NotRequired[int] - linklabel: NotRequired[str] - raw_info: NotRequired[RawStreamInfoTuple] - pipe: NotRequired[NPopen] - reader: NotRequired[ReaderThread] + + +class RawFilteredOutputInfoDict(TypedDict): + """raw output media stream info + + =================== ================================================================ + key description + =================== ================================================================ + `'dst_type'` `'buffer'` + `'media_type'` media stream identifier: `'audio'` or '`video'` + `'raw_info'` tuple of (dtype, shape, rate) + `'item_size` size of each frame/sample in bytes + `'bytes2data'` function to convert bytes to raw data blob + `'data_is_empty'` function to check empty data frame + `'data_count'` function to count number of frames/samples in a blob + `'user_map'` user specified FFmpeg map option of this stream + `'squeeze'` True to squeeze output shape (remove all length-1 dims) + `'linklabel'` mapped filtergraph output label + =============== ================================================================ + """ + + dst_type: Literal["buffer"] # True if file path/url + media_type: MediaType # + raw_info: RawStreamInfoTuple + item_size: int + bytes2data: FromBytesCallable + data_is_empty: IsEmptyCallable + data_count: CountDataCallable + user_map: str # user specified map option + squeeze: bool + linklabel: str + + +RawOutputInfoDict = RawDirectOutputInfoDict | RawFilteredOutputInfoDict +"""raw output media stream info + +=================== ================================================================ +key description +=================== ================================================================ +`'dst_type'` `'buffer'` +`'media_type'` media stream identifier: `'audio'` or '`video'` +`'raw_info'` tuple of (dtype, shape, rate) +`'bytes2data'` function to convert bytes to raw data blob +`'data_is_empty'` function to check empty raw data blob +`'data_count'` function to count number of frames/samples in a blob +`'squeeze'` True to squeeze output shape (remove all length-1 dims) +`'input_file_id'` (optional) input file id if there is no complex filtergraph +`'input_stream_id'` (optional) input stream id if there is no complex filtergraph +`'linklabel'` (optional) mapped filtergraph output label if there is complex + filtergraph +=============== ================================================================ +""" + + +class UrlOrPipedEncodedOutputInfoDict(TypedDict): + """url/filtergraph encoded input source info""" + + dst_type: Literal["url", "buffer"] + """output data goes to either a url/filepath or a pipe""" + + +class FileObjEncodedOutputInfoDict(TypedDict): + """fileobj encoded input info""" + + dst_type: Literal["fileobj"] + fileobj: IO # file object + + +EncodedOutputInfoDict = UrlOrPipedEncodedOutputInfoDict | FileObjEncodedOutputInfoDict +"""encoded output container stream information + +=============== ================================================================ +key description +=============== ================================================================ +`'src_type'` `'url'`, `'filtergraph'`, `'buffer'`, or `'fileobj'` +`'buffer'` (optional for `src_type = 'buffer') known media data bytes to be + input (typically for a batch operation) +`'pipe'` (optional for `src_type` is `'buffer'` or `'fileobj'`) + named pipe assigned to this data stream +`'writer'` (optional for `src_type` is `'buffer'` or `'fileobj'`) + writer thread assigned to this data stream +=============== ================================================================ +""" + + +OutputInfoDict = RawOutputInfoDict | EncodedOutputInfoDict +"""combined output info""" + + +class OutputPipeInfoDict(TypedDict): + """ + =============== ================================================================ + `'pipe'` named pipe assigned to this data stream + `'reader'` reader thread assigned to this data stream + `'itemsize'` (optional) one frame/sample size in bytes + `'nmin'` (optional) minimum read block size + =============== ================================================================ + """ + + pipe: NPopen | Literal["stdout"] + reader: PipeReader | CopyFileObjThread itemsize: NotRequired[int] nmin: NotRequired[int] + + +################################################## + + +class AudioFilterGraphInfoDict(TypedDict): + media_type: Literal["audio"] + sample_fmt: str + ac: int + ar: int + + +class VideoFilterGraphInfoDict(TypedDict): + media_type: Literal["video"] + r: int | Fraction + pix_fmt: str + + +FilterGraphInfoDict = AudioFilterGraphInfoDict | VideoFilterGraphInfoDict diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 170dca6e..64017d79 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -1,119 +1,67 @@ """Audio Read/Write Module""" +from __future__ import annotations + +import logging import warnings -from . import ffmpegprocess, utils, configure, FFmpegError, plugins, analyze -from .utils import log as log_utils + +from . import analyze, configure, utils +from . import filtergraph as fgb +from ._typing import Any, ProgressCallable, RawDataBlob +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, +) +from .errors import FFmpegioError +from .filtergraph.abc import FilterGraphObject +from .std_runners import run_and_return_encoded, run_and_return_raw + +logger = logging.getLogger("ffmpegio") __all__ = ["create", "read", "write", "filter", "detect"] -def _run_read( +def create( + expr: str | fgb.abc.FilterGraphObject, *args, - show_log=None, - sp_kwargs=None, - **kwargs, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, ): - """run FFmpeg and retrieve audio stream data - :param *args ffmpegprocess.run arguments - :type *args: tuple - :param sample_fmt_in: input sample format if known but not specified in the ffmpeg arg dict, defaults to None - :type sample_fmt_in: str, optional - :param ac_in: number of input channels if known but not specified in the ffmpeg arg dict, defaults to None - :type ac_in: int, optional - :param ar_in: input sampling rate if known but not specified in the ffmpeg arg dict, defaults to None - :type ar_in: int, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param **kwargs ffmpegprocess.run keyword arguments - :type **kwargs: tuple - :return: [description] - :rtype: [type] - """ - """ - - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :rtype: (int, str) - """ - - outopts = args[0]["outputs"][0][1] - outopts["map"] = "0:a:0" - dtype, ac, rate = configure.finalize_audio_read_opts( - args[0], - input_info=[ - {"src_type": "filtergraph" if outopts.get("f", None) == "lavfi" else "url"} - ], - ) - - if sp_kwargs is not None: - kwargs = {**sp_kwargs, **kwargs} - - if ac is None or rate is None: - configure.clear_loglevel(args[0]) - - out = ffmpegprocess.run(*args, capture_log=True, **kwargs) - if show_log: - print(out.stderr) - if out.returncode: - raise FFmpegError(out.stderr) - - info = log_utils.extract_output_stream(out.stderr) - - ac = info.get("ac", None) - ac = ac and (ac,) - rate = info.get("ar", None) - else: - out = ffmpegprocess.run( - *args, - capture_log=None if show_log else True, - **kwargs, - ) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - - return rate, plugins.get_hook().bytes_to_audio( - b=out.stdout, dtype=dtype, shape=ac, squeeze=False - ) - - -def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options): """Create audio data using an audio source filter :param expr: name of the source filter or full filter expression - :type expr: str - :param \\*args: sequential filter option arguments. Only valid for - a single-filter expr, and they will overwrite the - options set by expr. - :type \\*args: seq, optional + :param args: sequential filter option arguments. Only valid for + a single-filter expr, and they will overwrite the + options set by expr. + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, + eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional + defaults to None (no show/capture) + Ignored if stream format must be retrieved automatically. :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**options: Named filter options or FFmpeg options. Items are - only considered as the filter options if expr is a - single-filter graph, and take the precedents over - general FFmpeg options. Append '_in' for input - option names (see :doc:`options`), and '_out' for - output option names if they conflict with the filter - options. - :type \\**options: dict, optional - :return: sampling rate and audio data (a plugin may change this behavior - with the `bytes_to_audio` hook.) - :rtype: tuple[int, object] + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + :param options: Named filter options or FFmpeg options. Items are + only considered as the filter options if expr is a + single-filter graph, and take the precedents over + general FFmpeg options. Append '_in' for input + option names (see :doc:`options`), and '_out' for + output option names if they conflict with the filter + options. + :return rate: sample rate in samples/second + :return data: audio data object specified by the selected ``bytes_to_audio`` + plugin hook (set by :py:func:`ffmpegio.use`). (pre v0.12.0) the output shape is always 2D with the time + axis in the first dimension. (since v0.12.0) The shape is default to 1D + if data is monaural. .. seealso:: https://ffmpeg.org/ffmpeg-filters.html#Audio-Sources for available @@ -130,53 +78,67 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) """ - input_options = utils.pop_extra_options(options, "_in") - output_options = utils.pop_extra_options(options, "_out") url, t_, options = configure.config_input_fg(expr, args, options) - options = {**options, **output_options} - if ( - t_ is None - and not any(a in input_options for a in ("t", "to")) - and not any(a in options for a in ("t", "to", "frames:a", "aframes")) + if t_ is None and not any( + a in options for a in ("t_in", "to_in", "t", "to", "frames:a", "aframes") ): warnings.warn( "neither input nor output duration specified. this function call may hang." ) - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"})[1][1] - configure.add_url(ffmpeg_args, "output", "-", {"sample_fmt": "dbl", **options})[1][ - 1 - ] - - return _run_read( - ffmpeg_args, + return read( + url, + squeeze=squeeze, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs, + **options, ) -def read(url, progress=None, show_log=None, sp_kwargs=None, **options): +def read( + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + *, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[int, RawDataBlob]: """Read audio samples. - :param url: URL of the audio file to read. - :type url: str + :param url: URL of the audio file to read or a list of URLs to be used by + complex filtergraph. Each url may be accompanied by its own input + options (a tuple pair of url and its option dict). These options + supersede the input options given with keyword arguments with `'_in'` + suffix. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be url string or a pair of a url string and + an option dict. + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, eliminating + the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional + :param show_log: True to show FFmpeg log messages on the console, defaults + to None (no show/capture). Ignored if stream format must be retrieved + automatically. :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: sample rate in samples/second and audio data object specified by `bytes_to_audio` plugin hook - :rtype: tuple(float, object) + `subprocess.Popen()` call used to run the FFmpeg, defaults to None + :param options: FFmpeg options, append '_in' for input option names + (see :doc:`options`) + :return rate: sample rate in samples/second + :return data: audio data object specified by selected `bytes_to_audio` + plugin hook. (pre v0.12.0) the output shape is always 2D with the time + axis in the first dimension. (since v0.12.0) The shape is default to 1D + data is monaural. .. note:: Even if :code:`start_time` option is set, all the prior samples will be read. The retrieved data will be truncated before returning it to the caller. @@ -187,149 +149,167 @@ def read(url, progress=None, show_log=None, sp_kwargs=None, **options): """ - input_options = utils.pop_extra_options(options, "_in") - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) - ) - - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options)[1][1] - configure.add_url(ffmpeg_args, "output", "-", options)[1][1] + # use user-specified map or default '0:a:0' map + output_map = options.pop("map", "0:a:0") - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = stdin - sp_kwargs["input"] = input + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_read( + [url] if utils.is_valid_input_url(url) else url, + [output_map], + options, + extra_outputs, + squeeze, + ) - return _run_read( - ffmpeg_args, - progress=progress, - show_log=show_log, - sp_kwargs=sp_kwargs, + if output_info is None: + raise FFmpegioError( + "Unknown configuration error occurred. Necessary output information could not be collected." + ) + if output_info[0]["media_type"] != "audio": + raise ValueError("Mapped stream is not an audio stream.") + + return run_and_return_raw( + args, + input_info, + output_info, + progress, + show_log, + sp_kwargs, ) def write( - url, - rate_in, - data, - progress=None, - overwrite=None, - show_log=None, - extra_inputs=None, - sp_kwargs=None, + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + rate_in: int, + data: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, **options, ): - """Write a NumPy array to an audio file. + """Write a raw audio data blob to an audio file. :param url: URL of the audio file to write. - :type url: str :param rate_in: The sample rate in samples/second. - :type rate_in: int :param data: input audio data object, converted to bytes by `audio_bytes` plugin hook . - :type data: object :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :type extra_inputs: seq(str|(str,dict)) :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - url, stdout, _ = configure.check_url(url, True) - input_options = utils.pop_extra_options(options, "_in") + # if filter_complex is not defined use '0:a:0' as default mapping + if ( + not any( + (o in options) + for o in ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + ) + or "map" not in options + ): + options["map"] = "0:a:0" - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_audio_input(rate_in, data=data, **input_options), + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_write( + url, [{"ar": rate_in}], extra_inputs, options, [data] ) - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - configure.add_url(ffmpeg_args, "output", url, options) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - { - "input": plugins.get_hook().audio_bytes(obj=data), - "stdout": stdout, - "progress": progress, - "overwrite": overwrite, - } + return run_and_return_encoded( + progress, overwrite, show_log, sp_kwargs, args, input_info, output_info ) - kwargs["capture_log"] = None if show_log else False - - out = ffmpegprocess.run(ffmpeg_args, **kwargs) - if out.returncode: - raise FFmpegError(out.stderr, show_log) def filter( - expr, - input_rate, - input, - progress=None, - show_log=None, - sp_kwargs=None, + expr: str | FilterGraphObject | None, + input_rate: int, + input: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, **options, -): +) -> tuple[int, RawDataBlob]: """Filter audio samples. - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None + :param expr: filter graph or None if implicit filtering via output options. :param input_rate: Input sample rate in samples/second - :type input_rate: int :param input: input audio data, accessed by `audio_info()` and `audio_bytes()` plugin hooks. - :type input: object + :param extra_inputs: list of additional input sources, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be url string or a pair of + a url string and an option dict. + :param squeeze: False to always returning 2D data with the 2nd dimension as + the audio channels, defaults to True to reduce monaural data + to 1D, eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: output sampling rate and audio data object, created by `bytes_to_audio` plugin hook - :rtype: tuple(int, object) + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :return rate: sample rate in samples/second + :return data: audio data object specified by selected `bytes_to_audio` plugin hook. + (pre v0.12.0) the output shape is always 2D with the time axis in the + first dimension. (since v0.12.0) The shape is default to 1D if + data is monaural. To match the shape """ - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_audio_input(input_rate, data=input, **input_options), + if expr is not None: + if extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:a"] = expr + options["map"] = "0:a:0" + else: + options["filter_complex"] = expr + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + [{"ar": input_rate}], + extra_inputs, + None, + extra_outputs, + options, + squeeze, + [input], ) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - if expr: - outopts["filter:a"] = expr - return _run_read( - ffmpeg_args, - input=plugins.get_hook().audio_bytes(obj=input), - progress=progress, - show_log=show_log, - sp_kwargs=sp_kwargs, + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") + + return run_and_return_raw( + args, input_info, output_info, progress, show_log, sp_kwargs ) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index ff41e7d4..c4ffa742 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,77 +1,135 @@ -from __future__ import annotations +"""`configure` module -from typing_extensions import ( - IO, - Literal, - get_args, - Any, - TypedDict, - Unpack, - Callable, -) +This module is used by all batch and streaming functions of `ffmpegio` to +process their input arguments and to generate FFmpeg arguments (`FFmpegArgs`) +and lists of input and output information (`InputInfoDict` and `OutputInfoDict`). -from ._typing import ( - DTypeString, - ShapeTuple, - RawStreamInfoTuple, - Buffer, - MediaType, - FFmpegUrlType, - InputSourceDict, - OutputDestinationDict, - RawStreamDef, - RawDataBlob, - FFmpegOptionDict, -) -from collections.abc import Sequence -from .utils import FFmpegInputUrlComposite, FFmpegOutputUrlComposite +There are four primary functions for the four operation types supported by +`ffmpegio`: +======================== ================================ +`init_media_read()` encoded data to raw media data +`init_media_write()` raw media data to encoded data +`init_media_filter()` raw media data to raw media data +`init_media_transcode()` encoded data to encoded data +======================== ================================ -from fractions import Fraction -import re -import logging +These functions call ffprobe to get raw media information best it could. -logger = logging.getLogger("ffmpegio") +The above functions do not initialize the pipes and IO threads. -from io import IOBase +- `assign_input_pipes()` +- `assign_output_pipes()` +- `init_named_pipes()` -from namedpipe import NPopen +""" + +from __future__ import annotations + +import logging +import re +import subprocess as fp +from collections import Counter +from collections.abc import Sequence from contextlib import ExitStack +from functools import cache +from itertools import count + +from namedpipe import NPopen -from . import ffmpegprocess as fp -from . import utils, probe, plugins from . import filtergraph as fgb +from . import plugins, utils +from ._typing import ( + Any, + Buffer, + Callable, + CountDataCallable, + DTypeString, + EncodedInputInfoDict, + EncodedOutputInfoDict, + FFmpegOptionDict, + FFmpegUrlType, + FilterGraphInfoDict, + FromBytesCallable, + InputInfoDict, + InputPipeInfoDict, + IsEmptyCallable, + Literal, + MediaType, + NotRequired, + OutputInfoDict, + OutputPipeInfoDict, + RawDataBlob, + RawInputInfoDict, + RawOutputInfoDict, + RawStreamInfoTuple, + ShapeTuple, + ToBytesCallable, + TypedDict, + cast, +) +from .errors import ( + FFmpegError, + FFmpegioError, + FFmpegioInsufficientInputData, + FFmpegioNoPipeAllowed, +) from .filtergraph.abc import FilterGraphObject -from .filtergraph.presets import ( - merge_audio, - filter_video_basic, - remove_video_alpha, - temp_video_src, - temp_audio_src, +from .stream_spec import parse_map_option, stream_type_to_media_type +from .threading import CopyFileObjThread, ReaderThread, WriterThread +from .utils import ( + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegOutputUrlComposite, + FFmpegOutputUrlNoPipe, ) from .utils.concat import FFConcat # for typing -from ._utils import as_multi_option, is_non_str_sequence -from .stream_spec import ( - stream_spec as compose_stream_spec, - StreamSpecDict, - stream_type_to_media_type, - is_unique_stream, - parse_map_option, - map_option as compose_map_option, -) -from .errors import FFmpegioError, FFmpegioNoPipeAllowed -from .threading import ReaderThread, WriterThread, CopyFileObjThread + +logger = logging.getLogger("ffmpegio") ################################# ## module types UrlType = Literal["input", "output"] +FFmpegInputOptionTuple = tuple[FFmpegInputUrlComposite, FFmpegOptionDict] +"""tuple pair of FFmpeg input url compatible objects and its option dict + +Supported input url objects: + +- `str` +- `os.Path` +- `urllib.UrlParseResult` +- `FFConcat` +- `FilterGraphObject` +- `IO` +- `Buffer` +""" + +FFmpegOutputOptionTuple = tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] +"""tuple pair of FFmpeg output url compatible objects and its option dict + +Supported output url objects: + +- `str` +- `IO` +- `Buffer` +""" + +FFmpegNoPipeInputOptionTuple = tuple[FFmpegInputUrlNoPipe, FFmpegOptionDict] +"""tuple pair of FFmpeg input non-pipe url compatible objects and its option dict + +Supported input url objects: + +- `str` +- `FFConcat` +- `FilterGraphObject` +""" + +FFmpegNoPipeOutputOptionTuple = tuple[FFmpegOutputUrlNoPipe, FFmpegOptionDict] +"""tuple pair of FFmpeg output non-pipe url compatible objects and its option dict +""" -FFmpegInputOptionTuple = tuple[ - FFmpegUrlType | IO | Buffer | FilterGraphObject | FFConcat, FFmpegOptionDict -] -FFmpegOutputOptionTuple = tuple[FFmpegUrlType | IO, FFmpegOptionDict] raw_formats = ("rawvideo", *(formats for _, formats in utils.audio_codecs.values())) @@ -89,11 +147,11 @@ class FFmpegArgs(TypedDict): InitMediaOutputsCallable = Callable[ [ FFmpegArgs, - list[InputSourceDict], + list[RawInputInfoDict | EncodedInputInfoDict], Any, list[list[RawDataBlob] | bytes], ], - list[OutputDestinationDict], + list[RawOutputInfoDict], ] """function to finalize the media output initialization @@ -117,564 +175,764 @@ class FFmpegArgs(TypedDict): ################################# ## module functions +############################################################################### +### compatible typed dicts for media initializer function keyword arguments ### +############################################################################### -def array_to_video_input( - rate: int | Fraction | None = None, - data: RawDataBlob | None = None, - pipe_id: str | None = None, - **opts: Unpack[FFmpegOptionDict], -) -> tuple[str, FFmpegOptionDict]: - """create an stdin input with video stream - - :param rate: input frame rate in frames/second - :param data: input video frame data, accessed with `video_info` plugin hook, defaults to None (manual config) - :param pipe_id: named pipe path, defaults None to use stdin - :param **opts: input options - :return: tuple of input url and option dict - """ - - if rate is None and "r" not in opts: - raise ValueError("rate argument must be specified if opts['r'] is not given.") - return ( - pipe_id or "-", - {**utils.array_to_video_options(data)[0], "r": rate, **opts}, - ) +class MediaReadKwsDict(TypedDict): + input_urls: Sequence[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ] + output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + options: FFmpegOptionDict + squeeze: bool + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + + +class MediaWriteKwsDict(TypedDict): + output_urls: Sequence[FFmpegOutputOptionTuple] + input_options: Sequence[FFmpegOptionDict] + options: FFmpegOptionDict + extra_inputs: NotRequired[ + Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ] + input_data: NotRequired[Sequence[RawDataBlob | None] | None] + input_dtypes: NotRequired[Sequence[DTypeString | None] | None] + input_shapes: NotRequired[Sequence[ShapeTuple | None] | None] -def array_to_audio_input( - rate: int | None = None, - data: RawDataBlob | None = None, - pipe_id: str | None = None, - **opts: Unpack[FFmpegOptionDict], -): - """create an stdin input with audio stream +class MediaFilterKwsDict(TypedDict): + input_options: Sequence[Literal["a", "v"]] + output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + options: FFmpegOptionDict + extra_inputs: NotRequired[ + Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ] + extra_outputs: NotRequired[ + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ] + input_data: NotRequired[ + Sequence[tuple[RawDataBlob | None, FFmpegOptionDict]] | None + ] + input_dtypes: NotRequired[Sequence[DTypeString] | None] + input_shapes: NotRequired[Sequence[ShapeTuple] | None] + squeeze: bool - :param rate: input sample rate in samples/second - :param data: input audio data, accessed by `audio_info` plugin hook, defaults to None (manual config) - :param pipe_id: input named pipe id, defaults to None to use the stdin - :return: tuple of input url and option dict - """ - if rate is None and "ar" not in opts: - raise ValueError("rate argument must be specified if opts['ar'] is not given.") +class MediaTranscoderKwsDict(TypedDict): + input_urls: Sequence[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ] + output_urls: list[FFmpegOutputOptionTuple] + options: FFmpegOptionDict - return ( - pipe_id or "-", - {**utils.array_to_audio_options(data)[0], "ar": rate, **opts}, - ) +FFmpegMediaKwsDict = ( + MediaReadKwsDict | MediaWriteKwsDict | MediaFilterKwsDict | MediaTranscoderKwsDict +) -def empty(global_options: FFmpegOptionDict | None = None) -> FFmpegArgs: - """create empty ffmpeg arg dict +####################R### +### I/O initializers ### +######################## - :param global_options: global options, defaults to None - :return: ffmpeg arg dict with empty 'inputs','outputs',and 'global_options' entries. - """ - return {"inputs": [], "outputs": [], "global_options": global_options or {}} +def init_media_read( + input_urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + output_streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, + options: FFmpegOptionDict | None, + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ), + squeeze: bool, +) -> tuple[FFmpegArgs, list[EncodedInputInfoDict], list[RawOutputInfoDict]]: + """Initialize FFmpeg arguments for media read -def check_url( - url: FFmpegInputUrlComposite, - nodata: bool = True, - nofileobj: bool = False, - format: str | None = None, - pipe_str: str | None = "-", -) -> tuple[ - FFmpegUrlType | FilterGraphObject | FFConcat, IOBase | None, memoryview | None -]: - """Analyze url argument for non-url input + :param urls: URLs of the media files to read. + :param output_streams: output stream mappings and optional per-stream options: + + - ``None`` to map all filtergraph outputs + - (str) output map option string + - (dict) output ffmpeg options with the required ``'map'`` option + - (Sequence) a sequence of output map option string or ffmpeg option + dict with a ``'map'`` key. + + :param options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + input url or '_in' to be applied to all inputs. The url-specific option gets the + preference (see :doc:`options` for custom options) + :param extra_outputs: list of additional output destinations, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param squeeze: True to remove length-1 dimensions from the output shape + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return output_info: output stream information, None if outputs not initialized - :param url: url argument string or data or file or a custom class - :param nodata: True to raise exception if url is a bytes-like object, default to True - :param nofileobj: True to raise exception if url is a file-like object, default to False - :param format: FFmpeg format option, default to None (unspecified) - :param pipe_str: specify an alternate FFmpeg pipe url or None to leave it blank, default to '-' - :return: url string, file object, and data object + Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run + `ffmpegio.video.read()` for each url. - Custom Pipe Class - ----------------- + Specify the streams to return by `map` output option: - `url` may be a class instance of which `str(url)` call yields a stdin pipe expression - (i.e., '-' or 'pipe:' or 'pipe:0') with `url.input` returns the input data. For such `url`, - `check_url()` returns url and data objects, accordingly. + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output + 'pix_fmt' option is not explicitly set, 'rgb24' is used. """ - def hasmethod(o, name): - return hasattr(o, name) and callable(getattr(o, name)) + options = {} if options is None else {**options} - fileobj = None - data = None - - if format != "lavfi": - try: - memoryview(url) - data = url - url = pipe_str - except: - if hasmethod(url, "fileno"): - if nofileobj: - raise ValueError("File-like object cannot be specified as url.") - fileobj = url - url = pipe_str - elif str(url) in ("-", "pipe:", "pipe:0"): - try: # for FFConcat - data = url.input - except: - pass - - if nodata and data is not None: - raise ValueError("Bytes-like object cannot be specified as url.") - - return url, fileobj, data + ninputs = len(input_urls) + if not ninputs: + raise ValueError("At least one URL must be given.") + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") -def add_url( - args: FFmpegArgs, - type: Literal["input", "output"], - url: FFmpegUrlType | None, - opts: FFmpegOptionDict | None = None, - update: bool = False, -) -> tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]: - """add new or modify existing url to input or output list + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") - :param args: ffmpeg arg dict (modified in place) - :param type: input or output (may use None to update later) - :param url: url of the new entry - :param opts: FFmpeg options associated with the url, defaults to None - :param update: True to update existing input of the same url, default to False - :return: file index and its entry - """ + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None - # get current list of in/outputs - filelist = args[f"{type}s"] - n = len(filelist) + # assign inputs + input_info = process_url_inputs(args, input_urls, inopts_default) - # if updating, get the existing id - file_id = ( - next((i for i in range(n) if filelist[i][0] == url), None) if update else None - ) - if file_id is None: - # new entry - file_id = n - filelist.append((url, {} if opts is None else {**opts})) - elif opts is not None: - # update option dict - filelist[file_id] = ( - url, - ( - opts - if filelist[file_id][1] is None - else ( - filelist[file_id][1] - if opts is None - else {**filelist[file_id][1], **opts} - ) - ), + # assign outputs + try: + output_info = process_raw_outputs( + args, input_info, output_streams, options, squeeze ) - return file_id, filelist[file_id] - + except FFmpegError as e: + raise FFmpegioInsufficientInputData( + "Failed to retrieve input stream information." + ) from e -def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: - """True if FFmpeg arguments specify a filter graph + # standardize output stream options - :param args: FFmpeg argument dict - :param type: filter type - :return: True if filter graph is specified - """ - try: - if ( - "filter_complex" in args["global_options"] - or "lavfi" in args["global_options"] - ): - return True - except: - pass # no global_options defined - - # input filter - if any( - ( - opts is not None and opts.get("f", None) == "lavfi" - for _, opts in args["inputs"] + if extra_outputs is not None: + output_info.extend( + process_url_outputs( + args, + input_info, + extra_outputs, + {}, + skip_automapping=True, + no_pipe=True, + ) ) - ): - return True - - # output filter - short_opt = {"video": "vf", "audio": "af"}[type] - other_st = {"video": "a", "audio": "v"}[type] - re_opt = re.compile(rf"{short_opt}$|filter(?::(?=[^{other_st}]).*?)?$") - if any( - (any((re_opt.match(key) for key in opts.keys())) for _, opts in args["outputs"]) - ): - return True - return False # no output options defined + return args, input_info, output_info -def finalize_video_read_opts( - args: FFmpegArgs, - ofile: int = 0, - input_info: list[InputSourceDict] = [], - fg_info: dict[str, dict] | None = None, -) -> RawStreamInfoTuple: - """finalize raw video read output options - - :param args: FFmpeg arguments (will be modified) - :param ofile: output index, defaults to 0 - :param input_info: source information of the inputs, defaults to [] - :return dtype: Numpy-style buffer data type string - :return s: video shape tuple (height, width, nb_components) - :return r: video framerate - """ +def init_media_write( + output_urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + input_options: Sequence[FFmpegOptionDict], + extra_inputs: ( + Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ] + | None + ), + options: FFmpegOptionDict | None, + input_data: Sequence[RawDataBlob | None] | None = None, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, +) -> tuple[ + FFmpegArgs, + list[RawInputInfoDict | EncodedInputInfoDict], + list[EncodedOutputInfoDict], +]: + """write multiple streams to a url/file - options = ["r", "pix_fmt", "s"] + :param output_url: output url + :param input_options: list of input option dicts. Each must include either + ``'ar'`` (audio) or ``'r'`` (video) to specify the + media type and rate. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + :param input_data: list of input data to be written in a batch-mode (or ``None`` + if streaming), defaults to no data. + :param input_dtypes: list of numpy-style data type strings of input samples or frames + of input media streams, defaults to `None` (auto-detect). + :param input_shapes: list of shapes of input samples or frames of input media streams, + defaults to `None` (auto-detect). + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return input_ready: Element is True if corresponding input is ready (known dtype and shape) + :return output_info: output stream information, None if outputs not initialized + :return output_options: output options, None if outputs already initialized - outopts = args["outputs"][ofile][1] - outmap = outopts["map"] - outmap_fields = parse_map_option( - outmap, input_file_id=0 if len(args["inputs"]) == 1 else None - ) - has_simple_filter = "vf" in outopts or "filter:v" in outopts - fill_color = outopts.get("fill_color", None) - if fill_color is not None and "remove_alpha" not in outopts: - outopts.pop("fill_color") + TIPS + ---- - # use the output option by default - opt_vals = [outopts.get(o, None) for o in options] + * All the input streams will be added to the output file by default, unless `map` option is specified + * If the input streams are of different durations, use `shortest=ffmpegio.FLAG` option to trim all streams to the shortest. + * Using merge_audio_streams: + - adds a `filter_complex` global option + - merged input streams are removed from the `map` option and replaced by the merged stream - # get the options of the input/filtergraph output - if linklabel := outmap_fields.get("linklabel", None): - if fg_info is None or not (info := fg_info.get(linklabel, None)): - raise FFmpegioError( - f"Complex filtergraph or the specified {linklabel=} do not exist." - ) - inopt_vals = [info["r"], info["pix_fmt"], info["s"]] - else: - # insert basic video filter if specified - build_basic_vf(args, False, ofile) + """ - ifile = outmap_fields["input_file_id"] + options = {} if options is None else {**options} - # get input option values - inopt_vals = utils.analyze_video_stream( - outmap_fields["stream_specifier"], - *args["inputs"][ifile], - input_info[ifile], - ) + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") - # directly from the input url (if not forced via input options) - if has_simple_filter: - # create a source chain with matching spec and attach it to the af graph - vf = temp_video_src(*inopt_vals) + outopts.get( - "filter:v", outopts.get("vf", None) - ) + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) - outpad = next(vf.iter_output_pads(unlabeled_only=True), None) - if outpad is not None: - vf = vf >> "[out0]" - inopt_vals = utils.analyze_video_stream( - "0", vf, {"f": "lavfi"}, {"src_type": "filtergraph"} - ) + # analyze and assign inputs + input_info = process_raw_inputs( + args, input_options, inopts_default, input_data, input_dtypes, input_shapes + ) + + # append extra (not-piped) inputs + if extra_inputs is not None: + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed as e: + raise FFmpegioError("extra_inputs cannot be piped in.") from e - # assign the values to individual variables - r, pix_fmt, s = opt_vals - r_in, pix_fmt_in, s_in = inopt_vals + # analyze and assign outputs + output_info = process_url_outputs(args, input_info, output_urls, options) - # pixel format must be specified - if pix_fmt is None: - if pix_fmt_in == "unknown": + # if output is piped, it must have the -f option specified + for url, opts in args["outputs"]: + if url is None and "f" not in opts: raise FFmpegioError( - "input pixel format unknown. Please specify output pix_fmt (to be autoset)" + 'all piped encoded output stream must have its format (`"f"`) defined in its option dict' ) - # deduce output pixel format from the input pixel format - try: - outopts["pix_fmt"], ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) - except: - ncomp = dtype = None - else: - # make sure assigned pix_fmt is valid - if pix_fmt_in is None: - try: - dtype, ncomp = utils.get_pixel_format(pix_fmt) - except: - ncomp = dtype = None - else: - _, ncomp, dtype, remove_alpha = utils.get_pixel_config(pix_fmt_in, pix_fmt) - if remove_alpha: - # append the remove-video-alpha filter chain - build_basic_vf(args, True, ofile) - - outopts["f"] = "rawvideo" - - # use output option value or else use the input value - r = r or r_in - s = s or s_in + return args, input_info, output_info - return dtype, None if s is None else (*s[::-1], ncomp), r +def init_media_filter( + input_options: Sequence[FFmpegOptionDict], + extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, + output_streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ), + options: FFmpegOptionDict | None, + squeeze: bool, + input_data: list[RawDataBlob | None] | None = None, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, +) -> tuple[FFmpegArgs, list[RawInputInfoDict], list[RawOutputInfoDict]]: + """Prepare FFmpeg arguments for media read -def check_alpha_change(args, dir=None, ifile=0, ofile=0): - # check removal of alpha channel - inopts = args["inputs"][ifile][1] - outopts = args["outputs"][ofile][1] - if inopts is None or outopts is None: - return None if dir is None else False # indeterminable - return utils.alpha_change(inopts.get("pix_fmt", None), outopts.get("pix_fmt", None)) + :param input_options: list of input option dicts. Each must include either + ``'ar'`` (audio) or ``'r'`` (video) to specify the + media type and rate. + :param extra_inputs: list of additional input sources, defaults to None. Each source may be url + string or a pair of a url string and an option dict. + :param output_streams: output stream mappings and optional per-stream options: + - ``None`` to map all filtergraph outputs + - (str) output map option string + - (dict) output ffmpeg options with the required ``'map'`` option + - (Sequence) a sequence of output map option string or ffmpeg option + dict with a ``'map'`` key. -def build_basic_vf( - args: FFmpegArgs, remove_alpha: bool | None = None, ofile: int = 0 -) -> bool: - """convert basic VF options to vf option + :param extra_outputs: list of additional output destinations, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + :param squeeze: True to squeeze output data blob shape + :param input_data: list of input data to be written in a batch-mode (or ``None`` + if streaming), defaults to no data. + :param input_dtypes: list of numpy-style data type strings of input samples or frames + of input media streams, use `None` to auto-detect. + :param input_shapes: list of shapes of input samples or frames of input media streams, + use `None` to auto-detect. + :return ffmpeg_args: FFmpeg argument dict (partial) + :return input_info: input stream information + :return output_info: output stream information, None if outputs not initialized - :param args: FFmpeg dict (may be modified if vf is added/changed) - :param remove_alpha: True to add overlay filter to add a background color, defaults to None - : This argument would be ignored if `'remove_alpha'` key is defined in `'args'`. - :param ofile: output file id, defaults to 0 - :return: True if vf option is added or changed """ - # get output opts, nothing to do if no option set - outopts = args["outputs"][ofile][1] - - # extract the options - fopts = { - name: outopts.pop(name, None) - for name in ("crop", "flip", "transpose", "square_pixels") - } - fill_color, remove_alpha = ( - outopts.pop(name, defval) - for name, defval in zip(("fill_color", "remove_alpha"), (None, remove_alpha)) - ) - if fill_color is not None: - remove_alpha = True - - # if `s` output option contains negative number, use scale filter - scale = outopts.get("s", None) - if scale is not None: - try: - # if given a string -s option value - m = re.match(r"(-?\d+)x(-?\d+)", scale) - scale = (int(m[1]), int(m[2])) - except: - pass - - if len(scale) != 2 or scale[0] <= 0 or scale[1] <= 0: - # must use scale filter, move the option from output to filter - outopts.pop("s") - fopts["scale"] = scale - - basic = any(fopts.values()) - if not (basic or remove_alpha): - return False # no filter needed - - # existing simple filter - vf = outopts.pop("filter:v", outopts.pop("vf", None)) or fgb.Chain() - - if basic: - vf = vf + filter_video_basic(**fopts) # Graph is remove alpha else Chain - - if remove_alpha: - if fill_color is None: - logger.warning( - "`fill_color` option not specified, uses white background color by default." - ) - fill_color = "white" - vf = vf + remove_video_alpha(fill_color) + options = {} if options is None else {**options} - outopts["vf"] = vf + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") - return True + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None -def finalize_audio_read_opts( - args: FFmpegArgs, - ofile: int = 0, - input_info: list[InputSourceDict] = [], - fg_info: dict[str, dict] | None = None, -) -> RawStreamInfoTuple: - """finalize a raw output audio stream - - :param args: FFmpeg arguments. The option dict in args['outputs'][ofile][1] may be modified. - :param ofile: output file index, defaults to 0 - :param input_info: list of input information, defaults to None - :return dtype: input data type (Numpy style) - :return ac: number of channels - :return ar: sampling rate - - * Possible Output Options Modification - - "f" and "c:a" - raw audio format and codec will always be set - - "sample_fmt" - planar format to non-planar equivalent format or 'dbl' if format is unknown - - - - * args['outputs'][ofile]['map'] is a valid mapping str (not a list of str) - * If complex filtergraph(s) is used, args['global_options']['filter_complex'] must be a list of fgb.Graph objects + # analyze and assign inputs + input_info = process_raw_inputs( + args, input_options, inopts_default, input_data, input_dtypes, input_shapes + ) + + if extra_inputs is not None: + try: + input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) + except FFmpegioNoPipeAllowed: + raise FFmpegioError("extra_inputs cannot be piped in.") + + # analyze and assign outputs + + try: + output_info = process_raw_outputs( + args, input_info, output_streams, options, squeeze + ) + except FFmpegError as e: + raise FFmpegioInsufficientInputData( + "Failed to retrieve input stream information." + ) from e + + # if additional (encoded) outputs are specified, append them to ffmpeg args + # and output info + if extra_outputs is not None: + try: + output_info.extend( + process_url_outputs( + args, + input_info, + extra_outputs, + {}, + skip_automapping=True, + no_pipe=True, + ) + ) + except FFmpegioNoPipeAllowed: + raise FFmpegioError("extra_outputs cannot be piped out.") + + return args, input_info, output_info + + +def init_media_transcode( + input_urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + output_urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + options: FFmpegOptionDict | None, +) -> tuple[FFmpegArgs, list[EncodedInputInfoDict], list[EncodedOutputInfoDict]]: + """initialize media transcoder + :param inputs: FFmpeg input options of piped inputs + :param outputs: FFmpeg output options of piped outputs + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + will be applied to all input streams unless the option has been already defined in `stream_data` + :return ffmpeg_args: FFmpeg argument dict + :return input_info: list of input stream information + :return output_info: list of output stream information """ - options = ["ar", "sample_fmt", "ac"] + options = {} if options is None else {**options} + + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") + + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + + input_info = process_url_inputs(args, input_urls, inopts_default) + + if not len(input_info): + raise ValueError("At least one input must be given.") - outopts = args["outputs"][ofile][1] - outmap = outopts["map"] - outmap_fields = parse_map_option( - outmap, input_file_id=0 if len(args["inputs"]) == 1 else None + output_info = process_url_outputs( + args, input_info, output_urls, options, skip_automapping=True ) - # use the output options by default - opt_vals = [outopts.get(o, None) for o in options] - if not all(opt_vals): - if linklabel := outmap_fields.get("linklabel", None): - if fg_info is None or not (info := fg_info.get(linklabel, None)): - raise FFmpegioError( - f"Complex filtergraph or the specified {linklabel=} do not exist." + if not len(output_info): + raise ValueError("At least one output must be given.") + + return args, input_info, output_info + + +############################################################### + + +def empty(global_options: FFmpegOptionDict | None = None) -> FFmpegArgs: + """create empty ffmpeg arg dict + + :param global_options: global options, defaults to None + :return: ffmpeg arg dict with empty 'inputs','outputs',and 'global_options' entries. + """ + return {"inputs": [], "outputs": [], "global_options": global_options or {}} + + +def add_url( + args: FFmpegArgs, + type: Literal["input", "output"], + url: FFmpegUrlType | None, + opts: FFmpegOptionDict | None = None, + update: bool = False, +) -> tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]: + """add new or modify existing url to input or output list + + :param args: ffmpeg arg dict (modified in place) + :param type: input or output (may use None to update later) + :param url: url of the new entry + :param opts: FFmpeg options associated with the url, defaults to None + :param update: True to update existing input of the same url, default to False + :return: file index and its entry + """ + + # get current list of in/outputs + filelist = args[f"{type}s"] + n = len(filelist) + + # if updating, get the existing id + file_id = ( + next((i for i in range(n) if filelist[i][0] == url), None) if update else None + ) + if file_id is None: + # new entry + file_id = n + filelist.append((url, {} if opts is None else {**opts})) + elif opts is not None: + # update option dict + filelist[file_id] = ( + url, + ( + opts + if filelist[file_id][1] is None + else ( + filelist[file_id][1] + if opts is None + else {**filelist[file_id][1], **opts} ) - inopt_vals = [info["ar"], info["sample_fmt"], info["ac"]] + ), + ) + return file_id, filelist[file_id] + + +def find_filtergraph_option( + args: FFmpegArgs, stream: int = -1, media_type: MediaType | None = None +) -> ( + Literal[ + "filter_complex", + "/filter_complex", + "lavfi", + "/lavfi", + "filter_complex_script", + "filter", + "/filter", + "af", + "/af", + "filter:a", + "/filter:a", + "vf", + "/vf", + "filter:v", + "/filter:v", + ] + | None +): + """True if FFmpeg arguments specify a filter graph + + :param args: FFmpeg argument dict + :param stream: output stream index, by default -1 to check global complex filtergraphs + :param media_type: for output stream filter, specify to check a particular + media type, defaults to checking both types of filters + :return: FFmpeg option name if filter graph is specified else None + """ + + if stream < 0: # global filtergraph + return utils.find_filter_complex_option(args["global_options"]) + else: + return utils.find_filter_simple_option(args["outputs"][stream], media_type) + + +def gather_video_read_opts( + options: FFmpegOptionDict, + skip_rate: bool = False, + args: FFmpegArgs | None = None, + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + get_fg_info: Callable[[], dict[str, FilterGraphInfoDict] | None] | None = None, +) -> tuple[RawStreamInfoTuple, FFmpegOptionDict | None]: + """Gathering raw video read output options + + :param options: option dict for this output. To run input/fg analysis, it + must contain a `'map'` item. + :param skip_rate: True to skip requiring the frame rate information, defaults + to False + :param args: FFmpeg argument dict populated `inputs` and `global_options` + items or None to skip input & filtergraph analysis, defaults to + None to skip the analysis + :param input_info: list of input information, only required if `args` is given + :param get_fg_info: function to retrieve filtergraph output info if available. + :return raw_info: tuple of (dtype, shape, r) where shape is a video shape + tuple (height, width, nb_components) + :return additional_options: additional output options or None if `raw_info` + is not complete + + The output `pix_fmt` must be a raw-data compatible format (i.e., grayscales + and RGBs, and byte-aligned alternate formats). + + If `args is None`, `options` must contain items with `s`, `pix_fmt`, and `r` + (the latter only if `skip_rate=False`) to be successful. + + If `args` is provided, `options['map']` must be present. Also, if `options['map']` + is a link label, `fg_info` must be provided to be successful. + + The input/fg analysis code path may raise an exception if necessary information + is not provided. + + """ + + # required options + req_opts = ("pix_fmt", "s", "r") + + # use the output option by default + opt_vals = [options.get(o, None) for o in req_opts] + + if opt_vals[0] is None: + dtype = None + ncomp = 0 + else: + dtype, ncomp = utils.get_pixel_format(opt_vals[0]) + + pix_fmt, s, r = opt_vals + outopts = {} + + scaled_s = bool(s) and all( + si > 0 for si in s + ) # true if output size requires input size + + if ( + scaled_s or not all(opt_vals[:-1] if skip_rate else opt_vals) + ) and args is not None: + # run input analysis + try: + map_spec = options["map"] + except KeyError as e: + raise FFmpegioError('`options["map"]` is missing') from e + map_fields = parse_map_option(map_spec, input_file_id=0) + + # get the options of the input/filtergraph output + if linklabel := map_fields.get("linklabel", None): + try: + info = get_fg_info()[linklabel] + except (AttributeError, KeyError) as e: + raise KeyError(f"`fg_info[{linklabel}]` is missing.") from e + try: + pix_fmt_in = info["pix_fmt"] + s_in = info["s"] + r_in = info["r"] + except KeyError as e: + raise KeyError( + f'`fg_info[{linklabel}]` is missing at least one of the required video attributes ("s", "pix_fmt", "r")' + ) from e else: - ifile = outmap_fields["input_file_id"] + # insert basic video filter if specified + # build_basic_vf(args, False, ofile) + + ifile = map_fields["input_file_id"] # get input option values - inopt_vals = utils.analyze_audio_stream( - outmap_fields["stream_specifier"], + r_in, pix_fmt_in, s_in = utils.analyze_video_stream( + map_fields["stream_specifier"], *args["inputs"][ifile], input_info[ifile], ) - # if a simple filter is present, use the stream specs of its output - if "af" in outopts or "filter:a" in outopts: - # create a source chain with matching specs and attach it to the af graph - af = temp_audio_src(*inopt_vals) - af = af + outopts.get("filter:a", outopts.get("af", None)) - inopt_vals = utils.analyze_audio_stream( - "0", af, {"f": "lavfi"}, {"src_type": "filtergraph"} + if (vf := (options.get("vf") or options.get("filter:v"))) or scaled_s: + # analyze output simple filter + r_in, pix_fmt_in, s_in = utils.analyze_output_video_filter( + vf, r_in, pix_fmt_in, s_in, s if scaled_s else None ) - opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] + # pixel format must be specified + if pix_fmt is None: + # use the analyzed value, falling back to 'rgb24' + if pix_fmt_in == "unknown": + raise FFmpegioError( + "input pixel format unknown. Please specify output pix_fmt" + ) - # assign the values to individual variables - ar, sample_fmt, ac = opt_vals + # deduce output pixel format from the input pixel format + pix_fmt, ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) + outopts["pix_fmt"] = pix_fmt - # sample format must be specified - if sample_fmt is None: - logger.warning( - 'Sample format of audio stream "%s" could not be retrieved. Uses "dbl".', - outmap, - ) - sample_fmt = outopts["sample_fmt"] = "dbl" - elif sample_fmt[-1] == "p": - # planar format is not supported - logger.warning( - "The audio stream %s uses a planar sample format '%s' which is not supported for audio data IO. Changed to %s.", - outmap, - sample_fmt, - sample_fmt[:-1], - ) - sample_fmt = sample_fmt[:-1] - outopts["sample_fmt"] = sample_fmt # set the format to non-planar + elif pix_fmt_in is None: + # make sure assigned pix_fmt is valid (shouldn't get here) + try: + dtype, ncomp = utils.get_pixel_format(pix_fmt) + except Exception as e: + raise FFmpegioError( + "could not resolve output pixel format. Please specify output `'pix_fmt'` option" + ) from e - # set output format and codec - outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) + if s is None: + s = s_in - # sample_fmt must be given - dtype, _ = utils.get_audio_format(sample_fmt, ac) + if r is None: + r = r_in - return dtype, ac and (ac,), ar + # get shape tuple if resolved + shape = (*s[::-1], ncomp) if s is not None and ncomp != 0 else None + raw_info = (dtype, shape, r) + # if any raw info is missing, return + if any(v is None for v in raw_info): + return raw_info, None -################################################################################ + # populate the rest of new option dict + outopts["f"] = "rawvideo" + + return raw_info, outopts -def get_option(ffmpeg_args, type, name, file_id=0, stream_type=None, stream_id=None): - """get ffmpeg option value from ffmpeg args dict - - :param ffmpeg_args: ffmpeg args dict - :type ffmpeg_args: dict - :param type: option type: 'video', 'audio', or 'global' - :type type: str - :param name: option name w/out stream specifier - :type name: str - :param file_id: index of target file, defaults to 0 - :type file_id: int, optional - :param stream_type: target stream type: 'v' or 'a', defaults to None - :type stream_type: str, optional - :param stream_id: target stream index (within specified stream type), defaults to None - :type stream_id: int, optional - :return: option value - :rtype: various - - If stream is specified, several option names are looked up till one is defined. For example, - 3 entries are checked for `name`='c', `stream_type`='v', and `stream_id`=0 in this order: - "c:v:0", "c:v", then "c". Function returns the first hit. +def gather_audio_read_opts( + options: FFmpegOptionDict, + skip_rate: bool = False, + args: FFmpegArgs | None = None, + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + get_fg_info: Callable[[], dict[str, FilterGraphInfoDict] | None] | None = None, + default_sample_fmt: str = "dbl", +) -> tuple[RawStreamInfoTuple, FFmpegOptionDict | None]: + """Gathering raw video read output options + + :param options: option dict for this output. To run input/fg analysis, it + must contain a `'map'` item. + :param skip_rate: True to skip requiring the frame rate information, defaults + to False + :param args: FFmpeg argument dict populated `inputs` and `global_options` + items or None to skip input & filtergraph analysis, defaults to + None to skip the analysis + :param input_info: list of input information, only required if `args` is given + :param get_fg_info: function to retrieve filtergraph output info if available. + :param default_sample_fmt: if the input sample format is incompatible, + force this format, defaults to 'dbl' + :return raw_info: audio shape tuple (nb_channels,) + :return additional_options: additional output options or None if `raw_info` + is not complete + + The output `sample_fmt` must be a raw-data compatible format (i.e., grayscales + and RGBs, and byte-aligned alternate formats). + + If `args is None`, `options` must contain items with `ac`, `sample_fmt`, and `ar` + (the latter only if `skip_rate=False`) to be successful. + + If `args` is provided, `options['map']` must be present. Also, if `options['map']` + is a link label, `fg_info` must be provided to be successful. + + The input/fg analysis code path may raise an exception if necessary information + is not provided. """ - if ffmpeg_args is None: - return None - names = [name] - if type.startswith("global"): - opts = ffmpeg_args.get("global_options", None) - else: - filelists = ffmpeg_args.get(f"{type}s", None) - if filelists is None: - return None - entry = filelists[file_id] - if entry is None: - return None - opts = entry[1] - if stream_type is not None: - name += f":{stream_type}" - names.append(name) - if stream_id is not None: - name += f":{stream_id}" - names.append(name) - if opts is None: - return None - - v = None - while v is None and len(names): - name = names.pop() - v = opts.get(name, None) - - return v - - -def merge_user_options(ffmpeg_args, type, user_options, file_index=None): - if type == "global": - type = "global_options" - opts = ffmpeg_args.get(type, None) - if opts is None: - opts = ffmpeg_args[type] = {**user_options} + + # required options + req_opts = ("sample_fmt", "ac", "ar") + + # TODO - support channel_layout/ch_layout options as stronger alternative to ac + + # use the output option by default + sample_fmt, ac, ar = [options.get(o, None) for o in req_opts] + + outopts = {} + + if ( + sample_fmt is None + or ac is None + or (not skip_rate and ar is None) + and args is not None + ): + # run input analysis + try: + map_spec = options["map"] + except KeyError as e: + raise FFmpegioError('`options["map"]` is missing') from e + map_fields = parse_map_option(map_spec, input_file_id=0) + + # get the options of the input/filtergraph output + if linklabel := map_fields.get("linklabel", None): + try: + info = get_fg_info()[linklabel] + except (AttributeError, KeyError) as e: + raise KeyError(f"`fg_info[{linklabel}]` is missing.") from e + try: + sample_fmt_in = info["sample_fmt"] + ac_in = info["ac"] + ar_in = info["ar"] + except KeyError as e: + raise KeyError( + f'`fg_info[{linklabel}]` is missing at least one of the required audio attributes ("ac", "sample_fmt", "ar")' + ) from e else: - ffmpeg_args[type] = {**opts, **user_options} + # insert basic video filter if specified + # build_basic_vf(args, False, ofile) + + ifile = map_fields["input_file_id"] + + # get input option values + ar_in, sample_fmt_in, ac_in = utils.analyze_audio_stream( + map_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) + + if af := (options.get("af") or options.get("filter:a")): + # analyze output simple filter + sample_fmt_in, ar_in, ac_in = utils.analyze_output_audio_filter( + af, ar_in, sample_fmt_in, ac_in + ) + + # sample format must be specified + if sample_fmt is None: + sample_fmt = sample_fmt_in or default_sample_fmt + + if ac is None: + ac = ac_in + + if ar is None: + ar = ar_in + + # planar format is not supported, convert to interleaved format + if sample_fmt[-1] == "p": + sample_fmt = sample_fmt[:-1] + outopts["sample_fmt"] = sample_fmt # set the format to non-planar + + # sample_fmt must be given + if sample_fmt is None: + dtype = None + shape = ac and (ac,) else: - type += "s" - filelist = ffmpeg_args.get(type, None) - if file_index is None: - file_index = 0 - if filelist is None or len(filelist) <= file_index: - raise Exception(f"{type} list does not have file #{file_index}") - url, opts = ffmpeg_args[type][file_index] - ffmpeg_args[type][file_index] = ( - url, - {**user_options} if opts is None else {**opts, **user_options}, - ) + dtype, shape = utils.get_audio_format(sample_fmt, ac) - return ffmpeg_args + # get shape tuple if resolved + raw_info = (dtype, shape, ar) + # if any raw info is missing, return + if any(v is None for v in raw_info): + return raw_info, None -def get_video_array_format(ffmpeg_args, type, file_id=0): - try: - opts = ffmpeg_args[f"{type}s"][file_id][1] - except: - raise ValueError(f"{type} file #{file_id} is not specified") - try: - dtype, ncomp = utils.get_pixel_format(opts["pix_fmt"]) - shape = [*opts["s"][::-1], ncomp] - except: - raise ValueError(f"{type} options must specify both `s` and `pix_fmt` options") + # set output format and codec + outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) + + return raw_info, outopts - return shape, dtype + +################################################################################ def move_global_options(args: FFmpegArgs) -> FFmpegArgs: @@ -705,98 +963,34 @@ def move_global_options(args: FFmpegArgs) -> FFmpegArgs: return args -def clear_loglevel(args: FFmpegArgs): - """clear global loglevel option - - :param args: FFmpeg argument dict - - """ - try: - del args["global_options"]["loglevel"] - logger.warn("loglevel option is cleared by ffmpegio") - except: - pass - - -def finalize_avi_read_opts(args): - """finalize multiple-input media reader setup - - :param args: FFmpeg dict - :type args: dict - :return: use_ya flag - True to expect grayscale+alpha pixel format rather than grayscale - :rtype: bool - - - assumes options dict of the first output is already present - - insert `pix_fmt='rgb24'` and `sample_fmt='sa16le'` options if these options are not assigned - - check for the use of both 'gray16le' and 'ya8', and returns True if need to use 'ya8' - - set f=avi and vcodec=rawvideo - - set acodecs according to sample_fmts - - """ - - # get output options, create new - options = args["outputs"][0][1] - - # check to make sure all pixel and sample formats are supported - gray16le = ya8 = 0 - for k in utils.find_stream_options(options, "pix_fmt"): - v = options[k] - if v in ("gray16le", "grayf32le"): - gray16le += 1 - elif v in ("ya8", "ya16le"): - ya8 += 1 - if gray16le and ya8: - raise ValueError( - "pix_fmts: grayscale with and without transparency cannot be mixed." - ) - - # if pix_fmt and sample_fmt not specified, set defaults - # user can conditionally override these by stream-specific option - if "pix_fmt" not in options: - options["pix_fmt"] = "rgb24" - if "sample_fmt" not in options: - options["sample_fmt"] = "s16" - - # add output formats and codecs - options["f"] = "avi" - options["c:v"] = "rawvideo" - - # add audio codec - for k in utils.find_stream_options(options, "sample_fmt"): - options["c:a" + k[10:]] = utils.get_audio_codec(options[k])[0] - - return ya8 > 0 - - -def config_input_fg(expr, args, kwargs): +def config_input_fg( + expr: str | FilterGraphObject, args: tuple, kwargs: dict +) -> tuple[str | fgb.Filter, float | None, dict]: """configure input filtergraph :param expr: filtergraph expression - :type expr: str :param args: input argument sequence, all arguments are intended to be used with the filter. Errors if expr yields a multi-filter filtergraph. - :type args: seq :param kwargs: input keyword argument dict. Only keys matching the filter's options are consumed. The rest are returned. - :type kwargs: dict - :return: original expression or a Filter object, duration in seconds if - known and finite, and unprocessed kwarg items. - :rtype: (str|Filter,float|None,dict) + :return expr: original expression or a Filter object + :return duration: duration in seconds if known and finite + :return kwargs: kwargs minus the filter options. """ - fg = fgb.Graph(expr) + fg = fgb.as_filtergraph_object(expr) dopt = None # duration option - if len(fg) != 1 or len(fg[0]) != 1: + if not isinstance(fg, fgb.Filter): # multi-filter input filtergraph, cannot take arguments if len(args): raise FFmpegioError( "filtergraph input expresion cannot take ordered options." ) - return expr, dopt, kwargs + return fg, dopt, kwargs # single-filter graph, can apply its options given in the arguments - f = fg[0][0] + f = fg info = f.info if info.inputs is None or len(info.inputs) > 0: raise FFmpegioError(f"{f.name} filter is not a source filter") @@ -809,7 +1003,7 @@ def config_input_fg(expr, args, kwargs): opts.add(o.name) opts.update(o.aliases) - # split filter named option andn other keyword arguments + # split filter named option and other keyword arguments fargs = {i: v for i, v in enumerate(args)} oargs = {} for k, v in kwargs.items(): @@ -833,221 +1027,208 @@ def config_input_fg(expr, args, kwargs): return f.apply(fargs), dopt, oargs -def add_urls( - ffmpeg_args: FFmpegArgs, - url_type: UrlType, - urls: ( - str - | tuple[str, FFmpegOptionDict] - | Sequence[str | tuple[str, FFmpegOptionDict]] - ), - *, - update: bool = False, -) -> list[tuple[int, FFmpegInputOptionTuple | FFmpegOutputOptionTuple]]: - """add one or more urls to the input or output list at once +class RawInputCallablesDict(TypedDict): + data2bytes: ToBytesCallable + data_count: CountDataCallable + data_is_empty: IsEmptyCallable - :param args: ffmpeg arg dict (modified in place) - :param url_type: input or output - :param urls: a sequence of urls (and optional dict of their options) - :param opts: FFmpeg options associated with the url, defaults to None - :param update: True to update existing input of the same url, default to False - :return: list of file indices and their entries - """ - 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 - ) - ) +class RawOutputCallablesDict(TypedDict): + bytes2data: FromBytesCallable + data_count: CountDataCallable + data_is_empty: IsEmptyCallable + + +def get_raw_output_plugin_callables( + media_type: MediaType, +) -> RawOutputCallablesDict: + """get three raw output plugin callbacks""" + hook = plugins.pm.hook + is_empty = cast(IsEmptyCallable, hook.is_empty) + if media_type == "audio": + return { + "bytes2data": cast(FromBytesCallable, hook.bytes_to_audio), + "data_count": cast(CountDataCallable, hook.audio_samples), + "data_is_empty": is_empty, + } - ret = process_one(urls) - return [process_one(url) for url in urls] if ret is None else [ret] + else: + return { + "bytes2data": cast(FromBytesCallable, hook.bytes_to_video), + "data_count": cast(CountDataCallable, hook.video_frames), + "data_is_empty": is_empty, + } -def add_filtergraph( +def resolve_raw_output_streams( + stream_opts: list[FFmpegOptionDict], args: FFmpegArgs, - filtergraph: fgb.Graph, - map: Sequence[str] | None = None, - automap: bool = True, - append_filter: bool = True, - append_map: bool = True, - ofile: int = 0, -): - """add a complex filtergraph to FFmpeg arguments + input_info: list[RawInputInfoDict | EncodedInputInfoDict], +) -> tuple[list[FFmpegOptionDict], list[dict]]: + """resolve the raw output streams from given sequence of map options + + :param stream_opts: output raw stream options + :param args: FFmpeg argument dict + :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` + :return: list of individual output streams. Each item is a tuple of + (stream_index, output_opts, partial_RawOutputInfoDict) + + -stream_index - index of streams + -map_spec - final output option + -partial_RawOutputInfoDict - to-be-completed output_info entry - :param args: FFmpeg argument dict (to be modified in place) - :param filtergraph: Filtergraph to be added to the FFmpeg arguments - :param map: output stream mapping, usually the output pad label, defaults to None - :param automap: True to auto map all the output pads of the filtergraph IF `map` is None, defaults to True. - :param append_filter: True to append `filtergraph` to the `filter_complex` global option if exists, False to replace, defaults to True - :param append_map: True to append `map` to the `map` output option if exists, False to replace, defaults to True - :param ofile: output file id, defaults to 0 + Since a map option value may yield multiple media streams (e.g., '0' or '0:v'), + the length of returned outputs may be longer than the number of streams given. + The user specified map value is returned in the 'user_label' field of the returned + dicts while the """ - if len(args["outputs"]) <= ofile: - raise ValueError( - f"The specified output #{ofile} is not defined in the FFmpegArgs dict." - ) + # parse all mapping option values + input_file_id = 0 if len(input_info) == 1 else None - if automap and map is None: - map = [f"[{l[0]}]" for l in filtergraph.iter_output_labels()] + inputs = args["inputs"] - # add the merging filter graph to the filter_complex argument - gopts = args.get("global_options", None) + output_opts = [] + output_info = [] + for i, opts in enumerate(stream_opts): + spec = opts["map"] - if append_filter: - complex_filters = None if gopts is None else gopts.get("filter_complex", None) - if complex_filters is None: - complex_filters = filtergraph - else: - complex_filters = as_multi_option( - complex_filters, (str, fgb.Graph, fgb.Chain) - ) - complex_filters.append(filtergraph) - else: - complex_filters = filtergraph - - if gopts is None: - args["global_options"] = {"filter_complex": complex_filters} - else: - gopts["filter_complex"] = complex_filters - - if not len(map): - # nothing to map - return - - outopts = args["outputs"][ofile][1] - if outopts is None: - args["outputs"][ofile] = (args["outputs"][ofile][0], {"map": map}) - else: - if append_map and "map" in outopts: - existing_map = outopts["map"] - - # remove merged streams from output map & append the output stream of the filter - map = ( - [*existing_map, *map] - if is_non_str_sequence(existing_map) - else [existing_map, *map] - ) - - outopts["map"] = map - - -def resolve_raw_output_streams( - args: FFmpegArgs, - input_info: list[InputSourceDict], - fg_info: dict[str, dict] | None, - streams: dict[str, str | None], -) -> dict[str, OutputDestinationDict]: - """resolve the raw output streams from given sequence of map options - - :param args: FFmpeg argument dict - :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` - :param streams: FFmpeg -map option values of output streams as their keys and - their custom names as the values. To use the map value as - the stream names, specify None as a value. - :return: output information keyed by a unique map option string - """ - - dst_type = "buffer" - - # parse all mapping option values - input_file_id = 0 if len(input_info) == 1 else None - - def parse_map(spec): try: - return parse_map_option( - spec, parse_stream=True, input_file_id=input_file_id - ) - except: - if fg_info is not None and (linklabel := f"[{spec}]") in fg_info: - return {"linklabel": linklabel, "user_label": spec} - raise + opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + except ValueError: + # incorrect spec if there is no complex filter in place + if not utils.find_filter_complex_option(args["global_options"]): + raise - map_options = [ - {"stream_specifier": {}, **opt} for opt in (parse_map(spec) for spec in streams) - ] + # test spec with possibly omitted brackets + spec = f"[{spec}]" + opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + opts["map"] = spec - inputs = args["inputs"] - stream_info = {} # one stream per item, value: map spec & media_type - for (spec, user_map), opt in zip(streams.items(), map_options): # get output stream information - if (fg_info and (info := fg_info.get(spec, None))) is not None: - # filtergraph output - stream_info[spec] = { - "dst_type": dst_type, - "user_map": user_map or spec, - "media_type": info["media_type"], - "input_file_id": None, - "input_stream_id": None, - "linklabel": spec, - } - elif ( - "index" in opt["stream_specifier"] - and (opt["stream_specifier"].get("stream_type", None) or "") in "avV" - ): - # specific input stream with known media type + if "linklabel" in opt: + # case 1: complex filtergraph requires only its outputs to be used + # link labels are unique, so each entry is guaranteed to be + # only associated with one label. + + output_opts.append(opts) + output_info.append( + { + "user_map": spec[1:-1], + "linklabel": opt["linklabel"], + } + ) + else: + if "negative" in opt: + raise ValueError("negative map is not supported.") + file_index = opt["input_file_id"] - info = input_info[file_index] stream_spec = opt["stream_specifier"] - media_type = is_unique_stream(stream_spec, return_media_type=True) - if isinstance(media_type, str): - # stream specified by media type (stream index not known, but may not be needed) - stream_data = [(None, media_type)] - else: - stream_data = retrieve_input_stream_ids( - info, *inputs[file_index], stream_spec=stream_spec - ) - unique_stream = len(stream_data) == 1 - for stream_index, media_type in stream_data: - stream_info[ - (spec if unique_stream else f"{file_index}:{stream_index}") - ] = { - "dst_type": dst_type, - "user_map": user_map or spec, - "media_type": media_type, - "input_file_id": file_index, - "input_stream_id": stream_index, - } - else: - # posibly multiple streams - for spec, opt in zip(streams, map_options): - stream_info[spec] = { - compose_map_option(**opt): { - "dst_type": dst_type, + # retrieve input stream data + if "index" in stream_spec and "stream_type" in stream_spec: + # case 2: specific input stream with known media type + output_opts.append(opts) + output_info.append( + { "user_map": spec, "media_type": stream_type_to_media_type( - opt["stream_specifier"].get("stream_type", None) + stream_spec["stream_type"] ), - "input_file_id": opt["input_file_id"], - "input_stream_id": None, + "input_file_id": file_index, + "input_stream_id": -1, # unknown and don't care } - } - return stream_info + ) + else: + # case 3: generic stream spec, possibly resultsing in multiple output streams + url, opts = inputs[file_index] + for stream_index, stream_spec in utils.input_file_stream_specs( + url, stream_spec, opts or {}, input_info[file_index] + ).items(): + # append all streams + spec = f"{file_index}:{stream_index}" + output_opts.append({**opts, "map": spec}) + output_info.append( + { + "user_map": spec, + "media_type": "audio" if stream_spec[0] == "a" else "video", + "input_file_id": file_index, + "input_stream_id": stream_index, + }, + ) + + # resolve duplicate user_map values + name_counts = Counter((v["user_map"] for v in output_info)) + + if any(v <= 1 for v in name_counts.values()): + return output_opts, output_info + + # create alt names in case {name}:{i} naming convention yields existing name + # e.g., 'v' vs. 'v:0' with 'v' resulting in multiples streams + + # first make sure alt name won't interfere with existing streams + aliases = [] + alias_bases = {} + for k, cnt in name_counts.items(): + if cnt <= 1: + continue + need_alias = False + use_alias = None + for i in count(): + alias = f"{k}:{i}" + if ( + alias in name_counts + ): # already used, cannot be used as stream name nor alias name + need_alias = True + if use_alias is None: + continue + else: + break + elif alias not in aliases and use_alias is None: + use_alias = alias + + if i >= cnt and (not need_alias or use_alias): + # must count past # of stream with this user_name + # continue counting until usable alias is found + break + + if need_alias: + aliases.append(use_alias) + alias_bases[k] = use_alias + + # keep renaming counter to avoid duplicate names + name_counter = {k: 0 for k in name_counts} + + # rename duplicate user_map's + for info in output_info: + user_map = info["user_map"] + if name_counter[user_map] > 1: + alt_base = alias_bases.get(user_map, user_map) + info["user_map"] = f"{alt_base}:{name_counter[user_map]}" + name_counter[user_map] += 1 + + return output_opts, output_info def auto_map( - args: FFmpegArgs, input_info: list[InputSourceDict], fg_info: dict[str, dict] | None -) -> dict[str, OutputDestinationDict]: + args: FFmpegArgs, + options: FFmpegOptionDict, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + fg_info: dict[str, FilterGraphInfoDict] | None, +) -> tuple[list[FFmpegOptionDict], list[dict[str, Any]]]: """list all available streams from all FFmpeg input sources + This function complements `resolve_raw_output_streams()` + :param args: FFmpeg argument dict. `filter_complex` argument may be modified. + :param options: FFmpeg output options to be applied to every output :param input_info: a list of input data source information - :param fg_info: list of filtergraph outputs or None if complex fitlergraph is - not specified - :return: a map of input/filtergraph output labels and their stream information. + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels or None if args does not contain any + complex filtergraph + :return stream_opts: a list of FFmpeg output options + :return stream_info: partial raw output info Mapping Input Streams vs. Complex Filtergraph Outputs ----------------------------------------------------- @@ -1058,54 +1239,38 @@ def auto_map( """ - if fg_info is None and "filter_complex" in args["global_options"]: - # if filter_complex is specified but no fg_info - # run the analysis - gopts = args["global_options"] - if "filter_complex" in gopts: - gopts["filter_complex"], fg_info = ( - utils.analyze_complex_filtergraphs( - gopts["filter_complex"], args["inputs"], input_info + stream_opts = [] + stream_info = [] + + if fg_info is None: + # if no filtergraph, get all video & audio streams from all the input urls + for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)): + for j, st_spec in utils.input_file_stream_specs( + url, None, opts or {}, info + ).items(): + spec = f"{i}:{st_spec}" + stream_opts.append({**options, "map": spec}) + stream_info.append( + { + "user_map": spec, + "media_type": "audio" if st_spec[0] == "a" else "video", + "input_file_id": i, + "input_stream_id": j, + } ) - if "filter_complex" in gopts - else None + else: + # return all filtergraph outputs + for linklabel, info in fg_info.items(): + stream_opts.append({**options, "map": linklabel}) + stream_info.append( + { + "user_map": linklabel[1:-1], + "media_type": info["media_type"], + "linklabel": linklabel, + } ) - else: - fg_info = None - if fg_info is not None: - return { - linklabel: { - "dst_type": "buffer", - "user_map": linklabel[1:-1], - "media_type": info["media_type"], - "linklabel": linklabel, - } - for linklabel, info in fg_info.items() - } - - counter = {"file": None, "audio": 0, "video": 0} - - def next_map_option(i, media_type): - if i != counter["file"]: - counter["audio"] = counter["video"] = 0 - counter["file"] = i - j = counter[media_type] - counter[media_type] = j + 1 - return f"{i}:{media_type[0]}:{j}" - - # if no filtergraph, get all video & audio streams from all the input urls - return { - (spec := next_map_option(i, media_type)): { - "dst_type": "buffer", - "user_map": spec, - "media_type": media_type, - "input_file_id": i, - "input_stream_id": j, - } - for i, ((url, opts), info) in enumerate(zip(args["inputs"], input_info)) - for j, media_type in retrieve_input_stream_ids(info, url, opts or {}) - } + return stream_opts, stream_info def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: @@ -1191,26 +1356,36 @@ def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: return map +################################################################################ + + def process_url_inputs( args: FFmpegArgs, - urls: list[ + urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] ], inopts_default: FFmpegOptionDict, no_pipe: bool = False, -) -> list[InputSourceDict]: - """analyze and process heterogeneous input url argument +) -> list[EncodedInputInfoDict]: + """analyze and process heterogeneous (encoded) input url argument :param args: FFmpeg argument dict, `args['inputs']` receives all the new inputs. If input is a buffer, a fileobj, or an FFconcat, the first element of the FFmpeg inputs entry is set to 'None', to be replaced by a pipe expression. - :param urls: list of input urls/data or a pair of input url and its options + :param urls: input urls/data or a pair of input url and its options or a list thereof :param inopts_default: default input options :param no_pipe: True to raise exception if an input is piped without data buffer, defaults to False :return: list of input information """ + urls = [urls] if utils.is_valid_input_url(urls) or isinstance(urls, tuple) else urls + + if len(urls) == 0: + raise FFmpegioError("At least one URL must be given.") + input_info_list = [None] * len(urls) for i, url in enumerate(urls): # add inputs # get the option dict @@ -1239,8 +1414,8 @@ def process_url_inputs( input_info = {"src_type": "filtergraph"} elif utils.is_fileobj(url, readable=True): - if not url.seekable(): - raise FFmpegioNoPipeAllowed("Fileobj input must be seekable.") + # if not url.seekable(): + # raise FFmpegioNoPipeAllowed("Fileobj input must be seekable.") input_info = {"src_type": "fileobj", "fileobj": url} url = None elif utils.is_pipe(url): @@ -1282,155 +1457,201 @@ def process_url_inputs( def process_raw_outputs( args: FFmpegArgs, - input_info: list[InputSourceDict], - streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, options: FFmpegOptionDict, - fg_info: dict[str, dict] | None = None, -) -> tuple[list[OutputDestinationDict], dict[str, dict] | None]: + squeeze: bool, +) -> list[OutputInfoDict]: """analyze and process piped raw outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is appended for each piped output. Output URLs are left `None`. :param input_info: list of input information (same length as `args['inputs']) - :param streams: user's list of map options to be included + :param streams: output stream mappings: + + - `None` to include all input streams OR all filtergraph outputs + - a sequence of either a map option or an output ffmpeg option + dict with `'map'` item + :param options: default output options - :param fg_info: filtergraph outputs if filtergraph has been pre-analyzed, - defaults to None to perform the filtergraph analysis internally + :param squeeze: True to remove shape dimensions with length 1 :return output_info: list of output information - :return fg_info: dict of filtergraph outputs, keyed by their linklabels + """ + if isinstance(streams, (str, dict)): + streams = [streams] + gopts = args["global_options"] - # only analyze the filtergraph again if it has not been pre-analyzed - if fg_info is None and "filter_complex" in gopts: - gopts["filter_complex"], fg_info = ( - utils.analyze_complex_filtergraphs( - gopts["filter_complex"], args["inputs"], input_info + # on-demand complex filtergraph analysis + @cache + def get_fg_info() -> dict[str, FilterGraphInfoDict] | None: + """:returns fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + """ + + optname = utils.find_filter_complex_option(gopts) + + if optname is None: + return None + + if optname in ("/filter_complex", "/lavfi", "filter_complex_script"): + raise NotImplementedError( + "filtergraph on a file is not yet supported. All output video streams must have `r`, `s`, and `pix_fmt` options defined." + "Likewise, all output audio streams mjust have `ar`, `ac`, and `sample_fmt` options defined." ) - if "filter_complex" in gopts - else None + + gopts[optname], fg_info = utils.analyze_complex_filtergraphs( + gopts[optname], args["inputs"], input_info ) + return fg_info # resolve requested output streams - stream_info: dict[str, OutputDestinationDict] - if streams is None or len(streams) == 0: - stream_info = auto_map(args, input_info, fg_info) - stream_maps = {st: options for st in stream_info} + stream_opts: list[FFmpegOptionDict] + stream_info: list[dict[str, Any]] # partial RawOutputInfoDict + if (streams is None or len(streams) == 0) and "map" not in options: + # gather all available streams keyed by their map specifier + stream_opts, stream_info = auto_map(args, options, input_info, get_fg_info()) else: - # add outputs to FFmpeg arguments - get_opts = isinstance(streams, dict) - - # analyze for custom labels - user_maps = {} - stream_maps = {} - for k, v in streams.items() if get_opts else ((s, None) for s in streams): - if isinstance(k, tuple): - k = ":".join(str(s) for s in k) - - # add default options (if given) - v = {**options} if v is None else {**options, **v} - - if "map" in v: - st_map = v["map"] - if not isinstance(st_map, str): - raise FFmpegioError( - "Only one FFmpeg map is allowable for filtering operation." - ) - user_maps[st_map] = k - elif fg_info is not None and (st_map := f"[{k}]") in fg_info: - # filtergraph linklabel without bracket given - user_maps[st_map] = k - else: - # an input stream specifier or a filtergraph output linklabel - user_maps[k], st_map = k, k - stream_maps[st_map] = v + if streams is None: + stream_opts = [options] + else: + stream_opts = [ + {**options, **({"map": v} if isinstance(v, str) else v)} + for v in streams + ] + + # expand all streams (targetting ) + stream_opts, stream_info = resolve_raw_output_streams( + stream_opts, args, input_info + ) + + # finalize the output configuration + + @cache + def get_callables(media_type): + return get_raw_output_plugin_callables(media_type) - # automatically map all the streams - stream_info = resolve_raw_output_streams(args, input_info, fg_info, user_maps) + for opts, info in zip(stream_opts, stream_info): + media_type = info.get("media_type", None) - # add outputs to FFmpeg arguments - for spec, info in stream_info.items(): - opts = {**stream_maps[spec], "map": spec} - add_url(args, "output", None, opts) + # if media_type is unknown (must be a linklabel not yet analyzed) + if media_type is None: + fg_info = get_fg_info() + pad_info = fg_info[info["linklabel"]] + info["media_type"] = media_type = pad_info["media_type"] + + # add outputs to FFmpeg arguments - # finalize each output streams and identify the output formats - for i, (_, info) in enumerate(stream_info.items()): # append raw_info key to the output info dict - info["raw_info"] = ( - finalize_audio_read_opts - if info["media_type"] == "audio" - else finalize_video_read_opts - )(args, i, input_info, fg_info) + gather_media_read_opts = ( + gather_audio_read_opts if media_type == "audio" else gather_video_read_opts + ) + + raw_info, more_opts = gather_media_read_opts( + opts, False, args, input_info, get_fg_info + ) + + if more_opts is None: + raise FFmpegioError( + f'failed to retrieve raw data information for the stream "{info["user_map"]}"' + ) - return list(stream_info.values()), fg_info + info["dst_type"] = "buffer" + info["raw_info"] = raw_info + info["item_size"] = utils.get_samplesize(*raw_info[1::-1]) + + info["squeeze"] = squeeze + info.update(get_callables(info["media_type"])) + + # finalize each output streams and identify the output formats + add_url(args, "output", None, {**opts, **more_opts}) + + return stream_info def process_raw_inputs( args: FFmpegArgs, - stream_types: Sequence[Literal["a", "v"]], - stream_args: Sequence[RawStreamDef], - inopts_default: FFmpegOptionDict, - dtypes: list[DTypeString] | None = None, - shapes: list[ShapeTuple] | None = None, -) -> list[InputSourceDict]: + stream_options: Sequence[FFmpegOptionDict], + default_options: FFmpegOptionDict, + data: Sequence[RawDataBlob | None] | None = None, + dtypes: list[DTypeString | None] | None = None, + shapes: list[ShapeTuple | None] | None = None, +) -> list[RawInputInfoDict]: + """configure input raw media streams + + :param args: FFmpeg argument dict (to be modified) + :param stream_options: per-stream dict of FFmpeg input options + :param default_options: dict of FFmpeg input options applied to all streams + :param data: per-stream data blob to be written when ffmpeg starts, defaults + to data + :param dtypes: per-stream data types (numpy dtype string), defaults to + auto-detect + :param shapes: per-stream data shapes, defaults to auto-detect + :return: a list of dict containing the provided info + """ - input_info: list[InputSourceDict] = [] - for i, (mtype, arg) in enumerate(zip(stream_types, stream_args)): - try: - a1, a2 = arg - if isinstance(a1, (int, float, Fraction)): - data = a2 - if mtype == "a": - opts = {"ar": round(a1)} - elif mtype == "v": - opts = {"r": a1} - else: - raise FFmpegioError( - "stream_type not specified, cannot resolve the `rate` input." - ) - else: - assert isinstance(a2, dict) - if mtype not in "av": # unknown - if "ar" in opts: - mtype = "a" - elif "r" in opts: - mtype = "v" - else: - raise FFmpegioError("unknown input stream media type") - data, opts = a1, a2 - except FFmpegioError: - raise - except: - raise ValueError( - f"""Invalid raw stream definition: {arg}.\nEach item of `stream_args` must be a two-element tuple: - - a rate (numeric) and a data_blob - - a data_blob and a dict of options - """ - ) + @cache + def get_callables(media_type: MediaType) -> RawInputCallablesDict: + hook = plugins.pm.hook + return ( + { + "data2bytes": cast(ToBytesCallable, hook.audio_bytes), + "data_is_empty": cast(IsEmptyCallable, hook.is_empty), + "data_count": cast(CountDataCallable, hook.audio_samples), + } + if media_type == "audio" + else { + "data2bytes": cast(ToBytesCallable, hook.video_bytes), + "data_is_empty": cast(IsEmptyCallable, hook.is_empty), + "data_count": cast(CountDataCallable, hook.video_frames), + } + ) + + nstreams = len(stream_options) + none_list = [None] * nstreams + input_info: list[RawInputInfoDict] = [] - opts = {**inopts_default, **opts} + for opts, blob, dtype, shape in zip( + stream_options, + none_list if data is None else data, + none_list if dtypes is None else dtypes, + none_list if shapes is None else shapes, + ): + # combine the default & per-stream options + opts = {**default_options, **opts} + mtype = "v" if "r" in opts else "a" more_opts = None - raw_info = None + shape_dtype = None if mtype == "a": # audio + if "r" in opts or "ar" not in opts: + raise ValueError( + "audio stream option dict must contain 'ar' option and must not contain 'r' option." + ) media_type = "audio" - if data is not None: - more_opts, raw_info = utils.array_to_audio_options(data) - data = plugins.get_hook().audio_bytes(obj=data) + opts["ar"] = rate = round(opts["ar"]) # force int sampling rate + if blob is not None: + more_opts, shape_dtype = utils.array_to_audio_options(blob) - elif dtypes and shapes: - raw_info = (shapes[i], dtypes[i]) - sample_fmt, ac = utils.guess_audio_format(shapes[i], dtypes[i]) + elif dtypes and shapes and shape is not None and dtype is not None: + shape_dtype = (shape, dtype) + sample_fmt, ac = utils.guess_audio_format(shape, dtype) acodec, f = utils.get_audio_codec(sample_fmt) more_opts = {"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f} else: # video + if "ar" in opts: + raise ValueError( + "video stream option dict must not contain 'ar' option." + ) media_type = "video" - if data is not None: - more_opts, raw_info = utils.array_to_video_options(data) - data = plugins.get_hook().video_bytes(obj=data) - elif dtypes and shapes: - raw_info = shapes[i], dtypes[i] + rate = opts["r"] + if blob is not None: + more_opts, shape_dtype = utils.array_to_video_options(blob) + elif dtype and shape: + shape_dtype = (shape, dtype) pix_fmt, s = utils.guess_video_format(*raw_info) more_opts = { "f": "rawvideo", @@ -1438,70 +1659,49 @@ def process_raw_inputs( "pix_fmt": pix_fmt, "s": s, } + + if shape_dtype is None: + raise FFmpegioInsufficientInputData( + "Both input_dtypes and input_shapes must be defined for all raw input streams." + ) + + raw_info = (*shape_dtype, rate) + if more_opts is not None: opts.update(more_opts) - info = {"src_type": "buffer", "media_type": media_type} - if raw_info is not None: - info["raw_info"] = raw_info + info = { + "src_type": "buffer", + "media_type": media_type, + "raw_info": (*raw_info, rate), + "item_size": utils.get_samplesize(*raw_info[:-1]), + **get_callables(media_type), + } if data is not None: - info["buffer"] = data + info["buffer"] = info["data2bytes"](obj=blob) + add_url(args, "input", None, opts) input_info.append(info) return input_info -def update_raw_input( - args: FFmpegArgs, - input_info: list[InputSourceDict], - stream_id: int, - data: RawDataBlob, -): - """update raw input stream from the data blob - - :param args: FFmpeg arguments to be modified - :param input_info: FFmpeg input information - :param stream_id: index of the input stream to be updated - :param data: input data blob - - * updates `args['inputs'][stream_id][1]` dict - * updates `raw_info` field of ``input_info[stream_id]` dict - - """ - - opts = args["inputs"][stream_id][1] - info = input_info[stream_id] - is_audio = info["media_type"] == "audio" - rate = opts["ar" if is_audio else "r"] - more_opts, raw_info = ( - utils.array_to_audio_options(data) - if is_audio - else utils.array_to_video_options(data) - ) - - opts.update(more_opts) - info["raw_info"] = (*raw_info[::-1], rate) # dtype, shape, rate - - def process_url_outputs( args: FFmpegArgs, - input_info: list[InputSourceDict], - urls: list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ], + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], options: FFmpegOptionDict, skip_automapping: bool = False, no_pipe: bool = False, -) -> tuple[list[OutputDestinationDict], FFmpegOptionDict | None]: +) -> list[EncodedOutputInfoDict]: """analyze and process url outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is appended for each piped output. Output URLs are left `None`. :param input_info: list of input information (same length as `args['inputs']) - :param fg_info: list of filtergraph outputs or None if complex fitlergraph is - not specified :param urls: output file names and optionally with file-specific options :param options: default output options. If `"map"` option is given, it is appended to the per-file `"map"` option in `streams` argument @@ -1510,9 +1710,15 @@ def process_url_outputs( :param no_pipe: True to raise exception if output is piped without data buffer, defaults to False :return output_info: list of output information - :return fg_info: dict of filtergraph outputs, keyed by their linklabels """ + urls = ( + [urls] if utils.is_valid_output_url(urls) or isinstance(urls, tuple) else urls + ) + + if len(urls) == 0: + raise FFmpegioError("At least one URL must be given.") + missing_map = False output_info_list = [None] * len(urls) for i, url in enumerate(urls): # add inputs @@ -1554,8 +1760,15 @@ def process_url_outputs( if missing_map and not skip_automapping: # some output file is missing `map` option # add all input streams or all complex filter outputs - map_opts = [*auto_map(args, input_info, None)] + fgname = find_filtergraph_option(args) + if fgname is None: + out_opts, _ = auto_map(args, options, input_info, None) + map_opts = [o["map"] for o in out_opts] + else: + # get filtergraph + fg = fgb.as_filtergraph(args["global_options"][fgname]) + map_opts = [label for label in fg.iter_output_labels()] # add outputs to FFmpeg arguments for _, opts in args["outputs"]: if "map" not in opts: @@ -1584,812 +1797,299 @@ def assign_output_url(args: FFmpegArgs, ofile: int, url: str): args["outputs"][ofile] = (url, args["outputs"][ofile][1]) -def retrieve_input_stream_ids( - info: InputSourceDict, - url: FFmpegUrlType | FilterGraphObject | None, - opts: FFmpegOptionDict, - stream_spec: str | StreamSpecDict | None = None, -) -> list[tuple[int, MediaType]]: - """Retrieve ids and media types of streams in an input source - - :param info: input file source information - :param url: URL or local file path of the input media file/device. None if data is provided via pipe - and data is in the `info` argument - :param opts: FFmpeg input options - :param stream_spec: Specify streams to return - :return: A list of indices and media types of the input streams. - Maybe empty if failed to probe the media (e.g., data inaccessible - or in an ffprobe incompatible format, e.g., ffconcat) +######################################## + + +def assign_output_pipes( + args: FFmpegArgs, + output_info: list[OutputInfoDict], + use_std_pipes: bool = False, +) -> tuple[dict[int, OutputPipeInfoDict], dict]: + """initialize pipes for write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) + :param use_std_pipes: True to assign the first piped output to stdout + :param sp_kwargs: the subprocess.Popen keyword arguments for stdout pipe + :returns pipe_info: output named pipes and their writer threads keyed by output_info index + :returns sp_kwargs: subprocess keywords with `stdout` if `use_std_pipes=True` + and there is at least one piped output """ - # check raw formats first - if info["src_type"] == "buffer" and "buffer" not in info: - # raw input real-time stream - return [[0, info["media_type"]]] - - # file/network input - process only if seekable - # get ffprobe subprocess keywords - url, sp_kwargs, exit_fcn = utils.set_sp_kwargs_stdin(url, info) - if sp_kwargs is None: - # something failed (warning logged) - return [] - - def get_spec(info, opts): - # run ffprobe - return probe.streams_basic( - url, - f=opts.get("f", None), - sp_kwargs=sp_kwargs, - stream_spec=( - compose_stream_spec(**stream_spec) - if isinstance(stream_spec, dict) - else stream_spec - ), - ) + pipe_info: dict[int, OutputPipeInfoDict] = {} + sp_kwargs = {} - # get the stream list if ffprobe can - try: - stream_ids = [ - (info["index"], info["codec_type"]) - for info in get_spec(info, opts) - if info["codec_type"] in get_args(MediaType) - ] - except: - # if failed, return empty - logger.warning("ffprobe failed.") - stream_ids = [] - finally: - # clean-up - exit_fcn() - return stream_ids + if output_info is None: + return sp_kwargs, sp_kwargs + # configure output pipes + use_stdout = False + has_pipeout = False -def init_media_read( - urls: list[ - FFmpegInputUrlComposite - | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] - ], - map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, - options: FFmpegOptionDict, -) -> tuple[FFmpegArgs, list[InputSourceDict], list[OutputDestinationDict]]: - """Initialize FFmpeg arguments for media read + for i, (info, arg) in enumerate(zip(output_info, args["outputs"])): + if arg[0]: + # url already configured + continue - :param *urls: URLs of the media files to read. - :param map: output stream mappings: - - `None` to include all input streams OR all filtergraph outputs - - a sequence of str to specify stream specifiers with file id's - - a dict with stream specifier keys to specify output options - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - :return ffmpeg_args: FFmpeg argument dict (partial) - :return input_info: input stream information - :return input_ready: Element is True if corresponding input is ready (known dtype and shape) - :return output_info: output stream information, None if outputs not initialized - :return output_options: output options, None if outputs already initialized + has_pipeout = True + if use_std_pipes and not use_stdout: + use_stdout = True + pipe_path = "pipe:1" - Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run - `ffmpegio.video.read()` for each url. + dst_type = info["dst_type"] + if dst_type == "fileobj": + assert "fileobj" in info + sp_kwargs["stdout"] = info["fileobj"] + elif dst_type == "buffer": + sp_kwargs["stdout"] = fp.PIPE + pipe_info[i] = {"pipe": "stdout"} + else: + # if fileobj or buffer output, use pipe + pipe = NPopen("r", bufsize=0) + pipe_path = pipe.path + pipe_info[i] = {"pipe": pipe} + assign_output_url(args, i, pipe_path) - Specify the streams to return by `map` output option: + if has_pipeout: + # if any output is piped, must run in the overwrite mode + args["global_options"].pop("n", None) + args["global_options"]["y"] = None - map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream + return pipe_info, sp_kwargs - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. - For audio streams, if 'sample_fmt' output option is not specified, 's16'. +def assign_input_pipes( + args: FFmpegArgs, + input_info: list[InputInfoDict], + use_std_pipes: bool = False, + set_sp_kwargs_input: bool = False, +) -> tuple[dict[int, InputPipeInfoDict], dict]: + """initialize named pipes for write operations with FFmpeg + + :param args: FFmpeg option arguments (modified) + :param input_info: FFmpeg input information, its length matches that of `args['inputs']` (modified) + :param use_std_pipes: True to assign the first piped output to stdout + :param set_sp_kwargs_input: True to assign 'input' instead of 'stdin' for sp_kwargs + :returns pipe_info: input pipe information keyed by the indices of the + `input_info` entries with named pipe + :returns sp_kwargs: Specify the subprocess.Popen keyword arguments for stdin related arguments + """ - ninputs = len(urls) - if not ninputs: - raise ValueError("At least one URL must be given.") + pipe_info = {} + sp_kwargs = {} - if "n" in options: - raise ValueError("Cannot have an `n` option set to output to named pipes.") + if input_info is None: + return pipe_info, sp_kwargs - # separate the options - inopts_default = utils.pop_extra_options(options, "_in") + # configure input pipes + use_stdin = False - # create a new FFmpeg dict - args = empty(utils.pop_global_options(options)) - gopts = args["global_options"] # global options dict - gopts["y"] = None + # configure input pipes (if needed) + for i, (info, arg) in enumerate(zip(input_info, args["inputs"])): + if arg[0]: + # url already configured + continue - # analyze and assign inputs - input_info = process_url_inputs(args, urls, inopts_default) + if use_std_pipes and not use_stdin: + use_stdin = True + pipe_path = "pipe:0" + + src_type = info["src_type"] + if src_type == "fileobj": + assert "fileobj" in info + sp_kwargs["stdin"] = info["fileobj"] + elif src_type == "buffer": + if set_sp_kwargs_input and "buffer" in info: + # given data to send to subprocess + sp_kwargs["input"] = info["buffer"] + else: + sp_kwargs["stdin"] = fp.PIPE + pipe_info[i] = {"pipe": "stdin"} + else: + pipe = NPopen("w", bufsize=0) + pipe_path = pipe.path + pipe_info[i] = {"pipe": pipe} + assign_input_url(args, i, pipe_path) - # make sure all inputs are complete - ready = utils.are_input_pipes_ready(args["inputs"], input_info, must_probe=True) + return pipe_info, sp_kwargs - # add the default output options to output_options dict with None as the key - output_options = (map, options) - if all(ready): - output_info = init_media_read_outputs(args, input_info, output_options) - output_options = None - else: - output_info = None +def init_named_pipes( + inpipe_info: dict[int, InputPipeInfoDict], + outpipe_info: dict[int, OutputPipeInfoDict], + input_info: list[InputInfoDict], + output_info: list[OutputInfoDict], + ref_stream: int | None = None, + ref_blocksize: int | None = None, + enc_blocksize: int | None = None, + queue_size: int | None = None, + timeout: float | None = None, + stack: ExitStack | None = None, +) -> ExitStack: + """initialize named pipes for read & write operations with FFmpeg - return args, input_info, ready, output_info, output_options + :param args: FFmpeg option arguments (modified) + :param input_info: FFmpeg input information, its length matches that of `args['inputs']` + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) + :param ref_stream: index of reference raw media output stream, defaults to 0 + if raw media stream is present or -1 if only encoded + :param ref_blocksize: block size of the reference stream, defaults to 1 if video + and 1024 for audio + :param encoded_blocksize: encoded data output block size in bytes, defaults to None (2**20 bytes) + :param queuesize: the depth of named pipe queues, defaults to 16. For + unlimited queue size, specify zero (0). + :param timeout: Default queue read timeout in seconds, defaults to `None` to + wait indefinitely. Note this timeout does not apply to + stdout pipe operation. + :param stack: ExitStack context manager object to handle __exit__() of NOpen and Thread objects + :returns: a list of indices of the FFmpeg outputs that are raw data streams + In addition to the retured list, this function modifies the dicts in its arguements. -def init_media_read_outputs( - args: FFmpegArgs, - input_info: list[InputSourceDict], - output_options: tuple[ - Sequence[str] | dict[str, FFmpegOptionDict | None] | None, - FFmpegOptionDict, - ], - deferred_inputs: list[bytes | None] = None, -) -> list[OutputDestinationDict]: - """Initialize FFmpeg arguments for media read + - The named pipe paths are assigned to the URLs of FFmpeg outputs (`args['outputs'][][0]`) + - The reader threads for FFmpeg outputs that are written to buffers (i.e., + `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` + so the reader object can be used to retrieve the data. - :param args: partial FFmpeg arguments (to be modified) - :param input_info: list of input information - :param output_options: tuple of mapping assignments and common output options - :param deferred_inputs: deferred (partial) input data, probable to retrieve - necessary stream information - :return output_info: output file information + + if any output is a piped, overwrite flag (-y) is automatically inserted """ - # if partial input bytes data given, load it up - if deferred_inputs is not None: - input_info = [ - {**info, "buffer": data} for info, data in zip(input_info, deferred_inputs) - ] + if stack is None: + stack = ExitStack() - # analyze and assign outputs - output_info, _ = process_raw_outputs(args, input_info, *output_options) + wr_kws = {"queuesize": queue_size, "timeout": timeout} - return output_info + # configure output pipes + if ref_stream is None and len(output_info): + ref_stream = 0 if "raw_info" in output_info[0] else -1 + ref_rate = 1 + if ref_stream is not None and ref_stream >= 0: + ref_rate = output_info[ref_stream]["raw_info"][-1] -def init_media_write( - urls: list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ], - stream_types: Sequence[Literal["a", "v"]], - stream_args: Sequence[RawStreamDef], - merge_audio_streams: bool | Sequence[int], - merge_audio_ar: int | None, - merge_audio_sample_fmt: str | None, - merge_audio_outpad: str | None, - extra_inputs: ( - Sequence[ - FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] - ] - | None - ), - options: dict[str, Any], - dtypes: list[DTypeString] | None = None, - shapes: list[ShapeTuple] | None = None, -) -> tuple[ - FFmpegArgs, - list[InputSourceDict], - list[OutputDestinationDict] | None, - tuple | None, - list[bool], -]: - """write multiple streams to a url/file + for i, pinfo in outpipe_info.items(): + info = output_info[i] - :param url: output url - :param stream_types: list/string of 'a' or 'v', specifying the input raw streams' media types - :param stream_args: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. - :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's - (indices of `stream_types`) to combine only specified streams. - :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream - :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options - will be applied to all input streams unless the option has been already defined in `stream_data` - :param dtypes: list of numpy-style data type strings of input samples or frames - of input media streams, defaults to `None` (auto-detect). - :param shapes: list of shapes of input samples or frames of input media streams, - defaults to `None` (auto-detect). - :return ffmpeg_args: FFmpeg argument dict (partial) - :return input_info: input stream information - :return input_ready: Element is True if corresponding input is ready (known dtype and shape) - :return output_info: output stream information, None if outputs not initialized - :return output_options: output options, None if outputs already initialized + pipe = pinfo["pipe"] - TIPS - ---- + if pipe == "stdout": + continue - * All the input streams will be added to the output file by default, unless `map` option is specified - * If the input streams are of different durations, use `shortest=ffmpegio.FLAG` option to trim all streams to the shortest. - * Using merge_audio_streams: - - adds a `filter_complex` global option - - merged input streams are removed from the `map` option and replaced by the merged stream - - """ - - noutputs = len(urls) - if not noutputs: - raise FFmpegioError("At least one URL must be given.") - - # separate the options - inopts_default = utils.pop_extra_options(options, "_in") - - # create a new FFmpeg dict - args = empty(utils.pop_global_options(options)) - - # analyze and assign inputs - input_info = process_raw_inputs( - args, stream_types, stream_args, inopts_default, dtypes, shapes - ) - - # append extra (not-piped) inputs - if extra_inputs is not None: - try: - input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) - except FFmpegioNoPipeAllowed as e: - raise FFmpegioError("extra_inputs cannot be piped in.") from e - - ready = utils.are_input_pipes_ready(args["inputs"], input_info) - - output_args = ( - urls, - options, - merge_audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad, - ) - - if all(ready): - output_info = init_media_write_outputs( - args, - input_info, - output_args, - ) - output_args = None - else: - output_info = None - - return args, input_info, ready, output_info, output_args - - -def init_media_write_outputs( - args: FFmpegArgs, - input_info: list[InputSourceDict], - output_args: tuple, - deferred_inputs: list[bytes | None] | None = None, -) -> list[OutputDestinationDict]: - """Initialize FFmpeg arguments for media read - - :param args: partial FFmpeg arguments (to be modified) - :param input_info: list of input information - :param output_args: output related init arguments - :param deferred_inputs: buffered raw data blocks, not used - :return output_info: output file information - - `args['inputs']` is expected to have all the necessary options of piped input - (see `PipedStreams.PipedRawInputMixin._write_stream`) - - """ - - ( - urls, - options, - merge_audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad, - ) = output_args - - # if `merge_audio_streams` is non-`None`, append audio-merge filtergraph - do_merge = bool(merge_audio_streams) - if do_merge: - try: - a_ids = [ - i for i, info in enumerate(input_info) if info["media_type"] == "audio" - ] - except KeyError: - raise NotImplementedError( - "audio merging mode is not currently implemented. Please use the `complex_filtergraph=ffmpegio.filtergraph.presets.merge_audio(...)` to assign a custom filtergraph." - ) - do_merge = len(a_ids) > 1 - if do_merge: - if merge_audio_streams is True: - # if True, convert to stream indices of audio inputs - merge_audio_streams = a_ids - else: - inputs = args["inputs"] - try: - assert all( - i in a_ids and "ar" in inputs[i][1] for i in merge_audio_streams - ) - except AssertionError as e: - raise ValueError( - "To merge audio streams their sampling rate must be the same." - ) from e - - # get FFmpeg input list - ffinputs = args["inputs"] - audio_streams = {i: ffinputs[i][1] for i in merge_audio_streams} - afilt = merge_audio( - audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad or "aout", - ) + stack.enter_context(pipe) - gopts = args["global_options"] - if "filter_complex" in gopts: - # prepare complex filter output - gopts["filter_complex"] = utils.as_multi_option( - gopts["filter_complex"], (str, FilterGraphObject) - ) - gopts["filter_complex"].append(afilt) + dst_type = info["dst_type"] + if dst_type == "fileobj": + assert "fileobj" in info + reader = CopyFileObjThread(pipe, info["fileobj"]) else: - gopts["filter_complex"] = [afilt] - - # analyze and assign outputs - output_info = process_url_outputs(args, input_info, urls, options) - - # if output is piped, it must have the -f option specified - for url, opts in args["outputs"]: - if url is None and "f" not in opts: - raise FFmpegioError( - 'all piped encoded output stream must have its format (`"f"`) defined in its option dict' - ) - - return output_info - - -def init_media_filter( - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], - input_types: Sequence[Literal["a", "v"]], - input_args: Sequence[RawStreamDef], - extra_inputs: ( - Sequence[ - FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] - ] - | None - ), - input_dtypes: list[DTypeString] | None, - input_shapes: list[ShapeTuple] | None, - options: FFmpegOptionDict, - output_options: dict[str, FFmpegOptionDict], -) -> tuple[ - FFmpegArgs, - list[InputSourceDict], - list[bool], - list[OutputDestinationDict] | None, - dict[str | None, FFmpegOptionDict] | None, -]: - """Prepare FFmpeg arguments for media read - - :param expr: complex filtergraph definition(s). - :param input_types: list/string of 'a' or 'v', specifying the input raw streams' media types - :param input_args: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param input_dtypes: list of numpy-style data type strings of input samples or frames - of input media streams, use `None` to auto-detect. - :param input_shapes: list of shapes of input samples or frames of input media streams, - use `None` to auto-detect. - :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options - will be applied to all input streams unless the option has been already defined in `stream_data` - :param output_options: FFmpeg output options for specific filtergraph outputs, - overriding the output options in the `options` argument - :return ffmpeg_args: FFmpeg argument dict (partial) - :return input_info: input stream information - :return input_ready: Element is True if corresponding input is ready (known dtype and shape) - :return output_info: output stream information, None if outputs not initialized - :return output_options: output options, None if outputs already initialized - - """ - - if "n" in options: - raise ValueError("Cannot have an `n` option set to output to named pipes.") - if "filter_complex" in options or "lavfi" in options: - raise ValueError("Cannot have a `filter_complex` or `lavfi` option set.") - - # separate the options - inopts_default = utils.pop_extra_options(options, "_in") - - # create a new FFmpeg dict - args = empty(utils.pop_global_options(options)) - gopts = args["global_options"] # global options dict - gopts["y"] = None - gopts["filter_complex"] = expr - - # analyze and assign inputs - input_info = process_raw_inputs( - args, input_types, input_args, inopts_default, input_dtypes, input_shapes - ) - - if extra_inputs is not None: - try: - input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) - except FFmpegioNoPipeAllowed: - raise FFmpegioError("extra_inputs cannot be piped in.") - - # make sure all inputs are complete - ready = utils.are_input_pipes_ready(args["inputs"], input_info) - if extra_inputs is not None and not all(r for r in ready[len(input_types) :]): - raise FFmpegioError( - "At least one extra input URL is either invalid or their data are not " - ) - - # add the default output options to output_options dict with None as the key - output_options = (output_options, options) - if all(ready): - output_info = init_media_filter_outputs(args, input_info, output_options) - output_options = None - else: - output_info = None - - return args, input_info, ready, output_info, output_options - - -def init_media_filter_outputs( - args: FFmpegArgs, - input_info: list[InputSourceDict], - output_options: tuple[dict[str, FFmpegOptionDict], FFmpegOptionDict], - deferred_inputs: list[list[RawDataBlob | None] | bytes] | None = None, -) -> list[OutputDestinationDict]: - """Initialize FFmpeg arguments for media read + assert dst_type == "buffer" + kws = {**wr_kws} + if "raw_info" in info: + kws["itemsize"] = info["item_size"] + if ref_rate is None: + ref_stream = i + ref_rate = info["raw_info"][-1] + kws["nmin"] = ref_blocksize + elif i == ref_stream: + kws["nmin"] = ref_blocksize + else: + rate = info["raw_info"][-1] + kws["nmin"] = round(rate / ref_rate) or 1 + else: + # encoded output in bytes + kws["itemsize"] = 1 + kws["nmin"] = enc_blocksize or 2**16 + reader = ReaderThread(pipe, **kws) - :param args: partial FFmpeg arguments (to be modified) - :param input_info: list of input information - :param output_options: default and specific output options - :param deferred_inputs: deferred_inputs- list of input data - :return output_info: output file information + pinfo["reader"] = reader + stack.enter_context(reader) # starts thread & wait for pipe connection - """ + # configure input pipes + for i, pinfo in inpipe_info.items(): + info = input_info[i] - # analyze filtergraph and create an output stream for each filtergraph output - gopts = args["global_options"] - gopts["filter_complex"], fg_info = utils.analyze_complex_filtergraphs( - gopts["filter_complex"], args["inputs"], input_info - ) + pipe = pinfo["pipe"] + if pipe == "stdin": + continue - # separate specific and default output options - (output_options, default_opts) = output_options + stack.enter_context(pipe) - # adjust output_options - out_maps = {} - for k, v in output_options.items(): - if "map" in v: - try: - out_maps[v["map"]] = k - except TypeError: - raise FFmpegioError( - "The `map` option of a raw output can specify only one stream." - ) - elif (st_map := f"[{k}]") in fg_info: - out_maps[st_map] = k - else: - out_maps[k] = k - - # create output map (stream name excludes the brackets) - streams = {} - for spec in fg_info: - if spec in out_maps: - name = out_maps[spec] - streams[name] = output_options[name] + src_type = info["src_type"] + if src_type == "fileobj": + assert "fileobj" in info + writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) + # starts thread & wait for pipe connection else: - label = spec[1:-1] - streams[label] = {} - - # analyze and assign outputs - output_info, fg_info = process_raw_outputs( - args, input_info, streams, default_opts, fg_info - ) - - return output_info - - -def init_media_transcoder( - inputs: Sequence[FFmpegInputOptionTuple], - outputs: Sequence[FFmpegOutputOptionTuple], - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, - extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, - options: FFmpegOptionDict, -) -> tuple[FFmpegArgs, InputSourceDict, OutputDestinationDict]: - """initialize media transcoder - - :param input_options: FFmpeg input options of piped inputs - :param output_options: FFmpeg output options of piped outputs - :param extra_inputs: a list of extra inputs: their URLs and optional options - :param extra_outputs: a list of extra outputs: their URLs and optional options - :return ffmpeg_args: FFmpeg argument dict - :return input_info: input stream information - :return output_info: output stream information - """ - - if "n" in options: - raise ValueError("Cannot have an `n` option set to output to named pipes.") - - # separate the options - inopts_default = utils.pop_extra_options(options, "_in") - - # create a new FFmpeg dict - args = empty(utils.pop_global_options(options)) - - input_info = process_url_inputs(args, inputs, inopts_default) - - if extra_inputs is not None: - try: - input_info.extend(process_url_inputs(args, extra_inputs, {}, no_pipe=True)) - except FFmpegioNoPipeAllowed: - raise FFmpegioError("extra_inputs cannot be piped in.") - - if not len(input_info): - raise ValueError("At least one input must be given.") - - output_info = process_url_outputs( - args, input_info, outputs, options, skip_automapping=True - ) - - if extra_outputs is not None: - try: - output_info.extend( - process_url_outputs( - args, - input_info, - extra_outputs, - {}, - skip_automapping=True, - no_pipe=True, - ) - ) - except FFmpegioNoPipeAllowed: - raise FFmpegioError("extra_outputs cannot be piped out.") - - if not len(output_info): - raise ValueError("At least one output must be given.") - - return args, input_info, output_info - - -def init_named_pipes( - args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], - update_rate: float | None = None, - blocksize: int | None = None, - queue_size: int | None = None, -) -> ExitStack | None: - """initialize named pipes for read & write operations with FFmpeg - - :param args: FFmpeg option arguments (modified) - :param input_info: FFmpeg input information, its length matches that of `args['inputs']` - :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) - :param update_rate: target rate at which queue transactions will occur for raw data output, - defaults to None (1 video frame or 1024 audio sample at a time) - :param blocksize: encoded data output block size in bytes, defaults to None (2**20 bytes) - :returns: a list of indices of the FFmpeg outputs that are raw data streams - - In addition to the retured list, this function modifies the dicts in its arguements. - - - The named pipe paths are assigned to the URLs of FFmpeg outputs (`args['outputs'][][0]`) - - The reader threads for FFmpeg outputs that are written to buffers (i.e., - `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` - so the reader object can be used to retrieve the data. - - - if any output is a piped, overwrite flag (-y) is automatically inserted - """ - - stack = ExitStack() - wr_kws = {"queuesize": queue_size} if queue_size else {} - - # configure output pipes - has_pipeout = False - for i, (output, info) in enumerate(zip(args["outputs"], output_info)): - if output[0] is None: - has_pipeout = True - - # if fileobj or buffer output, use pipe - pipe = NPopen("r", bufsize=0) - stack.enter_context(pipe) - assign_output_url(args, i, pipe.path) - dst_type = info["dst_type"] - if dst_type == "fileobj": - reader = CopyFileObjThread(info["fileobj"], pipe) - elif dst_type == "buffer": - kws = {**wr_kws} - if "raw_info" in info: - dtype, shape, rate = info["raw_info"] - kws["itemsize"] = utils.get_samplesize(shape, dtype) - if update_rate is not None: - kws["nmin"] = round(rate / update_rate) or 1 - else: - # assume encoded output - kws["itemsize"] = 1 - kws["nmin"] = blocksize or 2**16 - reader = ReaderThread(pipe, **kws) - else: - raise FFmpegioError(f"{dst_type=} is an unknown output data type.") - stack.enter_context(reader) # starts thread & wait for pipe connection - info["reader"] = reader - - # configure input pipes (if needed) - for i, (input, info) in enumerate(zip(args["inputs"], input_info)): - if input[0] is None: # no url == fileobj / buffer / other data via a pipe - pipe = NPopen("w", bufsize=0) - stack.enter_context(pipe) - assign_input_url(args, i, pipe.path) - src_type = info["src_type"] - if src_type == "fileobj": - writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) - stack.enter_context(writer) - # starts thread & wait for pipe connection - elif src_type == "buffer": - writer = WriterThread(pipe, **wr_kws) - # starts thread & wait for pipe connection - stack.enter_context(writer) - if "buffer" in info: - # data buffer given, feed the data and terminate - writer.write(info["buffer"]) - writer.write(None) # close the writer immediately - else: - # if no data given, provide the access to the writer - info["writer"] = writer + assert src_type == "buffer" + writer = WriterThread(pipe, **wr_kws) + # starts thread & wait for pipe connection + if "buffer" in info: + # data buffer given, feed the data and terminate + writer.write(info["buffer"]) + writer.write(None) # close the writer immediately else: - raise FFmpegioError(f"{src_type=} is an unknown input data type.") - - if has_pipeout: - # if any output is piped, must run in the overwrite mode - args["global_options"].pop("n", None) - args["global_options"]["y"] = None + # if no data given, provide the access to the writer + pinfo["writer"] = writer + stack.enter_context(writer) - return stack if len(input_info) or len(output_info) else None + return stack -def assign_std_pipes( - args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], - use_sp_run: bool = False, -) -> tuple[int | IO | None, int | IO | None, bytes | None]: - """initialize named pipes for read & write operations with FFmpeg +class StdWriter: + def __init__(self, proc: fp.Popen) -> None: + self._proc = proc - :param args: FFmpeg option arguments (modified) - :param input_info: list of input information - :param output_info: list of output information - :param use_sp_run: True to set `stdin` output to `None` even if input - data is given (so it's compatible with `subprocess.run()`) - :returns stdin: stdin argument of subsequent ffmpegprocess.Popen call - :returns stdout: stdout argument of subsequent ffmpegprocess.Popen call - :returns input: input argument of subsequent ffmpegprocess.Popen call + def write(self, data: bytes | None): + if data is None: + self.join() + else: + self._proc.stdin.write(data) - In addition to the retured list, this function modifies the dicts in its arguements. + def join(self): + # no thread, just close the stdin + self._proc.stdin.flush() + self._proc.stdin.close() - - The pipe names are assigned to the URLs of FFmpeg input and output (`args['inputs'][][0]` - and `args['outputs'][][0]`) - - The reader threads for FFmpeg outputs that are written to buffers (i.e., - `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` - so the reader object can be used to retrieve the data. + def closed(self) -> bool: + return self._proc.stdin.closed - if any output is a piped, overwrite flag (-y) is automatically inserted - """ +class StdReader: + def __init__(self, proc: fp.Popen, itemsize: int) -> None: + self._proc = proc + self._itemsize = itemsize - # configure output pipes - use_stdin = use_stdout = False - stdin = stdout = pinput = None - for i, (output, info) in enumerate(zip(args["outputs"], output_info)): - if output[0] is None or utils.is_pipe(output[0]): - if use_stdout: - raise FFmpegioError( - "More than 1 pipe to output found. Cannot use standard pipes." - ) - use_stdout = True - assign_output_url(args, i, "pipe:1") + def read(self, n: int = -1) -> bytes: + return self._proc.stdout.read(n if n <= 0 else n * self._itemsize) - dst_type = info["dst_type"] - if dst_type == "fileobj": - stdout = info["fileobj"] - elif dst_type == "buffer": - stdout = fp.PIPE - else: - raise FFmpegioError(f"{dst_type=} is an unknown output data type.") - - # configure input pipes (if needed) - for i, (input, info) in enumerate(zip(args["inputs"], input_info)): - if input[0] is None or utils.is_pipe(input[0]): - if use_stdin: - raise FFmpegioError( - "More than 1 pipe to input found. Cannot use standard pipes." - ) - use_stdin = True - assign_input_url(args, i, "pipe:0") - src_type = info["src_type"] - if src_type == "fileobj": - stdin = info["fileobj"] - elif src_type == "buffer": - if "buffer" in info: - pinput = info["buffer"] - if not use_sp_run: - stdin = fp.PIPE - else: - stdin = fp.PIPE - else: - raise FFmpegioError(f"{src_type=} is an unknown input data type.") - - if use_stdout: - # if any output is piped, must run in the overwrite mode - args["global_options"].pop("n", None) - args["global_options"]["y"] = None + def cool_down(self): + pass - return stdin, stdout, pinput + def join(self): + pass def init_std_pipes( - stdin: IO | None, - stdout: IO | None, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], - update_rate: float | None = None, - blocksize: int | None = None, - queue_size: int | None = None, -) -> ExitStack | None: - """initialize named pipes for read & write operations with FFmpeg - - :param args: FFmpeg option arguments (modified) - :param input_info: FFmpeg input information, its length matches that of `args['inputs']` - :param output_info: FFmpeg output information, its length matches that of `args['outputs']` (modified) - :param update_rate: target rate at which queue transactions will occur for raw data output, - defaults to None (1 video frame or 1024 audio sample at a time) - :param blocksize: encoded data output block size in bytes, defaults to None (2**20 bytes) - :returns: a list of indices of the FFmpeg outputs that are raw data streams - - In addition to the retured list, this function modifies the dicts in its arguements. - - - The pipe names are assigned to the URLs of FFmpeg input and output (`args['inputs'][][0]` - and `args['outputs'][][0]`) - - The reader threads for FFmpeg outputs that are written to buffers (i.e., - `output_info[]['dst_type']=='buffer'`) are saved as `output_info[]['reader']` - so the reader object can be used to retrieve the data. - + input_pipes: dict[int, InputPipeInfoDict], + output_pipes: dict[int, OutputPipeInfoDict], + output_info: list[OutputInfoDict], + proc: fp.Popen, +): + """initialize std pipe reader or writer - if any output is a piped, overwrite flag (-y) is automatically inserted + :param input_pipes: _description_ + :param output_pipes: _description_ + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` + :param proc: _description_ """ - - stack = ExitStack() - in_use = False - wr_kws = {"queuesize": queue_size} if queue_size else {} - - # configure output pipes - for info in output_info: - dst_type = info["dst_type"] - if dst_type == "buffer": - kws = {**wr_kws} - if "raw_info" in info: - dtype, shape, rate = info["raw_info"] - kws["itemsize"] = utils.get_samplesize(shape, dtype) - if update_rate is not None: - kws["nmin"] = round(rate / update_rate) or 1 - else: - # assume encoded output - kws["itemsize"] = 1 - kws["nmin"] = blocksize or 2**16 - info["reader"] = reader = ReaderThread(stdout, **kws) - stack.enter_context(reader) # starts thread & wait for pipe connection - in_use = True - break - - # configure input pipes (if needed) - for info in input_info: - src_type = info["src_type"] - if src_type == "buffer": - writer = WriterThread(stdin, **wr_kws) - # starts thread & wait for pipe connection - stack.enter_context(writer) - in_use = True - if "buffer" in info: - # data buffer given, feed the data and terminate - writer.write(info["buffer"]) - writer.write(None) # close the writer immediately - else: - # if no data given, provide the access to the writer - info["writer"] = writer - break - - return stack if in_use else None + stdin = next((st for st, p in input_pipes.items() if p["pipe"] == "stdin"), None) + if stdin is not None: + input_pipes[stdin]["writer"] = StdWriter(proc) + + stdout = next((st for st, p in output_pipes.items() if p["pipe"] == "stdout"), None) + if stdout is not None: + output_pipes[stdout]["reader"] = StdReader( + proc, output_info[stdout]["item_size"] + ) diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 0f3799d6..07271821 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -1,95 +1,56 @@ -from . import ffmpegprocess, utils, configure, FFmpegError, plugins -from .utils import log as log_utils - -__all__ = ["create", "read", "write", "filter"] - - -def _run_read(*args, show_log=None, sp_kwargs=None, **kwargs): - """run FFmpeg and retrieve audio stream data - :param *args ffmpegprocess.run arguments - :type *args: tuple - :param shape: output frame size if known, defaults to None - :type shape: (int, int), optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param **kwargs ffmpegprocess.run keyword arguments - :type **kwargs: tuple - :return: image data, created by `bytes_to_video` plugin hook - :rtype: object - """ - - outopts = args[0]["outputs"][0][1] - outopts["map"] = "0:v:0" - dtype, shape, _ = configure.finalize_video_read_opts( - args[0], - input_info=[ - {"src_type": "filtergraph" if outopts.get("f", None) == "lavfi" else "url"} - ], - ) - - if sp_kwargs is not None: - kwargs = {**sp_kwargs, **kwargs} - - if shape is None: - configure.clear_loglevel(args[0]) - - out = ffmpegprocess.run(*args, capture_log=True, **kwargs) - if show_log: - print(out.stderr) - if out.returncode: - raise FFmpegError(out.stderr) - - info = log_utils.extract_output_stream(out.stderr) - dtype, shape = utils.get_video_format(info["pix_fmt"], info["s"]) - else: - out = ffmpegprocess.run( - *args, - capture_log=None if show_log else True, - **kwargs, - ) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - - nbytes = utils.get_samplesize(shape, dtype) - - return plugins.get_hook().bytes_to_video( - b=out.stdout[-nbytes:], dtype=dtype, shape=shape, squeeze=True - ) - - -def create(expr, *args, show_log=None, sp_kwargs=None, **options): +import logging +from fractions import Fraction + +from . import configure, utils +from . import filtergraph as fgb +from ._typing import Any, DTypeString, ProgressCallable, RawDataBlob, ShapeTuple +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, +) +from .errors import FFmpegioError +from .std_runners import run_and_return_encoded, run_and_return_raw + +__all__ = ["create", "read", "write", "filter", "detect"] + +logger = logging.getLogger("ffmpegio") + + +def create( + expr: str | fgb.abc.FilterGraphObject, + *args, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> RawDataBlob: """Create an image using a source video filter :param name: name of the source filter - :type name: str - :param \\*args: sequential filter option arguments. Only valid for + :param args: sequential filter option arguments. Only valid for a single-filter expr, and they will overwrite the options set by expr. - :type \\*args: seq, optional + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: Named filter options or FFmpeg options. Items are + :param options: Named filter options or FFmpeg options. Items are only considered as the filter options if expr is a single-filter graph, and take the precedents over general FFmpeg options. Append '_in' for input option names (see :doc:`options`), and '_out' for output option names if they conflict with the filter options. - :type \\**options: dict, optional - :return: image data, created by `bytes_to_video` plugin hook - :rtype: object + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 3D (row x column x comp) if colored/transparent. + or 2D (row x column) if it is a grayscale image. .. seealso:: See https://ffmpeg.org/ffmpeg-filters.html#Video-Sources for @@ -97,171 +58,203 @@ def create(expr, *args, show_log=None, sp_kwargs=None, **options): """ - input_options = utils.pop_extra_options(options, "_in") - output_options = utils.pop_extra_options(options, "_out") - - url, _, options = configure.config_input_fg(expr, args, options) + url, t_, options = configure.config_input_fg(expr, args, options) - options = {**options, **output_options, "frames:v": 1} - - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"}) - configure.add_url( - ffmpeg_args, "output", "-", {"pix_fmt": "rgb24", **options, "f": "rawvideo"} + return read( + url, + progress=progress, + show_log=show_log, + sp_kwargs=sp_kwargs, + **options, ) - # TODO: filtergraph scanning will remove the default 'pix_fmt' setting - return _run_read(ffmpeg_args, show_log=show_log, sp_kwargs=sp_kwargs) - -def read(url, show_log=None, sp_kwargs=None, **options): +def read( + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + *, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> RawDataBlob: """Read an image file or a snapshot of a video frame :param url: URL of the image or video file to read. - :type url: str + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be a url string or a pair of + a url string and an option dict. + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: image data, created by `bytes_to_video` plugin hook - :rtype: object + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 3D (row x column x comp) if colored/transparent. + or 2D (row x column) if it is a grayscale image. Note on \\**options: To specify the video frame capture time, use `time` option which is an alias of `start` standard option. """ - input_options = utils.pop_extra_options(options, "_in") + # use user-specified map or default '0:V:0' map + output_map = options.pop("map", "0:V:0") - # get url/file stream - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) - ) + # make sure it reads only one file + options["vframes" if "vframes" in options else "frames:v"] = 1 - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - outopts["f"] = "rawvideo" - if "frames:v" not in outopts: - outopts["frames:v"] = 1 + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_read( + [url] if utils.is_valid_input_url(url) else url, + [output_map], + options, + extra_outputs, + True, + ) - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = stdin - sp_kwargs["input"] = input + if output_info is None: + raise FFmpegioError( + "Unknown configuration error occurred. Necessary output information could not be collected." + ) + if output_info[0]["media_type"] != "video": + raise ValueError("Mapped stream is not a video stream.") - return _run_read(ffmpeg_args, show_log=show_log, sp_kwargs=sp_kwargs) + return run_and_return_raw( + args, + input_info, + output_info, + progress, + show_log, + sp_kwargs, + )[1] def write( - url, - data, - overwrite=None, - show_log=None, - extra_inputs=None, - sp_kwargs=None, + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + data: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + dtype: DTypeString | None = None, + shape: ShapeTuple | None = None, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, **options, -): +) -> bytes | None: """Write a NumPy array to an image file. :param url: URL of the image file to write. - :type url: str :param data: image data, accessed by `video_info()` and `video_bytes()` plugin hooks - :type data: object :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :type extra_inputs: seq(str|(str,dict)) - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - url, stdout, _ = configure.check_url(url, True) + # if filter_complex is not defined use '0:V:0' as default mapping + if ( + not any( + (o in options) + for o in ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + ) + and "map" not in options + ): + options["map"] = "0:V:0" - input_options = utils.pop_extra_options(options, "_in") + options["vframes" if "vframes" in options else "frames:v"] = 1 - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(1, data=data, **input_options), + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_write( + url, [{"r": 1}], extra_inputs, options, [data], [dtype], [shape] ) - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - outopts = configure.add_url(ffmpeg_args, "output", url, options)[1][1] - outopts["frames:v"] = 1 - - configure.build_basic_vf(ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1)) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - { - "input": plugins.get_hook().video_bytes(obj=data), - "stdout": stdout, - "overwrite": overwrite, - } + return run_and_return_encoded( + progress, + overwrite, + show_log, + sp_kwargs, + args, + input_info, + output_info, ) - kwargs["capture_log"] = None if show_log else False - - out = ffmpegprocess.run(ffmpeg_args, **kwargs) - if out.returncode: - raise FFmpegError(out.stderr, show_log) -def filter(expr, input, show_log=None, sp_kwargs=None, **options): +def filter( + expr: str | fgb.abc.FilterGraphObject | None, + input: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Filter image pixels. :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None :param input: input image data, accessed by `video_info` and `video_bytes` plugin hooks - :type input: object + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - :return: output sampling rate and data, created by `bytes_to_video` plugin hook - :rtype: (int, object) + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 3D (row x column x comp) if colored/transparent. + or 2D (row x column) if it is a grayscale image. """ - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(1, data=input, **input_options), - ) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - outopts["f"] = "rawvideo" - if expr: - outopts["filter:v"] = expr - - return _run_read( - ffmpeg_args, - input=plugins.get_hook().video_bytes(obj=input), - show_log=show_log, - sp_kwargs=sp_kwargs, + if expr is not None: + if extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:v"] = expr + options["map"] = "0:V:0" + else: + options["filter_complex"] = expr + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + [{"r": 1}], extra_inputs, None, extra_outputs, options, True, [input] ) + + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") + + return run_and_return_raw( + args, input_info, output_info, progress, show_log, sp_kwargs + )[1] diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index db17d7db..bb203630 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -1,57 +1,69 @@ from __future__ import annotations import logging - -logger = logging.getLogger("ffmpegio") - from collections.abc import Sequence +from fractions import Fraction + +from . import configure, ffmpegprocess, utils from ._typing import ( + DTypeString, + FFmpegOptionDict, + InputInfoDict, + InputPipeInfoDict, Literal, - RawStreamDef, + OutputInfoDict, + OutputPipeInfoDict, ProgressCallable, RawDataBlob, + RawOutputInfoDict, + RawStreamDef, + ShapeTuple, Unpack, - FFmpegUrlType, - InputSourceDict, - OutputDestinationDict, - FFmpegOptionDict, ) from .configure import ( FFmpegArgs, - FFmpegOutputUrlComposite, FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, + FFmpegOutputUrlNoPipe, ) - -from fractions import Fraction - -from . import ffmpegprocess, utils, configure, FFmpegError, plugins -from .utils.log import extract_output_stream -from .errors import FFmpegioError +from .errors import FFmpegError from .filtergraph.abc import FilterGraphObject -__all__ = ["read", "write"] +logger = logging.getLogger("ffmpegio") + +__all__ = ["read", "write", "filter"] def _runner( args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], + input_info: list[InputInfoDict], + output_info: list[OutputInfoDict], show_log: bool | None, progress: ProgressCallable | None, sp_kwargs: dict | None, overwrite: bool | None = None, -) -> ffmpegprocess.Popen: - - # True if there is unknown datablob info - need_stderr = any( - info["dst_type"] == "pipe" and info["raw_info"] is None for info in output_info - ) - - # run FFmpeg - capture_log = True if need_stderr else None if show_log else True +) -> tuple[ + ffmpegprocess.Popen, dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict] +]: + # convert show_log to capture_log + capture_log = None if show_log else True # configure named pipes - stack = configure.init_named_pipes(args, input_info, output_info) + input_pipes: dict[int, InputPipeInfoDict] = {} + output_pipes: dict[int, OutputPipeInfoDict] = {} + if len(input_info): + input_pipes, sp_kwargs = configure.assign_input_pipes(args, input_info, False) + if len(output_info): + output_pipes, sp_kwargs = configure.assign_output_pipes( + args, output_info, False + ) + stack = configure.init_named_pipes( + input_pipes, output_pipes, input_info, output_info, queue_size=0 + ) def on_exit(rc): stack.close() @@ -78,62 +90,44 @@ def on_exit(rc): if proc.returncode: raise FFmpegError(proc.stderr, capture_log) - return proc + return proc, input_pipes, output_pipes def _gather_outputs( - output_info: list[OutputDestinationDict], proc: ffmpegprocess.Popen + output_info: list[RawOutputInfoDict], + pipe_info: dict[int, OutputPipeInfoDict], ) -> tuple[dict[str, int | Fraction], dict[str, RawDataBlob]]: rates = {} data = {} - for i, info in enumerate(output_info): + for i, pinfo in pipe_info.items(): + info = output_info[i] + if "media_type" not in info: + continue + spec = info["user_map"] - b = info["reader"].read_all() - - # get datablob info from stderr if needed - missing = any(v is None for v in info["raw_info"]) - - if missing: - logger.warning('Retrieving stream "%s" information from FFmpeg log.', spec) - new_info = extract_output_stream(proc.stderr, i) - - if info["media_type"] == "video": - dtype, shape, rate = info["raw_info"] - - if missing: - if dtype is None: - pix_fmt = new_info["pix_fmt"] - dtype = utils.get_pixel_format(pix_fmt)[0] - if shape is None: - shape = new_info["s"] - if rate is None: - rate = new_info["r"] - - data[spec] = plugins.get_hook().bytes_to_video( - b=b, dtype=dtype, shape=shape, squeeze=False - ) - else: # 'audio' - dtype, shape, rate = info["raw_info"] - if missing: - if dtype is None: - sample_fmt = new_info["sample_fmt"] - dtype = utils.get_audio_format(sample_fmt) - if shape is None: - shape = (new_info["ac"],) - if rate is None: - rate = new_info["ar"] - - data[spec] = plugins.get_hook().bytes_to_audio( - b=b, dtype=dtype, shape=shape, squeeze=False - ) + b = pinfo["reader"].read() + dtype, shape, rate = info["raw_info"] + + data[spec] = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) rates[spec] = rate - return rates, data + return rates, data def read( - *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], - map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None = None, + *urls: *tuple[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]], + streams: ( + Sequence[str] + | Sequence[FFmpegOptionDict] + | dict[str, FFmpegOptionDict | None] + | None + ) = None, + extra_outputs: ( + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + squeeze: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, @@ -142,13 +136,21 @@ def read( """Read video and audio data from multiple media files :param *urls: URLs of the media files to read or a tuple of the URL and its input option dict. - :param map: FFmpeg map options - :param progress: progress callback function, defaults to None + :param streams: a list of FFmpeg output stream map options. Alternately, the list + may consist of an FFmpeg output option dict (with a required `'map'` item) + a dict keyed by the map option value to apply different set of + output options to each output. If not specified (default), it + outputs all the streams. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be a url string or a pair of + a url string and an option dict. + :param squeeze: False to return 4D data for video and 2D data for audio. True + eliminates any dimensions which only has the length of one. + :param progress: progress callback function, defaults to None :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :param use_ya: True if piped video streams uses `ya8` pix_fmt instead of `gray16le`, default to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific + :param options: FFmpeg options, append '_in[input_url_id]' for input option names for specific input url or '_in' to be applied to all inputs. The url-specific option gets the preference (see :doc:`options` for custom options) :return: frame/sampling rates and raw data for each requested stream @@ -160,26 +162,19 @@ def read( map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. - - For audio streams, if 'sample_fmt' output option is not specified, 's16'. """ - # initialize FFmpeg argument dict and get input & output information - args, input_info, input_ready, output_info, _ = configure.init_media_read( - urls, map, options + args, input_info, output_info = configure.init_media_read( + list(urls), streams, options, extra_outputs, squeeze ) - # if any input buffer is empty, invalid - if not all(input_ready): - raise FFmpegioError("Not all inputs are resolved.") - # run FFmpeg - proc = _runner(args, input_info, output_info, show_log, progress, sp_kwargs) + proc, input_pipes, output_pipes = _runner( + args, input_info, output_info, show_log, progress, sp_kwargs + ) # gather and return output - return _gather_outputs(output_info, proc) + return _gather_outputs(output_info, output_pipes) def write( @@ -191,11 +186,9 @@ def write( ), stream_types: Sequence[Literal["a", "v"]], *stream_args: *tuple[RawStreamDef, ...], - merge_audio_streams: bool | Sequence[int] = False, - merge_audio_ar: int | None = None, - merge_audio_sample_fmt: str | None = None, - merge_audio_outpad: str | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + stream_dtypes: list[DTypeString | None] | None = None, + stream_shapes: list[ShapeTuple | None] | None = None, overwrite: bool | None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -205,21 +198,28 @@ def write( """write multiple streams to a url/file :param url: output url - :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) - :param stream_args: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob - or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's - (indices of `stream_types`) to combine only specified streams. - :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream - :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream + :param stream_types: list/string of input stream media types, each element + is either 'a' (audio) or 'v' (video) + :param stream_args: raw input stream data arguments, each input stream is + either a tuple of a sample rate (audio) or frame rate + (video) followed by a data blob, or a tuple of a data + blob and a dict of input options. The option dict must + include `'ar'` (audio) or `'r'` (video) to specify the + rate. + :param extra_inputs: list of additional input sources, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param stream_dtypes: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param stream_shapes: list of shapes of input samples or frames of input + media streams, defaults to `None` (auto-detect). :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) :param progress: progress callback function, defaults to None :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options will be applied to all input streams unless the option has been already defined in `stream_data` TIPS @@ -233,25 +233,18 @@ def write( """ - if not isinstance(urls, list): - urls = [urls] + input_options, input_data = utils.raw_input_options(stream_types, stream_args) - args, input_info, input_ready, output_info, _ = configure.init_media_write( + args, input_info, output_info = configure.init_media_write( urls, - stream_types, - stream_args, - merge_audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad, + input_options, extra_inputs, options, + input_data, + stream_dtypes, + stream_shapes, ) - # if any input buffer is empty, invalid - if not all(input_ready): - raise FFmpegioError("Invalid input data.") - # run FFmpeg _runner(args, input_info, output_info, show_log, progress, sp_kwargs, overwrite) @@ -265,11 +258,19 @@ def write( def filter( - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, input_types: Sequence[Literal["a", "v"]], *input_args: *tuple[RawStreamDef, ...], - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - output_options: dict[str, FFmpegOptionDict] | None = None, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + output_streams: Sequence[str | FFmpegOptionDict] | None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = False, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, @@ -278,18 +279,28 @@ def filter( """write multiple streams to a url/file :param expr: complex filtergraph expression or a list of expressions - :param input_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) - :param input_args: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob - or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param output_options: specific options for keyed filtergraph output pads. + :param input_types: list/string of input stream media types, each element is + either 'a' (audio) or 'v' (video) + :param input_args: raw input stream data arguments, each input stream is + either a tuple of a sample rate (audio) or frame rate + (video) followed by a data blob or a tuple of a data blob + and a dict of input options. The option dict must include + `'ar'` (audio) or `'r'` (video) to specify the rate. + :param extra_inputs: list of additional input sources, defaults to None. + Each source may be url string or a pair of a url string + and an option dict. + :param output_streams: specific options for keyed filtergraph output pads. + :param input_dtypes: list of numpy-style data type strings of input samples + or frames of input media streams, defaults to `None` + (auto-detect). + :param input_shapes: list of shapes of input samples or frames of input + media streams, defaults to `None` (auto-detect). :param progress: progress callback function, defaults to None :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options will be applied to all input streams unless the option has been already defined in `stream_data` TIPS @@ -301,25 +312,32 @@ def filter( for some outputs as needed. """ - args, input_info, input_ready, output_info, _ = configure.init_media_filter( - expr, - input_types, - input_args, + + if expr is not None: + options["filter_complex"] = expr + + input_options, input_data = utils.raw_input_options(input_types, input_args) + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + input_options, extra_inputs, - None, - None, + output_streams, + extra_outputs, options, - output_options or {}, + squeeze, + input_data, + input_dtypes, + input_shapes, ) - # if any input buffer is empty, invalid - if not all(input_ready): - raise FFmpegioError( - "Data type and shape of some inputs could not be determined." - ) + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") # run FFmpeg - proc = _runner(args, input_info, output_info, show_log, progress, sp_kwargs) + proc, input_pipes, output_pipes = _runner( + args, input_info, output_info, show_log, progress, sp_kwargs + ) # gather and return output - return _gather_outputs(output_info, proc) + return _gather_outputs(output_info, output_pipes) diff --git a/src/ffmpegio/std_runners.py b/src/ffmpegio/std_runners.py new file mode 100644 index 00000000..21e99d7c --- /dev/null +++ b/src/ffmpegio/std_runners.py @@ -0,0 +1,148 @@ +"""FFmpeg runner functions for SISO operations over standard pipes""" + +from __future__ import annotations + +import logging + +from . import configure +from . import ffmpegprocess as fp +from ._typing import ( + TYPE_CHECKING, + Any, + EncodedInputInfoDict, + EncodedOutputInfoDict, + ProgressCallable, + RawInputInfoDict, + RawOutputInfoDict, +) +from .errors import FFmpegError, FFmpegioError + +if TYPE_CHECKING: + from .configure import FFmpegArgs + +logger = logging.getLogger("ffmpegio") + +__all__ = ["run_and_return_raw", "run_and_return_encoded"] + + +def run_and_return_raw( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + output_info: list[RawOutputInfoDict | EncodedOutputInfoDict], + progress: ProgressCallable | None, + show_log: bool | None, + sp_kwargs: dict[str, Any] | None, +): + # check configuration yields at most one piped input + # check configuration yields at most one piped output + n_piped_inputs = sum( + info["src_type"] in ("buffer", "fileobj") for info in input_info + ) + if n_piped_inputs > 1: + raise ValueError( + "Only at most one input source can be a pipe or a file-stream object." + ) + + # check configuration yields exactly one piped audio output + if len(output_info) == 0: + raise FFmpegioError("No audio stream found.") + if len(output_info) > 1: + raise ValueError("Too many audio stream found.") + if output_info[0]["dst_type"] != "buffer": + raise ValueError("Not outputting to pipe") + + n_piped_outputs = sum( + info["dst_type"] in ("buffer", "fileobj") for info in output_info + ) + if n_piped_outputs > 1: + raise ValueError( + "Only at most one output destination can be a pipe or a file-stream object." + ) + + # assign the stdin and stdout pipes + kwargs = { + **configure.assign_input_pipes(args, input_info, True, True)[1], + **configure.assign_output_pipes(args, output_info, True)[1], + } + + if sp_kwargs is not None: + # ignore user's stdin, stdout, stdout if specified + kwargs = {**sp_kwargs, **kwargs} + + out = fp.run( + args, + progress=progress, + capture_log=None if show_log else True, + **kwargs, + ) + if out.returncode: + raise FFmpegError(out.stderr, show_log) + + oinfo = output_info[0] + dtype, shape, rate = oinfo["raw_info"] + + return rate, oinfo["bytes2data"]( + b=out.stdout, dtype=dtype, shape=shape, squeeze=oinfo["squeeze"] + ) + + +def run_and_return_encoded( + progress, + overwrite, + show_log, + sp_kwargs, + args, + input_info, + output_info, + two_pass=False, + pass1_omits=None, + pass1_extras=None, +): + if output_info is None: + raise FFmpegioError("Unknown error occurred to complete FFmpeg configuration.") + + # check configuration yields at most one piped output + n_piped_inputs = sum( + info["src_type"] in ("buffer", "fileobj") for info in input_info + ) + if n_piped_inputs > 1: + raise ValueError( + "Only at most one input source can be a pipe or a file-stream object." + ) + + n_piped_outputs = sum( + info["dst_type"] in ("buffer", "fileobj") for info in output_info + ) + if n_piped_outputs > 1: + raise ValueError( + "Only at most one output destination can be a pipe or a file-stream object." + ) + + # assign the stdin and stdout pipes + kwargs = { + **configure.assign_input_pipes(args, input_info, True, True)[1], + **configure.assign_output_pipes(args, output_info, True)[1], + } + + if sp_kwargs is not None: + # ignore user's stdin, stdout, stdout if specified + kwargs = {**sp_kwargs, **kwargs} + + if two_pass: + if pass1_omits is not None: + kwargs["pass1_omits"] = pass1_omits + if pass1_extras is not None: + kwargs["pass1_extras"] = pass1_extras + + out = (fp.run_two_pass if two_pass else fp.run)( + args, + progress=progress, + capture_log=None if show_log else True, + overwrite=overwrite, + **kwargs, + ) + if out.returncode: + raise FFmpegError(out.stderr, show_log) + + if n_piped_outputs and any(info["dst_type"] == "buffer" for info in output_info): + return out.stdout diff --git a/src/ffmpegio/streams/AviStreams.py b/src/ffmpegio/streams/AviStreams.py deleted file mode 100644 index dbe5b577..00000000 --- a/src/ffmpegio/streams/AviStreams.py +++ /dev/null @@ -1,238 +0,0 @@ -from .. import configure, threading, utils, ffmpegprocess - -__all__ = ["AviMediaReader"] - - -class AviMediaReader: - """Read video frames - - :param *urls: URLs of the media files to read. - :type *urls: tuple(str) - :param streams: list of file + stream specifiers or filtergraph label to output, alias of `map` option, - defaults to None, which outputs at most one video and one audio, selected by FFmpeg - :type streams: seq(str), optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - :type \\**options: dict, optional - - :return: frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: (`fractions.Fraction`, object) - - Note: Only pass in multiple urls to implement complex filtergraph. It's significantly faster to run - `ffmpegio.video.read()` for each url. - - - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. - - For audio streams, if 'sample_fmt' output option is not specified, 's16'. - - - streams = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - - """ - - readable = True - writable = False - multi_read = True - multi_write = False - - def __init__( - self, - *urls, - ref_stream=None, - blocksize=None, - progress=None, - show_log=None, - queuesize=0, - sp_kwargs=None, - **options, - ): - - self.ref_stream = ref_stream - #:str: specifier of reference output stream for iterator - self.blocksize = blocksize or 0 - #:int: if >0 number of samples of reference stream to include in each read; <=0 one chunk per read - - ninputs = len(urls) - if not ninputs: - raise ValueError("At least one URL must be given.") - - # separate the options - spec_inopts = utils.pop_extra_options_multi(options, r"_in(\d+)$") - inopts = utils.pop_extra_options(options, "_in") - - # create a new FFmpeg dict - args = configure.empty() - configure.add_url(args, "output", "-", options) # add piped output - for i, url in enumerate(urls): # add inputs - opts = {**inopts, **spec_inopts.get(i, {})} - # check url (must be url and not fileobj) - configure.check_url( - url, nodata=True, nofileobj=True, format=opts.get("f", None) - ) - configure.add_url(args, "input", url, opts) - - # configure output options - use_ya = configure.finalize_avi_read_opts(args) - - self._reader = threading.AviReaderThread(queuesize) - - # create logger without assigning the source stream - self._logger = threading.LoggerThread(None, show_log) - - # start FFmpeg - self._proc = ffmpegprocess.Popen(args, progress=progress, capture_log=True) - - # start the reader thrad - self._reader.start(self._proc.stdout, use_ya) - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - def specs(self): - """:list(str): list of specifiers of the streams""" - self._reader.wait() - return self._reader.streams and [ - v["spec"] for v in self._reader.streams.values() - ] - - def types(self): - """:dict(str:str): media type associated with the streams (key)""" - self._reader.wait() - ts = {"v": "video", "a": "audio"} - return self._reader.streams and { - v["spec"]: ts[v["type"]] for v in self._reader.streams.values() - } - - def rates(self): - """:dict(str:int|Fraction): sample or frame rates associated with the streams (key)""" - self._reader.wait() - rates = self._reader.rates - return self._reader.streams and { - v["spec"]: rates[k] for k, v in self._reader.streams.items() - } - - def dtypes(self): - """:dict(str:str): frame/sample data type associated with the streams (key)""" - self._reader.wait() - return self._reader.streams and { - v["spec"]: v["dtype"] for v in self._reader.streams.values() - } - - def shapes(self): - """:dict(str:tuple(int)): frame/sample shape associated with the streams (key)""" - self._reader.wait() - return self._reader.streams and { - v["spec"]: v["shape"] for v in self._reader.streams.values() - } - - def get_stream_info(self, spec): - id = self._reader.find_id(spec) - return self._reader.streams[id] - - def close(self): - """Flush and close this stream. This method has no effect if the stream is already - closed. Once the stream is closed, any read operation on the stream will raise - a ValueError. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - - """ - try: - self._proc.terminate() - except: - pass - self._proc.stdout.close() - self._proc.stderr.close() - self._reader.join() - self._logger.join() - - @property - def closed(self): - """:bool: True if the FFmpeg has been terminated.""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError: TODO Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def __bool__(self): - """True if FFmpeg stdout stream is still open or there are more frames in the buffer""" - return bool(self._reader) - - def __iter__(self): - return self - - def __next__(self): - try: - if self.blocksize > 0: # per time block (multiple streams) - frames = self._reader.read(self.blocksize, self.ref_stream) - try: - shapes = [f["shape"] for f in frames.values()] - except IndexError: - shapes = [f.shape for f in frames.values()] - else: # per AVI frame (1 stream at a time) - frames = self._reader.readchunk() - try: - shapes = [frames[1]["shape"]] - except IndexError: - shapes = [frames[1].shape] - - assert any(s[0] for s in shapes) - - return frames - except (AssertionError, threading.ThreadNotActive): - raise StopIteration - - def readlog(self, n=None): - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def readnext(self, timeout=None): - return self._reader.readchunk(timeout) - - def read(self, n=-1, ref_stream=None, timeout=None): - """Read and return video or audio data objects up to n frames/samples. If - the argument is omitted, None, or negative, data is read and - returned until EOF is reached. An empty bytes object is returned - if the stream is already at EOF. - - If the argument is positive, and the underlying raw stream is not - interactive, multiple raw reads may be issued to satisfy the byte - count (unless EOF is reached first). But for interactive raw streams, - at most one raw read will be issued, and a short result does not - imply that EOF is imminent. - - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" - - return self._reader.read(n, ref_stream, timeout) - - def readall(self, timeout=None): - return self._reader.readall(timeout) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py deleted file mode 100644 index 3af7ebec..00000000 --- a/src/ffmpegio/streams/PipedStreams.py +++ /dev/null @@ -1,1134 +0,0 @@ -from __future__ import annotations - -import logging - -logger = logging.getLogger("ffmpegio") - -from typing_extensions import Unpack -from collections.abc import Sequence -from .._typing import ( - DTypeString, - ShapeTuple, - ProgressCallable, - RawDataBlob, - Literal, - InputSourceDict, - OutputDestinationDict, - FFmpegOptionDict, -) -from ..configure import ( - FFmpegArgs, - FFmpegInputUrlComposite, - FFmpegUrlType, - MediaType, - FFmpegOutputUrlComposite, - InitMediaOutputsCallable, -) -from ..filtergraph.abc import FilterGraphObject -from ..configure import OutputDestinationDict -from contextlib import ExitStack - -import sys -from time import time -from fractions import Fraction - -from .. import configure, ffmpegprocess, plugins, utils, probe -from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError - -# fmt:off -__all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] -# fmt:on - - -class _PipedFFmpegRunner: - """Base class to run FFmpeg and manage its multiple I/O's""" - - def __init__( - self, - ffmpeg_args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict] | None, - input_ready: True | list[bool] | None, - init_deferred_outputs: InitMediaOutputsCallable | None, - deferred_output_args: list[FFmpegOptionDict | None], - *, - default_timeout: float | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - queuesize: int | None = None, - sp_kwargs: dict = None, - ): - """Encoded media stream transcoder - - :param ffmpeg_args: (Mostly) populated FFmpeg argument dict - :param input_info: FFmpeg output option dicts of all the output pipes. Each dict - must contain the `"f"` option to specify the media format. - :param output_info: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param input_ready: indicates if input is ready (True) or need its first batch of data to - provide necessary information for the outputs - :param init_deferred_outputs: function to initialize the outputs which have been deferred to - configure until the first batch of input data is in - :param deferred_output_args: - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :param options: global/default FFmpeg options. For output and global options, - use FFmpeg option names as is. For input options, append "_in" to the - option name. For example, r_in=2000 to force the input frame rate - to 2000 frames/s (see :doc:`options`). These input and output options - specified here are treated as default, common options, and the - url-specific duplicate options in the ``inputs`` or ``outputs`` - sequence will overwrite those specified here. - """ - - self._input_info = input_info - self._output_info = output_info - self._input_ready = input_ready - self._init_deferred_outputs = init_deferred_outputs - self._deferred_output_options = deferred_output_args - self._deferred_data = [] - - if input_ready is None or all(input_ready): - # all good to go - self._input_ready = True - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - # prepare FFmpeg keyword arguments - self._args = { - "ffmpeg_args": ffmpeg_args, - "progress": progress, - "capture_log": True, - "sp_kwargs": sp_kwargs, - } - - # set the default read block size for the referenc stream - self.default_timeout = default_timeout - self._pipe_kws = {"queue_size": queuesize} - self._proc = None - - def __enter__(self): - - self.open() - return self - - def open(self): - """start FFmpeg processing - - Note - ---- - - It may flag to defer starting the FFmpeg process if the input streams - are not fully specified and must wait to deduce them from the written - data. - - """ - - if self._input_ready is True: - self._open(False) - - def _init_named_pipes(self) -> ExitStack: - - return configure.init_named_pipes( - self._args["ffmpeg_args"], - self._input_info, - self._output_info, - **self._pipe_kws, - ) - - def _write_deferred_data(self): - pass - - def _open(self, deferred: bool): - - if deferred: - # finalize the output configurations - self._output_info = self._init_deferred_outputs( - self._args["ffmpeg_args"], - self._input_info, - self._deferred_output_options, - self._deferred_data, - ) - - # set up and activate pipes and read/write threads - stack = self._init_named_pipes() - - # run the FFmpeg - try: - self._proc = ffmpegprocess.Popen( - **self._args, on_exit=lambda _: stack.close() - ) - except: - stack.close() - raise - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - # if any pending data, queue them - if deferred: - self._write_deferred_data() - - return self - - def close(self): - """Kill FFmpeg process and close the streams""" - - if self._proc is not None and self._proc.poll() is None: - # kill the ffmpeg runtime - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - self._proc = None - - def __exit__(self, *exc_details) -> bool: - try: - self.close() - return False - except: - if not exc_details[0]: - exc_details = sys.exc_info() - finally: - try: - self._logger.join() - except RuntimeError: - pass - - @property - def closed(self) -> bool: - """True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self) -> FFmpegError: - """Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def readlog(self, n: int) -> str: - """read FFmpeg log lines - - :param n: number of lines to read - :return: logged messages - """ - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def wait(self, timeout: float | None = None) -> int | None: - """close all input pipes and wait for FFmpeg to exit - - :param timeout: a timeout for blocking in seconds, or fractions - thereof, defaults to None, to wait indefinitely - :raise `TimeoutExpired`: if a timeout is set, and the process does not - terminate after timeout seconds. It is safe to - catch this exception and retry the wait. - :return returncode: return subprocess Popen returncode attribute - """ - - if timeout is None: - timeout = self.default_timeout - - if self._proc: - if timeout is not None: - timeout += time() - - # write the sentinel to each input queue - for info in self._input_info: - if "writer" in info: - info["writer"].write( - None, None if timeout is None else timeout - time() - ) - - # wait until the FFmpeg finishes the job - try: - self._proc.wait(None if timeout is None else timeout - time()) - except TimeoutError: - raise - else: - rc = self._proc.returncode - if rc is not None: - self._proc = None - else: - rc = None - return rc - - -class _RawInputMixin: - _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} - _array_to_opts = { - "video": utils.array_to_video_options, - "audio": utils.array_to_audio_options, - } - - def __init__(self, **kwargs): - super().__init__(**kwargs) - hook = plugins.get_hook() - self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} - - # input data must be initially buffered - self._deferred_data = [[] for _ in range(len(self._input_info))] - - def _write_deferred_data(self): - for src, info in zip(self._deferred_data, self._input_info): - if "writer" in info and len(src): - writer = info["writer"] - media_type = info["media_type"] - for data in src: - writer.write( - self._get_bytes[media_type](obj=data), self.default_timeout - ) - self._deferred_data = [] - self._input_ready = True - - def _write_stream( - self, - info: OutputDestinationDict, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - media_type = info["media_type"] - b = self._get_bytes[media_type](obj=data) - if not len(b): - return - - if (self._input_ready or self._input_ready[stream_id]) is not True: - # need to collect input data type and shape from the actual data - # before starting the FFmpeg - - configure.update_raw_input( - self._args["ffmpeg_args"], self._input_info, stream_id, data - ) - - self._deferred_data[stream_id].append(b) - self._input_ready[stream_id] = True - - if all(self._input_ready): - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) - - else: - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() - - def write_stream( - self, stream_id: int, data: RawDataBlob, timeout: float | None = None - ): - """write a raw media data to a specified stream - - :param stream_id: input stream index or label - :param data: media data blob (depends on the active data conversion plugin) - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - :return: currently available encoded data (bytes) if returning the encoded - data back to Python - - Write the given NDArray object, data, and return the number - of bytes written (always equal to the number of data frames/samples, - since if the write fails an OSError will be raised). - - When in non-blocking mode, a BlockingIOError is raised if the data - needed to be written to the raw stream but it couldn’t accept all - the data without blocking. - - The caller may release or mutate data after this method returns, - so the implementation should only access data during the method call. - - """ - - # get input stream information - try: - info = self._input_info[stream_id] - except IndexError: - raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") - - if timeout is None: - timeout = self.default_timeout - - self._write_stream(info, stream_id, data, timeout) - - def write( - self, - data: Sequence[RawDataBlob] | dict[int, RawDataBlob], - timeout: float | None = None, - ) -> bytes | None: - """write data to all input streams - - :param data: media data blob keyed by stream index - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - - """ - - it_data = data.items() if isinstance(data, dict) else enumerate(data) - - if timeout is None: - timeout = self.default_timeout - - if timeout is not None: - timeout += time() - - info = self._input_info - for stream_id, stream_data in it_data: - self._write_stream( - info[stream_id], - stream_id, - stream_data, - None if timeout is None else timeout - time(), - ) - - -class _EncodedInputMixin: - def __init__(self, **kwargs): - - super().__init__(**kwargs) - - def _write_deferred_data(self): - for data, info in zip(self._deferred_data, self._input_info): - if len(data) and "writer" in info: - info["writer"].write(data, self.default_timeout) - self._deferred_data = [] - self._input_ready = True - - def _write_encoded_stream( - self, - index: int, - info: OutputDestinationDict, - data: bytes, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - if (self._input_ready or self._input_ready[index]) is not True: - # buffer must be contiguous - data0 = self._deferred_data[index] - if len(data0): - data = data0.append(data) - else: - self._deferred_data[index] = data - - # need to be able to probe the input streams before starting the FFmpeg - try: - probe.format_basic(data) - except FFmpegError: - pass # not ready yet - else: - self._input_ready[index] = True - - if all(self._input_ready): - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) - - else: - try: - info["writer"].write(data, timeout) - except: - raise FFmpegioError("Cannot write to a non-piped input.") - - def write_encoded_stream( - self, stream_id: int, data: bytes, timeout: float | None = None - ): - """write a raw media data to a specified stream - - :param stream_id: input stream index or label - :param data: media data bytes - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - :return: currently available encoded data (bytes) if returning the encoded - data back to Python - - Write the given NDArray object, data, and return the number - of bytes written (always equal to the number of data frames/samples, - since if the write fails an OSError will be raised). - - When in non-blocking mode, a BlockingIOError is raised if the data - needed to be written to the raw stream but it couldn’t accept all - the data without blocking. - - The caller may release or mutate data after this method returns, - so the implementation should only access data during the method call. - - """ - - # get input stream information - try: - info = self._input_info[stream_id] - except IndexError: - raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") - - if timeout is None: - timeout = self.default_timeout - - self._write_encoded_stream(stream_id, info, data, timeout) - - def write_encoded( - self, - data: Sequence[RawDataBlob] | dict[int, RawDataBlob], - timeout: float | None = None, - ) -> bytes | None: - """write data to all input streams - - :param data: media byte data keyed by stream index - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - - """ - - it_data = data.items() if isinstance(data, dict) else enumerate(data) - - if timeout is None: - timeout = self.default_timeout - - if timeout is not None: - timeout += time() - - info = self._input_info - for stream_id, stream_data in it_data: - self._write_encoded_stream( - stream_id, - info[stream_id], - stream_data, - None if timeout is None else timeout - time(), - ) - - -class _RawOutputMixin: - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(**kwargs) - hook = plugins.get_hook() - self._converters = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio} - self._get_num = {"video": hook.video_frames, "audio": hook.audio_samples} - - # set the default read block size for the reference stream - self._blocksize = blocksize - self._ref = ref_output - self._rates = None - self._n0 = None # timestamps of the last read sample - - @property - def output_labels(self) -> list[str]: - """FFmpeg/custom labels of output streams""" - return [v["user_map"] for v in self._output_info] - - @property - def output_types(self) -> dict[str, MediaType]: - """media type associated with the output streams (key)""" - return {v["user_map"]: v["media_type"] for v in self._output_info} - - @property - def output_rates(self) -> dict[str, int | Fraction]: - """sample or frame rates associated with the output streams (key)""" - return {v["user_map"]: v["raw_info"][2] for v in self._output_info} - - @property - def output_dtypes(self) -> dict[str, DTypeString]: - """frame/sample data type associated with the output streams (key)""" - return {v["user_map"]: v["raw_info"][1] for v in self._output_info} - - @property - def output_shapes(self) -> dict[str, ShapeTuple]: - """frame/sample shape associated with the output streams (key)""" - return {v["user_map"]: v["raw_info"][0] for v in self._output_info} - - @property - def output_counts(self) -> dict[str, int]: - """number of frames/samples read""" - return {v["user_map"]: n for v, n in zip(self._output_info, self._n0)} - - def _init_named_pipes(self) -> ExitStack: - - # set the default read block size for the referenc stream - info = self._output_info[self._ref] - if self._blocksize is None: - self._blocksize = 1 if info["media_type"] == "video" else 1024 - self._rates = [v["raw_info"][2] for v in self._output_info] - self._n0 = [0] * len(self._output_info) # timestamps of the last read sample - self._pipe_kws = { - **self._pipe_kws, - "update_rate": self._rates[self._ref] / Fraction(self._blocksize), - } - - # set up and activate pipes and read/write threads - return super()._init_named_pipes() - - def _read_stream( - self, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - converter = self._converters[info["media_type"]] - dtype, shape, _ = info["raw_info"] - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=False - ) - - # update the frame/sample counter - n = self._get_num[info["media_type"]](obj=data) # actual number read - self._n0[stream_id] += n - - return data - - def read_stream( - self, stream_id: int | str, n: int, timeout: float | None = None - ) -> RawDataBlob: - """read selected output stream - - :param stream_id: stream index or label - :param n: number of frames/samples to read, defaults to -1 to read as many as available - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is read - from the buffer queue - :return: retrieved data - - Effect of mixing `n` and `timeout` - ---------------------------------- - - === ========= ========================================================================= - `n` `timeout` Behavior - === ========= ========================================================================= - 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` frames/samples are retrieved - >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes - <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as many frames/samples until `timeout` seconds passes - === ========= ========================================================================= - - """ - - if timeout is None: - timeout = self.default_timeout - - info = self._output_info - stream_id = utils.get_output_stream_id(info, stream_id) - return self._read_stream(info[stream_id], stream_id, n, timeout) - - def read(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob]: - """Read data from all output streams - - :param n: number of frames/samples of the reference output stream to read - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is read - from the buffer queue - :return: retrieved data keyed by output streams - - Read all output streams and return retrieved data up to `n` frames/samples - of the reference output stream. The amount of the data of the other output - streams are calculated to match the time span of the retrieved reference - data. - - The returned `dict` is keyed by the output labels. - - Effect of mixing `n` and `timeout` - ---------------------------------- - - === ========= ========================================================================= - `n` `timeout` Behavior - === ========= ========================================================================= - 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` frames/samples are retrieved - >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes - <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as many frames/samples until `timeout` seconds passes - === ========= ========================================================================= - """ - - data = {} # output - - if timeout is None: - timeout = self.default_timeout - - if timeout is not None: - timeout += time() - - get_timeout = lambda: None if timeout is None else max(timeout - time(), 0) - - get_all = n < 0 and timeout is None - - # read the reference stream - i0 = self._ref - n0 = self._n0[i0] - ref_data = self._read_stream(self._output_info[i0], i0, n, get_timeout()) - if not get_all: - # get the timestamp of the final frame - T = (self._n0[i0] - n0) / self._rates[i0] - - # retrieve all the other streams up to T seconds mark - for i, info in enumerate(self._output_info): - if i != i0: - if not get_all: - n1 = int(T * self._rates[i]) - n = max(n1 - self._n0[i], 0) - stream_data = self._read_stream(info, i, n, get_timeout()) - else: - stream_data = ref_data - data[info["user_map"]] = stream_data - - return data - - -class _EncodedOutputMixin: - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - hook = plugins.get_hook() - - # set the default read block size - self._blocksize = blocksize - - def _init_named_pipes(self) -> ExitStack: - - # set the default read block size for the referenc stream - self._pipe_kws = {**self._pipe_kws, "blocksize": self._blocksize} - - # set up and activate pipes and read/write threads - return super()._init_named_pipes() - - def _read_encoded_stream( - self, - info: OutputDestinationDict, - n: int, - timeout: float | None = None, - ) -> bytes: - """read selected output stream (shared backend)""" - - return info["reader"].read(n, timeout) - - def read_encoded_stream( - self, stream_id: int, n: int, timeout: float | None = None - ) -> bytes: - """read selected output stream - - :param stream_id: stream index or label - :param n: number of bytes to read - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is read - from the buffer queue - :return: retrieved data - - Effect of mixing `n` and `timeout` - ---------------------------------- - - === ========= ========================================================================= - `n` `timeout` Behavior - === ========= ========================================================================= - 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` bytes are retrieved - >0 `float` Retrieve as many bytes up to `n` before `timeout` seconds passes - <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as many bytes until `timeout` seconds passes - === ========= ========================================================================= - - """ - - if timeout is None: - timeout = self.default_timeout - - info = self._output_info - stream_id = utils.get_output_stream_id(info, stream_id) - return self._read_encoded_stream(info[stream_id], n, timeout) - - def readall_encoded(self, timeout: float | None = None) -> dict[str, bytes]: - """Read available data from all output streams - - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until FFmpeg stops - :return: retrieved data keyed by output streams - - """ - - data = {} # output - - if timeout is None: - timeout = self.default_timeout - - if timeout is not None: - timeout += time() - - get_timeout = lambda: None if timeout is None else max(timeout - time(), 0) - - # retrieve all the other streams up to T seconds mark - for i, info in enumerate(self._output_info): - data[i] = self._read_encoded_stream(info, -1, get_timeout()) - - return data - - -class PipedMediaReader(_EncodedInputMixin, _RawOutputMixin, _PipedFFmpegRunner): - def __init__( - self, - *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], - map: Sequence[str] | dict[str, FFmpegOptionDict] | None = None, - ref_stream: int = 0, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Read video and audio data from multiple media files - - :param *urls: URLs of the media files to read or a tuple of the URL and its input option dict. - :param map: FFmpeg map options - :param ref_stream: index of the reference stream to pace read operation, defaults to 0. The - reference stream is guaranteed to have a frame data on every read operation. - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param progress: progress callback function, defaults to None - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - - Note: To read a single stream from a single source, use `audio.read()`, `video.read()` or `image.read()` - for reducing the overhead - - Specify the streams to return by `map` output option: - - map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - - Unlike :py:mod:`video` and :py:mod:`image`, video pixel formats are not autodetected. If output - 'pix_fmt' option is not explicitly set, 'rgb24' is used. - - For audio streams, if 'sample_fmt' output option is not specified, 's16'. - """ - - # initialize FFmpeg argument dict and get input & output information - args, input_info, ready, output_info, output_args = configure.init_media_read( - urls, map, {"probesize_in": 32, **options} - ) - - super().__init__( - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=ready, - init_deferred_outputs=configure.init_media_read_outputs, - deferred_output_args=output_args, - ref_output=ref_stream, - blocksize=blocksize, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - hook = plugins.get_hook() - self._get_bytes = {"video": hook.video_bytes, "audio": hook.audio_bytes} - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self._blocksize, self.default_timeout) - if not any( - len(self._get_bytes[info["media_type"]](obj=f)) - for f, info in zip(F.values(), self._output_info) - ): - raise StopIteration - return F - - -class PipedMediaWriter(_EncodedOutputMixin, _RawInputMixin, _PipedFFmpegRunner): - def __init__( - self, - urls: ( - FFmpegOutputUrlComposite - | list[ - FFmpegOutputUrlComposite - | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ] - ), - stream_types: Sequence[Literal["a", "v"]], - *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - merge_audio_streams: bool | Sequence[int] = False, - merge_audio_ar: int | None = None, - merge_audio_sample_fmt: str | None = None, - merge_audio_outpad: str | None = None, - overwrite: bool | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Write video and audio data from multiple media streams to one or more files - - :param url: output url - :param stream_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) - :param input_rates_or_opts: either sample rate (audio) or frame rate (video) - or a dict of input options. The option dict must - include `'ar'` (audio) or `'r'` (video) to specify - the rate. - :param input_dtypes: list of numpy-style data type strings of input samples - or frames of input media streams, defaults to `None` - (auto-detect). - :param input_shapes: list of shapes of input samples or frames of input media - streams, defaults to `None` (auto-detect). - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param merge_audio_streams: True to combine all input audio streams as a single multi-channel stream. Specify a list of the input stream id's - (indices of `stream_types`) to combine only specified streams. - :param merge_audio_ar: Sampling rate of the merged audio stream in samples/second, defaults to None to use the sampling rate of the first merging stream - :param merge_audio_sample_fmt: Sample format of the merged audio stream, defaults to None to use the sample format of the first merging stream - :param overwrite: True to overwrite existing files, defaults to None (auto-set) - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param progress: progress callback function, defaults to None - :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - """ - - if not isinstance(urls, list): - urls = [urls] - - options = {"probesize_in": 32, **options} - if overwrite: - if "n" in options: - raise FFmpegioError( - "cannot specify both `overwrite=True` and `n=ff.FLAG`." - ) - options["y"] = None - - stream_args = [ - (None, v) if isinstance(v, dict) else (v, None) for v in input_rates_or_opts - ] - args, input_info, input_ready, output_info, output_args = ( - configure.init_media_write( - urls, - stream_types, - stream_args, - merge_audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad, - extra_inputs, - {"probesize_in": 32, **options}, - input_dtypes, - input_shapes, - ) - ) - - super().__init__( - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=input_ready, - init_deferred_outputs=configure.init_media_write_outputs, - deferred_output_args=output_args, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - blocksize=blocksize, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - -class PipedMediaFilter(_RawOutputMixin, _RawInputMixin, _PipedFFmpegRunner): - def __init__( - self, - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], - input_types: Sequence[Literal["a", "v"]], - *input_rates_or_opts: *tuple[int | Fraction | FFmpegOptionDict, ...], - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - ref_output: int = 0, - output_options: dict[str, FFmpegOptionDict] | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Filter audio/video data streams with FFmpeg filtergraphs - - :param expr: complex filtergraph expression or a list of filtergraphs - :param input_types: list/string of input stream media types, each element is either 'a' (audio) or 'v' (video) - :param input_rates_or_opts: raw input stream data arguments, each input stream is either a tuple of a sample rate (audio) or frame rate (video) followed by a data blob - or a tuple of a data blob and a dict of input options. The option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. - :param input_dtypes: list of numpy-style data type strings of input samples - or frames of input media streams, defaults to `None` - (auto-detect). - :param input_shapes: list of shapes of input samples or frames of input media - streams, defaults to `None` (auto-detect). - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param ref_output: index or label of the reference stream to pace read operation, defaults to 0. - `PipedMediaFilter.read()` operates around the reference stream. - :param output_options: specific options for keyed filtergraph output pads. - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param progress: progress callback function, defaults to None - :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) - : defaults to `None` to use 1 video frame or 1024 audio frames - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options - will be applied to all input streams unless the option has been already defined in `stream_data` - """ - - input_args = [ - (None, v) if isinstance(v, dict) else (v, None) for v in input_rates_or_opts - ] - - ( - args, - input_info, - input_ready, - output_info, - deferred_output_args, - ) = configure.init_media_filter( - expr, - input_types, - input_args, - extra_inputs, - input_dtypes, - input_shapes, - {"probesize_in": 32, **options}, - output_options or {}, - ) - - super().__init__( - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=input_ready, - init_deferred_outputs=configure.init_media_filter_outputs, - deferred_output_args=deferred_output_args, - ref_output=ref_output, - blocksize=blocksize, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - -class PipedMediaTranscoder(_EncodedOutputMixin, _EncodedInputMixin, _PipedFFmpegRunner): - """Class to transcode encoded media streams""" - - def __init__( - self, - input_options: Sequence[FFmpegOptionDict], - output_options: Sequence[FFmpegOptionDict], - *, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict = None, - **options: Unpack[FFmpegOptionDict], - ): - """Encoded media stream transcoder - - :param input_options: FFmpeg input option dicts of all the input pipes. Each dict - must contain the `"f"` option to specify the media format. - :param output_options: FFmpeg output option dicts of all the output pipes. Each dict - must contain the `"f"` option to specify the media format. - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param extra_outputs: list of additional output destinations, defaults to None. Each destination - may be url string or a pair of a url string and an option dict. - :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :param options: global/default FFmpeg options. For output and global options, - use FFmpeg option names as is. For input options, append "_in" to the - option name. For example, r_in=2000 to force the input frame rate - to 2000 frames/s (see :doc:`options`). These input and output options - specified here are treated as default, common options, and the - url-specific duplicate options in the ``inputs`` or ``outputs`` - sequence will overwrite those specified here. - """ - - args, input_info, output_info = configure.init_media_transcoder( - [("pipe", opts) for opts in input_options], - [("pipe", opts) for opts in output_options], - extra_inputs, - extra_outputs, - {"y": None, **options}, - ) - - super().__init__( - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=None, - init_deferred_outputs=None, - deferred_output_args=None, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - blocksize=blocksize, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py deleted file mode 100644 index 54935819..00000000 --- a/src/ffmpegio/streams/SimpleStreams.py +++ /dev/null @@ -1,1301 +0,0 @@ -"""SimpleStreams Module: FFmpeg""" - -from __future__ import annotations - -from time import time -import logging - -logger = logging.getLogger("ffmpegio") - -from typing import Literal -from fractions import Fraction -from .._typing import RawDataBlob -from ..filtergraph.abc import FilterGraphObject -from ..errors import FFmpegioError - -from .. import utils, configure, ffmpegprocess, plugins -from ..threading import LoggerThread, ReaderThread, WriterThread - -# fmt:off -__all__ = [ "SimpleVideoReader", "SimpleAudioReader", "SimpleVideoWriter", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter"] -# fmt:on - - -class SimpleReaderBase: - """base class for SISO media read stream classes""" - - def __init__( - self, - converter, - viewer, - url, - show_log=None, - progress=None, - blocksize=None, - sp_kwargs=None, - **options, - ) -> None: - self._converter = converter # :Callable: f(b,dtype,shape) -> data_object - self._memoryviewer = viewer #:Callable: f(data_object)->bytes-like object - self.dtype = None # :str: output data type - self.shape = ( - None # :tuple of ints: dimension of each video frame or audio sample - ) - self.samplesize = ( - None #:int: number of bytes of each video frame or audio sample - ) - self.blocksize = None #:positive int: number of video frames or audio samples to read when used as an iterator - self.sp_kwargs = sp_kwargs #:dict[str,Any]: additional keyword arguments for subprocess.Popen - - # get url/file stream - options = {"probesize_in": 32, **options} - input_options = utils.pop_extra_options(options, "_in") - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) - ) - - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options) - configure.add_url(ffmpeg_args, "output", "-", options) - - # abstract method to finalize the options => sets self.dtype and self.shape if known - self._finalize(ffmpeg_args) - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - {"stdin": stdin, "progress": progress, "capture_log": True, "bufsize": 0} - ) - - # start FFmpeg - self._proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - # if byte data is given, feed it - if input is not None: - self._proc.stdin.write(input) - - # wait until output stream log is captured if output format is unknown - try: - if self.dtype is None or self.shape is None: - logger.debug( - "[reader main] waiting for logger to provide output stream info" - ) - info = self._logger.output_stream() - logger.debug(f"[reader main] received {info}") - self._finalize_array(info) - else: - self._logger.index("Output") - except: - if self._proc.poll() is None: - raise self._logger.Exception - else: - raise ValueError("failed retrieve output data format") - - self.samplesize = utils.get_samplesize(self.shape, self.dtype) - - self.blocksize = blocksize or max(1024**2 // self.samplesize, 1) - logger.debug("[reader main] completed init") - - def close(self): - """Flush and close this stream. This method has no effect if the stream is already - closed. Once the stream is closed, any read operation on the stream will raise - a ValueError. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - - """ - - if self._proc is None: - return - - self._proc.stdout.close() - self._proc.stderr.close() - - if self._proc.poll() is None: - try: - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - except: - print("failed to terminate") - pass - - logger.debug(f"[reader main] FFmpeg closed? {self._proc.poll()}") - - try: - self._proc.stdin.close() - except: - pass - self._logger.join() - - @property - def closed(self): - """:bool: True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError: Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self.blocksize) - if F is None: - raise StopIteration - return F - - def readlog(self, n=None): - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def read(self, n=-1): - """Read and return numpy.ndarray with up to n frames/samples. If - the argument is omitted, None, or negative, data is read and - returned until EOF is reached. An empty bytes object is returned - if the stream is already at EOF. - - If the argument is positive, and the underlying raw stream is not - interactive, multiple raw reads may be issued to satisfy the byte - count (unless EOF is reached first). But for interactive raw streams, - at most one raw read will be issued, and a short result does not - imply that EOF is imminent. - - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" - logger.debug(f"[reader main] reading {n} samples") - b = self._proc.stdout.read(n * self.samplesize if n > 0 else n) - logger.debug(f"[reader main] read {len(b)} bytes") - if not len(b): - self._proc.stdout.close() - return None - return self._converter(b=b, shape=self.shape, dtype=self.dtype, squeeze=False) - - def readinto(self, array): - """Read bytes into a pre-allocated, writable bytes-like object array and - return the number of bytes read. For example, b might be a bytearray. - - Like read(), multiple reads may be issued to the underlying raw stream, - unless the latter is interactive. - - A BlockingIOError is raised if the underlying raw stream is in non - blocking-mode, and has no data available at the moment.""" - - return ( - self._proc.stdout.readinto(self._memoryviewer(obj=array)) // self.samplesize - ) - - -class SimpleVideoReader(SimpleReaderBase): - readable = True - writable = False - multi_read = False - multi_write = False - - def __init__( - self, - url, - *, - show_log=None, - progress=None, - blocksize=1, - sp_kwargs=None, - **options, - ): - hook = plugins.get_hook() - super().__init__( - hook.bytes_to_video, - hook.video_bytes, - url, - show_log, - progress, - blocksize, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args): - # finalize FFmpeg arguments and output array - - inopts = ffmpeg_args.get("inputs", [])[0][1] - outopts = ffmpeg_args.get("outputs", [])[0][1] - - outopts["map"] = "0:v:0" - ( - self.dtype, - self.shape, - self.rate, - ) = configure.finalize_video_read_opts( - ffmpeg_args, - input_info=[ - { - "src_type": ( - "filtergraph" if outopts.get("f", None) == "lavfi" else "url" - ) - } - ], - ) - - pix_fmt = outopts.get("pix_fmt", None) - pix_fmt_in = inopts.get("pix_fmt", None) - - if pix_fmt_in is None and pix_fmt is None: - raise ValueError("pix_fmt must be specified.") - - # construct basic video filter if options specified - configure.build_basic_vf( - ffmpeg_args, utils.alpha_change(pix_fmt_in, pix_fmt, -1) - ) - - def _finalize_array(self, info): - # finalize array setup from FFmpeg log - - self.rate = info["r"] - self.dtype, self.shape = utils.get_video_format(info["pix_fmt"], info["s"]) - - -class SimpleAudioReader(SimpleReaderBase): - readable = True - writable = False - multi_read = False - multi_write = False - - def __init__( - self, - url, - *, - show_log=None, - progress=None, - blocksize=None, - sp_kwargs=None, - **options, - ): - hook = plugins.get_hook() - super().__init__( - hook.bytes_to_audio, - hook.audio_bytes, - url, - show_log, - progress, - blocksize, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args): - # finalize FFmpeg arguments and output array - - outopts = ffmpeg_args["outputs"][0][1] - outopts["map"] = "0:a:0" - ( - self.dtype, - self.shape, - self.rate, - ) = configure.finalize_audio_read_opts( - ffmpeg_args, - input_info=[ - { - "src_type": ( - "filtergraph" if outopts.get("f", None) == "lavfi" else "url" - ) - } - ], - ) - - def _finalize_array(self, info): - # finalize array setup from FFmpeg log - - self.rate = info["ar"] - self.dtype, self.shape = utils.get_audio_format( - info["sample_fmt"], info.get("ac", 1) - ) - - @property - def channels(self): - return self.shape[-1] - - -########################################################################### - - -class SimpleWriterBase: - def __init__( - self, - viewer, - url, - input_shape=None, - input_dtype=None, - show_log=None, - progress=None, - overwrite=None, - extra_inputs=None, - sp_kwargs=None, - **options, - ) -> None: - self._proc = None - self._viewer = viewer - self.input_dtype = input_dtype - self.input_shape = input_shape - - # get url/file stream - url, stdout, _ = configure.check_url(url, True) - - options = {"probesize_in": 32, **options} - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", "-", input_options) - configure.add_url(ffmpeg_args, "output", url, options) - - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - # abstract method to finalize the options only if self.dtype and self.shape are given - ready = self._finalize(ffmpeg_args) - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - # FFmpeg Popen arguments - self._cfg = {**sp_kwargs} if sp_kwargs else {} - self._cfg.update( - { - "ffmpeg_args": ffmpeg_args, - "progress": progress, - "capture_log": True, - "overwrite": overwrite, - "stdout": stdout, - "bufsize": 0, - } - ) - - if ready: - self._open() - - def _open(self, data=None): - # if data array is given, finalize the FFmpeg configuration with it - if data is not None: - self._finalize_with_data(data) - - # start FFmpeg - self._proc = ffmpegprocess.Popen(**self._cfg) - self._cfg = False - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - def close(self): - """close the output stream""" - if self._proc is None: - return - - if self._proc.stdin and not self._proc.stdin.closed: - try: - self._proc.stdin.close() # flushes the buffer first before closing - except OSError as e: - logger.error(e) - self._proc.wait() - if self._proc.stderr and not self._proc.stderr.closed: - try: - self._proc.stderr.close() - except OSError as e: - logger.error(e) - - self._logger.join() - - @property - def closed(self): - """:bool: True if stream is closed""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError or None: Last caught FFmpeg error""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def readlog(self, n=None): - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def write(self, data): - """Write the given numpy.ndarray object, data, and return the number - of bytes written (always equal to the number of data frames/samples, - since if the write fails an OSError will be raised). - - When in non-blocking mode, a BlockingIOError is raised if the data - needed to be written to the raw stream but it couldn’t accept all - the data without blocking. - - The caller may release or mutate data after this method returns, - so the implementation should only access data during the method call. - - """ - - if self._cfg: - # if FFmpeg not yet started, finalize the configuration with - # the data and start - self._open(data) - - logger.debug("[writer main] writing...") - - try: - self._proc.stdin.write(self._viewer(obj=data)) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() - - def flush(self): - self._proc.stdin.flush() - - -class SimpleVideoWriter(SimpleWriterBase): - readable = False - writable = True - multi_read = False - multi_write = False - - def __init__( - self, - url, - rate_in, - *, - input_shape=None, - input_dtype=None, - extra_inputs=None, - overwrite=None, - show_log=None, - progress=None, - sp_kwargs=None, - **options, - ): - options["r_in"] = rate_in - if "r" not in options: - options["r"] = rate_in - - super().__init__( - plugins.get_hook().video_bytes, - url, - input_shape, - input_dtype, - show_log, - progress, - overwrite, - extra_inputs, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args) -> None: - inopts = ffmpeg_args["inputs"][0][1] - inopts["f"] = "rawvideo" - - ready = "s" in inopts and "pix_fmt" in inopts - - if not (ready or (self.input_dtype is None or self.input_shape is None)): - s, pix_fmt = utils.guess_video_format((self.input_shape, self.input_dtype)) - if "s" not in inopts: - inopts["s"] = s - if "pix_fmt" not in inopts: - inopts["pix_fmt"] = pix_fmt - ready = True - - if ready: - # set basic video filter chain if related options are specified - configure.build_basic_vf( - ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1) - ) - return ready - - def _finalize_with_data(self, data): - ffmpeg_args = self._cfg["ffmpeg_args"] - inopts = ffmpeg_args["inputs"][0][1] - shape, dtype = plugins.get_hook().video_info(obj=data) - s, pix_fmt = utils.guess_video_format(shape, dtype) - - configure.build_basic_vf( - ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1) - ) - - if "s" not in inopts: - inopts["s"] = s - if "pix_fmt" not in inopts: - inopts["pix_fmt"] = pix_fmt - - self.input_shape = shape - self.input_dtype = dtype - - -class SimpleAudioWriter(SimpleWriterBase): - readable = False - writable = True - multi_read = False - multi_write = False - - def __init__( - self, - url, - rate_in, - *, - input_shape=None, - input_dtype=None, - extra_inputs=None, - overwrite=None, - show_log=None, - progress=None, - sp_kwargs=None, - **options, - ): - options["ar_in"] = rate_in - if "ar" not in options: - options["ar"] = rate_in - - super().__init__( - plugins.get_hook().audio_bytes, - url, - input_shape, - input_dtype, - show_log, - progress, - overwrite, - extra_inputs, - sp_kwargs, - **options, - ) - - def _finalize(self, ffmpeg_args): - # ffmpeg_args must have sample format & sampling rate specified - inopts = ffmpeg_args["inputs"][0][1] - ready = "sample_fmt" in inopts and "ac" in inopts - - if not ready and (self.input_dtype is not None or self.input_shape is not None): - inopts = ffmpeg_args["inputs"][0][1] - inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.input_shape, self.input_dtype - ) - ready = True - - if ready and not ("c:a" in inopts or "acodec" in inopts): - # fill audio codec and format options - inopts["c:a"], inopts["f"] = utils.get_audio_codec(inopts["sample_fmt"]) - if "acodec" in inopts: - del inopts["acodec"] - - return ready - - def _finalize_with_data(self, data): - self.input_shape, self.input_dtype = plugins.get_hook().audio_info(obj=data) - - inopts = self._cfg["ffmpeg_args"]["inputs"][0][1] - inopts["sample_fmt"], inopts["ac"] = utils.guess_audio_format( - self.input_shape, self.input_dtype - ) - inopts["c:a"], inopts["f"] = utils.get_audio_codec(inopts["sample_fmt"]) - - -############################################################################### - - -class SimpleFilterBase: - """base class for SISO media filter stream classes - - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None - :param rate_in: input sample rate - :type rate_in: int, float, Fraction, str - :param shape_in: input single-sample array shape, defaults to None - :type shape_in: seq of ints, optional - :param dtype_in: input data type string, defaults to None - :type dtype_in: str, optional - :param rate: output sample rate, defaults to None (auto-detect) - :type rate: int, float, Fraction, str, optional - :param shape: output single-sample array shape, defaults to None - :type shape: seq of ints, optional - :param dtype: output data type string, defaults to None - :type dtype: str, optional - :param blocksize: read buffer block size in samples, defaults to None - :type blocksize: int, optional - :param default_timeout: default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - - :type show_log: bool, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - - """ - - stream_type: Literal["a", "v"] - - # fmt:off - def _set_options(self, options, shape, dtype, rate=None, expr=None): ... - def _pre_open(self, ffmpeg_args): ... - def _finalize_output(self, info): ... - # fmt:on - - def __init__( - self, - converter, - data_viewer, - info_viewer, - expr, - rate_in, - shape_in=None, - dtype_in=None, - rate=None, - shape=None, - dtype=None, - blocksize=None, - default_timeout=None, - progress=None, - show_log=None, - sp_kwargs=None, - **options, - ) -> None: - if not rate_in: - if rate: - rate_in = rate - else: - raise ValueError("Either rate_in or rate must be defined.") - - # :Callable: create a new data block object - self._converter = converter - - # :Callable: get bytes-like object of the data block obj - self._memoryviewer = data_viewer - - # :Callable: get bytes-like object of the data block obj - self._infoviewer = info_viewer - - #:float: default filter operation timeout in seconds - self.default_timeout = default_timeout or 10e-3 - - #:int|Fraction: input sample rate - self.rate_in = rate_in - #:int|Fraction: output sample rate - self.rate = rate - - #:str: input array dtype - self.dtype_in = dtype_in - #:tuple(int): input array shape - self.shape_in = shape_in - #:str: output array dtype - self.dtype = dtype - #:tuple(int): output array shape - self.shape = shape - - self.nin = 0 #:int: total number of input samples sent to FFmpeg - self.nout = 0 #:int: total number of output sampless received from FFmpeg - # :float: # of output samples per 1 input sample - self._out2in = None - - # set this to false in _finalize() if guaranteed for the logger to have output stream info - self._loggertimeout = True - - self._proc = None - - options = {"probesize_in": 32, **options} - inopts = utils.pop_extra_options(options, "_in") - glopts = utils.pop_global_options(options) - - if "filter_complex" in glopts: - # prepare complex filter output - FFmpegioError( - "To use complex filtergraph (i.e., the `filter_complex` global option), use the PipedFilter class instead." - ) - - try: - not_ready, self.shape_in, self.dtype_in = self._set_options( - inopts, shape_in, dtype_in, rate_in - ) - except FFmpegioError as exc: - raise FFmpegioError( - exc.args[0].replace("dtype", "dtype_in").replace("shape", "shape_in") - ) from exc - - self._set_options(options, shape, dtype, rate, expr) - self._output_opts = options - - ffmpeg_args = configure.empty(glopts) - self._input_info = configure.process_raw_inputs( - ffmpeg_args, self.stream_type, [(None, inopts)], {} - ) - configure.assign_input_url(ffmpeg_args, 0, "pipe:0") - - # create the stdin writer without assigning the sink stream - self._writer = WriterThread(None, 0) - - # create the stdout reader without assigning the source stream - self._reader = ReaderThread(None, blocksize, 0) - self._reader_needs_info = True - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - # FFmpeg Popen arguments - self._cfg = {**sp_kwargs} if sp_kwargs else {} - self._cfg.update( - { - "ffmpeg_args": ffmpeg_args, - "progress": progress, - "capture_log": True, - "bufsize": 0, - } - ) - - # if input is fully configured, start FFmpeg now - if not not_ready: - self._open() - - def _open(self, data=None): - ffmpeg_args = self._cfg["ffmpeg_args"] - in_opts = ffmpeg_args["inputs"][0][1] - - # if data array is given, finalize input options (updates the initial options) - if data is not None: - _, self.shape_in, self.dtype_in = self._set_options( - in_opts, *self._infoviewer(obj=data) - ) - - # add the output pipe - self.dtype, self.shape, self.rate = configure.process_raw_outputs( - ffmpeg_args, - self._input_info, - [f"0:{self.stream_type}:0"], - self._output_opts, - )[0][0]["media_info"] - configure.assign_output_url(ffmpeg_args, 0, "pipe:1") - - # start FFmpeg - self._proc = ffmpegprocess.Popen(**self._cfg) - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - # start the writer - self._writer.stdin = self._proc.stdin - self._writer.start() - - if self.rate is not None and self.dtype is not None and self.shape is not None: - self._reader_needs_info = False - self._start_reader() - self._cfg = False - - def _get_output_info(self, timeout): - # run after the first input block is sent to FFmpeg - try: - info = self._logger.output_stream( - timeout=timeout if self._loggertimeout else None - ) - except TimeoutError as e: - raise e - except Exception: - if self._proc.poll() is None: - raise self._logger.Exception - else: - raise ValueError("failed retrieve output data format") - - self._finalize_output(info) - self._reader_needs_info = False - - def _start_reader(self): - self._bps_out = utils.get_samplesize(self.shape, self.dtype) - self._bps_in = utils.get_samplesize(self.shape_in, self.dtype_in) - self._out2in = self.rate / self.rate_in - - # start the FFmpeg output reader - self._reader.itemsize = self._bps_out - self._reader.stdout = self._proc.stdout - self._reader.start() - - self._reader_needs_info = False - - def close(self): - """Close the stream. - - This method has no effect if the stream is already closed. Once the - stream is closed, any read operation on the stream will raise a ThreadNotActive. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - """ - - if self._proc is None: - return - - try: - # write the sentinel to the writer thread to terminate immediately - self._writer.join() - except: - # possibly close before opening the writer thread - pass - - self._proc.stdout.close() - self._proc.stderr.close() - - # kill the process - try: - self._proc.terminate() - except: - pass - - self._proc.stdin.close() - - try: - self._logger.join() - except: - # possibly close before opening the logger thread - pass - try: - self._reader.join() - except: - # possibly close before opening the reader thread - pass - - @property - def closed(self): - """:bool: True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self): - """:FFmpegError: Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.close() - - def readlog(self, n=None): - """get FFmpeg log lines - - :param n: number of lines to return, defaults to None (every line logged) - :type n: int, optional - :return: string containing the requested logs - :rtype: str - - """ - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def filter(self, data, timeout=None): - """Run filter operation - - :param data: input data block - :type data: numpy.ndarray - :param timeout: timeout for the operation in seconds, defaults to None - :type timeout: float, optional - :return: output data block - :rtype: numpy.ndarray - - The input `data` array is expected to have the datatype specified by - Filter class' `dtype_in` property and the array shape to match Filter - class' `shape_in` property or with an additional dimension prepended. - - .. important:: - [audio only] For the first 2-seconds or 50000-samples, whichever - is smaller, TimeoutError may be raised because the necessary output - format information is not yet made available from FFmpeg. This - exception, however, only indicate the lack of output data and - the input data can be assumed properly enqueued to be sent to - FFmpeg process - - .. important:: - Once the output format is resolved, this method always return - numpy.ndarray object as output. However, the exact number of - samples is unknown, and it could be a properly shaped empty - array. Additional buffering may be required if the following - process requires a fixed number of samples. - - .. important:: - Filtering operation is always timed because the buffering - protocols used by various subsystems of FFmpeg are undeterminable - from Python. The operation timeout is controlled by `timeout` - argument if specified or else by `default_timeout` property. The - default timeout duration is 10 ms, but it could be optimized for - each use case (`blocksize` property, I/O rate ratio, typical size of - `data` argument, etc.). - - """ - - timeout = timeout or self.default_timeout - - timeout += time() - - if self._cfg: - # if FFmpeg not yet started, finalize the configuration with - # the data and start - self._open(data) - - inbytes = self._memoryviewer(obj=data) - - try: - self._writer.write(inbytes, timeout - time()) - except BrokenPipeError as e: - # TODO check log for error in FFmpeg - raise e - - if self._reader_needs_info: - # with the data written, FFmpeg should inform the output setup - self._get_output_info(timeout - time()) - self._start_reader() - - self.nin += len(inbytes) // self._bps_in - nread = (int(self.nin * self._out2in) - self.nout) * self._bps_out - y = self._reader.read(-nread, timeout - time()) - self.nout += len(y) // self._bps_out - return self._converter(b=y, dtype=self.dtype, shape=self.shape, squeeze=False) - - def flush(self, timeout: float = None) -> RawDataBlob: - """Close the stream input and retrieve the remaining output samples - - :param timeout: timeout duration in seconds, defaults to None - :return: remaining output samples - """ - - timeout = timeout or self.default_timeout - - # If no input, close stdin and read all remaining frames - self._writer.write(None) # sentinel message - self._writer.join() # wait until all written data reaches FFmpeg - self._proc.stdin.close() # close stdin -> triggers ffmpeg to shutdown - self._proc.wait() - y = self._reader.read_all(timeout) # read whatever is left in the read queue - nframes = len(y) // self._bps_out - self.nout += nframes - return self._converter( - b=y[: nframes * self._bps_out], - dtype=self.dtype, - shape=self.shape, - squeeze=False, - ) - - -class SimpleVideoFilter(SimpleFilterBase): - """SISO video filter stream class - - .. important:: - Number of output frames is not predetermined although it is - generally close to the expected number of frames based on the - number of input frames and the ratio of input and output frame - rate - - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None - :param rate_in: input frame rate - :type rate_in: int, float, Fraction, str - :param shape_in: input single-frame array shape, defaults to None - :type shape_in: seq of ints, optional - :param dtype_in: input numpy data type, defaults to None - :type dtype_in: str, optional - :param rate: output frame rate, defaults to None (auto-detect) - :type rate: int, float, Fraction, str, optional - :param shape: output single-frame array shape, defaults to None - :type shape: seq of ints, optional - :param dtype: output numpy data type, defaults to None - :type dtype: str, optional - :param blocksize: read buffer block size in frames, defaults to None (=1) - :type blocksize: int, optional - :param default_timeout: default filter timeout in seconds, defaults to None (10 ms) - :type default_timeout: float, optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - :type show_log: bool, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - - """ - - readable = True - writable = True - multi_read = False - multi_write = False - stream_type = "v" - - def __init__( - # fmt:off - self, - expr, - rate_in, - shape_in=None, - dtype_in=None, - rate=None, - shape=None, - dtype=None, - blocksize=None, - default_timeout=None, - progress=None, - show_log=None, - sp_kwargs=None, - **options, - # fmt:on - ) -> None: - hook = plugins.get_hook() - # fmt:off - super().__init__( - hook.bytes_to_video, hook.video_bytes, hook.video_info, - expr, rate_in, shape_in, dtype_in, rate, shape, dtype, - blocksize, default_timeout, progress, show_log, sp_kwargs,**options, - ) - # fmt:on - self._loggertimeout = False - - def _pre_open(self, ffmpeg_args): - # append basic video filter chain - configure.build_basic_vf( - ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1) - ) - - def _set_options( - self, - options: dict, - shape: tuple[int] | None, - dtype: str | None, - rate: Fraction | int | None = None, - expr: FilterGraphObject | None = None, - ) -> tuple[bool, tuple[int, ...], str]: - - pix_fmt = options.get("pix_fmt", None) - s = options.get("s", None) - - if ( - dtype is None - and pix_fmt is not None - and (s is not None or shape is not None) - ): - if shape is not None and s is None: - s = shape[2::-1] - if pix_fmt is not None and s is not None: - dtype_alt, shape_alt = utils.get_video_format(pix_fmt, s) - if dtype is not None and dtype != dtype_alt: - raise FFmpegioError( - f"Specifid {dtype=} and {pix_fmt=} are not compatible." - ) - if shape is not None and shape != shape_alt: - raise FFmpegioError( - f"Specifid {shape=}, {s=}, and {pix_fmt=} are not compatible." - ) - elif (pix_fmt is None or s is None) and dtype is not None and shape: - s_alt, pix_fmt_alt = utils.guess_video_format(shape, dtype) - if s is None: - s = s_alt - elif s != s_alt: - raise FFmpegioError( - f"Specifid {dtype=}, {shape=}, and {s=} are not compatible." - ) - if pix_fmt is None: - pix_fmt = pix_fmt_alt - elif pix_fmt != pix_fmt_alt: - raise FFmpegioError( - f"Specifid {dtype=}, {shape=}, and {pix_fmt=} are not compatible." - ) - - options["f"] = "rawvideo" - if rate is not None: - options["r"] = rate - if expr is not None: - options["vf"] = expr - if s is not None: - options["s"] = s - if pix_fmt is not None: - options["pix_fmt"] = pix_fmt - - return pix_fmt is None or s is None, shape, dtype - - def _finalize_output(self, info): - # finalize array setup from FFmpeg log - self.rate = info["r"] - self.dtype, self.shape = utils.get_video_format(info["pix_fmt"], info["s"]) - - -class SimpleAudioFilter(SimpleFilterBase): - """SISO audio filter stream class - - .. important:: - If the total duration of the stream is less than 2 seconds, use - :py:func:`audio.filter` function instead. FFmpeg does not start - the filtering process until about 2-seconds or about 50000-samples - worth of data are first accumulated. No output data will be produced - during this initial accumulation period. - - .. important:: - The exact number of output samples after each :py:meth:`filter` - call is not known and can be zero. - - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None - :param rate_in: input sample rate - :type rate_in: int, float, Fraction, str - :param shape_in: input single-sample array shape, defaults to None - :type shape_in: seq of ints, optional - :param dtype_in: input numpy data type, defaults to None - :type dtype_in: str, optional - :param rate: output sample rate, defaults to None (auto-detect) - :type rate: int, float, Fraction, str, optional - :param shape: output single-sample array shape, defaults to None - :type shape: seq of ints, optional - :param dtype: output numpy data type, defaults to None - :type dtype: str, optional - :param blocksize: read buffer block size in samples, defaults to None (=>1024) - :type blocksize: int, optional - :param default_timeout: default filter timeout in seconds, defaults to None (100 ms) - :type default_timeout: float, optional - :param progress: progress callback function, defaults to None - :type progress: callable object, optional - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - :type show_log: bool, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional - - ..note:: - Use of larger `blocksize` parameter could improve the processing speed - - """ - - readable = True - writable = True - multi_read = False - multi_write = False - stream_type = "a" - - def __init__( - self, - expr, - rate_in, - shape_in=None, - dtype_in=None, - rate=None, - shape=None, - dtype=None, - blocksize=None, - default_timeout=None, - progress=None, - show_log=None, - sp_kwargs=None, - **options, - ) -> None: - hook = plugins.get_hook() - # fmt: off - super().__init__(hook.bytes_to_audio, hook.audio_bytes, hook.audio_info, - expr, rate_in, shape_in, dtype_in, rate, shape, dtype, - blocksize, default_timeout, progress, show_log, sp_kwargs,**options) - # fmt: on - - def _pre_open(self, ffmpeg_args): - if self.dtype is None: - inopts = ffmpeg_args["inputs"][0][1] - outopts = ffmpeg_args["outputs"][0][1] - sample_fmt = outopts["sample_fmt"] = inopts["sample_fmt"] - outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) - - def _set_options( - self, - options: dict, - shape: tuple[int] | None, - dtype: str | None, - rate: Fraction | int | None = None, - expr: FilterGraphObject | None = None, - ) -> tuple[bool, tuple[int], str]: - - ac = options.get("ac", None) - - if shape is None: - if ac is not None: - shape = (ac,) - elif ac is None: - ac = shape[-1] - elif shape[-1] != ac: - raise FFmpegioError(f"{shape=} and {ac=} does not match") - - sample_fmt = options.get("sample_fmt", None) - if dtype is None and sample_fmt is not None: - if sample_fmt is not None: - dtype_alt, _ = utils.get_audio_format(options["sample_fmt"]) - if dtype is None: - dtype = dtype_alt - elif dtype != dtype_alt: - raise FFmpegioError( - f"Specifid {dtype=} and {pix_fmt=} are not compatible." - ) - elif (sample_fmt is None) and (dtype is not None): - sample_fmt_alt, _ = utils.guess_audio_format(dtype) - if sample_fmt is None: - sample_fmt = sample_fmt_alt - elif sample_fmt != sample_fmt_alt: - raise FFmpegioError( - f"Specifid {dtype=} and {sample_fmt=} are not compatible." - ) - - options["f"] = "rawvideo" - if rate: - options["ar"] = rate - if expr is not None: - options["af"] = expr - if sample_fmt is not None: - options["sample_fmt"] = sample_fmt - options["c:a"], options["f"] = utils.get_audio_codec(sample_fmt) - if ac is not None: - options["ac"] = ac - - return sample_fmt is None or ac is None, shape, dtype - - def _finalize_output(self, info): - # finalize array setup from FFmpeg log - self.rate = info["ar"] - self.dtype, self.shape = utils.get_audio_format(info["sample_fmt"], info["ac"]) - - @property - def channels(self): - """:int: Number of output channels (None if not yet determined)""" - return self.shape and self.shape[-1] - - @property - def channels_in(self): - """:int: Number of input channels (None if not yet determined)""" - return self.shape_in and self.shape_in[-1] diff --git a/src/ffmpegio/streams/StdStreams.py b/src/ffmpegio/streams/StdStreams.py deleted file mode 100644 index b78feca4..00000000 --- a/src/ffmpegio/streams/StdStreams.py +++ /dev/null @@ -1,1119 +0,0 @@ -from __future__ import annotations - -import logging - -logger = logging.getLogger("ffmpegio") - -from typing_extensions import Unpack -from collections.abc import Sequence -from .._typing import ( - DTypeString, - ShapeTuple, - ProgressCallable, - RawDataBlob, - FFmpegOptionDict, - InputSourceDict, - OutputDestinationDict, -) -from ..configure import ( - FFmpegArgs, - MediaType, - InitMediaOutputsCallable, -) -from ..filtergraph.abc import FilterGraphObject -from ..configure import OutputDestinationDict -from contextlib import ExitStack - -import sys -from time import time -from fractions import Fraction -from math import prod - -from .. import configure, ffmpegprocess, plugins, utils, probe -from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError - -# fmt:off -__all__ = ["StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", - "StdVideoDecoder", "StdVideoEncoder", "StdVideoFilter", "StdMediaTranscoder"] -# fmt:on - - -class _StdFFmpegRunner: - """Base class to run FFmpeg and manage its multiple I/O's""" - - def __init__( - self, - *, - get_num, - ffmpeg_args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict] | None, - input_ready: bool, - init_deferred_outputs: InitMediaOutputsCallable | None, - deferred_output_args: list[FFmpegOptionDict | None], - default_timeout: float | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - queuesize: int | None = None, - sp_kwargs: dict = None, - ): - """Encoded media stream transcoder - - :param ffmpeg_args: (Mostly) populated FFmpeg argument dict - :param input_info: FFmpeg output option dicts of all the output pipes. Each dict - must contain the `"f"` option to specify the media format. - :param output_info: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param input_ready: indicates if input is ready (True) or need its first batch of data to - provide necessary information for the outputs - :param init_deferred_outputs: function to initialize the outputs which have been deferred to - configure until the first batch of input data is in - :param deferred_output_args: - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :param options: global/default FFmpeg options. For output and global options, - use FFmpeg option names as is. For input options, append "_in" to the - option name. For example, r_in=2000 to force the input frame rate - to 2000 frames/s (see :doc:`options`). These input and output options - specified here are treated as default, common options, and the - url-specific duplicate options in the ``inputs`` or ``outputs`` - sequence will overwrite those specified here. - """ - - self._get_num = get_num - self._input_info = input_info - self._output_info = output_info - self._input_ready = input_ready - self._init_deferred_outputs = init_deferred_outputs - self._deferred_output_options = deferred_output_args - self._deferred_data = [] - - # all good to go - self._input_ready = all(input_ready) - - # create logger without assigning the source stream - self._logger = LoggerThread(None, show_log) - - # prepare FFmpeg keyword arguments - self._args = { - "ffmpeg_args": ffmpeg_args, - "progress": progress, - "capture_log": True, - "sp_kwargs": {**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, - } - - # set the default read block size for the referenc stream - self.default_timeout = default_timeout - self._pipe_kws = {"queue_size": queuesize} - self._proc = None - self._stack = None - - def __enter__(self): - - self.open() - return self - - def open(self): - """start FFmpeg processing - - Note - ---- - - It may flag to defer starting the FFmpeg process if the input streams - are not fully specified and must wait to deduce them from the written - data. - - """ - - if self._input_ready is True: - self._open(False) - - def _init_std_pipes(self) -> ExitStack: - - return configure.init_std_pipes( - self._proc.stdin, - self._proc.stdout, - self._input_info, - self._output_info, - **self._pipe_kws, - ) - - def _write_deferred_data(self): - pass - - def _close_io(self, _): - if self._stack: - self._stack.close() - self._stack = None - - def _open(self, deferred: bool): - - if deferred: - # finalize the output configurations - self._output_info = self._init_deferred_outputs( - self._args["ffmpeg_args"], - self._input_info, - self._deferred_output_options, - [self._deferred_data], - ) - - # get std pipes - stdin, stdout, input = configure.assign_std_pipes( - self._args["ffmpeg_args"], self._input_info, self._output_info - ) - - # run the FFmpeg - self._proc = ffmpegprocess.Popen( - **self._args, - stdin=stdin, - stdout=stdout, - input=input, - on_exit=self._close_io, - ) - - # set up and activate pipes and read/write threads - self._stack = self._init_std_pipes() - - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() - - # if any pending data, queue them - if deferred: - self._write_deferred_data() - - return self - - def close(self): - """Kill FFmpeg process and close the streams""" - - if self._proc is not None and self._proc.poll() is None: - # kill the ffmpeg runtime - self._proc.terminate() - if self._proc.poll() is None: - self._proc.kill() - self._proc = None - - def __exit__(self, *exc_details) -> bool: - try: - self.close() - return False - except: - if not exc_details[0]: - exc_details = sys.exc_info() - finally: - try: - self._logger.join() - except RuntimeError: - pass - - @property - def closed(self) -> bool: - """True if the stream is closed.""" - return self._proc.poll() is not None - - @property - def lasterror(self) -> FFmpegError: - """Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None - - def readlog(self, n: int | None = None) -> str: - """read FFmpeg log lines - - :param n: number of lines to read - :return: logged messages - """ - if n is not None: - self._logger.index(n) - with self._logger._newline_mutex: - return "\n".join(self._logger.logs or self._logger.logs[:n]) - - def wait(self, timeout: float | None = None) -> int | None: - """close all input pipes and wait for FFmpeg to exit - - :param timeout: a timeout for blocking in seconds, or fractions - thereof, defaults to None, to wait indefinitely - :raise `TimeoutExpired`: if a timeout is set, and the process does not - terminate after timeout seconds. It is safe to - catch this exception and retry the wait. - :return returncode: return subprocess Popen returncode attribute - """ - - if timeout is None: - timeout = self.default_timeout - - if self._proc: - if timeout is not None: - timeout += time() - - # write the sentinel to each input queue - for info in self._input_info: - if "writer" in info: - info["writer"].write( - None, None if timeout is None else timeout - time() - ) - - # wait until the FFmpeg finishes the job - try: - self._proc.wait(None if timeout is None else timeout - time()) - except TimeoutError: - raise - else: - rc = self._proc.returncode - if rc is not None: - self._proc = None - else: - rc = None - return rc - - -from collections.abc import Callable - - -class _RawInputBaseMixin: - _get_num: Callable - _input_info: InputSourceDict - default_timeout: float | None - - def __init__(self, get_bytes, array_to_opts, **kwargs): - super().__init__(**kwargs) - self._get_bytes = get_bytes - self._array_to_opts = array_to_opts - - # input data must be initially buffered - self._deferred_data = [] - self._nin = 0 - - def _write_deferred_data(self): - info = self._input_info[0] - writer = info["writer"] - for data in self._deferred_data: - writer.write(data, self.default_timeout) - self._deferred_data = None - self._input_ready = True - - @property - def input_count(self) -> int: - """number of input frames/samples written""" - return self._nin - - @property - def input_rate(self) -> int | Fraction: - """input sample or frame rates""" - return self._input_info[0]["raw_info"][2] - - @property - def input_dtype(self) -> DTypeString: - """input frame/sample data type""" - return self._input_info[0]["raw_info"][0] - - @property - def input_shape(self) -> ShapeTuple: - """input frame/sample shape""" - return self._input_info[0]["raw_info"][1] - - @property - def input_samplesize(self) -> int: - """input sample/pixel count per frame""" - return prod(self._input_info[0]["raw_info"][1]) - - def write(self, data: RawDataBlob, timeout: float | None = None): - """write a raw media data - - :param data: audio data blob (depends on the active data conversion plugin) - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - - - Write the given NDArray object, data, and return the number - of bytes written (always equal to the number of data frames/samples, - since if the write fails an OSError will be raised). - - When in non-blocking mode, a BlockingIOError is raised if the data - needed to be written to the raw stream but it couldn’t accept all - the data without blocking. - - The caller may release or mutate data after this method returns, - so the implementation should only access data during the method call. - """ - - b = self._get_bytes(obj=data) - self._nin += self._get_num(obj=data) - if not len(b): - return - - if data is None: - raise TypeError("data cannot be None") - - if self._input_ready: - logger.debug("[writer main] writing...") - try: - self._input_info[0]["writer"].write(b, timeout) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() - else: - # need to collect input data type and shape from the actual data - # before starting the FFmpeg - - configure.update_raw_input( - self._args["ffmpeg_args"], self._input_info, 0, data - ) - self._deferred_data.append(b) - self._input_ready = True - - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) - - -class _AudioInputMixin(_RawInputBaseMixin): - def __init__(self, **kwargs): - super().__init__( - plugins.get_hook().audio_bytes, utils.array_to_audio_options, **kwargs - ) - - -class _VideoInputMixin(_RawInputBaseMixin): - def __init__(self, **kwargs): - super().__init__( - plugins.get_hook().video_bytes, utils.array_to_video_options, **kwargs - ) - - -class _EncodedInputMixin: - def __init__(self, **kwargs): - - super().__init__(**kwargs) - - def _write_deferred_data(self): - data = self._deferred_data - info = self._input_info[0] - if len(data) and "writer" in info: - info["writer"].write(data, self.default_timeout) - self._deferred_data = None - self._input_ready = True - - def write_encoded(self, data: bytes, timeout: float | None = None): - """write encoded media data to stdout - - :param data: encoded data byte sequence - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is written - to the buffer queue - """ - - info = self._input_info[0] - - if self._input_ready: - try: - info["writer"].write(data, timeout) - except: - raise FFmpegioError("Cannot write to a non-piped input.") - - else: - # buffer must be contiguous - data0 = self._deferred_data - if len(data0): - data = data0.append(data) - else: - self._deferred_data = data - - # need to be able to probe the input streams before starting the FFmpeg - try: - probe.format_basic(data) - except FFmpegError: - pass # not ready yet - else: - self._input_ready = True - - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) - - -class _RawOutputBaseMixin: - def __init__(self, converter, blocksize, **kwargs): - super().__init__(**kwargs) - self._converter = converter - - # set the default read block size for the reference stream - self._blocksize = blocksize - self._n0 = None # timestamps of the last read sample - - @property - def output_label(self) -> str: - """FFmpeg/custom label of output stream""" - return self._output_info[0]["user_map"] - - @property - def output_type(self) -> MediaType: - """output media type""" - return self._output_info[0]["media_type"] - - @property - def output_rate(self) -> int | Fraction: - """output sample or frame rates""" - return self._output_info[0]["raw_info"][2] - - @property - def output_dtype(self) -> DTypeString: - """output frame/sample data type""" - return self._output_info[0]["raw_info"][0] - - @property - def output_shape(self) -> ShapeTuple: - """output frame/sample shape""" - return self._output_info[0]["raw_info"][1] - - @property - def output_samplesize(self) -> int: - """output sample/pixel count per frame""" - return prod(self._output_info[0]["raw_info"][1]) - - @property - def output_count(self) -> int: - """number of frames/samples read""" - return self._n0 - - def _init_std_pipes(self) -> ExitStack: - - # set the default read block size for the referenc stream - info = self._output_info[0] - if self._blocksize is None: - self._blocksize = 1 if info["media_type"] == "video" else 1024 - self._n0 = 0 - self._pipe_kws = {**self._pipe_kws} - - # set up and activate pipes and read/write threads - return super()._init_std_pipes() - - def read(self, n: int, timeout: float | None = None) -> RawDataBlob: - """read output stream - - :param n: number of frames/samples to read. Set -1 to read as many as available. - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is read - from the buffer queue - :return: retrieved data - - Effect of mixing `n` and `timeout` - ---------------------------------- - - === ========= ========================================================================= - `n` `timeout` Behavior - === ========= ========================================================================= - 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` frames/samples are retrieved - >0 `float` Retrieve as many frames/samples up to `n` before `timeout` seconds passes - <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as many frames/samples until `timeout` seconds passes - === ========= ========================================================================= - - """ - - info = self._output_info[0] - converter = self._converter - dtype, shape, _ = info["raw_info"] - - if timeout is None: - timeout = self.default_timeout - - b = info["reader"].read(n, timeout) - data = converter(b=b, dtype=dtype, shape=shape, squeeze=False) - - # update the frame/sample counter - n = self._get_num(obj=data) # actual number read - self._n0 += n - - return data - - -class _AudioOutputMixin(_RawOutputBaseMixin): - def __init__(self, **kwargs): - super().__init__(plugins.get_hook().bytes_to_audio, **kwargs) - - -class _VideoOutputMixin(_RawOutputBaseMixin): - def __init__(self, **kwargs): - super().__init__(plugins.get_hook().bytes_to_video, **kwargs) - - -class _EncodedOutputMixin: - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - - # set the default read block size - self._blocksize = blocksize - - def _init_std_pipes(self) -> ExitStack: - - # set the default read block size for the referenc stream - self._pipe_kws = {**self._pipe_kws, "blocksize": self._blocksize} - - # set up and activate pipes and read/write threads - return super()._init_std_pipes() - - def read_encoded(self, n: int, timeout: float | None = None) -> bytes: - """read encoded data from stdout - - :param n: number of bytes to read, set it to -1 to read as many as available - :param n: number of frames/samples to read. Set -1 to read as many as available. - :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` - then the operation will block until all the data is read - from the buffer queue - :return: retrieved byte sequence - - Effect of mixing `n` and `timeout` - ---------------------------------- - - === ========= ========================================================================= - `n` `timeout` Behavior - === ========= ========================================================================= - 0 --- Immediately returns - >0 `None` Wait indefinitely until `n` bytes are retrieved - >0 `float` Retrieve as much data up to `n` bytes before `timeout` seconds passes - <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as much data until `timeout` seconds passes - === ========= ========================================================================= - """ - - return self._output_info[0]["reader"].read(n, timeout) - - -class StdAudioDecoder(_EncodedInputMixin, _AudioOutputMixin, _StdFFmpegRunner): - def __init__( - self, - *, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Decode audio data from media data stream over std pipes - - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param progress: progress callback function, defaults to None - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - """ - - # initialize FFmpeg argument dict and get input & output information - map = options.pop("map", "0:a:0") - args, input_info, ready, output_info, output_args = configure.init_media_read( - ["pipe"], [map], {"probesize_in": 32, **options} - ) - - super().__init__( - get_num=plugins.get_hook().audio_samples, - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=ready, - init_deferred_outputs=configure.init_media_read_outputs, - deferred_output_args=output_args, - blocksize=blocksize, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - self._get_bytes = plugins.get_hook().audio_bytes - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self._blocksize, self.default_timeout) - if not len(self._get_bytes(obj=F)): - raise StopIteration - return F - - -class StdVideoDecoder(_EncodedInputMixin, _VideoOutputMixin, _StdFFmpegRunner): - def __init__( - self, - *, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Read audio data from encoded media data stream - - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param progress: progress callback function, defaults to None - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - """ - - # initialize FFmpeg argument dict and get input & output information - map = options.pop("map", "0:V:0") - args, input_info, ready, output_info, output_args = configure.init_media_read( - ["pipe"], [map], {"probesize_in": 32, **options} - ) - - super().__init__( - get_num=plugins.get_hook().video_frames, - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=ready, - init_deferred_outputs=configure.init_media_read_outputs, - deferred_output_args=output_args, - blocksize=blocksize, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - self._get_bytes = plugins.get_hook().video_bytes - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self._blocksize, self.default_timeout) - if not len(self._get_bytes(obj=F)): - raise StopIteration - return F - - -class StdAudioEncoder(_EncodedOutputMixin, _AudioInputMixin, _StdFFmpegRunner): - def __init__( - self, - input_rate: int, - *, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Write video and audio data from multiple media streams to one or more files - - :param rate: input sample rate - :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). - :param input_shape: shapes of input samples or frames streams, defaults to `None` (auto-detect). - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param progress: progress callback function, defaults to None - :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - """ - - args, input_info, input_ready, output_info, output_args = ( - configure.init_media_write( - ["pipe"], - "a", - [(input_rate, None)], - False, - None, - None, - None, - extra_inputs, - {"probesize_in": 32, **options}, - input_dtype, - input_shape, - ) - ) - - super().__init__( - get_num=plugins.get_hook().audio_samples, - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=input_ready, - init_deferred_outputs=configure.init_media_write_outputs, - deferred_output_args=output_args, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - blocksize=blocksize, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - -class StdVideoEncoder(_EncodedOutputMixin, _VideoInputMixin, _StdFFmpegRunner): - def __init__( - self, - input_rate: int, - *, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Write video and audio data from multiple media streams to one or more files - - :param rate: input frame rate - :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). - :param input_shape: list of shapes of input samples or frames, defaults to `None` (auto-detect). - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :param progress: progress callback function, defaults to None - :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in[input_url_id]' for input option names for specific - input url or '_in' to be applied to all inputs. The url-specific option gets the - preference (see :doc:`options` for custom options) - """ - - args, input_info, input_ready, output_info, output_args = ( - configure.init_media_write( - ["pipe"], - "v", - [(input_rate, None)], - False, - None, - None, - None, - extra_inputs, - {"probesize_in": 32, **options}, - input_dtype and [input_dtype], - input_shape and [input_shape], - ) - ) - - super().__init__( - get_num=plugins.get_hook().video_frames, - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=input_ready, - init_deferred_outputs=configure.init_media_write_outputs, - deferred_output_args=output_args, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - blocksize=blocksize, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - -class StdAudioFilter(_AudioOutputMixin, _AudioInputMixin, _StdFFmpegRunner): - def __init__( - self, - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], - input_rate: int, - *, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Filter audio/video data streams with FFmpeg filtergraphs - - :param expr: filtergraph expression or a list of filtergraphs - :param input_rate: input sampling rate. - :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). - :param input_shape: shapes of input samples or frames, defaults to `None` (auto-detect). - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param progress: progress callback function, defaults to None - :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) - : defaults to `None` to use 1 video frame or 1024 audio frames - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options - will be applied to all input streams unless the option has been already defined in `stream_data` - """ - - ( - args, - input_info, - input_ready, - output_info, - deferred_output_args, - ) = configure.init_media_filter( - expr, - "a", - [(input_rate, None)], - None, - input_dtype and [input_dtype], - input_shape and [input_shape], - {"probesize_in": 32, **options}, - {}, - ) - - super().__init__( - get_num=plugins.get_hook().audio_samples, - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=input_ready, - init_deferred_outputs=configure.init_media_filter_outputs, - deferred_output_args=deferred_output_args, - blocksize=blocksize, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - def filter( - self, data: RawDataBlob, timeout: float | None = None - ) -> RawDataBlob | None: - """Run filter operation - - :param data: input data block - :type data: numpy.ndarray - :param timeout: timeout for the operation in seconds, defaults to None - :type timeout: float, optional - :return: output data block - :rtype: numpy.ndarray - - The input `data` array is expected to have the datatype specified by - Filter class' `input_dtype` property and the array shape to match Filter - class' `input_shape` property or with an additional dimension prepended. - - .. important:: - If `timeout = None`, the read operation is non-blocking. There is at - least 32-frame latency is imposed by FFmpeg, so the initial few frames - will not produce any output. - - """ - - timeout = timeout or self.default_timeout - if timeout: - timeout += time() - - self.write(data, timeout and timeout - time()) - return self.read(self._get_num(obj=data), (timeout and timeout - time()) or 0) - - -class StdVideoFilter(_VideoOutputMixin, _VideoInputMixin, _StdFFmpegRunner): - def __init__( - self, - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], - input_rate: int | Fraction, - *, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Filter audio/video data streams with FFmpeg filtergraphs - - :param expr: complex filtergraph expression or a list of filtergraphs - :param input_rate: frame rate - :param input_dtype: numpy-style data type strings of input samples or frames, defaults to `None` (auto-detect). - :param input_shape: shapes of input samples or frames , defaults to `None` (auto-detect). - :param output_options: specific options for keyed filtergraph output pads. - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param progress: progress callback function, defaults to None - :param blocksize: Background reader thread blocksize (how many reference stream frames/samples to read at once from FFmpeg) - : defaults to `None` to use 1 video frame or 1024 audio frames - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call - used to run the FFmpeg, defaults to None - :param **options: FFmpeg options, append '_in' for input option names (see :doc:`options`). Input options - will be applied to all input streams unless the option has been already defined in `stream_data` - """ - - ( - args, - input_info, - input_ready, - output_info, - deferred_output_args, - ) = configure.init_media_filter( - expr, - "v", - [(input_rate, None)], - None, - input_dtype and [input_dtype], - input_shape and [input_shape], - {"probesize_in": 32, **options}, - {}, - ) - - super().__init__( - get_num=plugins.get_hook().video_frames, - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=input_ready, - init_deferred_outputs=configure.init_media_filter_outputs, - deferred_output_args=deferred_output_args, - blocksize=blocksize, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - def filter( - self, data: RawDataBlob, timeout: float | None = None - ) -> RawDataBlob | None: - """Run filter operation - - :param data: input data block - :type data: numpy.ndarray - :param timeout: timeout for the operation in seconds, defaults to None - :type timeout: float, optional - :return: output data block - :rtype: numpy.ndarray - - The input `data` array is expected to have the datatype specified by - Filter class' `input_dtype` property and the array shape to match Filter - class' `input_shape` property or with an additional dimension prepended. - - .. important:: - If `timeout = None`, the read operation is non-blocking. There is at - least 32-frame latency is imposed by FFmpeg, so the initial few frames - will not produce any output. - - """ - - timeout = timeout or self.default_timeout - if timeout: - timeout += time() - - self.write(data, timeout and timeout - time()) - return self.read(self._get_num(obj=data), (timeout and timeout - time()) or 0) - - -class StdMediaTranscoder(_EncodedOutputMixin, _EncodedInputMixin, _StdFFmpegRunner): - """Class to transcode one media stream to another via std pipes""" - - def __init__( - self, - *, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - default_timeout: float | None = None, - sp_kwargs: dict = None, - **options: Unpack[FFmpegOptionDict], - ): - """Encoded media stream transcoder - - :param extra_inputs: list of additional input sources, defaults to None. Each source may be url - string or a pair of a url string and an option dict. - :param extra_outputs: list of additional output destinations, defaults to None. Each destination - may be url string or a pair of a url string and an option dict. - :param progress: progress callback function, defaults to None - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :param options: global/default FFmpeg options. For output and global options, - use FFmpeg option names as is. For input options, append "_in" to the - option name. For example, r_in=2000 to force the input frame rate - to 2000 frames/s (see :doc:`options`). These input and output options - specified here are treated as default, common options, and the - url-specific duplicate options in the ``inputs`` or ``outputs`` - sequence will overwrite those specified here. - """ - - args, input_info, output_info = configure.init_media_transcoder( - [("pipe", {})], - [("pipe", {})], - extra_inputs, - extra_outputs, - {"y": None, **options}, - ) - - super().__init__( - get_num=None, - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - input_ready=True, - init_deferred_outputs=None, - deferred_output_args=None, - default_timeout=default_timeout, - progress=progress, - show_log=show_log, - blocksize=blocksize, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index de5065fe..dc8789a9 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -1,36 +1,33 @@ -from .SimpleStreams import ( - SimpleVideoReader, - SimpleVideoWriter, - SimpleAudioReader, - SimpleAudioWriter, - SimpleVideoFilter, - SimpleAudioFilter, -) -from .StdStreams import ( - StdAudioDecoder, - StdAudioEncoder, - StdAudioFilter, - StdVideoDecoder, - StdVideoEncoder, - StdVideoFilter, - StdMediaTranscoder, -) -from .PipedStreams import ( - PipedMediaReader, - PipedMediaWriter, - PipedMediaFilter, - PipedMediaTranscoder, +"""media streamer classes + +=============== ===================== ==================== +Class Name Input(s) Output(s) +=============== ===================== ==================== +SimpleReader multiple urls single audio/video +SimpleWriter single audio/video single url + +MediaReader multiple urls/encoded multiple audio/video +MediaWriter multiple audio/video multiple urls/encoded +MediaTranscoder multiple encoded multiple encoded +SISOMediaFilter single audio/video single audio/video +MISOMediaFilter multiple audio/video single audio/video +SIMOMediaFilter single audio/video multiple audio/video +MIMOMediaFilter multiple audio/video multiple audio/video +=============== ==================== ==================== +""" + +from .open import open +from .runners import ( + BaseFFmpegRunner, + PipedFFmpegRunner, + SISOFFmpegFilter, + StdFFmpegRunner, ) -from .AviStreams import AviMediaReader # TODO multi-stream write # TODO Buffered reverse video read # fmt: off -__all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", - "StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", - "StdVideoDecoder", "StdVideoEncoder", "StdVideoFilter", "StdMediaTranscoder", - "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder", - "AviMediaReader"] +__all__ = ['StdFFmpegRunner', 'PipedFFmpegRunner', 'BaseFFmpegRunner', + "SISOFFmpegFilter", "open"] # fmt: on diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py new file mode 100644 index 00000000..75287ecb --- /dev/null +++ b/src/ffmpegio/streams/open.py @@ -0,0 +1,1480 @@ +from __future__ import annotations + +"""Open a multimedia file/stream for read/write + +:param url_fg: URL of the media source/destination for file read/write or filtergraph definition + for filter operation. +:type url_fg: str or seq(str) +:param mode: specifies the mode in which the FFmpeg is used, see below +:type mode: str + +Start FFmpeg and open I/O link to it to perform read/write/filter operation and return +a corresponding stream object. If the file cannot be opened, an error is raised. +See :ref:`quick-streamio` for more examples of how to use this function. + +Just like built-in `open()`, it is good practice to use the with keyword when dealing with +ffmpegio stream objects. The advantage is that the ffmpeg process and associated threads are +properly closed after ffmpeg terminates, even if an exception is raised at some point. +Using with is also much shorter than writing equivalent try-finally blocks. + +:Examples: + +Open an MP4 file and process all the frames:: + + with ffmpegio.open('video_source.mp4', 'rv') as f: + frame = f.read() + while frame: + # process the captured frame data + frame = f.read() + +Read an audio stream of MP4 file and write it to a FLAC file as samples +are decoded:: + + with ffmpegio.open('video_source.mp4','ra') as rd: + fs = rd.sample_rate + with ffmpegio.open('video_dst.flac','wa',input_rate=fs) as wr: + frame = rd.read() + while frame: + wr.write(frame) + frame = rd.read() + +:Additional Notes: + +`urls_fgs` can be a string specifying either the path name (absolute or relative to the current +working directory) of the media target (file or streaming media) to be opened or a string describing +the filtergraph to be implemented. Its interpretation depends on the `mode` argument. + +`mode` is an optional string that specifies the mode in which the FFmpeg is opened. + +==== ======================================================= +Mode Description +==== ======================================================= +'r' read from encoded url/file/stream +'w' write to encoded url/file/stream +'f' filter data defined by fg +'t' transcode data +'->' I/O operator +'v' operate on video stream, 'vv' if multiple video streams +'a' operate on audio stream, 'aa' if multiple audio streams +'e' encoded data stream, 'ee' if multiple encoded streams +==== ======================================================= + +Each mode string is has one and only one operation specifier +(`'r'`, `'w'`, `'f'`, `'t'`, or `'->'`). For the operators `'rwf'`, accompany +them with a combination of the media specifiers `'v'` and `'a'` (repeated as +necessary). For the `'r'` operation, media specifiers specify the output +streams while they specify the input streams for `'w'` and `'f'`. + +""" + +"""`open()` module + +`rate` and `input_rate`: Video frame rates shall be given in frames/second and +may be given as a number, string, or `fractions.Fraction`. Audio sample rate in +samples/second (per channel) and shall be given as an integer or string. + +Optional `shape` or `input_shape` for video defines the video frame size and +number of components with a 2 or 3 element sequence: `(width, height[, ncomp])`. +The number of components and other optional `dtype` (or `input_dtype`) implicitly +define the pixel format (FFmpeg pix_fmt option): + +===== ===== ========= =================================== +ncomp dtype pix_fmt Description +===== ===== ========= =================================== + 1 \|u8 gray grayscale + 1 [va]{2,}'`` specify the input and output media types +========================= ================================================ +""" + +DecoderModeLiteral = LiteralString +"""decoder mode + +To configure FFmpeg as a decoder (encoded input, raw output), use + +================ ================================= +mode (regexp) description +================ ================================= +``'e+-\>[va]+'`` repeat ``'e'`` if multiple inputs +================ ================================= + +For example, ``'ee->vva'`` takes 2 encoded input streams and produces 3 raw +media output streams (video, video, audio) +""" + +EncoderModeLiteral = LiteralString +"""encoder mode + +To configure FFmpeg as an encoder (raw input, encoded output), use + +================ ================================== +mode (regexp) description +================ ================================== +``'[va]+-\>e+'`` repeat ``'e'`` if multiple outputs +================ ================================== + +For example, ``'vva->ee'`` takes 2 3 raw media output streams (video, video, +audio) and produces encoded input streams +""" + +TranscoderModeLiteral = LiteralString +"""transcoder mode + +To specify FFmpeg to transcode, use + +============= ========================================= +mode (regexp) description +============= ========================================= +``'e+-\>e+'`` repeat ``'e'`` if multiple inputs/outputs +============= ========================================= +""" + +MultiReaderModeLiteral = LiteralString +"""multiple-output reader mode + +To specify reading all media streams or multiple-streams, use + +============== ========================= +mode (regexp) description +============== ========================= +``'r'`` read all streams +``'r[va]{2}'`` read more than one stream +============== ========================= +""" + + +@overload +def open( + urls_fgs: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + mode: Literal["rv", "ra"], + /, + *, + map: str | None = None, + squeeze: bool = False, + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + | None = None, + blocksize: int | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> StdFFmpegRunner: + """open a single-stream reader + + :param urls_fgs: Specify encoded input file(s)/devices/filters in one of the + following styles: + + - an input file url or other input stream/device supported by FFmpeg + - a Python readable file object + - an ``ffmpegio`` input format/device class object + (e.g., ``FFConcat``) + - an FFmpeg input filtergraph expression or + ``ffmpegio.FilterGraphObject`` + - a pair of the url/filtergraph and a dict of FFmpeg input options + - a sequence of the urls/filtergraphs or the pairs, or a mixture + thereof. Use multiple inputs only to supply the data to a complex + filtergraph combining multiple streams into one. + + :param urls_fgs: URL string of the file or format/device object. It can be + an input filtergraph object or other input ffmpegio objects. The input + could also be fed by a readable file object. Multiple input sources + could also be assigned to feed a complex filtergraph. + :param mode: ``'rv'`` to read video data or ``'ra'`` to read audio + :param map: FFmpeg map output option, defaults to ``"0:v:0"`` for video and + ``"0:a:0"`` for audio. The map option is required if ``options`` + contains the ``filter_complex`` option. + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param blocksize: Read block size (in frames for video or samples in audio) + when the reader object is used as an iterator + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: a reader stream object + """ + + +@overload +def open( + urls_fgs: FFmpegOutputUrlNoPipe + | FFmpegNoPipeOutputOptionTuple + | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], + mode: Literal["wv", "wa"], + /, + input_rate: int | Fraction, + *, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + overwrite: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> StdFFmpegRunner: + """open a single-stream media writer + + :param urls_fgs: Specify encoded output file(s) in one of the following + styles: + + - an output file url + - a pair of an output url and a dict of FFmpeg output options + - a sequence of the urls or the pairs or a mixture thereof. This + commands FFmpeg to generate multiple files simultaneously from + the same input streams. + + :param mode: ``'wv'`` to create a media file from a raw video stream or + ``'wa'`` from a raw audio stream. + :param input_rate: input frame rate in frames/second (video) or sampling + rate in samples/second (audio) + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param input_shape: input video frame size (height, width) or number of + input audio channel, defaults to auto-detect + :param input_dtype: input data format in a Numpy dtype string, defaults to + auto-detect + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: str | FilterGraphObject | Literal["-"], + mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], + /, + input_rate: int | Fraction, + *, + squeeze: bool = False, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> SISOFFmpegFilter: + """open a single-input single-output media filter + + :param urls_fgs: Specify the filtergraph to be used with an FFmpeg + filtergraph expression or an ``ffmpegio.FilterGraphObject`` object. Use + ``'-'`` if the filtering is implicitly specified via output options + (such as rate or format changes). + :param mode: Specify the SISO filter mode by one of the following: + + - ``'fv'`` or ``'v->v'`` to take a video stream and produce a video stream + - ``'fa'`` or ``'a->a'`` to take an audio stream and produce an audio stream + - ``'v->a'`` to take a video stream and produce an audio stream + - ``'a->v'`` to take an audio stream and produce a video stream + + Note that currently the output stream media types are not checked. + :param input_rate: Input frame rate (video) or sampling rate (audio) + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param input_shape: input video frame size (height, width) or number of + input audio channels, defaults to None (auto-detect) + :param input_dtype: input data format as a Numpy dtype string, defaults to + None (auto-detect) + :param blocksize: Read queue item size of the in frames for video or samples + in audio Read block size. This size is also used when the reader object + is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: audio writer stream object + + """ + + +@overload +def open( + urls_fgs: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + mode: MultiReaderModeLiteral, + /, + *, + output_streams: Sequence[MapString | FFmpegOptionDict] | None = None, + squeeze: bool = False, + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a multi-stream reader + + :param urls_fgs: Specify encoded input file(s)/devices/filters in one of the + following styles: + + - an input file url or other input stream/device supported by FFmpeg + - a Python readable file object + - an ``ffmpegio`` input format/device class object + (e.g., ``FFConcat``) + - an FFmpeg input filtergraph expression or + ``ffmpegio.FilterGraphObject`` + - a pair of the url/filtergraph and a dict of FFmpeg input options + - a sequence of the urls/filtergraphs or the pairs, or a mixture + thereof. + + :param mode: Specify the multi-stream reader by one of the following: + + - ``'r'`` to include all the streams in the input urls or all the + outputs of the complex filtergraphs. + - ``'r'`` followed by a mixture of ``'v'`` and ``'a'`` to specify the + number of streams to read and their media types, e.g., ``'rvva'`` + reads two video streams and an audio stream. + + :param output_streams: output stream options: + + - `None` to auto-select. If ``mode='r'`` then all input streams (or + all filtergraph outputs) are selected. If ``mode`` specifies the + number of streams, then the streams are selected in their stream + indices if only one url is specified without any complex filtergraph. + - a sequence of str to specify map output option + - a sequence of output option dict with `'map'` item to output-specific + options + - a dict with map specifier or user keys to specify output options, + again to specify output-specific options. The keys will be used + as the keys of the raw data output, and can be different from + the `'map'` option so long as the `'map'` option is given in the + dict. + + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param primary_output: Index of a raw media output stream which serves as + the reference frame to sync all output streams, + defaults to ``0``. + :param blocksize: Read queue item size of the primary output stream in + frames for video or samples in audio Read block size. This size is also + used when the reader object is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: reader stream object + """ + + +@overload +def open( + urls_fgs: FFmpegOutputUrlNoPipe + | FFmpegNoPipeOutputOptionTuple + | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], + mode: MultiWriterModeLiteral, + /, + input_rates: list[int | Fraction], + *, + input_options: Sequence[FFmpegOptionDict] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + input_shapes: Sequence[ShapeTuple] | None = None, + input_dtypes: Sequence[DTypeString] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + overwrite: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a multi-stream media writer + + :param urls_fgs: Specify encoded output file(s) in one of the following + styles: + + - an output file url + - a pair of an output url and a dict of FFmpeg output options + - a sequence of the urls or the pairs or a mixture thereof. This + commands FFmpeg to generate multiple files simultaneously from + the same input streams. + + :param mode: Specify the multi-stream writer by one of the following: + + - ``'w'`` to set the input streams solely by ``input_options`` argument + - ``'w'`` followed by a mixture of ``'v'`` and ``'a'`` to specify the + number of streams to write and their media types, e.g., ``'wvva'`` + writes two video streams and an audio stream to the url. + + :param input_rates: list of input frame rates (video) and sampling rates + (audio) + :param input_options: Specify per-stream FFmpeg options of the raw input + streams. These option values are added to the default input options + specified in ``options``. If ``input_rates`` is not provided, the frame/ + sampling rate keys (i.e., ``'r'`` or ``'ar'``) must be present. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param input_shapes: input video frame sizes (height, width) or a number of + input audio channels, defaults to auto-detect. 'input_dtypes' must also + be specified for this argument to be processed. + :param input_dtypes: input data format as a Numpy dtype string, defaults to + auto-detect. 'input_shapes' must also be specified for this argument to + be processed. + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: optional FFmpeg options including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: str | FilterGraphObject | list[str | FilterGraphObject] | Literal["-"], + mode: MIMOFilterModeLiteral, + /, + input_rates: list[int | Fraction], + *, + input_options: Sequence[FFmpegOptionDict] | None = None, + output_streams: Sequence[MapString | FFmpegOptionDict] + | dict[str, MapString | FFmpegOptionDict] + | None = None, + squeeze: bool = False, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + input_shapes: list[ShapeTuple] | None = None, + input_dtypes: list[DTypeString] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """Open a multiple-input-multiple-output media filter + + :param urls_fgs: Specify the filtergraph to be used with an FFmpeg + filtergraph expression or an ``ffmpegio.FilterGraphObject`` object. Use + ``'-'`` if the filtering is implicitly specified via output options + (such as rate or format changes). If multiple complex filtergraphs are + needed, provide them as a list. + :param mode: Specify MIMO filter mode by one of the following: + + - ``'f'`` to auto-detect the numbers of input and output streams + - ``'f'`` followed by a mixture of ``'v'`` and ``'a'`` to specify the + number of input streams and their media types, e.g., ``'fvva'`` + takes two video input streams and an audio input stream. The output + stream is auto-detected. + - an arrow notation (``'->'``) with its input and output each specified + by a mixture of ``'v'`` and ``'a'``. For example, ``'vva->v'`` takes + two video input streams and an audio input stream to produce one video + stream. Note the output designators are currently not checked. + + :param input_rates: list of input frame rates (video) and sampling rates + (audio) + :param input_options: Specify per-stream FFmpeg options of the raw input + streams. These option values are added to the default input options + specified in ``options``. If ``input_rates`` is not provided, the frame/ + sampling rate keys (i.e., ``'r'`` or ``'ar'``) must be present. + :param output_streams: output stream options: + + - `None` to auto-select. If ``mode='r'`` then all input streams (or + all filtergraph outputs) are selected. If ``mode`` specifies the + number of streams, then the streams are selected in their stream + indices if only one url is specified without any complex filtergraph. + - a sequence of str to specify map output option + - a sequence of output option dict with `'map'` item to output-specific + options + - a dict with map specifier or user keys to specify output options, + again to specify output-specific options. The keys will be used + as the keys of the raw data output, and can be different from + the `'map'` option so long as the `'map'` option is given in the + dict. + + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param input_shapes: input video frame sizes (height, width) or a number of + input audio channels, defaults to auto-detect. 'input_dtypes' must also + be specified for this argument to be processed. + :param input_dtypes: input data format as a Numpy dtype string, defaults to + auto-detect. 'input_shapes' must also be specified for this argument to + be processed. + :param primary_output: Index of a raw media output stream which serves as + the reference frame to sync all output streams, + defaults to ``0``. + :param blocksize: Read queue item size of the primary output stream in + frames for video or samples in audio Read block size. This size is also + used when the reader object is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: reader stream object + """ + + +@overload +def open( + urls_fgs: Literal["-"], + mode: DecoderModeLiteral, # r"e+-\>[av]+", + /, + *, + output_streams: Sequence[MapString | FFmpegOptionDict] + | dict[str, MapString | FFmpegOptionDict] + | None = None, + squeeze: bool = False, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a media decoder (encoded streams in, raw streams out) + + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: Specify the decoder mode by an arrow notation (``'->'``) with + its input by a repeated ``'e'`` and output by a mixture of ``'v'`` and + ``'a'``. For example, ``'ee->vva'`` takes two encoded media streams and + produces two video output streams and an audio output stream. + :param output_streams: output stream options: + + - `None` to auto-select. If ``mode='r'`` then all input streams (or + all filtergraph outputs) are selected. If ``mode`` specifies the + number of streams, then the streams are selected in their stream + indices if only one url is specified without any complex filtergraph. + - a sequence of str to specify map output option + - a sequence of output option dict with `'map'` item to output-specific + options + - a dict with map specifier or user keys to specify output options, + again to specify output-specific options. The keys will be used + as the keys of the raw data output, and can be different from + the `'map'` option so long as the `'map'` option is given in the + dict. + + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param primary_output: Index of a raw media output stream which serves as + the reference frame to sync all output streams, + defaults to ``0``. + :param blocksize: Read queue item size of the primary output stream in + frames for video or samples in audio Read block size. This size is also + used when the reader object is used as an iterator. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination files, + defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: Literal["-"], + mode: EncoderModeLiteral, + /, + input_rates: list[int | Fraction], + *, + input_options: list[FFmpegOptionDict] | None = None, + output_options: list[FFmpegOptionDict], + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + input_shapes: list[ShapeTuple] | None = None, + input_dtypes: list[DTypeString] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: FFmpegOptionDict, +) -> PipedFFmpegRunner: + """open a media encoder (raw streams in, encoded streams out) + + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: Specify the encoder mode by an arrow notation (``'->'``) with + its input by a mixture of ``'v'`` and ``'a'`` and output by a repeated + ``'e'``. For example, ``'vva->ee'`` takes two video input streams and + an audio input stream to produce two encoded media streams. + :param input_rates: list of input frame rates (video) and sampling rates + (audio) + :param input_options: Specify per-stream FFmpeg options of the raw input + streams. These option values are added to the default input options + specified in ``options``. If ``input_rates`` is not provided, the frame/ + sampling rate keys (i.e., ``'r'`` or ``'ar'``) must be present. + :param output_options: Specify per-stream FFmpeg options of the encoded + output streams. These option values are added to the default output + options specified in ``options``. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param input_shapes: input video frame sizes (height, width) or a number of + input audio channels, defaults to auto-detect. 'input_dtypes' must also + be specified for this argument to be processed. + :param input_dtypes: input data format as a Numpy dtype string, defaults to + auto-detect. 'input_shapes' must also be specified for this argument to + be processed. + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: Literal["-"], + mode: TranscoderModeLiteral, # r"e+-\>e+", + /, + *, + input_options: list[FFmpegOptionDict] | None = None, + output_options: list[FFmpegOptionDict] | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool = False, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> PipedFFmpegRunner: + """open a media transcoder (encoded streams in, encoded streams out) + + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: Specify the transcoder mode by an arrow notation (``'->'``) + with its inputs and outputs each by a repeated ``'e'``. For example, + ``'e->ee'`` transcodes one encoded stream to two encoded streams. + :param input_options: Specify per-stream FFmpeg options of the encoded input + streams. These option values are added to the default input options + specified in ``options``. + :param output_options: Specify per-stream FFmpeg options of the encoded + output streams. These option values are added to the default output + options specified in ``options``. + :param extra_inputs: extra encoded input urls, Each element is a tuple pair + of url and input option dict. The url must be a url and not pipes or + pipe objects. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. Note: If output files will always be overwritten + if they exist. + :param enc_blocksize: Queue item size of the extra encoded output stream + in bytes, defaults to 64 MB (2**16 bytes). + :param queuesize: Background reader & writer threads queue size, defaults to + 16. Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to ``None`` to + wait indefinitely. + :param progress: progress callback function, defaults to None + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to ``None`` + :param options: global/default FFmpeg options. For output and global options, + use FFmpeg option names as is. For input options, append "_in" to the + option name. For example, r_in=2000 to force the input frame rate + to 2000 frames/s (see :doc:`options`). These input and output options + specified here are treated as default, common options, and the + url-specific duplicate options in the ``inputs`` or ``outputs`` + sequence will overwrite those specified here. + :return: writer stream object + + """ + + +def open( + urls_fgs, + mode, + /, + *args, + **kwargs, +) -> PipedFFmpegRunner | SISOFFmpegFilter | StdFFmpegRunner: + + # possible keywords, excluding FFmpeg options + # 'input_shape', 'input_dtype', 'input_rate', 'input_rates', + # 'input_options', 'input_dtypes', 'input_shapes', 'extra_inputs', + # 'output_streams', 'extra_outputs', 'squeeze' + + op_mode, in_types, out_types = _parse_mode(mode) + if urls_fgs == "-" and op_mode in "rw": + raise ValueError( + f"{'Input of a reader' if op_mode == 'r' else 'Output of a writer'} cannot be piped ('-'). Provide at least one URL." + ) + + runner_kws = { + k: kwargs.pop(k) + for k in ( + "input_shape", + "input_dtype", + "input_shapes", + "input_dtypes", + "primary_output", + "blocksize", + "enc_blocksize", + "queuesize", + "timeout", + "progress", + "show_log", + "overwrite", + "sp_kwargs", + ) + if k in kwargs + } + + if op_mode in "rdt" and len(args): + raise TypeError( + "Too many positional arguments. Only 2 positional arguments are allowed for reader/decoder/transcoder." + ) + + if op_mode == "r": + runner = _open_reader(out_types, urls_fgs, kwargs, runner_kws) + elif op_mode == "w": + runner = _open_writer(in_types, urls_fgs, args, kwargs, runner_kws) + elif op_mode == "f": + runner = _open_filter(in_types, out_types, urls_fgs, args, kwargs, runner_kws) + elif op_mode == "d": + runner = _open_decoder( + len(in_types), out_types, urls_fgs, args, kwargs, runner_kws + ) + elif op_mode == "e": + runner = _open_encoder( + in_types, len(out_types), urls_fgs, args, kwargs, runner_kws + ) + else: + runner = _open_transcoder( + len(in_types), len(out_types), urls_fgs, args, kwargs, runner_kws + ) + + return runner + + +def _parse_mode(mode: str) -> tuple[Literal["r", "w", "f", "d", "e", "t"], str, str]: + """parse operating mode literal string + + :return op_mode: operating mode character + :return input_types: input stream type sequence + :return output_types: output stream type sequence + """ + m = re.fullmatch( + r"(t)|([av]*?)([rwfed])([av]*?)|((?:[av]+|e+))-\>((?:[av]+|e+))", mode + ) + if m is None: + raise ValueError(f"{mode=} is an invalid operation mode specifier") + + op_mode = m[1] or m[3] + + if op_mode is not None: + inputs = m[2] or "" + outputs = m[4] or "" + if op_mode == "t": + inputs = outputs = "e" + elif op_mode in "efw": + # writer & (single-output) decoder -> output media types + inputs = inputs + outputs + outputs = "e" if op_mode == "e" else "" + else: + # others -> input media types + outputs = inputs + outputs + inputs = "e" if op_mode == "d" else "" + else: # arrow convention + inputs = m[5] or "" + outputs = m[6] or "" + in_encoded = all(c == "e" for c in inputs) + out_encoded = all(c == "e" for c in outputs) + op_mode = { + (False, False): "f", + (False, True): "e", + (True, False): "d", + (True, True): "t", + }[(in_encoded, out_encoded)] + + return op_mode, inputs, outputs + + +def _open_kws_set() -> list[str]: + return set( + [ + "input_shape", + "input_dtype", + "input_rate", + "input_rates", + "input_options", + "input_dtypes", + "input_shapes", + "extra_inputs", + "output_streams", + "extra_outputs", + "squeeze", + ] + ) + + +def _process_raw_input_args( + in_types: str, args: tuple, kwargs: dict +) -> tuple[ + set[str], + bool, + list[FFmpegOptionDict], + Sequence[str | tuple[str, FFmpegOptionDict]] | None, +]: + """process raw input arguments + + :param in_types: input media type sequence + :param args: positional arguments (3rd-) + :param kwargs: keyword arguments + :return used_kws: popped keyword arguments + :return signel_input: True if only one input stream + :return input_options: list of per-stream ffmpeg input options + :return extra_inputs: keyword arguemnt to define extra inputs + """ + nargs = len(args) + if nargs > 1: + raise TypeError( + f"ffmpegio.open() takes two to three positional arguments ({2 + len(args)} given) to open a writer" + ) + + input_options = kwargs.pop("input_options", None) + extra_inputs = kwargs.pop("extra_inputs", None) + used_kws = {"extra_inputs"} + single_input = len(in_types) == 1 # + + # establish input_options + if single_input: + input_rate = kwargs.pop("input_rate", None) + used_kws.add("input_rate") + if nargs: + if input_rate is None: + input_rate = args[0] + else: + raise TypeError("'input_rate' specified multiple times") + + rate_opt = {"ar" if in_types[0] == "a" else "r": input_rate} + input_options = [ + rate_opt if input_options is None else {**input_options, **rate_opt} + ] + + else: + input_rates = kwargs.pop("input_rates", None) + used_kws.add("input_rates") + input_options = kwargs.pop("input_options", None) + used_kws.add("input_options") + + if nargs: + if input_rates is None: + input_rates = args[0] + else: + raise TypeError("'input_rates' specified multiple times") + + if len(in_types) == 0: + # expects input_options to define the rates + if input_rates is not None and input_options is None: + raise ValueError("Cannot resolve the input streams.") + elif input_options is None: + input_options = [ + {"ar" if mtype == "a" else "r": r} + for mtype, r in zip(in_types, input_rates) + ] + else: + input_options = [ + {**opts, "ar" if mtype == "a" else "r": r} + for mtype, r, opts in zip(in_types, input_rates, input_options) + ] + + return used_kws, single_input, input_options, extra_inputs + + +def _process_raw_output_args( + out_types: Sequence[Literal["a", "v"]], kwargs: dict, nin: int +) -> tuple[ + set[str], + bool, + list[FFmpegOptionDict], + Sequence[FFmpegOutputOptionTuple] | None, + bool, +]: + """process arguments for raw output options + + :param out_types: output media sequence + :param kwargs: keyword arguments + :param nin: number of input urls + :return used_kws: set of keyword arguments consumed + :return single_output: True if single output + :return output_streams: ffmpeg output options + :return extra_outputs: extra output urls (+options) + :return squeeze: True to squeeze raw output blobs + """ + nout = len(out_types) + single_output = nout == 1 # single encoded stream + + output_streams = None + extra_outputs = kwargs.pop("extra_outputs", None) + squeeze = kwargs.pop("squeeze", None) + + used_kws = set(["extra_outputs", "squeeze"]) + if single_output: + used_kws.add("output_streams") + else: + output_streams = kwargs.pop("output_streams", None) + + if isinstance(output_streams, (str, dict)): + output_streams = [output_streams] + + if len(out_types) == 0: # autodetect + single_output = False # -> multi-output + else: + # resolve the output streams + nout = len(out_types) + + if output_streams is None: + output_streams = [{} for _ in range(nout)] + elif nout != len(output_streams): + raise ValueError( + "number of outputs in mode does not match the number of output options specified." + ) + else: + output_streams = [ + {**s} if isinstance(s, dict) else s for s in output_streams + ] + + if ( + "map" not in kwargs + and nin == 1 + and not utils.find_filter_complex_option(kwargs) + ): + stream_counts = {"a": 0, "v": 0} + for mtype, opts in zip(out_types, output_streams): + st = stream_counts[mtype] + stream_counts[mtype] += 1 + + if isinstance(opts, dict) and "map" not in opts: + opts["map"] = f"0:{mtype}:{st}" + return used_kws, single_output, output_streams, extra_outputs, squeeze + + +def _open_reader( + out_types: str, + urls: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + kwargs: dict, + runner_kws: dict, +) -> StdFFmpegRunner | PipedFFmpegRunner: + + # need to resolve if multiple input urls are given + urls = [urls] if utils.is_valid_input_url(urls) or isinstance(urls, tuple) else urls + nin = len(urls) + + used_kws, single_output, output_streams, extra_outputs, squeeze = ( + _process_raw_output_args(out_types, kwargs, nin) + ) + + open_kws = _open_kws_set() - used_kws + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the reader mode.") + + return ( + StdFFmpegRunner.open_simple_reader( + urls, + output_streams[0], + kwargs, + squeeze, + extra_outputs, + **runner_kws, + ) + if single_output + else PipedFFmpegRunner.open_media_reader( + urls, output_streams, kwargs, squeeze, extra_outputs, **runner_kws + ) + ) + + +def _open_writer( + in_types: str, + urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner | StdFFmpegRunner: + + used_kws, single_input, input_options, extra_inputs = _process_raw_input_args( + in_types, args, kwargs + ) + + open_kws = _open_kws_set() - used_kws + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + # insert default output mapping + if "map" not in kwargs and utils.find_filter_complex_option(kwargs) is None: + kwargs["map"] = [f"{i}:{mtype}:0" for i, mtype in enumerate(in_types)] + + return ( + StdFFmpegRunner.open_simple_writer( + urls, + input_options[0], + kwargs, + extra_inputs, + **runner_kws, + ) + if single_input + else PipedFFmpegRunner.open_media_writer( + urls, + input_options, + kwargs, + extra_inputs, + **runner_kws, + ) + ) + + +def _open_filter( + in_types: str, + out_types: str, + fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> SISOFFmpegFilter: + + used_kws, single_input, input_options, extra_inputs = _process_raw_input_args( + in_types, args, kwargs + ) + open_kws = _open_kws_set() - used_kws + + used_kws, single_output, output_streams, extra_outputs, squeeze = ( + _process_raw_output_args(out_types, kwargs, len(in_types)) + ) + open_kws -= used_kws + + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the filter mode.") + + single = single_input and (single_output or output_streams is None) + + if fgs is not None and fgs != "-": + kwargs["filter_complex"] = fgs + + return ( + SISOFFmpegFilter.create_and_open( + input_options[0], + output_streams and output_streams[0], + kwargs, + squeeze, + extra_inputs, + extra_outputs, + **runner_kws, + ) + if single + else PipedFFmpegRunner.open_media_filter( + input_options, + output_streams, + kwargs, + squeeze, + extra_inputs, + extra_outputs, + **runner_kws, + ) + ) + + +def _open_decoder( + nb_in: int, + out_types: str, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner: + + if urls != "-": + raise TypeError("urls_fgs argument for a decoder must be '-'.") + + if len(args): + raise TypeError( + "ffmpegio.open() does not take more than 2 positional arguments for a decoder." + ) + + used_kws, _, output_streams, extra_outputs, squeeze = _process_raw_output_args( + out_types, kwargs, nb_in + ) + open_kws = _open_kws_set() - used_kws + + input_options = kwargs.pop("input_options", None) + + if input_options is None: + input_options = [{} for i in range(nb_in)] + elif nb_in > 0 and len(input_options) != nb_in: + raise ValueError( + "the length of 'input_options' must match the number of encoded inputs" + ) + extra_inputs = kwargs.pop("extra_inputs", None) + + open_kws -= {"input_options", "extra_inputs"} + + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the decoder mode.") + + return PipedFFmpegRunner.open_media_decoder( + input_options, + output_streams, + kwargs, + squeeze, + extra_inputs, + extra_outputs, + **runner_kws, + ) + + +def _open_encoder( + in_types: str, + nb_out: int, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner: + + if urls != "-": + raise TypeError("urls_fgs argument for an encoder must be '-'.") + + used_kws, _, input_options, extra_inputs = _process_raw_input_args( + in_types, args, kwargs + ) + open_kws = _open_kws_set() - used_kws + + output_options = kwargs.pop("output_options", None) + + if output_options is None: + output_options = [{} for i in range(nb_out)] + elif nb_out > 0 and len(output_options) != nb_out: + raise ValueError( + "the length of 'input_options' must match the number of encoded inputs" + ) + extra_outputs = kwargs.pop("extra_outputs", None) + + open_kws -= {"output_options", "extra_outputs"} + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the encoder mode.") + + return PipedFFmpegRunner.open_media_encoder( + input_options, output_options, kwargs, extra_inputs, extra_outputs, **runner_kws + ) + + +def _open_transcoder( + nb_in: int, + nb_out: int, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, +) -> PipedFFmpegRunner: + + if urls != "-": + raise TypeError("urls_fgs argument for a decoder must be '-' for a transcoder.") + + if len(args): + raise TypeError( + "ffmpegio.open() takes only two positional arguments in a transcoder." + ) + + input_options = kwargs.pop("input_options", None) or [] + if len(input_options) == 0: + input_options = [{} for i in range(nb_in)] + elif nb_in > 0 and len(input_options) != nb_in: + raise ValueError( + f"input_options argument must have {nb_in} elements to match the specified transcoder mode." + ) + + output_streams = kwargs.pop("output_streams", None) or [] + if len(output_streams) == 0: + output_streams = [{} for i in range(nb_out)] + elif nb_out > 0 and len(output_streams) != nb_out: + raise ValueError( + f"output_streams argument must have {nb_out} elements to match the specified transcoder mode." + ) + + extra_inputs = kwargs.pop("extra_inputs", None) + extra_outputs = kwargs.pop("extra_outputs", None) + + used_kws = {"input_options", "output_options", "extra_inputs", "extra_outputs"} + open_kws = _open_kws_set() - used_kws + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid transcoder keyword inputs found: {k}") + + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the transcoder mode.") + + return PipedFFmpegRunner.open_media_transcoder( + input_options, output_streams, kwargs, extra_inputs, extra_outputs, **runner_kws + ) diff --git a/src/ffmpegio/streams/runners.py b/src/ffmpegio/streams/runners.py new file mode 100644 index 00000000..eaeb26e1 --- /dev/null +++ b/src/ffmpegio/streams/runners.py @@ -0,0 +1,2053 @@ +from __future__ import annotations + +import logging +from abc import ABCMeta +from contextlib import ExitStack +from enum import IntEnum +from fractions import Fraction +from functools import cached_property + +from .. import configure, ffmpegprocess, stream_spec, utils +from .._typing import ( + Any, + Callable, + DTypeString, + FFmpegOptionDict, + InputInfoDict, + InputPipeInfoDict, + Iterator, + MediaType, + OutputInfoDict, + OutputPipeInfoDict, + ProgressCallable, + RawDataBlob, + Sequence, + ShapeTuple, + override, +) +from ..configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegMediaKwsDict, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, + MediaFilterKwsDict, + MediaReadKwsDict, + MediaTranscoderKwsDict, + MediaWriteKwsDict, +) +from ..errors import FFmpegError, FFmpegioError, FFmpegioInsufficientInputData +from ..threading import LoggerThread + +logger = logging.getLogger("ffmpegio") + +__all__ = [ + "BaseFFmpegRunner", + "StdFFmpegRunner", + "PipedFFmpegRunner", + "SISOFFmpegFilter", +] + + +class FFmpegStatus(IntEnum): + """FFmpeg runner status enum + + FFmpeg runners are in one of the following 5 states: + + ============= ===== ====================================================== + member value description + ============= ===== ====================================================== + PREOPEN 0 Runner is not opened yet + BUFFERING 1 Runner was opened but requires buffering input to + complete analysis before running FFmpeg + ANALYSIS_DONE 2 Runner has completed analyzing the input, starting + FFmpeg subprocess + RUNNING 3 FFmpeg subprocess is running + STOPPED 4 FFmpeg subprocess has stopped + ============= ===== ====================================================== + + + """ + + PREOPEN = 0 + BUFFERING = 1 + ANALYSIS_DONE = 2 + RUNNING = 3 + STOPPED = 4 + + +class InitMediaKeywordsWithInputBuffer(dict): + """class to buffer FFmpeg input data before running it to probe configuration information""" + + # pre-analysis/buffering variables + _nraw = 0 + _raw_pipe_buffer: None | list[list[RawDataBlob] | None] # for 'input_data' + _enc_pipe_buffer: dict[int, bytes | None] # for 'input_urls' or 'extra_inputs' + # end-of-stream flags: True if buffer contains the entirety of the stream + _raw_pipe_eos: list[bool] + _enc_pipe_eos: dict[int, bool] + + def __init__(self, init_kws: dict): + """identify which input init_fun keyword arguments require data from pipe""" + super().__init__(init_kws) + self._raw_input = "input_options" in self + self._enc_pipe_buffer = {} + self._raw_pipe_buffer = None + self._enc_pipe_eos = {} + self._raw_pipe_eos = [] + + # analyze the keywords and replace items to be tweaked + if self._raw_input: + # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] + self._nraw = nin = len(self["input_options"]) + + self["input_data"] = [None for _ in range(nin)] + + self._raw_pipe_buffer = [None] * self._nraw + self._raw_pipe_eos = [False] * self._nraw + + if "extra_inputs" in self and self["extra_inputs"] is not None: + # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + self["extra_inputs"] = [*self["extra_inputs"]] + + for i, (url, _) in enumerate(self["extra_inputs"]): + if utils.is_pipe(url): + self._enc_pipe_buffer[i] = b"" + self._enc_pipe_eos[i] = False + + if "output_urls" in self: + self["output_urls"] = [ + out_args if isinstance(out_args, tuple) else (out_args, {}) + for out_args in self["output_urls"] + ] + + else: + # encoded: list[FFmpegInputUrlComposite|tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + self["input_urls"] = [ + in_args if isinstance(in_args, tuple) else (in_args, {}) + for in_args in self["input_urls"] + ] + + for i, (url, _) in enumerate(self["input_urls"]): + if utils.is_pipe(url): + self._enc_pipe_buffer[i] = None + self._enc_pipe_eos[i] = False + + def put_data(self, stream: int, data: RawDataBlob | bytes, last: bool) -> bool: + """write data to a buffer prior to running ffmpeg + + :param stream: input stream id, index to self._input_info + :param data: data blob if raw media data or bytes if encoded data + :param last: True if data is the last blob for the stream + :returns: the first data blob of the raw stream or all received bytes of + encoded stream (repeats every time) or None if no new raw + stream was buffered + + If ffprobe analysis is necessary to configure the FFmpeg arguments, + every input pipe must be filled with the first batch of data. This + function is to be called from a write function to sets pre-run written + data aside. + + if it contains data for a new stream, attempts to configure ffmpeg args + """ + + if self._raw_pipe_buffer is None: # encoded input + if self._enc_pipe_eos[stream]: + raise FFmpegioError(f"No more data can be written to the {stream=}") + + buf = self._enc_pipe_buffer[stream] + if buf is None: # first write + buf = data + else: + buf += data + self._enc_pipe_buffer[stream] = buf + + # replace the keyword's pipe url with the data + urls = self["input_urls"] + urls[stream] = (buf, urls[stream][1]) + + if last: + self._enc_pipe_eos[stream] = True + + else: # raw or encoded input + if isinstance(data, bytes): + if self._enc_pipe_eos[stream]: + raise FFmpegioError(f"No more data can be written to the {stream=}") + stream = stream - self._nraw + assert stream >= 0 + buf = self._enc_pipe_buffer[stream] + if buf is None: # first write + buf = data + else: + buf += data + self._enc_pipe_buffer[stream] = buf + if last: + self._enc_pipe_eos[stream] = True + + urls = self["extra_inputs"] + urls[stream] = (buf, urls[stream][1]) + else: + if self._raw_pipe_eos[stream]: + raise FFmpegioError(f"No more data can be written to the {stream=}") + buffer = self._raw_pipe_buffer[stream] + if buffer is None: # first write + self._raw_pipe_buffer[stream] = [data] + self["input_data"][stream] = data + else: + buffer.append(data) + return False + if last: + self._raw_pipe_eos[stream] = True + return True + + def clear_keywords(self): + """remove all the buffered data from the keywords""" + + if self._raw_pipe_buffer is not None: + del self["input_data"] + + kw = self["extra_inputs"] + for i, buf in self._enc_pipe_buffer.items(): + if buf is not None: + kw[i] = ("-", kw[i][1]) + else: + kw = self["input_urls"] + for i, buf in self._enc_pipe_buffer.items(): + if buf is not None: + kw[i] = ("-", kw[i][1]) + + def iter_raw_data(self) -> Iterator[tuple[int, RawDataBlob, bool]]: + """iterate over all items in the raw media pipe buffer + + :yield index: raw stream index + :yield data: buffered data blob + :yield last: True if data is the last blob of the stream + + If multiple blobs are buffered for a stream, iterator yields one blob at + a time. + """ + + if self._raw_pipe_buffer is None: + return + + for i, (buf, eos) in enumerate(zip(self._raw_pipe_buffer, self._raw_pipe_eos)): + if buf is not None: + for blob in buf[:-1]: + yield i, blob, False + yield i, buf[-1], eos + + def iter_enc_data(self) -> Iterator[tuple[int, bytes, bool]]: + """iterate over all items in the encoded pipe buffer + + :yield index: encoded stream index + :yield data: buffered data + :yield last: True if data is the entirety of the stream content + """ + for i, buf in self._enc_pipe_buffer.items(): + if buf is not None: + yield i, buf, self._enc_pipe_eos[i] + + def clear_data(self): + """release all the data blobs""" + if self._raw_pipe_buffer is not None: + self._raw_pipe_buffer = [None] * self._nraw + self._enc_pipe_buffer = {i: None for i in self._enc_pipe_buffer} + + @property + def encoded_inputs_only(self) -> bool: + """True if no raw stream""" + return self._raw_pipe_buffer is None + + @property + def num_encoded_inputs(self) -> int: + """Number of encoded streams""" + return len(self._enc_pipe_buffer) + + @property + def num_raw_inputs(self) -> int: + """Number of raw streams""" + return self._nraw + + def iter_encoded_input_pipes(self) -> Iterator[int]: + """iterates over encoded input pipes + + :yield: index of an encoded input pipe + """ + n0 = self._nraw + return (i + n0 for i in self._enc_pipe_buffer) + + @cached_property + def input_pipes(self) -> list[int]: + """list of the indices of all input pipes""" + return [*range(self._nraw), *self.iter_encoded_input_pipes()] + + +class BaseFFmpegRunner(metaclass=ABCMeta): + Status = FFmpegStatus + + _probesize: int = 32 + _dynamic_output: bool = False + _use_std_pipes: bool = False + _use_named_pipes: bool = False + + # object status enum + _status: Status = Status.PREOPEN + + # configure.init_media_xxx function & its keyword arguments + _init_func: Callable + _init_kws: InitMediaKeywordsWithInputBuffer + _pipe_kws: dict[str, Any] + _primary_output: int | None = None + _blocksize: int | None # read/queue blocksize in primary output's + + # ffmpeg arguments and associated input/output information + _args: dict[str, Any] + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] + + # ffmpeg subprocess and associated objects + _proc: ffmpegprocess.Popen | None = None + _input_pipes: dict[int, InputPipeInfoDict] + _output_pipes: dict[int, OutputPipeInfoDict] + _stack: ExitStack + _logger: LoggerThread + + def __init__( + self, + init_func: Callable, + init_kws: FFmpegMediaKwsDict, + *, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ): + """Streaming FFmpeg runner using std pipes and/or named pipes + + :param init_func: FFmpeg initialization function from :py:module:`configure` + :param init_kws: keyword arguments to call the FFmpeg initialization function + :param primary_output: (only for multi-stream readable) index of a raw + media output stream which serves as a frame count + reference , defaults to ``0``. + :param blocksize: (only for readable) iterator block size in frames/samples + to read raw media streams. If multiple output streams, + this size specifies the size for the ``primary_output`` + stream. If named pipes are used, this size is also the + size of queue items of the primary stream, defaults to + use ``1`` for a video stream and ``1024`` for audio stream. + :param enc_blocksize: (only for decodable with named pipes) the queue + item size of encoded output stream in bytes, defaults to 64 MB + (2**16 bytes). + :param queuesize: the depth of named pipe queues, defaults to None (16). + Use zero (0) to specify unlimited queue size. + :param timeout: Queue read timeout in seconds, defaults to `None` to + wait indefinitely. Note this timeout does not apply to stdout pipe + operation. + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, + defaults to None (no show/capture) + :param overwrite: _description_, defaults to None + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + """ + + self._init_func = staticmethod(init_func) + self._init_kws = InitMediaKeywordsWithInputBuffer(init_kws) + self._pipe_kws = { + "queue_size": queuesize, + "timeout": timeout, + "enc_blocksize": enc_blocksize, + } + self._primary_output = primary_output + self._blocksize = blocksize + + self._stack: ExitStack = ExitStack() + + # create logger without assigning the source stream + self._logger = LoggerThread(None, bool(show_log)) + + # prepare FFmpeg keyword arguments + self._args = { + "progress": progress, + "capture_log": True, + "sp_kwargs": sp_kwargs, + } + if overwrite is not None: + self._args["overwrite"] = overwrite + + def __bool__(self) -> bool: + """True if prebuffering or FFmpeg is running""" + + return self._status in (FFmpegStatus.BUFFERING, FFmpegStatus.RUNNING) + + @property + def status(self) -> FFmpegStatus: + """current status of the object""" + return self._status + + def _try_config_ffmpeg( + self, + stream: int = -1, + data: bytes | RawDataBlob | None = None, + last: bool = False, + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :param last: optional ``True`` if ``data`` is the last data blob of ``stream`` + :return: ``True`` if FFmpeg arguments are successfully configured + and `_input_info` and `_output_info` lists are fully + populated. Excludes the pipe information. + + + If this function returns ``True``, the class object is ready to call + `_run_ffmpeg() and input and output stream information (``_input_info`` + and ``_output_info``) are successfully lists are fully populated, except + for the pipe assignments. + + """ + + if self._status > FFmpegStatus.BUFFERING: + raise FFmpegioError("FFmpeg options have already been configured.") + + kws = self._init_kws + + if stream >= 0 and data is not None: + # load the new data blob/bytes to the respective keyword argument + if not kws.put_data(stream, data, last): + return False # no useful new data given (i.e., data was a second + # or later raw data blob) + + try: + ffmpeg_args, input_info, output_info = self._init_func(**kws) + except FFmpegioInsufficientInputData: + # fail only if the error was caused by insufficient input data + return False + + # Clear buffered data from the keywords dict + kws.clear_keywords() + + # Clear buffered data from input_info + for st in kws.input_pipes: + info = input_info[st] + info.pop("buffer", None) + + # save the final arguments and info lists + self._args["ffmpeg_args"] = ffmpeg_args + self._input_info = input_info + self._output_info = output_info + + # add probesize option to the input streams if not user specified + input_args = ffmpeg_args["inputs"] + for st in kws.input_pipes: + opts = input_args[st][1] + if "probesize" not in opts: + opts["probesize"] = self._probesize + + # ready to run + self._status = FFmpegStatus.ANALYSIS_DONE + + return True + + def _on_exit(self, rc): + if self._status == FFmpegStatus.RUNNING: + logger.debug("FFmpeg process has stopped") + self._stack.close() + self._status = FFmpegStatus.STOPPED + logger.debug("closed pipes and their threads") + + @property + def _output_rate(self) -> int | Fraction | None: + return None + + def _run_ffmpeg(self): + """configure pipes and run ffmpeg + + ``BaseFFmpegRunner`` neither configure/start pipes nor dump the pre-buffer + in ``_init_kws``. + """ + + if self._status != FFmpegStatus.ANALYSIS_DONE: + if self._status < FFmpegStatus.ANALYSIS_DONE: + raise FFmpegioError( + "FFmpeg configuration not set. Run `config_ffmpeg()` first." + ) + raise FFmpegioError("FFmpeg pipes have already configured.") + + args = self._args["ffmpeg_args"] + + # set up and activate standard pipes and read/write threads + # configure named pipes + more_args = {} + input_pipes = {} + output_pipes = {} + + # configure the pipes + if len(self._input_info): + input_pipes, more_args = configure.assign_input_pipes( + args, self._input_info, self._use_std_pipes + ) + + if len(self._output_info): + output_pipes, sp_kwargs = configure.assign_output_pipes( + args, self._output_info, self._use_std_pipes + ) + more_args.update(sp_kwargs) + + self._args.update(more_args) + + # find the primary output stream's rate + if self._use_named_pipes: + configure.init_named_pipes( + input_pipes, + output_pipes, + self._input_info, + self._output_info, + ref_stream=self.primary_output, + ref_blocksize=self.primary_output_blocksize, + stack=self._stack, + **self._pipe_kws, + ) + + # run the FFmpeg + try: + self._status = FFmpegStatus.RUNNING + self._proc = ffmpegprocess.Popen(**self._args, on_exit=self._on_exit) + except: + if self._stack is not None: + self._stack.close() + raise + + # set the log source and start the logger + self._logger.stderr = self._proc.stderr + self._stack.enter_context(self._logger) + + # # if stdin/stdout is used, attach StdWriter/StdReader object to each + if self._use_std_pipes: + configure.init_std_pipes( + input_pipes, output_pipes, self._output_info, self._proc + ) + + self._input_pipes = input_pipes + self._output_pipes = output_pipes + + # write pre-buffered data + for st, data, last in self._init_kws.iter_raw_data(): + self.write(data, st, last=last) + for st, data, last in self._init_kws.iter_enc_data(): + self.write_encoded(data, st, last=last) + + # clear pre-buffered data + self._init_kws.clear_data() + + def _terminate(self): + """Kill FFmpeg process and close the streams""" + + if self._proc is None or self._proc.poll() is not None: + return + + writers = [pinfo["writer"] for pinfo in self._input_pipes.values()] + readers = [pinfo["reader"] for pinfo in self._output_pipes.values()] + + # switch the readers to the cool-down (auto-flushing) mode + for reader in readers: + reader.cool_down() + + # write the sentinel to each input queue (if not already closed) + for writer in writers: + if not writer.closed(): + writer.write(None) + + # kill the ffmpeg runtime + self._proc.terminate() + if self._proc.poll() is None: + self._proc.kill() + + def open(self): + """start FFmpeg processing + + Note + ---- + + It may flag to defer starting the FFmpeg process if the input streams + are not fully specified and must wait to deduce them from the written + data. + + """ + + if self._status != FFmpegStatus.PREOPEN: + raise FFmpegioError("Already opened once.") + + # try configure FFmpeg arguments without any pre-buffered data + ok = self._try_config_ffmpeg() + + # if failed to configure, need to buffer input data first + if ok: + # ready to roll + self._run_ffmpeg() + + else: + # need input data to start ffmpeg + self._status = FFmpegStatus.BUFFERING + + def close(self): + """Kill FFmpeg process and close the streams""" + + if self._status != FFmpegStatus.RUNNING: + self._status = FFmpegStatus.STOPPED + else: + self._terminate() + + @property + def closed(self) -> bool: + """True if the stream is closed.""" + return self._proc is None or self._proc.poll() is not None + + def __enter__(self): + if self._status == FFmpegStatus.PREOPEN: + self.open() + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.close() + + @property + def lasterror(self) -> FFmpegError | None: + """Last error FFmpeg posted""" + if self._proc and self._proc.poll(): + return self._logger.Exception + else: + return None + + def readlog(self, n: int | None = None) -> str: + """read FFmpeg log lines + + :param n: number of lines to read or None to read all currently found in the buffer + :return: logged messages + """ + + with self._logger._newline_mutex: + return "\n".join(self._logger.logs if n is None else self._logger.logs[:n]) + + def wait(self, timeout: float | None = None) -> int | None: + """flushes and close all input pipes and waits for FFmpeg to exit + + :param timeout: a timeout for blocking in seconds, or fractions + thereof, defaults to None, to wait indefinitely + :raise `TimeoutExpired`: if a timeout is set, and the process does not + terminate after timeout seconds. It is safe to + catch this exception and retry the wait. + :return returncode: return subprocess Popen returncode attribute + """ + + if self._proc: + # write the sentinel to each input queue + for pinfo in self._input_pipes.values(): + pinfo["writer"].write(None) + + # wait until the FFmpeg finishes the job + self._proc.wait(timeout) + + rc = self._proc.returncode + if rc is not None: + self._proc = None + else: + rc = None + return rc + + @property + def _args_not_ready(self): + return self._status < FFmpegStatus.ANALYSIS_DONE + + ########################################################## + ### RAW MEDIA INPUT STREAM PROPERTIES/METHODS + ########################################################## + + @property + def writable(self) -> bool: + """Return ``True`` if there is at least one raw media stream to write to. + If ``False``, ``write()`` will raise ``FFmpegioError``. + + See also: ``BaseFFmpegRunner.num_input_streams`` + """ + + return self.num_input_streams > 0 + + @cached_property + def num_input_streams(self) -> int: + """Return the number of raw media input streams. + If ``0``, ``write()`` will raise ``FFmpegioError``.""" + + try: + return len(self._init_kws["input_options"]) + except KeyError: + return 0 + + def write(self, data: RawDataBlob, stream: int = 0, *, last: bool = False): + """write a raw media data blob to the specified stream + + :param data: raw media data blob, which is supported by one of loaded + plugins (e.g., a NumPy array if numpy is importable in the + Python workspace). The shape and dtype of the data must be + compatible with the stream's shape and pix_fmt/sample_fmt. + :param stream: stream index in accordance to the ``input_options`` + input array, defaults to 0 (write to the first stream). + :param last: ``True`` to indicate ``data`` is the last frame of the stream. + Once called with ``last=True``, the input stream can no longer + be written. + + """ + + try: + data2bytes = self._input_info[stream]["data2bytes"] + except AttributeError as e: + if self._status == FFmpegStatus.BUFFERING: + if self._try_config_ffmpeg(stream, data, last): + self._run_ffmpeg() + else: + raise FFmpegioError( + "unknown error occurred (_input_info missing)" + ) from e + except KeyError as e: + raise FFmpegioError(f"Specified {stream=} is not a raw stream.") from e + else: + b = data2bytes(obj=data) + writer = self._input_pipes[stream]["writer"] + if len(b): + writer.write(b) + if last: + writer.write(None) # write the sentinel + + @property + def input_types(self) -> list[MediaType]: + """media types (list of 'audio' or 'video') of raw input pipes""" + + try: + return [ + "video" if "r" in opts else "audio" + for opts in self._init_kws["input_options"] + ] + except KeyError: + return [] + + @property + def input_rates(self) -> list[int | Fraction]: + """audio sample or video frame rates associated with the input media streams""" + + kws = self._init_kws + try: + sopts = kws["input_options"] + except KeyError: + return [] # no input streams + + return [opts["r"] if "r" in opts else opts["ar"] for opts in sopts] + + @property + def input_dtypes(self) -> list[DTypeString] | None: + """frame/sample data type associated with the input raw media streams + + ``None`` is returned if input stream exists but FFmpeg is not running yet + and ``input_dtypes`` argument is not given or not fully populated. + """ + + nin = self.num_input_streams + if nin == 0: + return [] + + try: + # ffmpeg running + return [v["raw_info"][0] for v in self._input_info[:nin]] + except AttributeError: + # not running yet, gather as much as we can + dtypes = self._init_kws["input_dtypes"] + return ( + None + if dtypes is None + or len(dtypes) != nin + or any(dtype is None for dtype in dtypes) + else dtypes + ) + + @property + def input_shapes(self) -> list[ShapeTuple] | None: + """frame/sample shape associated with the input raw media streams + + ``None`` is returned if input stream is expected but FFmpeg is not running yet + and ``input_shapes`` argument is not given or not fully populated. + """ + + nin = self.num_input_streams + if nin == 0: + return [] + + try: + # ffmpeg running + return [v["raw_info"][1] for v in self._input_info[:nin]] + except AttributeError: + # not running yet, gather as much as we can + shapes = self._init_kws["input_shapes"] + return ( + None + if shapes is None + or len(shapes) != nin + or any(shape is None for shape in shapes) + else shapes + ) + + ########################################################## + ### ENCODED INPUT STREAM PROPERTIES/METHODS + ########################################################## + + @property + def decodable(self) -> bool: + """Return ``True`` if there is at least one encoded stream to write to. + If ``False``, ``write_encoded()`` will raise ``FFmpegioError``.""" + + return self.num_encoded_input_streams > 0 + + @cached_property + def num_encoded_input_streams(self) -> int: + """Return the number of encoded input streams. + If ``0``, ``write_encoded()`` will raise ``FFmpegioError``.""" + + return len(self.encoded_input_streams) + + @cached_property + def encoded_input_streams(self) -> list[int]: + """Return a list of encoded piped input streams. + If empty, write_encoded() will raise FFmpegioError.""" + + kws = self._init_kws + url_kw_or_none = kws.get("input_urls", kws.get("extra_inputs", None)) + return ( + [] + if url_kw_or_none is None + else [i for i, (url, _) in enumerate(url_kw_or_none) if utils.is_pipe(url)] + ) + + def write_encoded(self, data: bytes, stream: int = 0, *, last: bool = False): + """write encoded media data to the specified encoded stream + + :param data: encoded media data bytes to be written. + :param stream: encoded input stream index, defaults to 0 (write to the + first stream). Note that this stream index is that of all + encoded inputs. For example, if the runner is set up with + ``input_urls = ['video.mp4','-']`` then ``stream=0`` points + to `'video.mp4'` thus the write would fail, and ``stream=1`` + must be specified to write to the input pipe. + :param last: ``True`` to indicate ``data`` is the last frame of the stream. + Once called with ``last=True``, the input stream can no longer + be written. + + """ + + if stream not in self.encoded_input_streams: + raise FFmpegioError(f"Specified {st=} is not a valid input encoded stream.") + if len(data) == 0: + return # no data to write + + st = stream + self.num_input_streams + try: + writer = self._input_pipes[st]["writer"] + except AttributeError as e: + # _input_info wouldn't exist if FFmpeg is not running, write to prebuffer + if self._status == FFmpegStatus.BUFFERING: + if self._try_config_ffmpeg(st, data, last): + self._run_ffmpeg() + else: + raise FFmpegioError( + "unknown error occurred (_input_info missing)" + ) from e + else: + writer.write(data) + if last: + writer.write(None) + + ########################################################## + ### OUTPUT PROPERTIES + ########################################################## + + @cached_property + def readable(self) -> bool | None: + """Return ``True`` if there is at least one raw media stream to read from. + If ``False``, ``read()`` will raise ``FFmpegioError``.""" + nout = self.num_output_streams + return nout and nout > 0 + + @cached_property + def num_output_streams(self) -> int | None: + """Return the number of raw media stream to read from. If ``0``, ``read()`` + will raise ``FFmpegioError``.""" + + # assuming that ``output_stream`` keyword only =specifies unique map + + try: + output_info = self._output_info + except AttributeError: + ostreams = self._init_kws.get("output_streams", None) + return ostreams and len(ostreams) + else: + return sum("media_type" in info for info in output_info) + + def read(self, n: int, stream: int = 0) -> RawDataBlob: + """read selected output stream (shared backend)""" + + try: + info = self._output_info[stream] + assert "media_type" in self._output_info[stream] + except AttributeError as e: + raise FFmpegioError("FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{stream} is not a raw stream.") from e + + (dtype, shape, _) = info["raw_info"] + b = self._output_pipes[stream]["reader"].read(n) + + data = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) + + return data + + @property + def output_types(self) -> list[MediaType] | None: + """media types of the raw media output pipes. + + Note: If a pipe outputs a filtergraph output (or streamspec is not + unique), ``None`` is returned prior to FFmpeg starts""" + + nout = self.num_output_streams + + if nout == 0: # no media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + kw = self._init_kws["output_streams"] + out = [""] * nout + for i, opts in enumerate( + kw if isinstance(kw, list) else (v for v in kw.values()) + ): + mapopts = stream_spec.parse_map_option( + opts["map"], input_file_id=0, parse_stream=True + ) + if "linklabel" in mapopts: + return None # linklabel requires filtergraph analysis + + media_type = stream_spec.is_unique_stream(mapopts["stream_specifier"]) + if media_type is False: + return None # just in case + out[i] = media_type + return out + else: + return [info["media_type"] for info in stream_info[:nout]] + + @property + def output_labels(self) -> list[str] | None: + """labels of the raw media output pipes. + + If the same input stream is mapped to multiple outputs without unique + user labels, ``None`` is returned prior to FFmpeg starts""" + + nout = self.num_output_streams + + if nout == 0: # no media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + kw = self._init_kws["output_streams"] + out = [""] * nout + for i, (name, opts) in enumerate( + ((None, v) for v in kw) if isinstance(kw, list) else kw.items() + ): + out[i] = opts["map"] if name is None else name + return out if len(set(out)) == nout else None + else: + return [v["user_map"] for v in stream_info[:nout]] + + @property + def output_rates(self) -> list[int | Fraction] | None: + """sample or frame rates associated with the output streams + + ``None`` is returned before FFmpeg starts unless user specify the + rates of all output streams (i.e., resample/change frame rate). + """ + + nout = self.num_output_streams + + if nout == 0: # no output media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet, get user options + rates = [0] * nout + kw = self._init_kws["output_streams"] + if isinstance(kw, dict): + kw = kw.values() + for i, opts in enumerate(kw): + r = opts.get("r", opts.get("ar", None)) + if r is None: + return None + rates[i] = r + return rates + else: + return [v["raw_info"][2] for v in stream_info[:nout]] + + @property + def output_dtypes(self) -> list[DTypeString] | None: + """frame/sample data type associated with the output streams + + Each element is a Numpy-style dtype string like '|u1' for unsigned 8-bit + integer. + + If FFmpeg process has not been started, this property returns ``None``. + """ + + nout = self.num_output_streams + + if nout == 0: # no output media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet + return None + else: + return [v["raw_info"][0] for v in stream_info[:nout]] + + @property + def output_shapes(self) -> list[ShapeTuple] | None: + """frame/sample shape associated with the output streams + + Each element is a Numpy-style shape integer tuple of each time sample. + For a video stream, it has 3 elements (height, width, components); for + an audio stream, it has 1 element (channels,). + + If FFmpeg process has not been started, this property returns ``None``. + """ + + nout = self.num_output_streams + + if nout == 0: # no output media stream + return [] + + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet + return None + else: + return [v["raw_info"][1] for v in stream_info[:nout]] + + @property + def output_itemsizes(self) -> list[int] | None: + """frame/sample item sizes in bytes or ``None`` if accessed before ffmpeg + is configured. + """ + + nout = self.num_output_streams + if nout == 0: + return [] + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet + return None + else: + return [v["item_size"] for v in stream_info[:nout]] + + ### PRIMARY OUTPUT SETTING + + @property + def primary_output(self) -> int: + """index of the primary output stream or ``-1`` if no output raw media stream""" + + _user_val = self._primary_output + if _user_val is None: + return 0 if self.readable else -1 + nout = self.num_output_streams + if _user_val < 0 or _user_val >= nout: + raise FFmpegioError( + f"FFmpeg runner object was created with an invalid primary stream ({_user_val})" + ) + + return _user_val + + @property + def primary_output_blocksize(self) -> int | None: + """blocksize for iterator-based read and if queued-stream size of block in queue""" + + if not self.readable: + return None + + bsize = self._blocksize + if bsize is None: + media_types = self.output_types + if media_types is not None: + mtype = media_types[self.primary_output] + bsize = {"audio": 1024, "video": 1}[mtype] + + return bsize + + @property + def primary_output_label(self) -> str | None: + """primary raw media stream label (None if FFmpeg not started or no output raw stream)""" + + st = self.primary_output + if st < 0 or self._output_info is None: + return None + return self._output_info[st].get("user_map", None) + + @property + def primary_output_rate(self) -> int | Fraction | None: + """sample/frame rate of the primary raw media stream (None if FFmpeg not started or no output raw stream)""" + st = self.primary_output + try: + return self._output_info[st]["raw_info"][-1] + except (AttributeError, IndexError): + return None + + def output_frames( + self, primary_frames: int | None = None + ) -> list[int | Fraction] | None: + """calculate the number of frames of raw output streams + + :param primary_frames: number of frames of the reference output stream, + defaults to ``primary_output_blocksize`` + :return: numbers of frames of all the output streams. If FFmpeg process + has not been started, it returns None + """ + if primary_frames is None: + primary_frames = self.primary_output_blocksize + rates = self.output_rates + rate0 = self.primary_output_rate + if primary_frames is None or rates is None or rate0 is None: + return None + + fr = Fraction(primary_frames, rate0) + return [r * fr for r in rates] + + def output_pending(self) -> bool: + """True if FFmpeg is running or at least one output buffer has data""" + return bool(self) or any( + pipe["reader"].qsize() for pipe in self._output_pipes.values() + ) + + ########################################################## + ### ENCODED INPUT STREAM PROPERTIES/METHODS + ########################################################## + + @property + def encodable(self) -> bool: + """Return ``True`` if there is at least one encoded stream to read. + If ``False``, ``read_encoded()`` will raise ``FFmpegioError``.""" + + return self.num_encoded_output_streams > 0 + + @cached_property + def num_encoded_output_streams(self) -> int: + """Return the number of encoded output streams. + If ``0``, ``read_encoded()`` will raise ``FFmpegioError``.""" + + return len(self.encoded_output_streams) + + @cached_property + def encoded_output_streams(self) -> list[int]: + """Return a list of encoded piped output streams. + If empty, ``read_encoded()`` will raise ``FFmpegioError``.""" + + kws = self._init_kws + url_kw_or_none = kws.get("output_urls", kws.get("extra_outputs", None)) + return ( + [] + if url_kw_or_none is None + else [i for i, (url, _) in enumerate(url_kw_or_none) if utils.is_pipe(url)] + ) + + def read_encoded(self, n: int, stream: int = 0) -> bytes: + """read encoded media data from the specified encoded stream + + :param n: number of bytes to be read. If <=0 to read + :param stream: encoded output stream index, defaults to 0 (write to the + first stream). Note that this stream index is that of all + encoded inputs. For example, if the runner is set up with + ``input_urls = ['video.mp4','-']`` then ``stream=0`` points + to `'video.mp4'` thus the write would fail, and ``stream=1`` + must be specified to write to the input pipe. + :returns: bytes + """ + + if stream not in self.encoded_output_streams: + raise FFmpegioError( + f"Specified {stream=} is not a valid output encoded stream." + ) + + st = stream + self.num_output_streams + + try: + pipe = self._output_pipes[st] + except AttributeError as e: + raise FFmpegioError("FFmpeg is not running yet.") from e + + return pipe["reader"].read(n) + + +class SISOMixin: + input_rates: list[int | Fraction] + output_rates: list[int | Fraction] + input_dtypes: list[DTypeString] | None + output_dtypes: list[DTypeString] | None + input_shapes: list[ShapeTuple] | None + output_shapes: list[ShapeTuple] | None + + @property + def rate_in(self) -> int | Fraction | None: + """frame/sample rate input raw stream (``None`` if no input)""" + rates = self.input_rates + return None if rates is None else rates[0] + + @property + def rate(self) -> int | Fraction | None: + """frame/sample rate output raw stream (``None`` if no output)""" + rates = self.output_rates + return None if rates is None else rates[0] + + @property + def dtype_in(self) -> DTypeString | None: + """NumPy-style data type string of the input raw stream (``None`` if no input)""" + dtypes = self.input_dtypes + return None if dtypes is None else dtypes[0] + + @property + def dtype(self) -> DTypeString | None: + """NumPy-style data type string of the output raw stream (``None`` if no output)""" + dtypes = self.output_dtypes + return None if dtypes is None else dtypes[0] + + @property + def shape_in(self) -> ShapeTuple | None: + """shape tuple of input data frame (``None`` if no input) + + - audio frame: ``(channels,)`` + - video frame: ``(height, width, components)`` + """ + shapes = self.input_shapes + return None if shapes is None else shapes[0] + + @property + def shape(self) -> ShapeTuple | None: + """shape tuple of output data frame (``None`` if no output) + + - audio frame: ``(channels,)`` + - video frame: ``(height, width, components)`` + """ + shapes = self.output_shapes + return None if shapes is None else shapes[0] + + +class StdFFmpegRunner(SISOMixin, BaseFFmpegRunner): + _dynamic_output: bool = False + _use_std_pipes: bool = True + _use_named_pipes: bool = False + + def __init__( + self, + init_func: Callable, + init_kws: MediaReadKwsDict | MediaWriteKwsDict, + blocksize: int | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ): + """FFmpeg runner with only 1 buffered std pipe + + :param init_func: FFmpeg initialization function from :py:module:`configure` + :param init_kws: keyword arguments to call the FFmpeg initialization function + :param blocksize: (only for readable) iterator block size in frames/samples + to read raw media streams, defaults to use ``1`` (frame) + for a video stream and ``1024`` (samples) for audio stream. + :param progress: progress callback function, defaults to None + :param show_log: True to show FFmpeg log messages on the console, defaults + to None (no show/capture) + :param overwrite: True to overwrite existing file, defaults to False to + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + + """ + super().__init__( + init_func, + init_kws, + blocksize=blocksize, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + + def _try_config_ffmpeg( + self, + stream: int = -1, + data: bytes | RawDataBlob | None = None, + last: bool = False, + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :param last: optional ``True`` if ``data`` is the last data blob of ``stream`` + :return: ``True`` if FFmpeg arguments are successfully configured + and ``_input_info`` and ``_output_info`` lists are fully + populated. + + This subclass overloading adds additional validation for having only one + pipe to guarantee a simple operation with one buffered std pipe. + + """ + + ok = super()._try_config_ffmpeg(stream, data, last) + if ok: + # validate + nin = self.num_input_streams + nout = self.num_output_streams + nein = self.num_encoded_input_streams + neout = self.num_encoded_output_streams + if nin + nout + nein + neout != 1: + if max(nin, nein) > 1: + raise FFmpegioError( + "More than one input stream assigned to use stdin" + ) + if max(nout, neout) > 1: + raise FFmpegioError( + "More than one output stream assigned to use stdout" + ) + else: + raise FFmpegioError( + "StdFFmpegRunner can only use either stdin or stdout" + ) + + return ok + + @override + def __iter__(self) -> Iterator[RawDataBlob]: + """iterator to read raw media data + + :yield: data blob containing at most ``primary_output_blocksize`` + frames/samples of the output stream. + + Note: The iterator of :py:class:`streams.StdFFmpegRunner` is not compatible with + :py:class:`streams.BaseFFmpegRunner` and :py:class:`streams.PipedFFmpegRunner`. + The other classes yield a list of data blobs as they allow multiple output + raw output streams. + """ + + nout = self.num_output_streams + if nout == 0: + raise FFmpegioError("No output stream to create a frame iterator") + + if self.decodable or self.encodable or self.writable: + raise FFmpegioError("Frame iterator is only supported for a pure reader") + + ref_st = self.primary_output + ref_sz = self.primary_output_blocksize + + isempty = self._output_info[ref_st]["data_is_empty"] + + F = self.read(ref_sz, ref_st) + while not isempty(obj=F): + yield F + F = self.read(ref_sz, ref_st) + + @staticmethod + def open_simple_reader( + input_urls: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + output_stream: str | FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_outputs: ( + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + *, + blocksize: int | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> StdFFmpegRunner: + """create a single-pipe media reader + + :param input_urls: URL string of the file or format/device object. It + can be an input filtergraph object or other input ffmpegio objects. + The input could also be fed by a readable file object. Multiple + input sources could be assigned to feed a complex filtergraph. + :param output_stream: Either an FFmpeg map option value or a dict of + FFmpeg output options. If dict, it must include a ``'map'`` key. The + ``'map'`` must resolve to only one stream. + :param options: optional FFmpeg option dict including input, output, and + global options. For input options, append ``'_in'`` to the end of + FFmpeg option names. + :param squeeze: ``True`` (default) to eliminate raw output's singleton + dimensions. Use ``False`` to always return 2D array for audio and 4D + array for video. + :param extra_outputs: extra encoded output urls, Each element is a tuple + pair of url and output option dict. The url must be a url and not + pipes or pipe objects. + :param blocksize: Read block size (in frames for video or samples in + audio) when the reader object is used as an iterator + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination + files, defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to + ``None`` + """ + + init_kws: MediaReadKwsDict = { + "input_urls": input_urls, + "output_streams": [output_stream], + "options": options, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + } + runner = StdFFmpegRunner( + init_func=configure.init_media_read, + init_kws=init_kws, + blocksize=blocksize, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_simple_writer( + output_urls: ( + FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + ), + input_options: FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> StdFFmpegRunner: + """single-pipe media writer + + :param input_options: ffmpeg input options for the raw media input + must contain a rate option (``r`` or ``ar``). + :param output_urls: Specify encoded output file(s) in one of the + following styles: + + - an output file url + - a pair of the url and FFmpeg output options + - a sequence of the urls or the pairs or a mixture thereof. This + commands FFmpeg to generate multiple files simultaneously from + the same input streams. + + :param options: optional ffmpeg option dict including input, output, and + global options. For input options, append ``'_in'`` to the + end of ffmpeg option names. + :param extra_inputs: extra encoded input urls, Each element is a tuple + pair of url and input option dict. The url must be a url and not + pipes or pipe objects. + :param input_shape: input video frame size (height, width) or number of + input audio channel, defaults to auto-detect + :param input_dtype: input data format in a Numpy dtype string, defaults + to auto-detect + :param progress: progress callback function, defaults to ``None`` + :param show_log: ``True`` to show FFmpeg log messages on the console, + defaults to ``False``, hiding the logged messages + :param overwrite: ``True`` to overwrite ``extra_outputs`` destination + files, defaults to ``False`` + :param sp_kwargs: keyword dict to be passed to ``subprocess.run()`` or + ``subprocess.Popen()`` call used to run the FFmpeg, defaults to + ``None`` + """ + + init_kws: MediaWriteKwsDict = { + "input_options": [input_options], + "output_urls": output_urls, + "extra_inputs": extra_inputs, + "options": options, + "input_dtypes": None if input_dtype is None else [input_dtype], + "input_shapes": None if input_shape is None else [input_shape], + } + runner = StdFFmpegRunner( + init_func=configure.init_media_write, + init_kws=init_kws, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + +class PipedFFmpegRunner(BaseFFmpegRunner): + """Streaming FFmpeg runner using named pipes""" + + _dynamic_output: bool = False + _use_std_pipes: bool = False + _use_named_pipes: bool = True + + def read_nowait(self, n: int, stream: int = 0) -> RawDataBlob: + """read selected output stream (shared backend)""" + + try: + info = self._output_info[stream] + assert "media_type" in self._output_info[stream] + except AttributeError as e: + raise FFmpegioError("FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{stream} is not a raw stream.") from e + + (dtype, shape, _) = info["raw_info"] + b = self._output_pipes[stream]["reader"].read_nowait( + n * info["item_size"] if n > 0 else n + ) + + data = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) + + return data + + def read_encoded_nowait(self, n: int, stream: int = 0) -> bytes: + """read encoded media data from the specified encoded stream + + :param n: number of bytes to be read. If <=0 to read + :param stream: encoded output stream index, defaults to 0 (write to the + first stream). Note that this stream index is that of all + encoded inputs. For example, if the runner is set up with + ``input_urls = ['video.mp4','-']`` then ``stream=0`` points + to `'video.mp4'` thus the write would fail, and ``stream=1`` + must be specified to write to the input pipe. + :returns: bytes + """ + + if stream not in self.encoded_output_streams: + raise FFmpegioError( + f"Specified {stream=} is not a valid output encoded stream." + ) + + if self.status == FFmpegStatus.BUFFERING: + return b"" + + st = stream + self.num_output_streams + + try: + pipe = self._output_pipes[st] + except AttributeError: + return b"" + + return pipe["reader"].read_nowait(n) + + def __iter__(self) -> Iterator[list[RawDataBlob]]: + """iterator to read raw media data + + :yield: a list of raw data blobs, one for each output raw media stream, + containing at most ``primary_output_blocksize`` frames of + the primary stream given by ``primary_output``. The frame sizes + of other streams are proportional to their ``output_rates`` wrt + the primary output. + """ + nout = self.num_output_streams + if nout == 0: + raise FFmpegioError("No output stream to create a frame iterator") + + if self.decodable or self.encodable or self.writable: + raise FFmpegioError("Frame iterator is only supported for a pure reader") + + nperread = self.output_frames() + count = [self._output_info[i]["data_count"] for i in range(nout)] + nf = nperread.copy() + nread = [1] * nout + + # loop while FFmpeg is running + while self: + # read the next block of the reference stream + out = [ + (self.read)(round(max(ni, 0)), st) for st, ni in zip(range(nout), nf) + ] + nread = [counti(obj=Fi) for counti, Fi in zip(count, out)] + + # yield the last read frames + yield out + + # calculate how many frames to read next (fractional) + nf = [nfi - nr + nnext for nfi, nr, nnext in zip(nf, nread, nperread)] + + # if there is any secondary streams with leftover frames, do the last yield + if self.output_pending() and any(n > 0 for n in nread): + out = [self.read(round(max(ni, 0)), st) for st, ni in zip(range(nout), nf)] + yield out + + @staticmethod + def open_media_reader( + input_urls: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + output_streams: ( + str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None + ) = None, + options: FFmpegOptionDict | None = None, + squeeze: bool = False, + extra_outputs: ( + list[FFmpegOutputOptionTuple] | dict[str, FFmpegOptionDict] | None + ) = None, + *, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + output_streams = utils.expand_raw_output_streams( + output_streams, input_urls, options + ) + init_kws: MediaReadKwsDict = { + "input_urls": input_urls, + "output_streams": output_streams, + "options": options, + "squeeze": squeeze, + "extra_outputs": extra_outputs, + } + runner = PipedFFmpegRunner( + configure.init_media_read, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_writer( + output_urls: ( + FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + ), + input_options: list[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + *, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + init_kws: MediaWriteKwsDict = { + "output_urls": output_urls, + "input_options": input_options, + "options": options, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + "extra_inputs": extra_inputs, + } + runner = PipedFFmpegRunner( + configure.init_media_write, + init_kws, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_filter( + input_options: list[FFmpegOptionDict], + output_streams: str | FFmpegOptionDict | Sequence[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + *, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + init_kws: MediaFilterKwsDict = { + "input_options": input_options, + "output_streams": output_streams, + "options": options, + "extra_inputs": extra_inputs, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + } + runner = PipedFFmpegRunner( + configure.init_media_filter, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_encoder( + input_options: list[FFmpegOptionDict], + output_options: list[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + *, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + output_urls: list[FFmpegOutputOptionTuple] = [ + ("-", opts) for opts in output_options + ] + if extra_outputs is not None: + output_urls.extend(extra_outputs) + + init_kws: MediaWriteKwsDict = { + "output_urls": output_urls, + "input_options": input_options, + "options": options, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + "extra_inputs": extra_inputs, + } + runner = PipedFFmpegRunner( + configure.init_media_write, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_decoder( + input_options: Sequence[FFmpegOptionDict], + output_streams: str | FFmpegOptionDict | Sequence[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[FFmpegOutputOptionTuple] | None = None, + *, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + input_urls: list[FFmpegInputOptionTuple] = [ + ("-", opts) for opts in input_options + ] + if extra_inputs is not None: + input_urls.extend(extra_inputs) + + init_kws: MediaReadKwsDict = { + "input_urls": input_urls, + "output_streams": output_streams, + "options": options, + "squeeze": squeeze, + "extra_outputs": extra_outputs, + } + runner = PipedFFmpegRunner( + configure.init_media_read, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + @staticmethod + def open_media_transcoder( + input_options: list[FFmpegOptionDict], + output_options: list[FFmpegOptionDict], + options: FFmpegOptionDict | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + *, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> PipedFFmpegRunner: + input_urls = [("pipe", opts) for opts in input_options] + output_urls = [("pipe", opts) for opts in output_options] + + if extra_inputs is not None: + input_urls.extend(extra_inputs) + if extra_outputs is not None: + output_urls.extend(extra_outputs) + + init_kws: MediaTranscoderKwsDict = { + "input_urls": input_urls, + "output_urls": output_urls, + "options": options, + } + runner = PipedFFmpegRunner( + configure.init_media_transcode, + init_kws, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + runner.open() + return runner + + +class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): + """Streaming FFmpeg runner for a SISO filtering using named pipes. + + This class mixes in the single input convenience properties to + the py::class`PipedFFmpegRunner`. + """ + + @staticmethod + def create_and_open( + input_options: FFmpegOptionDict, + output_stream: str | FFmpegOptionDict | None = None, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: ( + list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: Callable[[dict[str, Any], bool], bool] | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ) -> SISOFFmpegFilter: + runner = SISOFFmpegFilter( + input_options, + output_stream, + squeeze=squeeze, + extra_inputs=extra_inputs, + extra_outputs=extra_outputs, + input_dtype=input_dtype, + input_shape=input_shape, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + options=options, + ) + runner.open() + return runner + + def __init__( + self, + input_options: FFmpegOptionDict, + output_stream: str | FFmpegOptionDict | None = None, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_inputs: ( + list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + *, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + primary_output: int | None = None, + blocksize: int | None = None, + enc_blocksize: int | None = None, + queuesize: int | None = None, + timeout: float | None = None, + progress: Callable[[dict[str, Any], bool], bool] | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ): + init_func = configure.init_media_filter + init_kws: MediaFilterKwsDict = { + "input_options": [input_options], + "output_streams": output_stream and [output_stream], + "options": options, + "extra_inputs": extra_inputs, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + "input_dtypes": None if input_dtype is None else [input_dtype], + "input_shapes": None if input_shape is None else [input_shape], + } + super().__init__( + init_func, + init_kws, + primary_output=primary_output, + blocksize=blocksize, + enc_blocksize=enc_blocksize, + queuesize=queuesize, + timeout=timeout, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) + + def _try_config_ffmpeg( + self, + stream: int = -1, + data: bytes | RawDataBlob | None = None, + last: bool = False, + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :param last: ``True`` if ``data`` is the last blob of ``stream`` + :return: ``True`` if FFmpeg arguments are successfully configured + and ``_input_info`` and ``_output_info`` lists are fully + populated. + + This subclass overloading adds additional validation for having only one + pipe to guarantee a simple operation with one buffered std pipe. + + """ + + ok = super()._try_config_ffmpeg(stream, data, last) + if ok: + # validate + nin = self.num_input_streams + nout = self.num_output_streams + if nin != 1 or nout != 1: + raise FFmpegioError( + "SISOFFmpegFilter takes only one each of raw input and output." + ) + if self.num_encoded_input_streams or self.num_encoded_output_streams: + raise FFmpegioError( + "SISOFFmpegFilter does not accept any encoded input or output." + ) + + return ok + + # def filter(self, data: RawDataBlob, *, last: bool = False) -> RawDataBlob: + # """filter a raw media data blob to the specified stream + + # :param data: raw media data blob, which is supported by one of loaded + # plugins (e.g., a NumPy array if numpy is importable in the + # Python workspace). The shape and dtype of the data must be + # compatible with the stream's shape and pix_fmt/sample_fmt. + # :param last: ``True`` to mark ``data`` the last input blob, defaults to + # ``False`` + # :returns: filter output blob. + + # This method shall be used with caution especially if the input and output + # rates are not the same. It is recommended to set a timeout. + # """ + + # self.write(data, last=last) + + # if self.rate_in is None or self.rate is None: + # raise FFmpegioError("FFmpeg is not running yet.") + + # n = self._input_info[0]["data_count"](obj=data) + # nout = int((n * self.rate / self.rate_in)) + + # return self.read(nout) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 24362f27..5f76171e 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -771,291 +771,6 @@ def full(self) -> bool: return self._queue.full() -class AviReaderThread(Thread): - class InvalidAviStream(FFmpegError): ... - - def __init__(self, queuesize=None): - super().__init__() - self.reader = AviReader() #:utils.avi.AviReader: AVI demuxer - self.streamsready = Event() #:Event: Set when received stream header info - self.rates = None # :dict(int:int|Fraction) - self._queue = Queue(queuesize or 0) # inter-thread data I/O - self._ids = None #:dict(int:int): stream indices - self._nread = None #:dict(int:int): number of samples read/stream - self._carryover = ( - None #:dict(int:ndarray) extra data that was not previously read by user - ) - - @property - def streams(self): - return self.reader.streams if self.streamsready else None - - def start(self, stdout, use_ya=None): - self._args = (stdout, use_ya) - super().start() - - def join(self, timeout=None): - # if queue is full, - super().join(timeout) - - def __bool__(self): - """True if FFmpeg stdout stream is still open or there are more frames in the buffer""" - return self.is_alive() or not self._queue.empty() - - # def __enter__(self): - # self.start() - # return self - - # def __exit__(self, *_): - # self.join() # will wait until stdout is closed - # return self - - def run(self): - reader = self.reader - - try: - # start the AVI reader to process stdout byte stream - reader.start(*self._args) - - # initialize the stream properties - self._ids = ids = [i for i in reader.streams] - self._nread = {k: 0 for k in ids} - self.rates = { - k: v["frame_rate"] if v["type"] == "v" else v["sample_rate"] - for k, v in reader.streams.items() - } - except Exception as e: - logger.critical(e) - return - finally: - self.streamsready.set() - - reader = self.reader - for id, data in reader: - self._queue.put((id, data)) - self._queue.put(None) # end of stream - - def wait(self, timeout: float | None = None) -> bool: - """wait till stream is ready to be read - - :param timeout: timeout in seconds, defaults to None (waits indefinitely) - :type timeout: float, optional - :raises InvalidAviStream: if thread has been terminated before stream header info was read - :return: tuple of stream specifier and data array - :rtype: (str, object) - """ - - flag = self.streamsready.wait(timeout) - if not (flag or self.is_alive()): - raise self.InvalidAviStream( - "No stream header info was found in FFmpeg's AVI stream." - ) - return flag - - def readchunk(self, timeout=None) -> tuple[str, object]: - """read the next avi chunk - - :param timeout: timeout in seconds, defaults to None (waits indefinitely) - :type timeout: float, optional - :raises TimeoutError: if terminated due to timeout - :return: tuple of stream specifier and data array - """ - - # wait till matching line is read by the thread - tend = timeout and time() + timeout - - # if stream header not received in time, raise error - if not self.wait(timeout): - raise TimeoutError("timed out waiting for the stream headers") - - block = self.is_alive() - - # if any leftover data available, return the first one - if self._carryover is not None: - (id, data) = next( - ((k, v) for k, v in self._carryover.items() if v is not None) - ) - self._carryover[id] = None - if all((k for k, v in self._carryover.items() if v is None)): - self._carryover = None - return self.reader.streams[id]["spec"], self.reader.from_bytes(id, data) - - # get next chunk - try: - if timeout is not None: - timeout = tend - time() - assert timeout > 0 - chunk = self._queue.get(block, timeout) - if chunk is None: - raise ThreadNotActive("reached end-of-stream") - id, data = chunk - except Empty: - raise TimeoutError("timed out waiting for next chunk") - self._queue.task_done() - - return self.reader.streams[id]["spec"], self.reader.from_bytes(id, data) - - def find_id(self, ref_stream: str) -> object: - self.wait() - try: - return next( - (k for k, v in self.reader.streams.items() if v["spec"] == ref_stream) - ) - except: - ValueError(f"{ref_stream} is not a valid stream specifier") - - def read( - self, n: int = -1, ref_stream: str | None = None, timeout: float | None = None - ) -> dict[str, bytes]: - """read data from all streams - - :param n: number of samples, negate to non-blocking, defaults to -1 - :param ref_stream: stream specifier to count the samples, - defaults to None (first stream) - :param timeout: timeout in seconds, defaults to None (waits indefinitely) - :raises TimeoutError: if terminated due to timeout - :return: dict of data object keyed by stream specifier string, each data object is - created by `bytes_to_video` or `bytes_to_image` plugin hook. If all frames - have been read, dict items would be all empty - """ - - # wait till matching line is read by the thread - block = self.is_alive() and n != 0 - tend = timeout and (time() + timeout) - - # if stream header not received in time, raise error - if not self.wait(timeout): - raise TimeoutError("timed out waiting for the stream headers") - - # get the reference stream id - if ref_stream is None: - ref_stream = self._ids[0] - else: - ref_stream = self.find_id(ref_stream) - - # identify how many samples are needed for each stream - nref = max(n, -n) - tref = (self._nread[ref_stream] + nref) / self.rates[ref_stream] - n_need = { - k: ceil(tref * self.rates[k]) - self._nread[k] if k != ref_stream else nref - for k in self._ids - } - nremain = deepcopy(n_need) - - # initialize output arrays - arrays = {k: [] for k in self._ids} - - itemsizes = self.reader.itemsizes - - # grab any leftover data from previous read - if self._carryover is not None: - for k, v in self._carryover.items(): - if v is not None: - arrays[k] = [v] - nremain[k] -= len(v) // itemsizes[k] - self._carryover = None - - # loop till enough data are collected - while any((v > 0 for k, v in nremain.items() if n_need[k] > 0)): - try: - if timeout: - timeout = tend - time() - if timeout <= 0: - break - chunk = self._queue.get(block, timeout) - if chunk is None: - break - k, data = chunk - self._queue.task_done() - arrays[k].append(data) - nremain[k] -= len(data) // itemsizes[k] - - except Empty: - break - - def combine(id, array, n, nr): - # combine all the data and return requested amount - if not len(array): - return (id, None, None) - all_data = b"".join(array) - nbytes = n * itemsizes[id] - return ( - (id, all_data, None) - if nr >= 0 - else (id, all_data[:nbytes], all_data[nbytes:]) - ) - - ids, data, excess = zip( - *( - combine(id, array, n_need[id], nremain[id]) - for id, array in arrays.items() - ) - ) - - # any excess samples, store as a _carryover dict - if any((sdata is not None for sdata in excess)): - self._carryover = {id: sdata for id, sdata in zip(ids, excess)} - - # final formatting of data - out = {} - for id, sdata in zip(ids, data): - info = self.reader.streams[id] - spec = info["spec"] - if sdata is None: - out[spec] = self.reader.from_bytes(id, b"") - else: - self._nread[id] += len(sdata) // itemsizes[id] - out[spec] = self.reader.from_bytes(id, sdata) - - return out - - def readall(self, timeout: float | None = None) -> dict[str, bytes]: - # wait till matching line is read by the thread - if timeout is not None: - timeout = time() + timeout - - # if stream header not received in time, raise error - if not self.wait(timeout): - raise TimeoutError("timed out waiting for the stream headers") - - # initialize output arrays - arrays = {k: [] for k in self._ids} - - itemsizes = self.reader.itemsizes - - # grab any leftover data from previous read - if self._carryover is not None: - for k, v in self._carryover.items(): - if v is not None: - arrays[k] = [v] - self._nread[k] += len(v) // itemsizes[k] - self._carryover = None - - # loop till enough data are collected - while True: - try: - chunk = self._queue.get(self.is_alive(), timeout and timeout - time()) - if chunk is None: - break # end of stream - k, data = chunk - self._queue.task_done() - arrays[k].append(data) - self._nread[k] += len(data) // itemsizes[k] - except Empty: - break - - # final formatting of data - out = {} - for id, sdata in arrays.items(): - info = self.reader.streams[id] - spec = info["spec"] - out[spec] = self.reader.from_bytes( - id, b"" if sdata is None else b"".join(sdata) - ) - - return out - - class CopyFileObjThread(Thread): """run shutil.copyfileobj in the thread diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index c8d31ebb..e3e429d2 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -84,8 +84,8 @@ def transcode( if utils.is_valid_output_url(outputs): outputs = [outputs] - args, input_info, output_info = configure.init_media_transcoder( - inputs, outputs, None, None, options + args, input_info, output_info = configure.init_media_transcode( + inputs, outputs, options ) # check number of pipes @@ -93,7 +93,7 @@ def transcode( nb_outpipes = sum(info["dst_type"] == "buffer" for info in output_info) # if 0 or 1 buffered input and 0 or 1 buffered output, just use stdin/stdout - simple_mode = nb_inpipes < 2 and nb_outpipes < 2 + simple_mode = (nb_inpipes + nb_outpipes) < 2 if not simple_mode: raise NotImplementedError( @@ -101,25 +101,27 @@ def transcode( ) # convert basic VF options to vf option - for i in range(len(output_info)): - configure.build_basic_vf(args, None, i) - - stdin, stdout, input = configure.assign_std_pipes( - args, input_info, output_info, use_sp_run=True - ) + # for i in range(len(output_info)): + # configure.build_basic_vf(args, None, i) kwargs = {**sp_kwargs} if sp_kwargs else {} + + # configure a std pipe if used + if nb_inpipes: + kwargs.update(configure.assign_input_pipes(args, input_info, True, True)[1]) + elif nb_outpipes: + kwargs.update(configure.assign_output_pipes(args, output_info, True)[1]) + kwargs.update( { "progress": progress, "overwrite": overwrite, - "stdin": stdin, - "stdout": stdout, - "input": input, "capture_log": None if show_log else True, } ) if two_pass: + if len(output_info) > 1: + raise ValueError("transcode() only supports two_pass mode for one output.") kwargs["pass1_omits"] = pass1_omits kwargs["pass1_extras"] = pass1_extras diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 3fa20530..060272af 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -2,57 +2,79 @@ from __future__ import annotations -from collections.abc import Sequence, Callable -from numbers import Number - import logging - -logger = logging.getLogger("ffmpegio") - - -from math import cos, radians, sin import re +from collections import defaultdict +from collections.abc import Callable, Sequence from fractions import Fraction +from math import cos, radians, sin +from numbers import Number -from .. import caps, plugins, probe -from .._utils import * -from ..stream_spec import * -from ..errors import FFmpegError, FFmpegioError +from .. import caps, plugins, probe, stream_spec +from .. import filtergraph as fgb from .._typing import ( - Any, - MediaType, - InputSourceDict, - RawDataBlob, - OutputDestinationDict, - FFmpegUrlType, IO, + Any, Buffer, + DTypeString, FFmpegOptionDict, + FFmpegUrlType, + FilterGraphInfoDict, + InputInfoDict, + Literal, + MediaType, + OutputInfoDict, + RawDataBlob, + RawStreamDef, ShapeTuple, - DTypeString, ) +from .._utils import ( + as_multi_option, + escape, + get_samplesize, + is_fileobj, + is_namedpipe, + is_non_str_sequence, + is_pipe, + is_url, + prod, + unescape, +) +from ..errors import FFmpegioError from ..filtergraph.abc import FilterGraphObject -from .. import filtergraph as fgb -from ..filtergraph.presets import temp_video_src, temp_audio_src +from ..filtergraph.presets import temp_audio_src, temp_video_src +from ..stream_spec import is_unique_stream, parse_map_option from .concat import FFConcat +# from .._utils import * + +logger = logging.getLogger("ffmpegio") + + FFmpegInputUrlComposite = FFmpegUrlType | FFConcat | FilterGraphObject | IO | Buffer +"""all input types supported by ffmpegio""" FFmpegOutputUrlComposite = FFmpegUrlType | IO +"""all output types supported by ffmpegio""" + +FFmpegInputUrlNoPipe = FFmpegUrlType | FFConcat | FilterGraphObject +"""all non-piped input types supported by ffmpegio""" + +FFmpegOutputUrlNoPipe = FFmpegUrlType +"""all non-piped output types supported by ffmpegio""" # TODO: auto-detect endianness # import sys # sys.byteorder -def get_pixel_config( - input_pix_fmt: str, pix_fmt: str | None = None -) -> tuple[str, int, DTypeString, bool]: +def get_pixel_config(input_pix_fmt: str) -> tuple[str, int, DTypeString, bool]: """get best pixel configuration to read video data in specified pixel format :param input_pix_fmt: input pixel format - :param pix_fmt: desired output pixel format, defaults to None (auto-select) - :return: output pix_fmt, number of components, data type string, and whether - alpha component must be removed + :return pix_fmt_out: output pix_fmt + :return ncomp: number of components + :return dtype: data type string + :return has_alpha: True if alpha component must be removed ===== ===== ========= =================================== ncomp dtype pix_fmt Description @@ -71,6 +93,7 @@ def get_pixel_config( 4 bool | int | None: - """get best pixel configuration to read video data in specified pixel format - - :param input_pix_fmt: input pixel format - :param output_pix_fmt: output pixel format - :param dir: specify the change direction for boolean answer, defaults to None - :return: dir None: 0 if no change, 1 if alpha added, -1 if alpha removed, None if indeterminable - dir int: True if changes in the specified direction or False - - """ - if input_pix_fmt is None or output_pix_fmt is None: - return None if dir is None else False - n_in = caps.pix_fmts()[input_pix_fmt]["nb_components"] - n_out = caps.pix_fmts()[output_pix_fmt]["nb_components"] - d = (n_in % 2) - (n_out % 2) - return d if dir is None else d > 0 if dir > 0 else d < 0 if dir < 0 else d == 0 - - -def get_pixel_format(fmt: str) -> tuple[str, int]: +def get_pixel_format(fmt: str) -> tuple[DTypeString, int]: """get data format and number of components associated with video pixel format :param fmt: ffmpeg pix_fmt - :return: data type string and the number of components associated with the pix_fmt + :return dtype: data type string compatible with `pix_fmt` + :return nb_components: the number of components of `pix_fmt` + + If `fmt` is not rgb or grayscale, the format must have byte-aligned pixel depth. + Also, such `fmt`'s are assumed to have integer pixel values. As a result, + floating-point pixel format may lead to an incorrect `dtype` return value, and + requires a post-read type casting. + """ try: return dict( @@ -150,8 +158,16 @@ def get_pixel_format(fmt: str) -> tuple[str, int]: rgba=("|u1", 4), rgba64le=(" 1 else "|u1" + return dtype, fmt_info["nb_components"] def get_video_format( @@ -175,7 +191,8 @@ def guess_video_format( :param shape: frame data shape :param dtype: frame data type - :return: frame size and pix_fmt + :return s: frame size + :return pix_fmt: frame pixel format ``` X = np.ones((100,480,640,3),'|u1') @@ -211,22 +228,6 @@ def guess_video_format( return size, pix_fmt -def get_rotated_shape(w: int, h: int, deg: float) -> tuple[int, int]: - """compute the shape of rotated rectangle - - :param w: rectangle width - :param h: rectangle height - :param deg: rotation angle in degrees, positive in clockwise direction - :return: the (width, height) after rotation - """ - theta = radians(deg) - C = cos(theta) - S = sin(theta) - return int(round(abs(C * w - S * h))), int(round(abs(S * w + C * h))), theta - # X = [[C, -S], [S, C]], [[w, w, 0.0], [0.0, h, h]] - # return int(round(abs(X[0, 0] - X[0, 2]))), int(round(abs(X[1, 1]))), theta - - audio_codecs = dict( u8=("pcm_u8", "u8"), s16=("pcm_s16le", "s16le"), @@ -246,18 +247,17 @@ def get_audio_codec(fmt: str) -> tuple[str, str]: """ try: return audio_codecs[fmt] - except: - raise ValueError(f"{fmt} is not a valid raw audio sample_fmt") + except KeyError as e: + raise ValueError(f"{fmt} is not a valid raw audio sample_fmt") from e -def get_audio_format( - fmt: str, ac: int | None = None -) -> str | tuple[DTypeString, ShapeTuple]: +def get_audio_format(fmt: str, ac: int | None = None) -> tuple[DTypeString, ShapeTuple]: """get audio sample data format :param fmt: ffmpeg sample_fmt or data type string :param ac: number of channels, default to None (to return only dtype) - :return: data type string and array shape tuple + :return dtype: numpy-style dtype string + :return shape: array shape tuple """ try: @@ -287,12 +287,11 @@ def guess_audio_format(shape: ShapeTuple, dtype: DTypeString) -> tuple[int, str] # => sample_fmt='s16', ac=2 """ - if shape is not None: - ndim = len(shape) - if ndim < 1 or ndim > 2: - raise ValueError( - "invalid audio data dimension: data shape must be must be 1d or 2d" - ) + ndim = len(shape) + if ndim < 1 or ndim > 2: + raise ValueError( + "invalid audio data dimension: data shape must be must be 1d or 2d" + ) try: sample_fmt = { @@ -310,7 +309,6 @@ def guess_audio_format(shape: ShapeTuple, dtype: DTypeString) -> tuple[int, str] def parse_video_size(expr: str | tuple[int, int]) -> tuple[int, int]: - if isinstance(expr, str): m = re.match(r"(\d+)x(\d+)", expr) if m: @@ -321,62 +319,6 @@ def parse_video_size(expr: str | tuple[int, int]) -> tuple[int, int]: return expr -def parse_frame_rate(expr) -> Fraction: - try: - return Fraction(expr) - except ValueError: - return caps.frame_rate_presets[expr] - - -def parse_color(expr) -> tuple[int, int, int, int | None]: - m = re.match( - r"([^@]+)?(?:@(0x[\da-f]{2}|[0-1]\.[0-9]+))?$", - expr, - re.IGNORECASE, - ) - expr = m[1] - alpha = m[2] and (int(m[2], 16) if m[2][1] == "x" else float(m[2])) - - m = re.match( - r"(?:0x|#)?([\da-f]{6})([\da-f]{2})?$", - expr, - re.IGNORECASE, - ) - if m: - rgb = m[1] - if m[2] and alpha is None: - alpha = int(m[2], 16) - else: - colors = caps.colors() - name = next((k for k in colors.keys() if k.lower() == expr.lower()), None) - if name is None: - raise Exception("invalid color expression") - rgb = colors[name][1:] - - return int(rgb[:2], 16), int(rgb[2:4], 16), int(rgb[4:], 16), alpha - - -def compose_color(r: str | Sequence[Number], *args: tuple[Number]) -> str: - - if isinstance(r, str): - colors = caps.colors() - name = next((k for k in colors.keys() if k.lower() == r.lower()), None) - if name is None: - raise Exception("invalid predefined color name") - return name - else: - - def conv(x): - if isinstance(x, float): - x = int(x * 255) - return f"{x:02X}" - - if len(args) < 4: - args = (*args, *([255] * (3 - len(args)))) - - return "".join((conv(x) for x in (r, *args))) - - def layout_to_channels(layout: str) -> int: layouts = caps.layouts()["layouts"] names = caps.layouts()["channels"].keys() @@ -429,18 +371,6 @@ def parse_time_duration(expr: str | float) -> float: return expr -def find_stream_options(options: dict[str, Any], name: str) -> dict[str, Any]: - """find option keys, which may be stream-specific - - :param options: source option dict (content will be modified) - :param suffix: matching suffix - :return: popped options - """ - - re_opt = re.compile(rf"{name}(?=\:|$)") - return [k for k in options if re_opt.match(k)] - - def pop_extra_options(options: dict[str, Any], suffix: str) -> dict[str, Any]: """pop matching keys from options dict @@ -540,7 +470,7 @@ def array_to_video_options( def set_sp_kwargs_stdin( - url: str | None, info: InputSourceDict, sp_kwargs: dict = {} + url: str | None, info: InputInfoDict, sp_kwargs: dict = {} ) -> tuple[str, dict | None, Callable]: """configure sp_kwargs for ffprobe/ffmpeg call to pipe-in the data via stdin @@ -578,9 +508,9 @@ def analyze_input_file( fields: list[str], input_url: str | None, input_opts: dict, - input_info: InputSourceDict, + input_info: InputInfoDict, stream: str | StreamSpecDict | None = None, -) -> list[list]: +) -> list[dict]: """analyze a file and return requested field values of all returned streams :param fields: a list of stream properties @@ -616,7 +546,7 @@ def analyze_input_stream( media_type: MediaType, input_url: FFmpegUrlType | None, input_opts: FFmpegOptionDict, - input_info: InputSourceDict, + input_info: InputInfoDict, ) -> list: """analyze a stream and return requested field values @@ -625,17 +555,14 @@ def analyze_input_stream( :param input_url: url or None if piped or fileobj :param input_opts: input options :param input_info: input infomration - :raises NotImplementedError: _description_ + :raises FFmpegError: if provided data in input_info is insufficient :return values of the requested fields of the stream """ - try: - q = analyze_input_file( - [*fields, "codec_type"], input_url, input_opts, input_info, stream - ) - except FFmpegError: - # no change - return [None] * len(fields) + # run ffprobe on the input file for the stream to be used + q = analyze_input_file( + [*fields, "codec_type"], input_url, input_opts, input_info, stream + ) q = [i for i in q if media_type is None or i["codec_type"] == media_type] if len(q) != 1: @@ -647,7 +574,9 @@ def analyze_input_stream( return [q.get(f, None) for f in fields] -def video_fields_to_options(pix_fmt, width, height, r1, r2): +def video_fields_to_options( + pix_fmt: str, width: int, height: int, r1: Fraction | int, r2: Fraction | int +) -> tuple[Fraction | int, str, tuple[int, int]]: return r1 or r2, pix_fmt, (width, height) if width and height else None @@ -655,7 +584,7 @@ def analyze_video_stream( stream_specifier: str, inurl: FFmpegUrlType, inopts: FFmpegOptionDict, - input_info: InputSourceDict, + input_info: InputInfoDict, ) -> tuple[int | Fraction | None, str | None, tuple[int, int] | None]: """analyze video stream core attributes @@ -689,7 +618,7 @@ def analyze_audio_stream( stream_specifier: str, inurl: FFmpegUrlType, inopts: FFmpegOptionDict, - input_info: InputSourceDict, + input_info: InputInfoDict, ) -> tuple[int | None, str | None, int | None]: """analyze input audio stream @@ -697,7 +626,7 @@ def analyze_audio_stream( :param ofile: output file index, defaults to 0 :param input_info: list of input information, defaults to None :return ar: sampling rate - :return sample_fmt: input data type (Numpy style) + :return sample_fmt: input sample format :return ac: number of channels * Possible Output Options Modification @@ -728,8 +657,8 @@ def analyze_audio_stream( def analyze_complex_filtergraphs( filtergraphs: list[FilterGraphObject | str], inputs: list[tuple[FFmpegUrlType | None, FFmpegOptionDict]], - inputs_info: list[InputSourceDict], -) -> tuple[list[FilterGraphObject], dict[str, dict]]: + inputs_info: list[InputInfoDict], +) -> tuple[list[FilterGraphObject], dict[str, FilterGraphInfoDict]]: """analyze filtergraphs and return requested field values :param fields: a list of stream properties @@ -745,7 +674,7 @@ def analyze_complex_filtergraphs( for fg in as_multi_option(filtergraphs, (str, FilterGraphObject)) ] - # name the output + # label unlabeled outputs (and return modified fg's) i = 0 for j, fg in enumerate(filtergraphs): new_labels = [] @@ -863,77 +792,74 @@ def analyze_complex_filtergraphs( return filtergraphs, fg_info -def are_input_pipes_ready( - inputs: list[tuple[FFmpegUrlType, FFmpegOptionDict]], - input_info: list[InputSourceDict], - must_probe: bool = False, -) -> list[bool]: - """Test if all the input information is provided for raw output initialization +def analyze_output_video_filter( + filtergraph: FilterGraphObject, + r_in: Fraction | int, + pix_fmt_in: str, + s_in: tuple[int, int], + s: tuple[int, int] | None = None, +) -> tuple[int | Fraction, str, tuple[int, int]]: + """analyze an output video filter + + :param filtergraph: simple filter graph. + :param r_in: input frame rate + :param pix_fmt_in: input pixel format + :param s_in: input frame shape (width, height) + :param s: -s output option, defaults to None (not given) + :return r: output frame rate + :return pix_fmt: output pixel format + :return s: output frame shape (width, height) - :param inputs: url-option pairs of input sources - :param input_info: input source information - :param must_probe: True to skip required option check and fail if piped in, - defaults to False - :return: If i-th element is True, it indicates that the i-th input is ready + """ - What it checks - -------------- + # append a color source filter to the filtergraph + fg = temp_video_src(r_in, pix_fmt_in, s_in) + fgb.as_filtergraph_object(filtergraph) - * OK if input is NOT buffered (e.g., given url or file object) - * buffered input is OK if its data is given in info[i]['buffer'] - * buffered input without data is OK only if necessary information is provided - in the input options to deduce the raw output data type and shape: + if s is not None: + fg += fgb.scale(*s) - video: `pix_fmt` and `s` - audio: `sample_fmt` and `ac` - """ + # query the filtergraph + fields = ["pix_fmt", "width", "height", "r_frame_rate", "avg_frame_rate"] + stream = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + )[0] - required_options = { - "audio": ("sample_fmt", "ac"), - "video": ("pix_fmt", "s"), - } + return video_fields_to_options(*(stream[f] for f in fields)) - return [ - ( - info["src_type"] != "buffer" - or "buffer" in info - or ( - not must_probe - and all(o in opts for o in required_options[info["media_type"]]) - ) - ) - for (_, opts), info in zip(inputs, input_info) - ] +def analyze_output_audio_filter( + filtergraph: FilterGraphObject, + ar_in: int, + sample_fmt_in: str, + ac_in: int, +) -> tuple[int, str, tuple[int, int]]: + """analyze an output audio filter -def get_output_stream_id( - output_info: list[OutputDestinationDict], stream: str | int -) -> int: - """get output stream id + :param filtergraph: simple filter graph. + :param ar: input sampling rate + :param sample_fmt: input sample format + :param ac: input number of channels + :return ar: output sampling rate + :return sample_fmt: output sample format + :return ac: output number of channels - :param output_info: list of output stream information - :param stream: name or index of an output stream - :return: index of the output stream """ - if isinstance(stream, str): - try: - stream = next( - i for i, info in enumerate(output_info) if stream == info["user_map"] - ) - except StopIteration: - raise FFmpegioError( - f'"{stream=}") does not match any of the output stream names {tuple(output_info)}' - ) - elif stream < 0 or stream >= len(output_info): - raise FFmpegioError( - f'"{stream=}") is not a valid output index (0-{len(output_info) - 1})' - ) - return stream + # append a color source filter to the filtergraph + fg = temp_audio_src(ar_in, sample_fmt_in, ac_in) + fgb.as_filtergraph_object( + filtergraph + ) + # query the filtergraph + fields = ["sample_rate", "sample_fmt", "channels"] + stream = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + )[0] -def is_valid_input_url(url: FFmpegInputUrlComposite) -> bool: # get the option dict + return (*stream.values(),) + +def is_valid_input_url(url: FFmpegInputUrlComposite) -> bool: # get the option dict # check url (must be url and not fileobj) valid = isinstance(url, (str, FilterGraphObject, FFConcat)) if not valid: @@ -951,7 +877,6 @@ def is_valid_input_url(url: FFmpegInputUrlComposite) -> bool: # get the option def is_valid_output_url(url: FFmpegOutputUrlComposite) -> bool: - valid = isinstance(url, str) # check url (must be url and not fileobj) @@ -959,3 +884,262 @@ def is_valid_output_url(url: FFmpegOutputUrlComposite) -> bool: valid = is_fileobj(url, writable=True) return valid + + +def find_filter_simple_option( + options: FFmpegOptionDict, media_type: MediaType | None = None +) -> ( + Literal[ + "filter_complex_script", + "filter", + "/filter", + "af", + "/af", + "filter:a", + "/filter:a", + "vf", + "/vf", + "filter:v", + "/filter:v", + ] + | None +): + """Returns FFmpeg argument which specify a simple filter graph + + :param options: FFmpeg argument dict + :param media_type: for output stream filter, specify to check a particular + media type, defaults to checking both types of filters + :return: FFmpeg option name if filter graph is specified else None + """ + + optnames = { + None: ( + "filter", + "/filter", + "af", + "/af", + "filter:a", + "/filter:a", + "vf", + "/vf", + "filter:v", + "/filter:v", + ), + "audio": ("af", "/af", "filter:a", "/filter:a"), + "video": ("vf", "/vf", "filter:v", "/filter:v"), + }[media_type] + + return next((o for o in optnames if o in options), None) + + +def find_filter_complex_option( + options: FFmpegOptionDict, +) -> ( + Literal[ + "filter_complex", + "/filter_complex", + "lavfi", + "/lavfi", + "filter_complex_script", + ] + | None +): + """Return FFmpeg option name, which specifies a complex filter graph + + :param options: FFmpeg option argument dict + :return: FFmpeg option name if filter graph is specified else None + """ + + optnames = ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + + return next((o for o in optnames if o in options), None) + + +def input_file_stream_specs( + url: FFmpegUrlType | FilterGraphObject | None, + stream_spec: str | None = None, + stream_options: FFmpegOptionDict | None = None, + stream_info: InputInfoDict | None = None, +) -> dict[int, str]: + """probe a url and return stream index to stream spec mapping + + :param url: media file url + :return: mapping of audio or video stream indices to stream specs. + """ + + info = stream_info or {"src_type": "url"} + opts = stream_options or {} + + # check raw formats first + if "media_type" in info: + # raw input format, always single-stream + return {0: f"{info['media_type'][0]}:0"} + + # file/network input - process only if seekable + # get ffprobe subprocess keywords + url, sp_kwargs, exit_fcn = set_sp_kwargs_stdin(url, info) + if sp_kwargs is None: + # something failed (warning logged) + return {} + + try: + streams = [ + st + for st in analyze_input_file( + ["index", "codec_type"], url, opts, {"src_type": "url"}, stream_spec + ) + if st["codec_type"] in ("audio", "video") + ] + finally: + exit_fcn() + + specs = {} + counts = defaultdict(int) + for st in streams: + media_type = st["codec_type"] + specs[st["index"]] = f"{media_type[0]}:{counts[media_type]}" + counts[media_type] += 1 + return specs + + +def expand_raw_output_streams( + output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, + input_urls: list[FFmpegInputOptionTuple], + options: FFmpegOptionDict, +) -> list[FFmpegOptionDict] | dict[str, FFmpegOptionDict]: + """resolve the raw output streams from given sequence of map options + + :param stream_opts: output raw stream options + :param stream_names: user-specified names of output streams keyed by the index of `stream_opts` + :param args: FFmpeg argument dict + :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` + :return: list of individual output streams. Each item is a tuple of + (stream_index, output_opts, partial_RawOutputInfoDict) + + -stream_index - index of streams + -map_spec - final output option + -partial_RawOutputInfoDict - to-be-completed output_info entry + + Since a map option value may yield multiple media streams (e.g., '0' or '0:v'), + the length of returned outputs may be longer than the number of streams given. + The user specified map value is returned in the 'user_label' field of the returned + dicts while the + + simpler version of configure.resolve_raw_output_streams() + + """ + + if output_streams is not None and len(output_streams) == 0: + output_streams = None + + # if no complex filtergraph + fg_opt = find_filter_complex_option(options) + if fg_opt is None: + if output_streams is None: + # nothing specified, use all streams + input_streams = {} + for i, (url, opts) in enumerate(input_urls): + if not is_url(url): + raise ValueError( + "output_streams cannot be autoassigned for a non-url input." + ) + + input_streams |= { + (i, j): f"{i}:{spec}" + for j, spec in input_file_stream_specs(url).items() + } + return [{"map": v} for v in input_streams.values()] + + # parse all mapping option values + input_file_id = None if len(input_urls) > 1 else 0 + + if isinstance(output_streams, dict): + stream_names = list[output_streams] + output_streams = list[output_streams.values()] + else: + stream_names = [None] * len(output_streams) + + # expand + new_streams = [] + new_names = [] + for name, opts in zip(stream_names, output_streams): + map_opt = stream_spec.parse_map_option( + opts["map"], input_file_id=input_file_id + ) + if "linklabel" in map_opt: + raise FFmpegioError( + f"linklabel {map_opt['linklabel']} is mapped but no complex filter defined." + ) + + file_id = map_opt["input_file_id"] + url = input_urls[file_id] + stream_info = input_file_stream_specs(url, map_opt["stream_specifier"]) + for st_map in stream_info.values(): + new_streams.append(opts | {"map": f"{file_id}:{st_map}"}) + new_names.append(name) + + return ( + new_streams + if new_names[0] is None + else {k: v for k, v in zip(new_names, new_streams)} + ) + + else: + if output_streams is None: + # assign all the output linklabels + fg = fgb.as_filtergraph(options[fg_opt]) + return [{"map": f"[{label}]"} for label in fg.iter_output_labels()] + else: + # filtergraph output label must be uniquely mapped + return output_streams + + +def raw_input_options( + stream_types: Sequence[Literal["a", "v"]], + stream_args: Sequence[RawStreamDef], +) -> tuple[list[FFmpegOptionDict], list[RawDataBlob]]: + """convert raw input stream type+args specification to options+data format + + :param input_stream_types: list/string of 'a' or 'v', specifying the media types + :param input_stream_args: list of a tuple pair of rate & data or data & options + If option dict specified, it must include `'ar'` + (audio) or `'r'` (video) to specify the stream rate. + :return options: list of input options dict + :return data: list of input data + """ + opts = [] + data = [] + for mtype, arg in zip(stream_types, stream_args): + try: + ropt = {"v": "r", "a": "ar"}[mtype] # rate option + except KeyError as e: + raise FFmpegioError( + "Invalid stream type specification (must be 'a' or 'v')" + ) from e + + a1, a2 = arg + if isinstance(a1, (int, Fraction, float)): + # rate specified + if not isinstance(a1, (int, Fraction)): + try: + a1 = Fraction.from_float(float(a1)) + except ValueError as e: + raise ValueError( + "Stream rate must be given as an int or Fraction" + ) from e + data.append(a2) + opts.append({ropt: a1}) + else: + # options specified + if ropt not in a2: + raise ValueError(f"Missing the required rate option: {ropt}") + data.append(a1) + opts.append(a2) + + return opts, data diff --git a/src/ffmpegio/utils/avi.py b/src/ffmpegio/utils/avi.py deleted file mode 100644 index 88d0e778..00000000 --- a/src/ffmpegio/utils/avi.py +++ /dev/null @@ -1,645 +0,0 @@ -from io import SEEK_CUR -import fractions -import re -from struct import Struct -from collections import namedtuple -from itertools import accumulate - -from ..utils import get_video_format, get_audio_format, stream_spec, get_samplesize -from .. import plugins - -# https://docs.microsoft.com/en-us/previous-versions//dd183376(v=vs.85)?redirectedfrom=MSDN - - -class FlagProcessor: - def __init__(self, name, flags, masks, defaults): - self.template = namedtuple( - name, - flags, - defaults=defaults, - ) - self.masks = self.template._make(masks) - - def default(self): - return self.template() - - def unpack(self, flags): - return self.template._make((bool(flags & mask) for mask in self.masks)) - - def pack(self, flags): - return sum((mask if flag else 0 for flag, mask in zip(flags, self.masks))) - - -class StructProcessor: - def __init__(self, name, format, fields, defaults=None, **flags): - if "S" in format or "C" in format: - # expand the format - m = re.match(r"([<>!=])?(.+)", format) - fmt_items = [ - (int(m[1]) if m[1] else 1, m[2]) - for m in re.finditer(r"(\d*)([xcCbB?hHiIlLqQnNefdsSpP])", m[2]) - ] - fmt_counts = [1 if f in "sSp" else count for count, f in fmt_items] - fmt_offsets = list((0, *accumulate(fmt_counts))) - is_str = [False] * fmt_offsets[-1] - for itm, offset in zip(fmt_items, fmt_offsets[:-1]): - is_str[offset] = itm[1] in "SC" - self.is_str = [fields[i] for i, tf in enumerate(is_str) if tf] - format = format.replace("C", "c").replace("S", "s") - else: - self.is_str = () - - self.struct = Struct(format) - self.template = namedtuple(name, fields, defaults=defaults) - self.flags = ((k, FlagProcessor(*v)) for k, v in flags.items()) - - def default(self): - data = self.template() - return data._replace(**{k: proc.default() for k, proc in self.flags}) - - def _unpack(self, data): - data = self.template._make(data) - return data._replace( - **{field: getattr(data, field).decode("utf-8") for field in self.is_str}, - **{k: proc.unpack(getattr(data, k)) for k, proc in self.flags}, - ) - - def unpack(self, buffer): - return self._unpack(self.struct.unpack(buffer)) - - def unpack_from(self, buffer, offset=0): - return self._unpack(self.struct.unpack_from(buffer, offset)) - - def _pack(self, ntuple): - return ntuple._replace( - **{k: proc.pack(getattr(ntuple, k)) for k, proc in self.flags}, - **{field: ntuple[field].encode("utf-8") for field in self.is_str}, - ) - - def pack(self, ntuple): - return self.struct.pack(*self._pack(ntuple)) - - def pack_into(self, buffer, offset, ntuple): - self.struct.pack_into(buffer, offset, *self._pack(ntuple)) - - @property - def size(self): - return self.struct.size - - -AVIMainHeader = StructProcessor( - "Avih", - "<10I", - ( - "micro_sec_per_frame", - "max_bytes_per_sec", - "padding_granularity", - "flags", - "total_frames", - "initial_frames", - "streams", - "suggested_buffer_size", - "width", - "height", - ), - (0,) * 10, - flags=( - "AvihFlags", - ( - "copyrighted", - "has_index", - "is_interleaved", - "must_use_index", - "was_capture_file", - ), - ( - int("0x00020000", 0), - int("0x00000010", 0), - int("0x00000100", 0), - int("0x00000020", 0), - int("0x00010000", 0), - ), - (False,) * 5, - ), -) - - -AVIStreamHeader = StructProcessor( - "AVISTREAMHEADER", - "<4S4SI2H8I4h", - ( - "fcc_type", # 'auds','mids','txts','vids' - "fcc_handler", - "flags", - "priority", - "language", - "initial_frame", - "scale", - "rate", - "start", - "length", - "suggested_buffer_size", - "quality", - "sample_size", - "frame_left", - "frame_top", - "frame_right", - "frame_bottom", - ), - (b"\0" * 4, b"\0" * 4, *((0,) * 15)), - flags=( - "StrhFlags", - ( - "video_pal_changes", - "disabled", - ), - ( - int("0x00000001", 0), - int("0x00010000", 0), - ), - (False,) * 2, - ), -) - -# PCM audio -WAVE_FORMAT_PCM = 1 -# IEEE floating-point audio -WAVE_FORMAT_IEEE_FLOAT = 3 -WAVE_FORMAT_EXTENSIBLE = int("FFFE", 16) # /* Microsoft, 65534 */ - -BitmapInfoHeader = StructProcessor( - "BITMAPINFOHEADER", - "IiiHH4sIiiII", - ( - "size", - "width", - "height", - "planes", - "bit_count", - "compression", # convert to str if 1st byte is >=4 - "size_image", - "x_pels_per_meter", - "y_pels_per_meter", - "clr_used", - "clr_important", - ), - (0,) * 11, -) - -WaveFormatEx = StructProcessor( - "WAVEFORMATEX", - "HHIIHH", - ( - "format_tag", - "channels", - "samples_per_sec", - "avg_bytes_per_sec", - "block_align", - "bits_per_sample", - ), - (0,) * 6, -) - -WaveFormatExtensible = StructProcessor( - "WAVEFORMATEXTENSIBLE", - "HHIH14s", - ( - "size", - "samples", - "channel_mask", - "sub_format_wave", - "sub_format_rest", - ), - (*((0,) * 3), 0, "\0" * 14), -) - - -VideoPropHeader = StructProcessor( - "VPRP", - "5IHH3I", - ( - "video_format_token", - "video_standard", - "vertical_refresh_rate", - "h_total_in_t", - "v_total_in_lines", - "frame_aspect_ratio_y", - "frame_aspect_ratio_x", - "frame_width_in_pixels", - "frame_height_in_lines", - "field_per_frame", - ), - ((0,) * 10), -) - -VPRP_VideoField = StructProcessor( - "VPRP_VIDEO_FIELD_DESC", - "8I", - ( - "compressed_bm_height", - "compressed_bm_width", - "valid_bm_height", - "valid_bm_width", - "valid_bm_x_offset", - "valid_bm_y_offset", - "video_x_offset_in_t", - "video_y_valid_start_line", - ), - ((0,) * 8), -) - - -ChunkHeader = StructProcessor("CHDR", "<4SI", ("id", "datasize")) - - -fcc_types = dict(vids="v", auds="a", txts="s") # , mids="midi") - - -def read_chunk_header(f): - b = f.read(ChunkHeader.size) - id, datasize = ChunkHeader.unpack(b) - list_type = None - if id in ("RIFF", "LIST"): - list_type = f.read(4).decode("utf-8") - datasize -= 4 - chunksize = datasize + 1 if datasize % 2 else datasize - return id, datasize, chunksize, list_type - - -def get_chunk_header(b, offset=0): - id, datasize = ChunkHeader.unpack_from(b, offset) - offset += ChunkHeader.size - list_type = None - if id in ("RIFF", "LIST"): - list_type = b[offset : offset + 4].decode("utf-8") - offset += 4 - datasize -= 4 - chunksize = datasize + 1 if datasize % 2 else datasize - return offset, chunksize, id, list_type - - -def get_stream_header(b, offset, end): - data = {} - - offset, chunksize, id, _ = get_chunk_header(b, offset) - data[id] = strh = AVIStreamHeader.unpack_from(b, offset) - offset += chunksize - - offset, chunksize, id, _ = get_chunk_header(b, offset) - if strh.fcc_type == "vids": - data[id] = BitmapInfoHeader.unpack_from(b, offset) - - # if 1st byte is a readable ascii char - compression = data[id].compression - comp_val = compression[0] - data[id] = data[id]._replace( - compression=comp_val if comp_val < 32 else compression.decode("utf-8") - ) - - # offset += chunksize - # while offset < end: - # offset, chunksize, id, _ = get_chunk_header(b, offset) - # if id == "vprp": - # vprp = VideoPropHeader.unpack_from(b, offset) - # offset += VideoPropHeader.size - # ninfo = VPRP_VideoField.size - # field_info = [ - # VPRP_VideoField.unpack_from(b, i) - # for i in range(offset, offset + ninfo * vprp.field_per_frame, ninfo) - # ] - # data[id] = namedtuple( - # type(vprp).__name__, (*vprp._fields, "field_info") - # )(*vprp, field_info) - # break - # else: - # offset += chunksize - - elif strh.fcc_type == "auds": - strf = WaveFormatEx.unpack_from(b, offset) - if strf.format_tag == WAVE_FORMAT_EXTENSIBLE: - strfext = WaveFormatExtensible.unpack_from(b, offset + WaveFormatEx.size) - strf = namedtuple( - type(strfext).__name__, (*strf._fields, *strfext._fields) - )(strfext.sub_format_wave, *strf[1:], *strfext) - data[id] = strf - else: - raise RuntimeError(f"Unsupported stream type: {strh.fcc_type}") - - return data - - -def _seek(f, n): - try: - f.seek(n, SEEK_CUR) - except: - f.read(n) - - -def read_header(f, pix_fmt=None): - - # read the RIFF header - id, datasize, chunksize, list_type = read_chunk_header(f) - if id != "RIFF" or list_type != "AVI ": - raise RuntimeError("File stream is not AVI") - - # read the hdrl chunk - id, datasize, chunksize, list_type = read_chunk_header(f) - if id != "LIST" and list_type != "hdrl": - raise RuntimeError("AVI is missing header chunk") - b = f.read(datasize) - if chunksize > datasize: - _seek(f, 1) - - # read until encountering the movi list - while True: - id, _, chunksize, list_type = read_chunk_header(f) - if list_type == "movi": - break - _seek(f, chunksize) - - # parse hdrl LIST chunk - offset, chunksize, id, list_type = get_chunk_header(b) - if id != "avih": - raise RuntimeError("missing avi chunk") - avih = AVIMainHeader.unpack_from(b, offset) - offset += chunksize - streams = [] - while True: - try: - offset, chunksize, id, list_type = get_chunk_header(b, offset) - except: - break - if list_type != "strl": - break - - streams.append(get_stream_header(b, offset, offset + chunksize)) - offset += chunksize - - def get_stream_info(i, strl, use_ya): - strh = strl["strh"] - strf = strl["strf"] - type = fcc_types[strh.fcc_type] # raises if not valid type - info = dict(index=i, type=type) - if type == fcc_types["vids"]: - info["frame_rate"] = fractions.Fraction(strh.rate, strh.scale) - info["width"] = strf.width - info["height"] = abs(strf.height) - bpp = strf.bit_count - compression = strf.compression - # force unsupported pixel formats - info["pix_fmt"] = ( - {"Y800": "gray", "RGBA": "rgba"}.get(compression, None) - if isinstance(compression, str) - else (compression, bpp) - if compression - else "rgba64le" - if bpp == 64 - else "rgb48le" - if bpp == 48 - else ("ya16le" if use_ya else "grayf32le") - if bpp == 32 - else "rgb24" - if bpp == 24 - else ("ya8" if use_ya else "gray16le") - if bpp == 16 - else None - ) - # vprp = strl.get("vprp", None) - # info["dar"] = ( - # fractions.Fraction(vprp.frame_aspect_ratio_x, vprp.frame_aspect_ratio_y) - # if vprp - # else None - # ) - info["dtype"], info["shape"] = get_video_format( - info["pix_fmt"], (info["width"], info["height"]) - ) - elif type == fcc_types["auds"]: #'audio' - info["sample_rate"] = strf.samples_per_sec - info["channels"] = strf.channels - - strf_format = ( - strf.format_tag, - strf.bits_per_sample, - ) - - info["sample_fmt"] = { - (WAVE_FORMAT_PCM, 8): "u8", - (WAVE_FORMAT_PCM, 16): "s16", - (WAVE_FORMAT_PCM, 32): "s32", - (WAVE_FORMAT_PCM, 64): "s64", - (WAVE_FORMAT_IEEE_FLOAT, 32): "flt", - (WAVE_FORMAT_IEEE_FLOAT, 64): "dbl", - }.get(strf_format, strf_format) - # TODO: if need arises, resolve more formats, need to include codec names though - info["dtype"], info["shape"] = get_audio_format( - info["sample_fmt"], info["channels"] - ) - return info - - return [get_stream_info(i, strl, pix_fmt) for i, strl in enumerate(streams)], ( - avih, - streams, - ) - - -re_movi = re.compile(r"\d{2}(?:wb|db|dc|tx)") - - -def read_frame(f): - while True: - id, datasize, chunksize, list_type = read_chunk_header(f) - if not list_type: - m = re_movi.match(id) - if m: # data chunk found - b = f.read(datasize) - if chunksize > datasize: - _seek(f, chunksize - datasize) - return int(id[:2]), b - else: - _seek(f, chunksize) - - id, datasize, chunksize, list_type = read_chunk_header(f) - - -####################################################################################################### - - -class AviReader: - def __init__(self): - self._f = None - self.ready = False #:bool: True if AVI headers has been processed - self.streams = None #:dict: Stream headers keyed by stream id (int key) - self.itemsizes = None #:dict: sample size of each stream in bytes - - hook = plugins.get_hook() - self.converters = {"v": hook.bytes_to_video, "a": hook.bytes_to_audio} - #:dict : bytes to media data object conversion functions keyed by stream type - - def start(self, f, pix_fmt=None): - self._f = f - hdr = read_header(self._f, pix_fmt)[0] - - cnt = {"v": 0, "a": 0, "s": 0} - - def set_stream_info(hdr): - st_type = hdr["type"] - id = cnt[st_type] - cnt[st_type] += 1 - return { - "spec": stream_spec(id, st_type), - **hdr, - } - - self.streams = {v["index"]: set_stream_info(v) for v in hdr} - self.itemsizes = { - v["index"]: get_samplesize(v["shape"], v["dtype"]) for v in hdr - } - self.ready = True - - def __next__(self): - i = d = None - while i is None: # None if unknown frame format, skip - try: - i, d = read_frame(self._f) - except: - raise StopIteration - return i, d - - def __iter__(self): - return self - - def from_bytes(self, id, b): - info = self.streams[id] - return self.converters[info["type"]]( - b=b, dtype=info["dtype"], shape=info["shape"], squeeze=False - ) - - -# ( -# "hdrl", -# [ -# ( -# "avih", -# { -# "micro_sec_per_frame": 66733, -# "max_bytes_per_sec": 3974198, -# "padding_granularity": 0, -# "flags": 0, -# "total_frames": 0, -# "initial_frames": 0, -# "streams": 2, -# "suggested_buffer_size": 1048576, -# "width": 352, -# "height": 240, -# }, -# ), -# ( -# "strl", -# [ -# ( -# "strh", -# { -# "fcc_type": "vids", -# "fcc_handler": "\x00\x00\x00\x00", -# "flags": 0, -# "priority": 0, -# "language": 0, -# "initial_frames": 0, -# "scale": 200, -# "rate": 2997, -# "start": 0, -# "length": 1073741824, -# "suggested_buffer_size": 1048576, -# "quality": 4294967295, -# "sample_size": 0, -# "frame_left": 0, -# "frame_top": 0, -# "frame_right": 352, -# "frame_bottom": 240, -# }, -# ), -# ( -# "strf", -# { -# "size": 40, -# "width": 352, -# "height": -240, -# "planes": 1, -# "bit_count": 24, -# "compression": "rgb24", -# "size_image": 253440, -# "x_pels_per_meter": 0, -# "y_pels_per_meter": 0, -# "clr_used": 0, -# "clr_important": 0, -# }, -# ), -# ( -# "vprp", -# { -# "video_format_token": 0, -# "video_standard": 0, -# "vertical_refresh_rate": 15, -# "h_total_in_t": 352, -# "v_total_in_lines": 240, -# "frame_aspect_ratio": Fraction(15, 22), -# "frame_width_in_pixels": 352, -# "frame_height_in_lines": 240, -# "field_per_frame": 1, -# "field_info": ( -# { -# "compressed_bm_height": 240, -# "compressed_bm_width": 352, -# "valid_bm_height": 240, -# "valid_bm_width": 352, -# "valid_bmx_offset": 0, -# "valid_bmy_offset": 0, -# "video_x_offset_in_t": 0, -# "video_y_valid_start_line": 0, -# }, -# ), -# }, -# ), -# ], -# ), -# ( -# "strl", -# [ -# ( -# "strh", -# { -# "fcc_type": "auds", -# "fcc_handler": "\x01\x00\x00\x00", -# "flags": 0, -# "priority": 0, -# "language": 0, -# "initial_frames": 0, -# "scale": 1, -# "rate": 44100, -# "start": 0, -# "length": 1073741824, -# "suggested_buffer_size": 12288, -# "quality": 4294967295, -# "sample_size": 4, -# "frame_left": 0, -# "frame_top": 0, -# "frame_right": 0, -# "frame_bottom": 0, -# }, -# ), -# ( -# "strf", -# { -# "format_tag": 1, -# "channels": 2, -# "samples_per_sec": 44100, -# "avg_bytes_per_sec": 176400, -# "block_align": 4, -# "bits_per_sample": 16, -# }, -# ), -# ], -# ), -# ], -# 368, -# ) diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 80484903..b141f46b 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -1,95 +1,70 @@ +import logging import warnings -from . import ffmpegprocess as fp, utils, configure, FFmpegError, plugins, analyze -from .utils import log as log_utils +from fractions import Fraction + +from . import analyze, configure, utils +from . import filtergraph as fgb +from ._typing import ( + Any, + DTypeString, + FFmpegOptionDict, + ProgressCallable, + RawDataBlob, + ShapeTuple, +) +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, +) +from .errors import FFmpegioError +from .std_runners import run_and_return_encoded, run_and_return_raw __all__ = ["create", "read", "write", "filter", "detect"] +logger = logging.getLogger("ffmpegio") -def _run_read(*args, show_log=None, sp_kwargs=None, **kwargs): - """run FFmpeg and retrieve audio stream data - :param *args ffmpegprocess.run arguments - :type *args: tuple - :param show_log: True to show FFmpeg log messages on the console, - defaults to None (no show/capture) - Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - :type sp_kwargs: dict, optional - :param \\**kwargs: All additional keyword arguments to call `ffmpegprocess.run`. - These keywords take precedence over `sp_kwargs`. - :type \\**kwargs: dict, optional - :return: video data, created by `bytes_to_video` plugin hook - :rtype: object - """ - - outopts = args[0]["outputs"][0][1] - outopts["map"] = "0:v:0" - dtype, shape, r = configure.finalize_video_read_opts( - args[0], - input_info=[ - {"src_type": "filtergraph" if outopts.get("f", None) == "lavfi" else "url"} - ], - ) - - if sp_kwargs is not None: - kwargs = {**sp_kwargs, **kwargs} - - if shape is None or r is None: - configure.clear_loglevel(args[0]) - - out = fp.run(*args, capture_log=True, **kwargs) - if show_log: - print(out.stderr) - if out.returncode: - raise FFmpegError(out.stderr) - - info = log_utils.extract_output_stream(out.stderr) - dtype, shape = utils.get_video_format(info["pix_fmt"], info["s"]) - r = info["r"] - else: - out = fp.run( - *args, - capture_log=None if show_log else False, - **kwargs, - ) - if out.returncode: - raise FFmpegError(out.stderr) - return r, plugins.get_hook().bytes_to_video( - b=out.stdout, dtype=dtype, shape=shape, squeeze=False - ) - -def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options): +def create( + expr: str | fgb.abc.FilterGraphObject, + *args, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Create a video using a source video filter - :param name: name of the source filter - :type name: str - :param \\*args: sequential filter option arguments. Only valid for + :param expr: source filter graph + :param args: sequential filter option arguments. Only valid for a single-filter expr, and they will overwrite the options set by expr. - :type \\*args: seq, optional + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, + eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: Named filter options or FFmpeg options. Items are + :param options: Named filter options or FFmpeg options. Items are only considered as the filter options if expr is a single-filter graph, and take the precedents over general FFmpeg options. Append '_in' for input option names (see :doc:`options`), and '_out' for output option names if they conflict with the filter options. - :type \\**options: dict, optional - :return: frame rate and video data, created by `bytes_to_video` plugin hook - :rtype: tuple[Fraction,object] + :return rate: frame rate in frames/second + :return data: video data object specified by selected `bytes_to_video` plugin hook. + The output shape is 4D (time x row x column x comp). + (since v0.12.0) With `squeeze=True` the shape dimensions with + length 1 are removed. ...seealso:: https://ffmpeg.org/ffmpeg-filters.html#Video-Sources for available @@ -97,206 +72,232 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) """ - input_options = utils.pop_extra_options(options, "_in") - output_options = utils.pop_extra_options(options, "_out") url, t_, options = configure.config_input_fg(expr, args, options) - options = {**options, **output_options} - if ( - t_ is None - and not any(a in input_options for a in ("t", "to")) - and not any(a in options for a in ("t", "to", "frames:v", "vframes")) + if t_ is None and not any( + a in options for a in ("t_in", "to_in", "t", "to", "frames:v", "vframes") ): warnings.warn( "neither input nor output duration specified. this function call may hang." ) - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, {**input_options, "f": "lavfi"}) - configure.add_url( - ffmpeg_args, "output", "-", {"pix_fmt": "rgb24", **options, "f": "rawvideo"} - ) - # TODO: filtergraph scanning will remove the default 'pix_fmt' setting - - return _run_read( - ffmpeg_args, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs + return read( + url, + squeeze=squeeze, + progress=progress, + show_log=show_log, + sp_kwargs=sp_kwargs, + **options, ) -def read(url, progress=None, show_log=None, sp_kwargs=None, **options): +def read( + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + *, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Read video frames - :param url: URL of the video file to read. - :type url: str + :param url: URL of the video file to read or a list of URLs to be used by + complex filtergraph. Each url may be accompanied by its own input + options (a tuple pair of url and its option dict). These options + supersede the input options given with keyword arguments with `'_in'` + suffix. + :param extra_outputs: list of additional encoded output sources, defaults to + None. Each destination may be a url string or a pair of + a url string and an option dict. + :param squeeze: False to return 2D data with the 2nd dimension as the audio + channels, defaults to True to reduce monaural data to 1D, + eliminating the singular audio channel dimension. :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) Ignored if stream format must be retrieved automatically. - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) :return: frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: (fractions.Fraction, object) """ - # get pix_fmt of the input file only if needed - input_options = utils.pop_extra_options(options, "_in") + # use user-specified map or default '0:a:0' map + output_map = options.pop("map", "0:V:0") - # get url/file stream - url, stdin, input = configure.check_url( - url, False, format=input_options.get("f", None) + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_read( + [url] if utils.is_valid_input_url(url) else url, + [output_map], + options, + extra_outputs, + squeeze, ) - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options) - configure.add_url(ffmpeg_args, "output", "-", options) - - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = stdin - sp_kwargs["input"] = input - - return _run_read( - ffmpeg_args, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs + if output_info is None: + raise FFmpegioError( + "Unknown configuration error occurred. Necessary output information could not be collected." + ) + if output_info[0]["media_type"] != "video": + raise ValueError("Mapped stream is not a video stream.") + + return run_and_return_raw( + args, + input_info, + output_info, + progress, + show_log, + sp_kwargs, ) def write( - url, - rate_in, - data, - progress=None, - overwrite=None, - show_log=None, - two_pass=False, - pass1_omits=None, - pass1_extras=None, - extra_inputs=None, - sp_kwargs=None, + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + rate_in: Fraction | int, + data: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + dtype: DTypeString | None = None, + shape: ShapeTuple | None = None, + progress: ProgressCallable | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + two_pass: bool = False, + pass1_omits: list[str] | None = None, + pass1_extras: list[FFmpegOptionDict] | None = None, + sp_kwargs: dict[str, Any] | None = None, **options, -): - """Write Numpy array to a video file +) -> bytes | None: + """Write raw video data blob :param url: URL of the video file to write. - :type url: str :param rate_in: frame rate in frames/second - :type rate_in: `float`, `int`, or `fractions.Fraction` :param data: video frame data object, accessed by `video_info` and `video_bytes` plugin hooks - :type data: object :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param two_pass: True to encode in 2-pass :param pass1_omits: list of output arguments to ignore in pass 1, defaults to None - :type pass1_omits: seq(str), optional :param pass1_extras: list of additional output arguments to include in pass 1, defaults to None - :type pass1_extras: dict(int:dict(str)), optional :param extra_inputs: list of additional input sources, defaults to None. Each source may be url string or a pair of a url string and an option dict. - :type extra_inputs: seq(str|(str,dict)) :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - url, stdout, _ = configure.check_url(url, True) - - input_options = utils.pop_extra_options(options, "_in") + # if filter_complex is not defined use '0:V:0' as default mapping + if ( + not any( + (o in options) + for o in ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) + ) + and "map" not in options + ): + options["map"] = "0:V:0" - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(rate_in, data=data, **input_options), + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_write( + url, [{"r": rate_in}], extra_inputs, options, [data] ) - # add extra input arguments if given - if extra_inputs is not None: - configure.add_urls(ffmpeg_args, "input", extra_inputs) - - configure.add_url(ffmpeg_args, "output", url, options) - - configure.build_basic_vf(ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1)) - - kwargs = {**sp_kwargs} if sp_kwargs else {} - kwargs.update( - { - "input": plugins.get_hook().video_bytes(obj=data), - "stdout": stdout, - "progress": progress, - "overwrite": overwrite, - } + return run_and_return_encoded( + progress, + overwrite, + show_log, + sp_kwargs, + args, + input_info, + output_info, + two_pass, + pass1_omits, + pass1_extras, ) - kwargs["capture_log"] = None if show_log else False - if pass1_omits is not None: - kwargs["pass1_omits"] = [pass1_omits] - if pass1_extras is not None: - kwargs["pass1_extras"] = [pass1_extras] - out = (fp.run_two_pass if two_pass else fp.run)(ffmpeg_args, **kwargs) - if out.returncode: - raise FFmpegError(out.stderr, show_log) - -def filter(expr, rate, input, progress=None, show_log=None, sp_kwargs=None, **options): +def filter( + expr: str | fgb.abc.FilterGraphObject | None, + input_rate: Fraction | int, + input: RawDataBlob, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = None, + **options, +) -> tuple[Fraction | int, RawDataBlob]: """Filter video frames. - :param expr: SISO filter graph or None if implicit filtering via output options. - :type expr: str, None + :param expr: filter graph or None if implicit filtering via output options. :param rate: input frame rate in frames/second - :type rate: `float`, `int`, or `fractions.Fraction` - :param input: input video frame data object, accessed by `video_info` and `video_bytes` plugin hooks - :type input: object + :param input: input video frame data blob, accessed by `video_info` and `video_bytes` plugin hooks :param progress: progress callback function, defaults to None - :type progress: callable object, optional :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) - :type show_log: bool, optional :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :type sp_kwargs: dict, optional - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) - :type \\**options: dict, optional + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) :return: output frame rate and video frame data, created by `bytes_to_video` plugin hook - :rtype: object """ - input_options = utils.pop_extra_options(options, "_in") - - ffmpeg_args = configure.empty() - configure.add_url( - ffmpeg_args, - "input", - *configure.array_to_video_input(rate, data=input, **input_options), + if expr is not None: + if extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:v"] = expr + options["map"] = "0:V:0" + else: + options["filter_complex"] = expr + # expects map option is set + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + [{"r": input_rate}], + extra_inputs, + None, + extra_outputs, + options, + squeeze, + [input], ) - outopts = configure.add_url(ffmpeg_args, "output", "-", options)[1][1] - - if expr: - outopts["filter:v"] = expr - # override user specified stdin and input if given - sp_kwargs = {**sp_kwargs} if sp_kwargs else {} - sp_kwargs["stdin"] = None - sp_kwargs["input"] = plugins.get_hook().video_bytes(obj=input) + if output_info is None: + raise RuntimeError("Something went wrong in setting up filter operation...") - return _run_read( - ffmpeg_args, progress=progress, show_log=show_log, sp_kwargs=sp_kwargs + return run_and_return_raw( + args, input_info, output_info, progress, show_log, sp_kwargs ) diff --git a/tests/test_audio.py b/tests/test_audio.py index 89cace56..dc3a733b 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -4,6 +4,10 @@ import logging from os import path import pytest +import numpy as np +from io import BytesIO + +from namedpipe import NPopen logging.basicConfig(level=logging.DEBUG) @@ -29,11 +33,11 @@ def test_create(): fs, x = audio.create("anoisesrc", d=60, c="pink", r=44100, a=0.5) print(x["shape"], 60 * 44100) - assert x["shape"] == (60 * 44100, 1) + assert x["shape"] == (60 * 44100,) fs, x = audio.create("sine", f=220, b=4, d=5) print(x["shape"], 5 * 44100) - assert x["shape"] == (5 * 44100, 1) + assert x["shape"] == (5 * 44100,) @pytest.mark.skip(reason="takes too long to test") @@ -68,6 +72,28 @@ def test_read(): # assert np.array_equal(x1, x2) +def test_read_af(): + + url = "tests/assets/testaudio-1m.mp3" + + T = 0.51111 + T = 0.49805 + fs, x = audio.read( + url, t=T, show_log=True, af="aresample=8000,channelmap=map=FL-FC" + ) + assert fs == 8000 + assert len(x["shape"]) == 1 + +def test_read_filter(): + + url = "tests/assets/testaudio-1m.mp3" + + T = 0.51111 + T = 0.49805 + fs, x = audio.read( + [url,url], t=T, show_log=True, filter_complex="[0][1]amix[mixed]",map='[mixed]' + ) + def test_read_write(): url = "tests/assets/testaudio-1m.mp3" outext = ".flac" @@ -128,11 +154,54 @@ def test_filter(): output_rate, output = audio.filter(expr, input_rate, input) assert output_rate == 44100 assert output["shape"] == (44100, 2) - assert output["dtype"] == " 0 + + +def test_write_fileobj(): + + fs = 16000 + x = np.random.randint(-(2**15), 2**15, fs, np.int16) + with tempfile.TemporaryDirectory() as tmpdirname: + + url = path.join(tmpdirname, "test.flv") + with open(url, "wb") as f: + audio.write(f, fs, x, f="flv", acodec="aac", show_log=True) + + if __name__ == "__main__": import logging diff --git a/tests/test_avistreams.py b/tests/test_avistreams.py deleted file mode 100644 index b3c4fafa..00000000 --- a/tests/test_avistreams.py +++ /dev/null @@ -1,87 +0,0 @@ -from ffmpegio.streams import AviStreams -from ffmpegio import open - - -def test_open(): - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - with open((url1, url2), "rav", t=1, blocksize=0) as reader: - for st, data in reader: - print(st, data["shape"], data["dtype"]) - - print('testing "rvv"') - with open( - url1, - "rvv", - t=1, - blocksize=0, - filter_complex="[0:v]split=2[out1][out2]", - map=["[out1]", "[out2]"], - ) as reader: - for st, data in reader: - print(st, data["shape"], data["dtype"]) - - print('testing "raa"') - with open( - url2, - "raa", - t=1, - blocksize=0, - filter_complex="[0:a]asplit=2[out1][out2]", - map=["[out1]", "[out2]"], - ) as reader: - for st, data in reader: - print(st, data["shape"], data["dtype"]) - # print(reader.readlog()) - - -def test_avireadstream(): - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - with AviStreams.AviMediaReader(url1, url2, t=1, blocksize=0) as reader: - for st, data in reader: - print(st, data["shape"], data["dtype"]) - - with AviStreams.AviMediaReader(url1, url2, t=1, blocksize=1) as reader: - for data in reader: - print({k: (v["shape"], v["dtype"]) for k, v in data.items()}) - - with AviStreams.AviMediaReader( - url1, url2, t=1, blocksize=1000, ref_stream="a:0" - ) as reader: - for data in reader: - print({k: (v["shape"], v["dtype"]) for k, v in data.items()}) - - with AviStreams.AviMediaReader(url1, url2, t=1) as reader: - print(reader.specs()) - print(reader.types()) - print(reader.rates()) - print(reader.dtypes()) - print(reader.shapes()) - print(reader.get_stream_info("v:0")) - print(reader.get_stream_info("a:0")) - - data = reader.readall() - print({k: (v["shape"], v["dtype"]) for k, v in data.items()}) - - -if __name__ == "__main__": - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - - from pprint import pprint - - with AviStreams.AviMediaReader(url1, url2, t=1) as reader: - reader._reader.wait() - print(f"thread is running {reader._reader.is_alive()}") - pprint(reader.specs()) - print(reader.types()) - print(reader.rates()) - print(reader.dtypes()) - print(reader.shapes()) - print(reader.get_stream_info("v:0")) - print(reader.get_stream_info("a:0")) - - data = reader.readall() - print({k: (v["shape"], v["dtype"]) for k, v in data.items()}) diff --git a/tests/test_configure.py b/tests/test_configure.py index 70160ddf..b96101d6 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -12,42 +12,6 @@ mul_url = "tests/assets/testmulti-1m.mp4" -def test_array_to_audio_input(): - fs = 44100 - N = 44100 - nchmax = 4 - data = {"buffer": b"0" * N * nchmax * 2, "dtype": "ev", None), + ("ee->av", ("d", "ee", "av")), + ("av->ee", ("e", "av", "ee")), + ("av->va", ("f", "av", "va")), + ], +) +def test_mode_parser(mode, ret): + if ret is None: + with pytest.raises(ValueError): + _parse_mode(mode) + else: + assert _parse_mode(mode) == ret + + +url = "tests/assets/testmulti-1m.mp4" + + +@pytest.mark.parametrize( + "mode,output_streams,cls", + [ + ("ra", ["0:a:0"], StdFFmpegRunner), + ("rva", ["0:v:0", "0:a:0"], PipedFFmpegRunner), + ], +) +def test_open_reader(mode, output_streams, cls): + runner = open( + url, + mode, + ouput_streams=output_streams, + squeeze=False, + extra_outputs=None, + blocksize=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) + assert isinstance(runner, cls) + assert runner.readable + assert not runner.writable + assert not runner.decodable + assert not runner.encodable + + +@pytest.mark.parametrize( + "mode,input_rates,cls", + [ + ("wa", 8000, StdFFmpegRunner), + ("wva", [30, 8000], PipedFFmpegRunner), + ], +) +def test_open_writer(mode, input_rates, cls): + + opts = ( + {"input_shape": None, "input_dtype": None} + if cls == StdFFmpegRunner + else { + "input_options": None, + "input_shapes": None, + "input_dtypes": None, + "enc_blocksize": None, + "queuesize": None, + "timeout": None, + } + ) + + with TemporaryDirectory() as tmpdirname: + outfile = path.join(tmpdirname, "out.mp4") + with open( + outfile, + mode, + input_rates, + **opts, + extra_inputs=None, + progress=None, + show_log=False, + overwrite=True, + sp_kwargs=None, + to=1, + ) as runner: + assert isinstance(runner, cls) + assert not runner.readable + assert runner.writable + assert not runner.decodable + assert not runner.encodable + + +@pytest.mark.parametrize( + "mode,input_rates,data,cls", + [ + ("fa", 8000, [np.zeros((128, 1), np.int16)], SISOFFmpegFilter), + ( + "fva", + [30, 8000], + [np.zeros((100, 100, 1), np.uint8), np.zeros((128, 1), np.int16)], + PipedFFmpegRunner, + ), + ], +) +def test_open_filter(mode, input_rates, data, cls): + + ff.use("read_numpy") + + opts = ( + {"input_shape": None, "input_dtype": None} + if cls == SISOFFmpegFilter + else { + "input_options": None, + "output_streams": None, + "input_shapes": None, + "input_dtypes": None, + "primary_output": None, + } + ) + + with open( + "-", + mode, + input_rates, + **opts, + squeeze=False, + extra_inputs=None, + extra_outputs=None, + blocksize=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + r=20, + ar=4000, + ) as runner: + for i, blob in enumerate(data): + runner.write(blob, stream=i) + assert isinstance(runner, cls) + assert runner.readable + assert runner.writable + assert not runner.decodable + assert not runner.encodable + + +def test_open_decoder(): + + with builtins.open(url, "rb") as f: + b = f.read(1024) + + with open( + "-", + "e->a", + ouput_streams=None, + squeeze=False, + extra_inputs=None, + extra_outputs=None, + primary_output=None, + blocksize=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) as runner: + runner.write_encoded(b) + assert isinstance(runner, PipedFFmpegRunner) + assert runner.readable + assert not runner.writable + assert runner.decodable + assert not runner.encodable + + +def test_open_encoder(): + + with open( + "-", + "a->e", + 8000, + input_options=None, + output_options=None, + extra_inputs=None, + extra_outputs=None, + input_shapes=None, + input_dtypes=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) as runner: + assert isinstance(runner, PipedFFmpegRunner) + assert not runner.readable + assert runner.writable + assert not runner.decodable + assert runner.encodable + + +def test_open_transcoder(): + + with open( + "-", + "e->e", + input_options=None, + output_options=None, + extra_inputs=None, + extra_outputs=None, + enc_blocksize=None, + queuesize=None, + timeout=None, + progress=None, + show_log=False, + sp_kwargs=None, + to=1, + ) as runner: + assert isinstance(runner, PipedFFmpegRunner) + assert not runner.readable + assert not runner.writable + assert runner.decodable + assert runner.encodable diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py deleted file mode 100644 index 070d31f6..00000000 --- a/tests/test_pipedstreams.py +++ /dev/null @@ -1,124 +0,0 @@ -import logging - -logging.basicConfig(level=logging.DEBUG) - - -import ffmpegio as ff -from ffmpegio import streams - -mult_url = "tests/assets/testmulti-1m.mp4" -video_url = "tests/assets/testvideo-1m.mp4" -audio_url = "tests/assets/testaudio-1m.mp3" -outext = ".mp4" - - -def test_PipedMediaReader(): - with streams.PipedMediaReader(mult_url, t=1) as reader: - # data = reader.read(2) - for data in reader: - for k, v in data.items(): - print(f"{k}: {len(v['buffer'])}") - - -def test_PipedMediaWriter_audio(): - - ff.use("read_numpy") - - rates, data = ff.media.read(audio_url, t=1, ar=8000, sample_fmt="s16") - stream_types = [spec.split(":", 2)[1] for spec in data] - - with streams.PipedMediaWriter( - "pipe", - stream_types, - *rates.values(), - show_log=True, - f="matroska", - # loglevel="debug", - ) as writer: - for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): - writer.write_stream(i, frame) - - # close the input and wait for FFmpeg to finish encoding and terminate - writer.wait(10) - - # read the encoded bytes - b = writer.readall_encoded() - - -def test_PipedMediaWriter(): - - ff.use("read_numpy") - - rates, data = ff.media.read(mult_url, t=1) - stream_types = [spec.split(":", 2)[1] for spec in data] - - with streams.PipedMediaWriter( - "pipe", stream_types, *rates.values(), show_log=True, f="matroska" - ) as writer: - for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): - if mtype == "v": - writer.write_stream(i, frame[0]) - else: - writer.write_stream(i, frame) - - writer.wait(10) - b = writer.read_encoded_stream(0, -1, 10) - assert isinstance(b, bytes) and len(b) > 0 - - -def test_PipedMediaFilter(): - - ff.use("read_bytes") - - fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) - - fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", to=1) - - print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") - - with streams.PipedMediaFilter( - ["[0:V:0][1:V:0]vstack,split", "[2:a:0][3:a:0]amerge"], - "vvaa", - fps, - fps, - fs, - fs, - output_options={"[out0]": {}, "audio": {"map": "[out2]"}}, - show_log=True, - loglevel="debug", - # queuesize=4, - ) as f: - # f.write([F, F]) - f.write([F, F, x, x]) - # sleep(1) - f.wait(10) - data = f.read(F["shape"][0], 10) - - assert all(k in ("[out0]", "out1", "audio") for k in data) - n = f.output_counts - assert all(v["shape"][0] == n[k] for k, v in data.items()) - - -def test_PipedMediaTranscoder(): - url = "tests/assets/testmulti-1m.mp4" - - with streams.PipedMediaTranscoder( - [], - [{"f": "matroska", "codec": "copy", "to": 1}], - extra_inputs=[url], - show_log=False, - ) as f: - if f.wait(timeout=10): - raise f.lasterror - data = f.read_encoded_stream(0, -1, timeout=10) - - with streams.PipedMediaTranscoder( - [{"f": "matroska"}], - [{"f": "flac"}, {"f": "matroska", "codec": "copy"}], - show_log=False, - ) as f: - f.write_encoded_stream(0, data, timeout=10) - if f.wait(timeout=10): - raise f.lasterror - enc_data = f.readall_encoded(timeout=10) - assert len(enc_data) == 2 diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py deleted file mode 100644 index fed020a9..00000000 --- a/tests/test_simplestreams.py +++ /dev/null @@ -1,196 +0,0 @@ -import logging - -logging.basicConfig(level=logging.DEBUG) - -import ffmpegio -import tempfile -import re -from os import path -from ffmpegio import streams, utils - -url = "tests/assets/testmulti-1m.mp4" -outext = ".mp4" - - -def test_read_video(): - w = 420 - h = 360 - with streams.SimpleVideoReader( - url, vf="transpose", pix_fmt="gray", s=(w, h), show_log=True - ) as f: - F = f.read(10) - print(f.rate) - assert f.shape == (h, w, 1) - assert f.samplesize == w * h - assert F["shape"] == (10, h, w, 1) - assert F["dtype"] == f.dtype - - -def test_read_write_video(): - fs, F = ffmpegio.video.read(url, t=1) - bps = utils.get_samplesize(F["shape"][-3:], F["dtype"]) - F0 = { - "buffer": F["buffer"][:bps], - "shape": (1, *F["shape"][1:]), - "dtype": F["dtype"], - } - F1 = { - "buffer": F["buffer"][bps:], - "shape": (F["shape"][0] - 1, *F["shape"][1:]), - "dtype": F["dtype"], - } - - with tempfile.TemporaryDirectory() as tmpdirname: - out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleVideoWriter(out_url, rate_in=fs) as f: - f.write(F0) - f.write(F1) - - -def test_read_audio(caplog): - # caplog.set_level(logging.DEBUG) - - fs, x = ffmpegio.audio.read(url) - bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) - - with streams.SimpleAudioReader(url, show_log=True, blocksize=1024**2) as f: - # x = f.read(1024) - # assert x['shape'] == (1024, f.ac) - blks = [blk["buffer"] for blk in f] - x1 = b"".join(blks) - assert x["buffer"] == x1 - - n0 = int(0.5 * fs) - n1 = int(1.2 * fs) - t0 = n0 / fs - t1 = n1 / fs - - with streams.SimpleAudioReader( - url, ss_in=t0, to_in=t1, show_log=True, blocksize=1024**2 - ) as f: - blks, shapes = zip(*[(blk["buffer"], blk["shape"][0]) for blk in f]) - log = f.readlog() - shape = sum(shapes) - - print(log) - - x2 = b"".join(blks) - # # print("# of blks: ", len(blks), x1['shape']) - # for i, xi in enumerate(x2): - # print(i, xi-x[n0 + i]) - # assert np.array_equal(xi, x[n0 + i]) - assert shape == n1 - n0 - assert x["buffer"][n0 * bps : n1 * bps] == x2 - - -def test_read_write_audio(): - outext = ".flac" - - with streams.SimpleAudioReader(url) as f: - F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) - fs = f.rate - shape = f.shape - dtype = f.dtype - bps = f.samplesize - - out = {"dtype": dtype, "shape": shape} - - with tempfile.TemporaryDirectory() as tmpdirname: - out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleAudioWriter(out_url, rate_in=fs, show_log=True) as f: - f.write({**out, "buffer": F[: 100 * bps]}) - f.write({**out, "buffer": F[100 * bps :]}) - - -def test_video_filter(): - url = "tests/assets/testvideo-1m.mp4" - - fps = 10 # fractions.Fraction(60000,1001) - - with ( - streams.SimpleVideoReader(url, blocksize=30, t=30) as src, - streams.SimpleVideoFilter( - "scale=200:100", rate_in=src.rate, rate=fps, show_log=True - ) as f, - ): - - def process(i, frames): - print(f"{i} - output {frames['shape'][0]} frames ({f.nin},{f.nout})") - - for i, frames in enumerate(src): - process(i, f.filter(frames)) - assert f.rate_in == src.rate - assert f.rate == fps - process("end", f.flush()) - - -def test_audio_filter(): - url = "tests/assets/testaudio-1m.mp3" - - sps = 4000 # fractions.Fraction(60000,1001) - - with streams.SimpleAudioReader(url, blocksize=1024 * 8, t=10, ar=32000) as src: - samples = src.read(src.blocksize) - - with streams.SimpleAudioFilter( - "lowpass", - rate_in=src.rate, - rate=sps, - show_log=True, - # ac=src.channels, - # dtype=src['dtype'], - ) as f: - - def process(i, samples): - if len(samples): - print( - f"{i} - output {samples['shape'][0]} samples ({f.nin, f.nout})" - ) - - try: - process(-1, f.filter(samples)) - except TimeoutError: - pass - for i, samples in enumerate(src): - try: - process(i, f.filter(samples)) - except TimeoutError: - pass - assert f.rate_in == src.rate - assert f.rate == sps - process("end", f.flush()) - - -def test_write_extra_inputs(): - url_aud = "tests/assets/testaudio-1m.mp3" - - fs, F = ffmpegio.video.read(url, t=1) - F = { - "buffer": F["buffer"], - "shape": F["shape"], - "dtype": F["dtype"], - } - - with tempfile.TemporaryDirectory() as tmpdirname: - out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleVideoWriter( - out_url, fs, extra_inputs=[url_aud], map=["0:v", "1:a"], show_log=True - ) as f: - f.write(F) - - info = ffmpegio.probe.streams_basic(out_url) - assert len(info) == 2 - - with streams.SimpleVideoWriter( - out_url, - fs, - extra_inputs=[("anoisesrc", {"f": "lavfi"})], - map=["0:v", "1:a"], - shortest=None, - show_log=True, - overwrite=True, - ) as f: - f.write(F) - - info = ffmpegio.probe.streams_basic(out_url) - assert len(info) == 2 diff --git a/tests/test_stdstreams.py b/tests/test_stdstreams.py deleted file mode 100644 index 5b27f7de..00000000 --- a/tests/test_stdstreams.py +++ /dev/null @@ -1,186 +0,0 @@ -import logging - -logging.basicConfig(level=logging.DEBUG) - -import ffmpegio as ff -from ffmpegio import streams, utils - -url = "tests/assets/testmulti-1m.mp4" -outext = ".mp4" - - -def test_read_video(): - w = 420 - h = 360 - b = ff.transcode(url, "-", f="matroska", c="copy", to=1) - with ( - streams.StdVideoDecoder( - vf="transpose", pix_fmt="gray", s=(w, h), show_log=True - ) as f, - ): - f.write_encoded(b) - F = f.read(10) - print(f.output_rate) - assert f.output_shape == (h, w, 1) - assert f.output_samplesize == w * h - assert F["shape"] == (10, h, w, 1) - assert F["dtype"] == f.output_dtype - - -def test_read_write_video(): - fs, F = ff.video.read(url, t=1) - bps = utils.get_samplesize(F["shape"][-3:], F["dtype"]) - F0 = { - "buffer": F["buffer"][:bps], - "shape": (1, *F["shape"][1:]), - "dtype": F["dtype"], - } - F1 = { - "buffer": F["buffer"][bps:], - "shape": (F["shape"][0] - 1, *F["shape"][1:]), - "dtype": F["dtype"], - } - - with streams.StdVideoEncoder(fs, f="matroska", show_log=True) as f: - f.write(F0) - f.write(F1) - f.wait() - b = f.read_encoded(-1) - - -def test_read_audio(caplog): - # caplog.set_level(logging.DEBUG) - - b = ff.transcode(url, "-", f="matroska", c="copy", vn=None) - fs, x = ff.audio.read(b, to=10, show_log=True, sample_fmt="flt") - bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) - with streams.StdAudioDecoder(show_log=True, blocksize=1024**2, to=10) as f: - f.write_encoded(b) - # x = f.read(1024) - # assert x['shape'] == (1024, f.ac) - blks = [blk["buffer"] for blk in f] - x1 = b"".join(blks) - assert x["buffer"] == x1 - - -def test_read_write_audio(): - outext = ".flac" - b = ff.transcode(url, "-", f="matroska", c="copy", vn=None) - - with streams.StdAudioDecoder(show_log=True, to=10) as f: - f.write_encoded(b) - F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) - fs = f.output_rate - shape = f.output_shape - dtype = f.output_dtype - bps = f.output_samplesize - - out = {"dtype": dtype, "shape": shape} - - with streams.StdAudioEncoder(fs, show_log=True, f="matroska") as f: - f.write({**out, "buffer": F[: 100 * bps]}) - f.write({**out, "buffer": F[100 * bps :]}) - - -def test_video_filter(): - url = "tests/assets/testvideo-1m.mp4" - - fps = 10 # fractions.Fraction(60000,1001) - - with ( - streams.SimpleVideoReader(url, blocksize=30, t=30) as src, - streams.StdVideoFilter("scale=200:100", src.rate, r=fps, show_log=True) as f, - ): - - def process(i, frames): - print( - f"{i} - output {frames['shape'][0]} frames ({f.input_count},{f.output_count})" - ) - - for i, frames in enumerate(src): - process(i, f.filter(frames)) - assert f.input_rate == src.rate - assert f.output_rate == fps - f.wait() - process("end", f.read(-1)) - - -def test_audio_filter(): - url = "tests/assets/testaudio-1m.mp3" - - sps = 4000 # fractions.Fraction(60000,1001) - - with ( - streams.SimpleAudioReader(url, blocksize=1024 * 8, t=10, ar=32000) as src, - streams.StdAudioFilter("lowpass", src.rate, ar=sps, show_log=True) as f, - ): - samples = src.read(src.blocksize) - - def process(i, samples): - if len(samples): - print( - f"{i} - output {samples['shape'][0]} samples ({f.input_count, f.output_count})" - ) - - try: - process(-1, f.filter(samples)) - except TimeoutError: - pass - - for i, samples in enumerate(src): - try: - process(i, f.filter(samples)) - except TimeoutError: - pass - assert f.input_rate == src.rate - assert f.output_rate == sps - f.wait() - process("end", f.read(-1)) - - -def test_write_extra_inputs(): - url_aud = "tests/assets/testaudio-1m.mp3" - - fs, F = ff.video.read(url, t=1) - F = { - "buffer": F["buffer"], - "shape": F["shape"], - "dtype": F["dtype"], - } - - with streams.StdVideoEncoder( - fs, - extra_inputs=[url_aud], - f="matroska", - map=["0:v", "1:a"], - show_log=True, - ) as f: - f.write(F) - f.wait() - b = f.read_encoded(-1) - - info = ff.probe.streams_basic(b) - assert len(info) == 2 - - with streams.StdVideoEncoder( - fs, - extra_inputs=[("anoisesrc", {"f": "lavfi"})], - f="matroska", - map=["0:v", "1:a"], - shortest=None, - show_log=True, - ) as f: - f.write(F) - f.wait() - b = f.read_encoded(-1) - - info = ff.probe.streams_basic(b) - assert len(info) == 2 - - -if __name__ == "__main__": - print("starting test") - logging.debug("logging check") - test_video_filter() - - # python tests\test_simplestreams.py diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py new file mode 100644 index 00000000..d170a53b --- /dev/null +++ b/tests/test_streams_piped.py @@ -0,0 +1,221 @@ +import logging + +import numpy as np + +import ffmpegio as ff +from ffmpegio import streams + +logging.basicConfig(level=logging.DEBUG) + +mult_url = "tests/assets/testmulti-1m.mp4" +video_url = "tests/assets/testvideo-1m.mp4" +audio_url = "tests/assets/testaudio-1m.mp3" +outext = ".mp4" + + +def test_MediaReader(): + with streams.PipedFFmpegRunner.open_media_reader( + [(mult_url, {})], None, options={"t_in": 1}, squeeze=False + ) as reader: + nframes = [0] * reader.num_output_streams + for i, data in enumerate(reader): + nframes = [n0 + v["shape"][0] for n0, v in zip(nframes, data)] + + assert nframes == [30, 44100, 25, 44100] + + +def test_MediaWriter_audio(): + ff.use("read_numpy") + + rates, data = ff.media.read(audio_url, t=1, ar=8000, sample_fmt="s16") + + with streams.PipedFFmpegRunner.open_media_encoder( + [{"ar": rates["0:a:0"]}], + [{"f": "matroska"}], + show_log=True, + ) as writer: + for i, frame in enumerate(data.values()): + writer.write(frame, i) + # read the encoded bytes if any available + b = writer.read_encoded_nowait(0) + + # close the input and wait for FFmpeg to finish encoding and terminate + writer.wait(timeout=10) + + # read the rest + b = writer.read_encoded(0) + + +def test_MediaWriter(): + ff.use("read_numpy") + + rates, data = ff.media.read(mult_url, t=1) + stream_types = [spec.split(":", 2)[1] for spec in data] + + rate_opt_name = {"a": "ar", "v": "r"} + stream_opts = [ + {rate_opt_name[mtype]: r} for mtype, r in zip(stream_types, rates.values()) + ] + + with streams.PipedFFmpegRunner.open_media_encoder( + stream_opts, + [{"f": "matroska", "map": range(len(stream_types))}], + show_log=True, + ) as writer: + # write full audio streams + video_frames = {} + for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): + if mtype == "a": + writer.write(frame, i) + else: + video_frames[i] = frame.shape[0] + + # write video stream one frame at a time + frame_count = {k: 0 for k in video_frames} + while any( + n < nall for n, nall in zip(frame_count.values(), video_frames.values()) + ): + for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): + if i in frame_count: + j = frame_count[i] + print(j) + try: + writer.write(frame[j], i) + except IndexError: + pass + else: + b = writer.read_encoded_nowait(0) + frame_count[i] = j + 1 + + writer.wait(10) + b = writer.read_encoded(-1) + assert isinstance(b, bytes) and len(b) > 0 + + +def test_SimpleMediaFilter(): + ff.use("read_numpy") + + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=0.1) + + nin = 1024 + nblocks = len(x) // nin + + X = x[: nin * nblocks, ...].reshape(nblocks, nin, -1) + + with ff.streams.SISOFFmpegFilter.create_and_open( + {"ar": fs}, + {"map": "[out]"}, + options={"filter_complex": "[0:a:0]showcqt=s=vga[out]"}, + show_log=True, + squeeze=False, + ) as f: + # write the first frame (so the output rate is resolved) + f.write(X[0]) + + dt = nin / f.rate_in + ntotal = int(nin * nblocks * f.rate / f.rate_in) # total # of frames + cumnout = np.astype(np.arange(1, nblocks) * dt * f.rate, int) + nread = 0 + + for i, (n, Xn) in enumerate(zip(cumnout, X[1:])): + assert bool(f) + + ntry = n - nread + if ntry > 0: + out = f.read_nowait(n - nread) + nread += out.shape[0] + print(f"[{i:2}] expects {ntry} new frames, {out.shape[0]} frames read") + f.write(Xn, last=i == nblocks - 2) + + ntry = ntotal - nread + if ntry > 0: + print(f"[last] reading the remaining {ntry} frames") + out = f.read(ntry) + nread += out.shape[0] + print(f"[last] final read obtained {out.shape[0]} frames") + assert nread == ntotal + + +def test_MediaFilter(): + ff.use("read_bytes") + + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) + + fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", to=1) + + print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") + + with ff.streams.PipedFFmpegRunner.open_media_filter( + [{"r": fps}, {"r": fps}, {"ar": fs}, {"ar": fs}], + output_streams=["[out0]", {"map": "[out1]"}], + options={"filter_complex": ["[0:V:0][1:V:0]vstack", "[2:a:0][3:a:0]amerge"]}, + show_log=True, + # loglevel="debug", + # queuesize=4, + ) as f: + # f.write([F, F]) + for i, frame in enumerate([F, F, x, x]): + f.write(frame, i, last=True) + # sleep(1) + + assert ["out0", "out1"] == f.output_labels + assert f.num_output_streams == 2 + + frames_per_read = f.output_frames() + nnext = list(frames_per_read) + + for i in range(F["shape"][0]): + for st in range(2): + n = int(nnext[st]) + Fout = f.read(n, st) + print(Fout["shape"], n) + # assert Fout["shape"][0] == n + nnext[st] = nnext[st] - n + frames_per_read[st] + + # just in case + f.wait(1) + + +def test_MediaTranscoder(): + url = "tests/assets/sample.mp4" + + data = b"" + + # 1. transcode from a file to pipe + with streams.PipedFFmpegRunner.open_media_transcoder( + [], + [{"f": "matroska", "to": 1}], + extra_inputs=[(url, {})], + show_log=True, + # loglevel="debug", + ) as f: + while f: + b = f.read_encoded_nowait(-1) + data += b + b = f.read_encoded_nowait(-1) + data += b + + assert len(data) > 0 + + print(f"FIRST TRANCODING YIELDED {len(data)} bytes") + + with streams.PipedFFmpegRunner.open_media_transcoder( + [{}], + [{"f": "flac", "vn": None}, {"f": "matroska", "codec": "copy"}], + show_log=True, + # loglevel="debug", + ) as f: + f.write_encoded(data, last=True) + + out = [b"", b""] + + while f: + for st in range(2): + out[st] += f.read_encoded_nowait(-1, stream=st) + + assert len(out[0]) > 0 + assert len(out[1]) > 0 + + print( + f"SECOND TRANCODING YIELDED {len(out[0])} bytes for flac and {len(out[1])} bytes for matroska" + ) diff --git a/tests/test_streams_simple.py b/tests/test_streams_simple.py new file mode 100644 index 00000000..cd260307 --- /dev/null +++ b/tests/test_streams_simple.py @@ -0,0 +1,160 @@ +import logging + +logging.basicConfig(level=logging.DEBUG) + +import re +import tempfile +from os import path + +import ffmpegio +from ffmpegio import utils +from ffmpegio.streams import StdFFmpegRunner + +url = "tests/assets/testmulti-1m.mp4" +outext = ".mp4" + + +def test_read_video(): + w = 420 + h = 360 + with StdFFmpegRunner.open_simple_reader( + [(url, {})], + {"map": "0:V:0", "vf": "transpose", "pix_fmt": "gray", "s": (w, h), "r": 30}, + show_log=True, + ) as f: + F = f.read(10) + assert f.output_rates[0] == 30 + assert f.output_shapes[0] == (h, w, 1) + assert F["shape"] == (10, h, w) + assert F["dtype"] == f.output_dtypes[0] + + +def test_read_write_video(): + fs, F = ffmpegio.video.read(url, t=1) + bps = utils.get_samplesize(F["shape"][-3:], F["dtype"]) + F0 = { + "buffer": F["buffer"][:bps], + "shape": (1, *F["shape"][1:]), + "dtype": F["dtype"], + } + F1 = { + "buffer": F["buffer"][bps:], + "shape": (F["shape"][0] - 1, *F["shape"][1:]), + "dtype": F["dtype"], + } + + with tempfile.TemporaryDirectory() as tmpdirname: + out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) + with StdFFmpegRunner.open_simple_writer([(out_url, {})], {"r": fs}) as f: + f.write(F0) + f.write(F1) + f.wait() + fs, F = ffmpegio.video.read(out_url) + assert len(F["buffer"]) + + +def test_read_audio(): + fs, x = ffmpegio.audio.read(url) + bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) + + # validate read iterator obtains all the samples + with StdFFmpegRunner.open_simple_reader( + [(url, {})], {"map": "0:a:0"}, show_log=True, blocksize=1024**2 + ) as f: + # x = f.read(1024) + # assert x['shape'] == (1024, f.ac) + blks = [blk["buffer"] for blk in f] + x1 = b"".join(blks) + assert x["buffer"] == x1 + + # validate starting + n0 = int(0.5 * fs) + n1 = int(1.2 * fs) + t0 = n0 / fs + t1 = n1 / fs + + with StdFFmpegRunner.open_simple_reader( + [(url, {})], + {"map": "0:a:0"}, + show_log=True, + blocksize=1024**2, + options={"ss_in": t0, "to_in": t1}, + ) as f: + blks, shapes = zip(*[(blk["buffer"], blk["shape"][0]) for blk in f]) + shape = sum(shapes) + + x2 = b"".join(blks) + # # print("# of blks: ", len(blks), x1['shape']) + # for i, xi in enumerate(x2): + # print(i, xi-x[n0 + i]) + # assert np.array_equal(xi, x[n0 + i]) + assert shape == n1 - n0 + assert x["buffer"][n0 * bps : n1 * bps] == x2 + + +def test_read_write_audio(): + outext = ".flac" + + with StdFFmpegRunner.open_simple_reader([(url, {})], {"map": "0:a:0"}) as f: + F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) + fs = f.output_rates[0] + shape = f.output_shapes[0] + dtype = f.output_dtypes[0] + bps = f.output_itemsizes[0] + + out = {"dtype": dtype, "shape": shape} + + print(len(F[: 100 * bps])) + + with tempfile.TemporaryDirectory() as tmpdirname: + out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) + with StdFFmpegRunner.open_simple_writer( + [(out_url, {})], {"ar": fs}, show_log=True + ) as f: + f.write({**out, "buffer": F[: 100 * bps]}) + f.write({**out, "buffer": F[100 * bps :]}) + f.wait() + assert path.exists(out_url) + + +def test_write_extra_inputs(): + url_aud = "tests/assets/testaudio-1m.mp3" + + fs, F = ffmpegio.video.read(url, t=1) + F = { + "buffer": F["buffer"], + "shape": F["shape"], + "dtype": F["dtype"], + } + print(len(F["buffer"])) + + with tempfile.TemporaryDirectory() as tmpdirname: + out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) + with StdFFmpegRunner.open_simple_writer( + [(out_url, {})], + {"r": fs}, + extra_inputs=[(url_aud, {})], + show_log=True, + options={"map": ["0:v", "1:a"], "loglevel": "debug"}, + ) as f: + f.write(F) + f.wait() + print(f.readlog()) + + info = ffmpegio.probe.streams_basic(out_url) + assert len(info) == 2 + + with StdFFmpegRunner.open_simple_writer( + [(out_url, {})], + {"r": fs}, + extra_inputs=[("anoisesrc", {"f": "lavfi"})], + show_log=True, + overwrite=True, + options={"map": ["0:v", "1:a"], "shortest": None}, + ) as f: + f.write(F) + f.wait() + print(f.readlog()) + + info = ffmpegio.probe.streams_basic(out_url) + assert len(info) == 2 diff --git a/tests/test_transcode.py b/tests/test_transcode.py index fe4cf710..5591d2f9 100644 --- a/tests/test_transcode.py +++ b/tests/test_transcode.py @@ -92,21 +92,5 @@ def test_transcode_vf(): assert path.isfile(out_url) -def test_transcode_image(): - url = "tests/assets/ffmpeg-logo.png" - with tempfile.TemporaryDirectory() as tmpdirname: - # print(probe.audio_streams_basic(url)) - out_url = path.join(tmpdirname, path.basename(url) + ".jpg") - transcode( - url, - out_url, - show_log=True, - remove_alpha=True, - s=[300, -1], - transpose=0, - vframes=1, - ) - - if __name__ == "__main__": test_transcode_from_filter() diff --git a/tests/test_utils.py b/tests/test_utils.py index 6f1e1a2c..6fec56cb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,8 +1,6 @@ -import math - import pytest -from ffmpegio import FFmpegioError, utils +from ffmpegio import utils def test_string_escaping(): @@ -44,29 +42,17 @@ def test_get_pixel_config(): assert cfg[0] == "rgb24" and cfg[1] == 3 and cfg[2] == "|u1" -def test_alpha_change(): - - cases = (("rgb24", "rgba", 1), ("rgb24", "rgb24", 0), ("ya8", "gray", -1)) - - for input_pix_fmt, output_pix_fmt, dir in cases: - dout = utils.alpha_change(input_pix_fmt, output_pix_fmt) - assert dir == dout - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, dir) is True - if dir: - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, -dir) is False - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, 0) is False - else: - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, 1) is False - assert utils.alpha_change(input_pix_fmt, output_pix_fmt, -1) is False +def test_get_pixel_format(): + with pytest.raises(KeyError): + utils.get_pixel_format("yuv") # unknown format + cfg = utils.get_pixel_format("rgb24") # unknown format + assert cfg[1] == 3 and cfg[0] == "|u1" -def test_get_rotated_shape(): - w = 1000 - h = 400 - print(utils.get_rotated_shape(w, h, 30)) - print(utils.get_rotated_shape(w, h, 45)) - print(utils.get_rotated_shape(w, h, 60)) - assert utils.get_rotated_shape(w, h, 90) == (h, w, math.pi / 2.0) + with pytest.raises(ValueError): + utils.get_pixel_format("yuv420p") + cfg = utils.get_pixel_format("yuv444p") # unknown format + assert cfg[0] == "|u1" and cfg[1] == 3 def test_get_audio_codec(): @@ -79,52 +65,14 @@ def test_get_audio_format(): assert cfg[0] == " Date: Thu, 12 Feb 2026 23:38:32 -0600 Subject: [PATCH 323/333] wip 1 - fixin' fgb --- CHANGELOG.md | 15 ++ Makefile | 2 +- docsrc/analysis.rst | 6 +- docsrc/conf.py | 60 +++-- docsrc/filtergraph.rst | 8 +- docsrc/index.rst | 54 ++++- docsrc/install.rst | 43 ++-- docsrc/options.rst | 118 +--------- docsrc/quick.rst | 2 +- docsrc/requirements.txt | 9 +- pyproject.toml | 7 +- src/ffmpegio/filtergraph/Chain.py | 6 + src/ffmpegio/filtergraph/Filter.py | 35 ++- src/ffmpegio/filtergraph/Graph.py | 299 +++++++++++++++---------- src/ffmpegio/filtergraph/GraphLinks.py | 201 +++++++++++++---- src/ffmpegio/filtergraph/__init__.py | 1 + src/ffmpegio/filtergraph/abc.py | 34 ++- src/ffmpegio/filtergraph/build.py | 5 + src/ffmpegio/filtergraph/presets.py | 95 +++++--- src/ffmpegio/typing.py | 6 +- src/ffmpegio/utils/log.py | 3 +- tests/test_filtergraph.py | 69 ++---- tests/test_filtergraph_build.py | 122 +++------- tests/test_filtergraph_chain.py | 98 ++------ tests/test_filtergraph_fglinks.py | 5 +- tests/test_filtergraph_presets.py | 6 +- 26 files changed, 719 insertions(+), 590 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb0ff9f..e339a01b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a ## [Unreleased] +### Changed + +- allow writers' `extra_inputs` arguments to be `str` or `tuple[str, dict|None]` +- `probe` functions accepts PathLike object as the media url + +### Added + +- `media` module - block +- `PipedStreams` module - media stream classes with multiple inputs and outputs. + +### Removed + +- `build_basic_vf()` options from video readers and filters. Users can generate + equivalent filter chains via `filtergraph.presets.filter_video_basic()`. + ## [0.11.1] - 2025-05-17 ### Fixed diff --git a/Makefile b/Makefile index dde022fb..6fbc22f5 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= +SPHINXOPTS ?= -j auto -n -v -W -T SPHINXBUILD ?= sphinx-build SOURCEDIR = docsrc BUILDDIR = build diff --git a/docsrc/analysis.rst b/docsrc/analysis.rst index 122b345b..a4c791c7 100644 --- a/docsrc/analysis.rst +++ b/docsrc/analysis.rst @@ -8,7 +8,7 @@ There are a number of `FFmpeg filters `_ which analyze video and audio streams and inject per-frame results into frame metadata to be used in a later stage of -a filtergraph. :py:mod:`ffmpegio.analyze.run` retrieves the injected metadata by appending ``metadata`` +a filtergraph. :py:mod:`run` retrieves the injected metadata by appending ``metadata`` and ``ametadata`` filters and logs the frame metadata outputs. You can use either the supplied Python classes or a custom class, which conforms to :py:class:`MetadataLogger` interface to specify the FFmpeg filter and to log its output. @@ -88,10 +88,10 @@ Analyze API Reference :nosignatures: :recursive: - ffmpegio.analyze.run + run ffmpegio.video.detect ffmpegio.audio.detect - ffmpegio.analyze.MetadataLogger + MetadataLogger .. autofunction:: ffmpegio.analyze.run .. autofunction:: ffmpegio.video.detect diff --git a/docsrc/conf.py b/docsrc/conf.py index 3ba4541e..a5809871 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -20,7 +20,7 @@ project = "python-ffmpegio" copyright = ( - "2021-2022, Takeshi (Kesh) Ikuma, Louisiana State University Health Sciences Center" + "2021-2026, Takeshi (Kesh) Ikuma, Louisiana State University Health Sciences Center" ) author = "Takeshi (Kesh) Ikuma" release = ffmpegio.__version__ @@ -36,12 +36,37 @@ "sphinx.ext.intersphinx", "sphinx.ext.autosummary", "sphinx.ext.todo", - "sphinxcontrib.blockdiag", - "sphinxcontrib.repl", + # "sphinx.ext.graphviz", + # "sphinxcontrib.repl", "matplotlib.sphinxext.plot_directive", ] # Looks for objects in external projects + +# Autodoc configuration +autodoc_member_order = "groupwise" +autodoc_type_aliases = { + "ArrayLike": "~numpy.typing.ArrayLike", + "NDArray": "~numpy.typing.NDArray", + "ff": "ffmpegio", +} +autodoc_mock_imports = ["builtins"] +autodoc_typehints_format = "short" +# autodoc_class_signature = "separated" +autodoc_default_options = {"exclude-members": "__new__", "class-doc-from": "init"} +autodoc_typehints = "description" + +overloads_location = "signature" + +# Intersphinx configuration +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "scipy": ("https://docs.scipy.org/doc/scipy/", None), + "matplotlib": ("https://matplotlib.org/stable/", None), + "python": ("https://docs.python.org/3/", None), +} + autodoc_typehints = "description" # autodoc_type_aliases = {'AgentAssignment': 'AgentAssignment'} @@ -53,29 +78,38 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" + +copybutton_selector = "div:not(.output_area) > div.highlight > pre" + +graphviz_output_format = "svg" # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +html_theme = "sphinx_book_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] -# html_sidebars = { -# "**": ["globaltoc.html", "relations.html", "sourcelink.html", "searchbox.html"] -# } - -# Fontpath for blockdiag (truetype font) -blockdiag_fontpath = "_static/ipagp.ttf" -blockdiag_html_image_format = "SVG" -intersphinx_mapping = { - "numpy": ("https://numpy.org/doc/stable", None), +# html_logo = "images/logo.png" +html_theme_options = { + # "logo": { + # "image_light": "images/wave-reflection-model-light.png", + # "image_dark": "images/wave-reflection-model-dark.png", + # }, + "path_to_docs": "docs/", + "repository_url": "https://github.com/tikuma-lsuhsc/pyLeTalker", + # "repository_branch": branch_or_commit, + "use_repository_button": True, + "use_source_button": True, + "show_toc_level": 2, } plot_html_show_source_link = False diff --git a/docsrc/filtergraph.rst b/docsrc/filtergraph.rst index 4982211c..2634fcd9 100644 --- a/docsrc/filtergraph.rst +++ b/docsrc/filtergraph.rst @@ -30,9 +30,9 @@ These functions are served by three classes: :nosignatures: :recursive: - ffmpegio.filtergraph.Filter - ffmpegio.filtergraph.Chain - ffmpegio.filtergraph.Graph + Filter + Chain + Graph See :ref:`api` section below for the full documentation of these classes and other helper functions. @@ -528,7 +528,7 @@ temporary script file. The previous example can also run as follows: with fg.as_script_file() as script_path: ffmpegio.ffmpegprocess.run( { - 'inputs': [('input.mp4', None)] + 'inputs': [('input.mp4', None)], 'outputs': [('output.mp4', {'filter_script:v': script_path})] }) diff --git a/docsrc/index.rst b/docsrc/index.rst index 967e3ea1..cd6612e1 100644 --- a/docsrc/index.rst +++ b/docsrc/index.rst @@ -14,6 +14,9 @@ .. |github-status| image:: https://img.shields.io/github/actions/workflow/status/python-ffmpegio/python-ffmpegio/test_n_pub.yml?branch=main :alt: GitHub Workflow Status +* GitHub Repository +* GitHub Discussion Board + Python `ffmpegio` package aims to bring the full capability of `FFmpeg `__ to read, write, probe, and manipulate multimedia data to Python. FFmpeg is an open-source cross-platform multimedia framework, which can handle most of the multimedia formats available today. @@ -39,10 +42,13 @@ Main Features * I/O device enumeration to eliminate the need to look up device names. (currently supports only: Windows DirectShow) * More features to follow -Installation ------------- -Install the full `ffmpegio` package via ``pip``: +Where to start +-------------- + +* Read :ref:`Quick-start guide ` + +* Install via ``pip``: .. code-block:: bash @@ -330,3 +336,45 @@ Run FFmpeg and FFprobe Directly }, capture_log=True) >>> print(out.stderr) # print the captured FFmpeg logs (banner text omitted) >>> b = out.stdout # width*height bytes of the first frame + +Introductory Info +----------------- + +.. toctree:: + :maxdepth: 1 + + quick + install + + +High-level API Reference +------------------------ + +.. toctree:: + :maxdepth: 1 + + basicio + probe + options + filtergraph + caps + analysis + devices + concat + +Advanced Topics +--------------- + +.. toctree:: + :maxdepth: 1 + + adv-ffmpeg + adv-args + +External Links +-------------- + +.. toctree:: + :maxdepth: 1 + + links diff --git a/docsrc/install.rst b/docsrc/install.rst index 308865ea..581ac0c2 100644 --- a/docsrc/install.rst +++ b/docsrc/install.rst @@ -18,41 +18,54 @@ Install the :py:mod:`ffmpegio` package via :code:`pip`. Install FFmpeg program ^^^^^^^^^^^^^^^^^^^^^^ -There are two platform independent approaches to install FFmpeg for the use in Python: +There are two Python libraries to install FFmpeg for the use in Python: -::code::`ffmpeg-downloader` -""""""""""""""""""""""""""" +The installation of FFmpeg is platform dependent. One platform agnostic approach +is to use our sister package: +`ffmpeg-downloader `__. + +Install with `ffmpeg-downloader` +"""""""""""""""""""""""""""""""" + +First, install the `ffmpegio-downloader` package, then run its cli command `ffdl`: .. code-block:: + pip install ffmpeg-downloader - ffdl install -U # grabs the latest version + ffdl install - # optionally - ffdl install -U --add-path to have it on the system path in Windows or MacOS +If you wish to use the FFmpeg outside of `ffmpegio`, you can also install and add +the installed directory to the user's system path (only for Windows and MacOS). -::code::`static-ffmpeg` -""""""""""""""""""""""" +.. code-block:: + + # optionally + ffdl install --add-path +At a later date, you could re-run `ffdl` to look for an update (similar to `pip`): +I .. code-block:: - pip install static-ffmpeg - static_ffmpeg_paths -The installation of FFmpeg is platform dependent. For Ubuntu/Debian Linux, + ffdl install -U + +Install on Ubuntu/Debian Linux +"""""""""""""""""""""""""""""" .. code-block:: sudo apt install ffmpeg -and for MacOS, +Install on MacOS +"""""""""""""""" .. code-block:: brew install ffmpeg -no other actions are needed as these commands will place the FFmpeg executables -on the system path. +Install on Windows +"""""""""""""""""" -For Windows, it is a bit more complicated. +It is a bit more complicated in Windows. 1. Download pre-built packages from the links available on the `FFmpeg's Download page `__. diff --git a/docsrc/options.rst b/docsrc/options.rst index f44e39da..940cff98 100644 --- a/docsrc/options.rst +++ b/docsrc/options.rst @@ -117,109 +117,15 @@ Like `pix_fmt`, `sample_fmt` also has concrete relationship to the `dtype` optio Built-in Video Manipulation Options ----------------------------------- -While the use of the :code:`vf` or :code:`filter_complex` option enables the full spectrum -of FFmpeg's filtering capability (`FFmpeg Documentation `__), -:py:mod:`ffmpegio`'s video and image routines adds several convenience -video options to perform simple video maninpulations without the need of setting -up a filtergraph. - - -.. list-table:: Options to manipulate video frames - :widths: auto - :header-rows: 1 - :class: tight-table - - * - name - - value - - FFmpeg filter - - Description - * - :code:`crop` - - seq(int[, int[, int[, int]]]) - - `crop `__ - - video frame cropping/padding, values representing the number of pixels to crop from [left top right bottom]. - If positive, the video frame is cropped from the respective edge. If negative, the video frame is padded on - the respective edge. If right or bottom is missing, uses the same value as left or top, respectively. If top - is missing, it defaults to 0. - * - :code:`flip` - - {:code:`'horizontal'`, :code:`'vertical'`, :code:`'both'`} - - `hflip `__ or `vflip `__ - - flip the video frames horizontally, vertically, or both. - * - :code:`transpose` - - int - - `transpose `__ - - tarnspose the video frames. Its value specifies the mode of operation. Use 0 for the conventional transpose operation. - For the others, see the FFmpeg documentation. - * - :code:`square_pixels` - - {:code:`'upscale'`, :code:`'downscale'`, :code:`'upscale_even'`, - :code:`'downscale_even'`} - - `scale `__ and `setsar `__ - - Resize video frames so that their pixels are square (i.e., SAR=1:1). - :code:`'upscale'` stretches the short side - of the pixels while :code:`'downscale'` compresses the long side. - :code:`'even'` makes sure that the resulting frame size is even (required by some codecs). - * - :code:`remove_alpha` - - bool - - `overlay `__ and `color `__ - - Fill transparent background with :code:`fill_color` color. This filter is automatically - inserted if input :code:`'pix_fmt'` has alpha but not the output. - * - :code:`fill_color` - - str - - n/a - - This option is used for the auto-conversion of an image with transparency to - opaque by setting the output option :code:`pix_fmt`. The option value - specifies a color according to - `FFmpeg Color Specifications `__. - Default color is :code:`'white'`. - -Note that the these operations are pre-wired to perform in a specific order: - -.. blockdiag:: - :caption: Video Manipulation Order - - blockdiag { - square_pixels -> crop -> flip -> transpose; - crop -> flip [folded] - } - -Be aware of this ordering as these filters are non-commutative (i.e., a change in the -order of operation alters the outcome). If your desired order of filters differs or -need to use additional filters, use the :code:`vf` option to specify your own filtergraph. - -.. list-table:: Examples of manipulated images - :class: tight-table - - * - .. plot:: - - IM = ffmpegio.image.read('ffmpeg-logo.png') - plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) - plt.imshow(IM) - plt.gca().set_position((0, 0, 1, 1)) - plt.axis('off') - - .. code-block:: python - - ffmpegio.image.read('ffmpeg-logo.png') - - * - .. plot:: - - IM = ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), transpose=0) - plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) - plt.imshow(IM) - plt.gca().set_position((0, 0, 1, 1)) - plt.axis('off') - - .. code-block:: python - - ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), transpose=0) - - * - .. plot:: - - IM = ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), flip='both', s=(200,50)) - plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) - plt.imshow(IM) - plt.gca().set_position((0, 0, 1, 1)) - plt.axis('off') - - .. code-block:: python - - ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), flip='both', size=(200,-1)) +.. deprecated:: 0.12 + + This feature has been deprecated. It is now implemented in + :py:mod:`filtergraph.presets` to generate a filtergraph, which can then be + used with :code:`vf` or :code:`filter_complex` options. + + - :py:func:`filtergraph.presets.filter_video_basic` - a filterchain + with scale, crop, flip, and transpose filters + - :py:func:`filtergraph.presets.remove_alpha` - a filterchain to remove alpha + channel + - :py:func:`filtergraph.presets.square_pixels` - a filterchain to square + pixels diff --git a/docsrc/quick.rst b/docsrc/quick.rst index 7beff41e..9942103a 100644 --- a/docsrc/quick.rst +++ b/docsrc/quick.rst @@ -384,7 +384,7 @@ for the list of predefined color names. * - :code:`'gray'` with light gray background - .. plot:: - IM = ffmpegio.image.read('ffmpeg-logo.png', pix_fmt='gray', fill_color='#F0F0F0') + IM = ffmpegio.image.read('ffmpeg-logo.png', pix_fmt='gray', vf=ffmpegio.filtergraph.presets.remove_alpha('#F0F0F0')) plt.figure(figsize=(IM.shape[1]/96, IM.shape[0]/96), dpi=96) plt.imshow(IM, cmap='gray') plt.gca().set_position((0, 0, 1, 1)) diff --git a/docsrc/requirements.txt b/docsrc/requirements.txt index c7bf27eb..6648811b 100644 --- a/docsrc/requirements.txt +++ b/docsrc/requirements.txt @@ -1,15 +1,10 @@ -Pillow==10.3.0 sphinx sphinx-rtd-theme sphinx-autopackagesummary -sphinxcontrib-blockdiag -blockdiag @ git+https://github.com/yuzutech/blockdiag.git sphinxcontrib-repl sphinx-exec-directive sphinx-autobuild -PyQt5-sip -PyQt5 +sphinx-book-theme +sphinx-copybutton matplotlib -ffmpegio numpy -kiwisolver \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 722009b1..eeda756d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,14 +18,14 @@ classifiers = [ "Topic :: Multimedia :: Video", "Topic :: Multimedia :: Video :: Capture", "Topic :: Multimedia :: Video :: Conversion", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] dynamic = ["version"] -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "pluggy", "packaging", @@ -47,3 +47,6 @@ version = { attr = "ffmpegio.__version__" } testpaths = ["tests"] # minversion = "6.0" # addopts = "-ra -q" + +[tool.ruff] +typing-modules = ["ffmpegio._typing"] \ No newline at end of file diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index eccc587e..665b278c 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -32,6 +32,12 @@ class Error(FFmpegioError): def __init__(self, filter_specs=None): # convert str to a list of filter_specs + if isinstance(filter_specs, fgb.Graph): + nchains = len(filter_specs) + if nchains != 1: + raise TypeError("Cannot convert a `Graph` object to a `Chain` object") + filter_specs = filter_specs[0] if nchains == 1 else "" + if isinstance(filter_specs, fgb.Filter): filter_specs = [filter_specs] elif filter_specs is not None: diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 6ea48be1..c2e2e70a 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -64,7 +64,23 @@ def _get_info(name: str) -> FilterInfo: def __new__(self, filter_spec, *args, filter_id=None, **kwargs): """_summary_""" + + if isinstance(filter_spec, fgb.Graph): + if len(filter_spec) != 1: + raise TypeError( + "Cannot convert a `Graph` object with more than one filter to a `Filter` object" + ) + filter_spec = filter_spec[0] + + if isinstance(filter_spec, fgb.Chain): + if len(filter_spec) != 1: + raise TypeError( + "Cannot convert a `Chain` or `Graph` object to a `Filter` object if it does not have exactly one filter." + ) + filter_spec = filter_spec[0] + proto = [] + if isinstance(filter_spec, Filter): if filter_spec.id and filter_id is not None: # new id proto.append((filter_spec.name, filter_id)) @@ -370,6 +386,20 @@ def _concat(): self.get_option_value("v") + self.get_option_value("a") ) + def _scale(): + # ref input supported in v7.1 or later + w_expr = self.get_option_value("w") + h_expr = self.get_option_value("h") + return ( + 2 + if any( + expr.find(key) >= 0 + for expr in (w_expr, h_expr) + for key in ("ref_", "rw", "rh") + ) + else 1 + ) + option_name, inc = { "afir": ("nbirs", 1), "concat": (None, _concat), @@ -382,6 +412,7 @@ def _concat(): "premultiply": (None, _inplace), "unpremultiply": (None, _inplace), "signature": ("nb_inputs", 0), + "scale": (None, _scale), # "astreamselect": ("inputs", 0), # "bm3d": ("inputs", 0), # "hstack": ("inputs", 0), @@ -467,7 +498,9 @@ def _channelsplit(): else inc() ) - def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: """normalize pad index. Returns three-element pad index with non-negative indices. diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 8f455508..c4016c8e 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -119,6 +119,11 @@ def __init__( """Filter|None: swscale flags for automatically inserted scalers """ + @property + def links(self) -> GraphLinks | None: + """full filtergraph link definition""" + return self._links + def get_num_chains(self) -> int: """get the number of hains""" return len(self) @@ -216,7 +221,9 @@ def resolve_pad_index( chainable_first=chainable_first, ) - def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: """normalize pad index. Returns three-element pad index with non-negative indices. @@ -853,14 +860,63 @@ def is_chain_siso( except IndexError: raise ValueError(f"{chain_id=} is an invalid chain id.") - if check_input and chain.get_num_inputs() != 1: + if len(chain) == 0: + return False # empty chain + + if check_input and chain[0].get_num_inputs() != 1: return False - if check_output and chain.get_num_outputs() != 1: + if check_output and chain[-1].get_num_outputs() != 1: return False return not (check_link and self._links.chain_has_link(chain_id)) + def is_chain_prependable(self, chain_id: int) -> bool: + """True if another chain can be prepended to the specified filter chain""" + + try: + chain = self[chain_id] + except IndexError: + raise ValueError(f"{chain_id=} is an invalid chain id.") + + if len(chain) == 0: + return True # empty chain + + # must have at least one input pad + nin = chain[0].get_num_inputs() + if nin == 0: + return False + + inpad = (chain_id, 0, nin - 1) + conn_from = self._links.input_dict().get(inpad) + + return conn_from is None or isinstance(conn_from, str) + + def is_chain_appendable(self, chain_id: int) -> bool: + """True if another chain can be appended to the specified filter chain + + :param chain_id: chain id + """ + + try: + chain = self[chain_id] + except IndexError: + raise ValueError(f"{chain_id=} is an invalid chain id.") + + if len(chain) == 0: + return True # empty chain + + nout = chain[-1].get_num_outputs() + if nout == 0: # a sink filter, no connectivity + return False + + # the last output pad must not be already connected + filter_id = len(chain) - 1 + outpad = (chain_id, filter_id, nout - 1) + + conn_to = self._links.output_dict().get(outpad) + return conn_to is None or isinstance(conn_to, str) + def _stack( self, other: fgb.abc.FilterGraphObject, @@ -905,7 +961,7 @@ def _stack( try: fg._links.update( - other._links.map_chains(len(self), False), auto_link=auto_link + other._links.map_chains(len(self)), auto_link=auto_link ) except Exception as e: if auto_link: @@ -945,75 +1001,124 @@ def _connect( """ - fg = Graph(self) - - must_link_fwd = [True] * len(fwd_links) - right_chained = [] + # procedure outline + # 0. analyze fwd_links whether they can be chained or not + # 1. chain or stack each chain of the right filtergraph object + # - chain if there is a responsible fwd_link else stack + # - drop chained fwd_link from the list + # 2. if right is a Graph, add its links to the output fg with adjustments + # 3. add remaining fwd_links + # 4. add bwd_links - 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 + fg = Graph(self) - right = fgb.as_filtergraph(right, copy=True) + right_links = ( + GraphLinks(right._links) if isinstance(right, Graph) else GraphLinks(None) + ) - # chain links if there is no ambiguity - for i, (outpad, inpad) in enumerate(fwd_links): - ochain, ichain = outpad[0], inpad[0] + lut_shift = {} + lut_map = {} - # 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 + # scan fwd_links: split fwd_links to be chained and stacked + fwd_chain_links = {} # keyed by input chain idx + fwd_stack_links = [] + for outpad, inpad in fwd_links: + link = ( + self.normalize_pad_index(False, outpad), + right.normalize_pad_index(True, inpad), ) - # transfer the right links to fg (remap chains) - fg._links.update(right_links) - - # create iterators to organize the links in (input, output) of the combined graph - it_fwd = ( - ((lut[r[0]], *r[1:]), l) - for (l, r), do_link in zip(fwd_links, must_link_fwd) - if do_link - ) - it_bwd = ((l, (lut[r[0]], *r[1:])) for (r, l) in bwd_links) - fg._links.update( - {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, - validate=False, + if ( + chain_siso + and self._output_pad_is_chainable(link[0]) + and right._input_pad_is_chainable(link[1]) + ): + # there should be only 1 link which is a chaining link for inpad (and also for outpad) + fwd_chain_links[link[1][0]] = link + else: + fwd_stack_links.append(link) + + # drop labels currently exists on these pads + label = fg._links.find_outpad_label(outpad) + if label is not None: + assert isinstance(label, str) + fg._links.remove_label(label) + label = right_links.find_inpad_label(inpad) + if label is not None: + assert isinstance(label, str) + right_links.remove_label(label) + + # scan bwd_links + bwd_links_ = [] + for outpad, inpad in bwd_links: + link = ( + self.normalize_pad_index(False, outpad), + right.normalize_pad_index(True, inpad), ) + bwd_links_.append(link) + + # drop labels currently exists on these pads + label = right_links.find_outpad_label(outpad) + if label is not None: + assert isinstance(label, str) + right_links.remove_label(label) + label = fg._links.find_inpad_label(inpad) + if label is not None: + assert isinstance(label, str) + fg._links.remove_label(label) + + # stack/chain the chains of the right filtergraph to the left fg + n0 = len(fg) # chain index offset + for i, c in right.iter_chains(): + if i in fwd_chain_links: + op, ip = fwd_chain_links[i] + + # all the links on this chain gets mapped to outpad's chain + # and shifted by the length of the chain before chaining + lut_map[ip[0]] = op[0] + lut_shift[ip[0]] = len(fg[op[0]]) + + # chain + fg[op[0]].extend(c) + + else: # stack + # map the right links to the new chain + lut_map[i] = n0 + # increment the chain counter + n0 += 1 + # stack the new chain + fg = fg._stack(c) + + # map the remainig right links to the new fg + right_links = right_links.map_chains(lut_map, lut_shift) + + # make sure labels don't collide + right_links = { + fg._links.resolve_label(label, auto_index=True): link + for label, link in right_links.items() + } + + # transfer the right links to fg (remap chains) + fg._links.update(right_links) + + # add the new links in (input, output) of the combined graph + def adjust_right_pad(pad): + c = pad[0] + if c in lut_shift: + pad = (pad[0], pad[1] + lut_shift[c], pad[2]) + if c in lut_map: + pad = (lut_map[c], *pad[1:]) + return pad + + it_fwd = tuple((adjust_right_pad(r), l) for l, r in fwd_stack_links) + it_bwd = tuple((l, adjust_right_pad(r)) for r, l in bwd_links) + fg._links.update( + {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, + validate=False, + ) - if replace_sws_flags and right.sws_flags: + # if commanded, use the right sws flags as the output sws flags + if replace_sws_flags and isinstance(right, Graph) and right.sws_flags: fg.sws_flags = right.sws_flags return fg @@ -1041,60 +1146,9 @@ def _rconnect( """ - # return fgb.as_filtergraph(left)._connect( - # self, fwd_links, bwd_links, chain_siso, replace_sws_flags - # ) - - fg = Graph(self) - - must_link_fwd = [True] * len(fwd_links) - left_chained = [] - - if chain_siso and not len(bwd_links): - # if linking chains are both siso and free of any other linkages and both pads are not labeled - # the chain of the right fg is joined to the chain of the left - - left = fgb.as_filtergraph(left, copy=True) - - # chain links if there is no ambiguity - for i, (outpad, inpad) in enumerate(fwd_links): - ochain, ichain = outpad[0], inpad[0] - - # label check - if ( - fg.is_chain_siso( - ichain, check_input=True, check_output=False, check_link=False - ) - and not fg._links.are_linked(inpad, None) - and left.is_chain_siso( - ochain, check_input=False, check_output=True, check_link=True - ) - ): - # add the right chain to the matching left chain - left_chain = left[ochain] - fg[ichain].data = [*left_chain, *fg[ichain]] - - label = fg._links.find_inpad_label(inpad) - if label: - fg._links.remove_label(label) - - fg._links.adjust_filters(ichain, 0, len(left_chain)) - - # mark already connected - must_link_fwd[i] = False - left_chained.append(ochain) - - # stack the remaining chains - if len(bwd_links) or any(must_link_fwd): - fg = left._connect( - fg, - [link for link, do_link in zip(fwd_links, must_link_fwd) if do_link], - bwd_links, - chain_siso=False, - replace_sws_flags=replace_sws_flags, - ) - - return fg + return fgb.as_filtergraph(left)._connect( + self, fwd_links, bwd_links, chain_siso, replace_sws_flags + ) def _iter_io_pads(self, is_input, how, ignore_labels=False): """Iterates input/output pads of the filtergraph @@ -1209,10 +1263,7 @@ def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified input pad index is available""" # check linked indices - if any( - link[1] == index - for link in self._links.iter_links(include_input_stream=True) - ): + if self._links.are_linked(inpad=index, outpad=None, check_input_stream=True): # already connected return False @@ -1223,7 +1274,7 @@ def _output_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified output pad index is available""" # check linked indices - if any(link[2] == index for link in self._links.iter_links()): + if self._links.are_linked(outpad=index, inpad=None): # already connected return False diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index ec640f62..d1c7f534 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -255,7 +255,7 @@ def link( if not (in_label or out_label): # new label, resolve - label = self._resolve_label(label, force) + label = self.resolve_label(label, force) # create the new link (overwrite if forced) self.data[label] = (inpad, outpad) @@ -304,11 +304,13 @@ def _refresh_autolabels(self): for id in range(new_id + 1, old_id + 1): del self.data[id] - def _resolve_label( + def resolve_label( self, label: str | int | None, force: bool = False, check_stream_spec: bool = True, + auto_index: bool = False, + auto_index_sep: str = "", ) -> str | int: """check the label name for duplicate, adjust as needed @@ -316,6 +318,10 @@ def _resolve_label( is ignored and replaced with the autonumbering label :param force: True to allow overwrite an existing label, defaults to False :param check_stream_spec: False to skip stream spec check, defaults to True + :param auto_index: True to append a number to a string label until a unique + label is found, defaults to False to error out. + :param auto_index_sep: a string to separate the label and the auto-index number, + defaults to '' :return: validated label name/id """ @@ -329,12 +335,106 @@ def _resolve_label( return label if not force and label in self: - raise GraphLinks.Error(f"{label=} is already in use.") + if not auto_index: + raise GraphLinks.Error(f"{label=} is already in use.") + i = 0 + label_ = f"{label}{auto_index_sep}" + while label in self: + i += 1 + label = f"{label_}{i}" self.validate_label(label) return label + @staticmethod + def duplicates( + *link_objs: tuple[GraphLinks | None, ...], + ) -> dict[str | int, list[tuple[int, str]]]: + """re-label the duplicate label names of multiple ``GraphLink`` objects + + :param link_objs: ``GraphLink``s objects to be re-labeled. ``None`` elements + are ignored + :return: copies of ``link_objs`` with relabeled ``GraphLink``s + """ + + # accumulate all the labels (remove trailing numbers if exist to match) + labels: dict[str | int, list[tuple[int, str]]] = {} + regexp = re.compile(r"\d+$") + for i, obj in enumerate(link_objs): + if obj is None: + continue + for label in obj: + key = label + if isinstance(key, str): + m = regexp.search(key) + if m: + key = key[: m.start()] + + if key in labels: + labels[key].append((i, label)) + else: + labels[key] = [(i, label)] + + return {k: v for k, v in labels.items() if len(v) > 1} + + @staticmethod + def relabel_duplicates( + *link_objs: tuple[GraphLinks | None, ...], + ) -> tuple[GraphLinks | None, ...]: + """re-label the duplicate label names of multiple ``GraphLink`` objects + + :param link_objs: ``GraphLink``s objects to be re-labeled. ``None`` elements + are ignored + :return: copies of ``link_objs`` with relabeled ``GraphLink``s + """ + + # accumulate all the labels (remove trailing numbers if exist to match) + labels: dict[str | int, list[tuple[int, str]]] = {} + regexp = re.compile(r"\d+$") + for i, obj in enumerate(link_objs): + if obj is None: + continue + for label in obj: + key = label + if isinstance(key, str): + m = regexp.search(key) + if m: + key = key[: m.start()] + + if key in labels: + labels[key].append((i, label)) + else: + labels[key] = [(i, label)] + + # copy the link objects + new_links = [obj or GraphLinks(obj) for obj in link_objs] + + # generate new labels for duplicated labels + int_counter = 0 + for key, matches in labels.items(): + if isinstance(key, int): + # integer label (auto-labels) + for i, old_label in matches: + new_label = int_counter + int_counter += 1 + if new_label != old_label: + obj = new_links[i] + obj[new_label] = obj.pop(old_label) + else: + # user label's + if len(matches) == 1: + # unique, keep + continue + + for j, (i, old_label) in enumerate(matches): + new_label = f"{key}{j}" + if new_label != old_label: + obj = new_links[i] + obj[new_label] = obj.pop(old_label) + + return new_links + def __getitem__(self, key: str | int) -> PAD_PAIR: """get link item by label or by inpad pad id tuple @@ -661,7 +761,7 @@ def create_label( is_stspec = is_map_option(label, allow_missing_file_id=True) if not is_stspec: - label = self._resolve_label(label, force=force, check_stream_spec=False) + label = self.resolve_label(label, force=force, check_stream_spec=False) label_in_use = label in self @@ -765,7 +865,7 @@ def rename(self, old_label: str, new_label: str, force: bool = False) -> str: :return: renamed label name """ v = self.data[old_label] - label = self._resolve_label(new_label, force) + label = self.resolve_label(new_label, force) del self.data[old_label] self.data[label] = v return label @@ -897,21 +997,55 @@ def adj(pid): self._modify_pad_ids(select, adj) def map_chains( - self, mapper: int | Mapping[int:int], validate_new: bool = True + self, + mapper: int | Mapping[int, int] | None, + shifter: Mapping[int, int] | None = None, ) -> GraphLinks: """Generate a new GraphLink object with a chain id mapper - :param mapper: the current chain id as a key and the new chain id as its value + :param mapper: the current chain id as a key and the new chain id as its + value. If an int value, all the chains are offset by the value. + :param shifter: keyed chain links are shifted by the given value if specified + + Note: if a chain is indexed in both `mapper` and `shifter`, its links + are first shifted then mapped. + """ + if shifter is not None and len(shifter): + + def shift_padidx(pad): + if pad[0] in shifter: + pad = (pad[0], pad[1] + shifter[pad[0]], pad[2]) + return pad + + def shift_pair(inpads, outpad): + if outpad is not None: + outpad = shift_padidx(outpad) + if inpads is not None: + if isinstance(inpads[0], int): # single-input + inpads = shift_padidx(inpads) + else: # multiple-inputs (an input stream) + inpads = tuple(shift_padidx(d) for d in inpads) + return (inpads, outpad) + + data = {label: shift_pair(*value) for label, value in self.items()} + else: + data = self.data + # check for duplicate value if isinstance(mapper, int): class OffsetMapper: + nmap = len(self) + def __init__(self, offset): self._off = offset + def __len__(self): + return self.nmap + def __contains__(self, _): # applies to all return True @@ -923,41 +1057,28 @@ def get(self, k, defaults=None): return k + self._off mapper = OffsetMapper(mapper) - elif validate_new: - new_ids = sorted(set(mapper.values())) - if len(new_ids) != len(mapper): - raise ValueError("Values of mapper must have no duplicate.") - if new_ids != list(range(len(new_ids))): - raise ValueError( - "Values of mapper must be values between 0 and len(mapper)." - ) - def adjust_pair(inpads, outpad): - if outpad is not None: - if outpad[0] not in mapper: - return None - outpad = (mapper[outpad[0]], *outpad[1:]) - if inpads is not None: - if isinstance(inpads[0], int): # single-input - if inpads[0] not in mapper: - return None - inpads = (mapper[inpads[0]], *inpads[1:]) - else: # multiple-inputs (an input stream) - inpads = tuple( - (cid, *d[1:]) - for d in inpads - if ((cid := mapper.get(d[0], None)) is not None) - ) - if not len(inpads): - return None - return (inpads, outpad) + if mapper is not None and len(mapper): + + def map_padidx(pad): + if pad[0] in mapper: + pad = (mapper[pad[0]], *pad[1:]) + return pad + + def adjust_pair(inpads, outpad): + if outpad is not None: + outpad = map_padidx(outpad) + if inpads is not None: + if isinstance(inpads[0], int): # single-input + inpads = map_padidx(inpads) + else: # multiple-inputs (an input stream) + inpads = tuple(map_padidx(d) for d in inpads) + return (inpads, outpad) + + data = {label: adjust_pair(*value) for label, value in data.items()} fglinks = GraphLinks() - fglinks.data = { - label: pair - for label, value in self.data.items() - if (pair := adjust_pair(*value)) is not None - } + fglinks.data = data return fglinks def drop_labels(self, labels: Sequence[str], keep_links: bool = True) -> GraphLinks: @@ -971,7 +1092,7 @@ def drop_labels(self, labels: Sequence[str], keep_links: bool = True) -> GraphLi def keep(k): if isinstance(k, str) and k in labels: if keep_links and self.is_linked(k): - return self._resolve_label(None) + return self.resolve_label(None) return None else: return k diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index 20978dde..be35ef1e 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -120,6 +120,7 @@ __all__ = [ "abc", + "presets", "as_filter", "as_filterchain", "as_filtergraph", diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index cf0f17dd..65c707ae 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -13,6 +13,16 @@ class FilterGraphObject(ABC): + @staticmethod + def relabel_duplicates(*fgs: tuple[FilterGraphObject]): ... + + def relabel(self, labels: dict[str | int, str | int]): ... + + @property + def links(self) -> GraphLinks | None: + """filtergraph link definition only if filtergraph""" + return None + def get_num_pads(self, input: bool) -> int: """get the number of available pads at input or output @@ -266,7 +276,9 @@ def _get_label(self, input: bool, index: PAD_INDEX): return None @abstractmethod - def normalize_pad_index(self, input: bool, index: PAD_INDEX) -> PAD_INDEX: + def normalize_pad_index( + self, input: bool, index: PAD_INDEX + ) -> tuple[int, int, int]: """normalize pad index. Returns three-element pad index with non-negative indices. @@ -660,8 +672,28 @@ def compose( :param show_unconnected_inputs: display [UNC#] on all unconnected input pads, defaults to True :param show_unconnected_outputs: display [UNC#] on all unconnected output pads, defaults to True + """ + # def __eq__(self, value: FilterGraphObject | str) -> bool: + def __eq__(self, value: object) -> bool: + + try: + value = fgb.convert.as_filtergraph_object_like(value, self) + except Exception: + return False + + return super().__eq__(value) + + # def __ne__(self, value: FilterGraphObject | str) -> bool: + def __ne__(self, value: object) -> bool: + try: + value = fgb.convert.as_filtergraph_object_like(value, self) + except Exception: + return True + + return super().__ne__(value) + def __str__(self) -> str: return self.compose(False, False) diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index ca45fd8f..68ce005b 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -410,6 +410,11 @@ def stack( if len(fgs) == 1: return fgb.as_filtergraph_object(fgs[0], copy=True) + # re-label the links + for fg, links in zip(fgs, GraphLinks.relabel_duplicates([fg.links for fg in fgs])): + if links is not None: + fg._links = links + fg = fgb.as_filtergraph(fgs[0], copy=not inplace) if n == 1: diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 110ccc50..0e4dba4e 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -6,6 +6,7 @@ from .. import filtergraph as fgb from .._typing import TYPE_CHECKING, Any, Literal, Sequence +from ..path import check_version from ..stream_spec import StreamSpecDict if TYPE_CHECKING: @@ -13,18 +14,53 @@ from .Graph import Graph -def remove_video_alpha( - fill_color: str, input_label: str | None = None, output_label: str | None = None +def remove_alpha( + fill_color: str, + pix_fmt: str | None = None, + *, + input_label: str | None = None, + output_label: str | None = None, ) -> Graph: + """generate a filter graph to remove alpha channel from a video - fg = fgb.Graph("scale2ref[l2],[l2]overlay=shortest=1").rconnect( - f"color=c={fill_color}", (0, 0, 0), (0, 0, 0) - ) + :param fill_color: _description_ + :param input_label: _description_, defaults to None + :param output_label: _description_, defaults to None + :return: Resulting filter graph in the form: + + ``` + color,[in]scale2ref[main],[main]overlay[out] + ``` + + """ + + if input_label is None: + input_label = "in" + if output_label is None: + output_label = "out" + + if check_version("7.1.0", "<"): + expr = f"color=c={fill_color}[cout],[cout]scale2ref[l2],[l2]overlay=shortest=1" + inpad = (0, 1, 1) + outpad = (0, 2, 0) + else: + expr = ( + "split[in1][in2];" + f"color=c={fill_color}[cout];" + "[cout][in1]scale=rw:rh[sout];" + "[sout][in2]overlay=shortest=1" + ) + inpad = (0, 0, 0) + outpad = (3, 0, 0) + + fg = fgb.Graph(expr) + + if pix_fmt is not None: + fg += fgb.format(pix_fmts=pix_fmt) + outpad = (outpad[0], outpad[1] + 1, 0) - if input_label is not None: - fg.add_label(input_label, (1, 0, 1)) - if output_label is not None: - fg.add_label(output_label, outpad=(1, 1, 0)) + fg.add_label(input_label, inpad) + fg.add_label(output_label, outpad=outpad) return fg @@ -34,26 +70,9 @@ def filter_video_basic( 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, ) -> Chain: vfilters = [] - if square_pixels == "upscale": - vfilters.append("scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1") - elif square_pixels == "downscale": - vfilters.append("scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1") - elif square_pixels == "upscale_even": - vfilters.append( - "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1" - ) - elif square_pixels == "downscale_even": - vfilters.append( - "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1" - ) - elif square_pixels is not None: - raise ValueError(f"unknown `square_pixels` option value given: {square_pixels}") if crop: try: @@ -93,6 +112,30 @@ def filter_video_basic( return sum(vfilters, start=fgb.Chain()) +def square_pixels( + mode: Literal["upscale", "downscale", "upscale_even", "downscale_even"], +) -> Chain: + """a filter chain to square pixels of video frames + + :param mode: whether to 'upscale' by preserving the long side and elongating + the short side or 'downscale' by preserving the short side and + shrinking the long side. Both modes can be made to force an even + numbered frame size to accommodate video codecs like h264. + :return: a chain of `scale` and `setsar` filters + """ + try: + expr = { + "upscale": "scale='max(iw,ih*dar)':'max(iw/dar,ih)':eval=init,setsar=1/1", + "downscale": "scale='min(iw,ih*dar)':'min(iw/dar,ih)':eval=init,setsar=1/1", + "upscale_even": "scale='trunc(max(iw,ih*dar)/2)*2':'trunc(max(iw/dar,ih)/2)*2':eval=init,setsar=1/1", + "downscale_even": "scale='trunc(min(iw,ih*dar)/2)*2':'trunc(min(iw/dar,ih)/2)*2':eval=init,setsar=1/1", + }[mode] + except KeyError as e: + raise ValueError(f"unknown mode: {mode}") from e + + return fgb.Chain(expr) + + def merge_audio( streams: dict[StreamSpecDict, dict[str, Any]], output_ar: int | None = None, diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py index 684274cb..9b225456 100644 --- a/src/ffmpegio/typing.py +++ b/src/ffmpegio/typing.py @@ -3,8 +3,10 @@ from __future__ import annotations from typing import * + from typing_extensions import * from ._typing import * - - +from .configure import FFmpegArgs, FFmpegUrlType +from .filtergraph.abc import FilterGraphObject +from .stream_spec import StreamSpecDict, StreamSpecStreamType diff --git a/src/ffmpegio/utils/log.py b/src/ffmpegio/utils/log.py index d18d5a38..8b6fde3f 100644 --- a/src/ffmpegio/utils/log.py +++ b/src/ffmpegio/utils/log.py @@ -1,8 +1,7 @@ import re from fractions import Fraction -from .. import utils -from .._typing import RawStreamInfoTuple, Sequence +from .._typing import Sequence from ..caps import sample_fmts from . import layout_to_channels diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index 3be99e26..da7563f8 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -275,14 +275,14 @@ def test_resolve_pad_index( "trim", None, None, - "[UNC0]split=2[C][L0];[C]crop[UNC1];[L0]trim[UNC2]", + "[UNC0]split=2[C],trim[UNC1];[C]crop[UNC2]", ), ( "split=2[C][out];[C]crop", "trim", "out", None, - "[UNC0]split=2[C][L0];[C]crop[UNC1];[L0]trim[UNC2]", + "[UNC0]split=2[C],trim[UNC1];[C]crop[UNC2]", ), ], ) @@ -302,21 +302,11 @@ def test_attach(fg, fc, left_on, right_on, out): # 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]", - ), + ("fps;crop", "trim", (1, 0, 0), "[UNC0]trim,crop[UNC2];[UNC1]fps[UNC3]"), + ("fps;[in]crop", "trim", "in", "[UNC0]trim,crop[UNC2];[UNC1]fps[UNC3]"), + ("[L]fps;crop[L]", "trim", None, "[UNC0]trim,crop[L];[L]fps[UNC1]"), + ("[C]overlay;crop[C]", "trim", None, "[UNC0]trim,[C]overlay[UNC2];[UNC1]crop[C]"), + ("[C][in]overlay;crop[C]", "trim", "in", "[UNC0]trim,[C]overlay[UNC2];[UNC1]crop[C]"), # fmt: on ], ) @@ -442,30 +432,9 @@ def test_get_output_pad(fg, id, out): "fg, r, to_l,to_r,chain, out", [ # fmt: off - ( - "[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]", - ), + ("[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[la1]"), + ("[a1]fps;crop[b]", "[c]trim;scale[d1]", ['b'], ['c'], True, "[a1]fps[UNC2];[UNC0]crop,trim[UNC3];[UNC1]scale[d1]"), # fmt: on ], ) @@ -484,21 +453,9 @@ def test_connect(fg, r, to_l, to_r, chain, out): "fg, r, how, unlabeled_only, out", [ # fmt: off - ( - "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[ou1];[in2]trim[UNC1];[L0]scale[out2]", - ), - ("fps", "overlay", "per_chain", False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), + ("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,scale[out2];[UNC0]crop[ou1];[in2]trim[UNC1]"), + ("fps", "overlay", 'per_chain', False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), # fmt: on ], ) diff --git a/tests/test_filtergraph_build.py b/tests/test_filtergraph_build.py index aa496bd2..85e7f522 100644 --- a/tests/test_filtergraph_build.py +++ b/tests/test_filtergraph_build.py @@ -6,42 +6,14 @@ "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]", - ), + ("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,[0:v]vstack[out]'), + ("scale", "[in1][0:v]vstack[out]",(0,0,0),(0,0,0),True,'[UNC0]scale[L0];[L0][0:v]vstack[out]'), # fmt: on ], ) @@ -56,64 +28,16 @@ def test_connect(left, right, from_left, to_right, chain_siso, ret): "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]", - ), + ("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]'), + ("[vin1]scale;[vin2]split","vstack[vout1];trim[vout2]",'all',0,False,False,'[vin1]scale[L0];[vin2]split[L1],trim[vout2];[L0][L1]vstack[vout1]'), + ("[vin]scale;[ain]asplit","vstack[vout];atrim[aout]",'per_chain',0,False,False,'[vin]scale[L0];[ain]asplit[L1][UNC1];[L0][UNC0]vstack[vout];[L1]atrim[aout]'), + ("[vin]scale;[ain]asplit","vstack[vout]",'all',0,False,False,'[vin]scale[L0];[ain]asplit[L1][UNC0];[L0][L1]vstack[vout]'), + ("[vin]scale;[ain]asplit","vstack[vout]",'all',0,True,False,None), + ("split[out]","[in]vstack",'all',0,False,True,'[UNC0]split[out],[in]vstack[UNC1]'), # fmt: on ], ) @@ -148,3 +72,13 @@ def test_attach(left, right, left_on, right_on, ret): else: fg = fgb.attach(left, right, left_on, right_on) assert fg.compose() == ret + + +def test_join_bug(): + af1 = fgb.Chain("aevalsrc,aformat") + af2 = fgb.Graph("channelmap,bandpass,aresample") + af3 = fgb.Chain("channelmap,bandpass,aresample") + af_a = af1 + af2 + af_b = af1 + af3 + + assert af_a==af_b \ No newline at end of file diff --git a/tests/test_filtergraph_chain.py b/tests/test_filtergraph_chain.py index 113f6106..2a83f207 100644 --- a/tests/test_filtergraph_chain.py +++ b/tests/test_filtergraph_chain.py @@ -163,90 +163,20 @@ def test_iter_chains(expr, skip_if_no_input, skip_if_no_output, chainable_only, "op, lhs,rhs,expected", [ # fmt:off - ( - 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.__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,[L0]hstack[UNC2]"), + (operator.__rshift__, fgb.Chain("split"), ["[v1]","[v2]"], "[UNC0]split[v1][v2]"), # (operator.__rshift__, fgb.Graph("split[out1][out2]"), ('[out1]', '[over]', "[base][over]overlay"), "split[out1][out2];[base][out1]overlay"), # fmt:on ], diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index c227d3b7..ea2b993f 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -2,9 +2,10 @@ logging.basicConfig(level=logging.INFO) -from ffmpegio.filtergraph.GraphLinks import GraphLinks import pytest +from ffmpegio.filtergraph.GraphLinks import GraphLinks + @pytest.mark.parametrize( ("dsts", "expects"), @@ -155,7 +156,7 @@ def test_resolve_label(labels, expects): links = GraphLinks() def update(label): - links.data[links._resolve_label(label)] = None + links.data[links.resolve_label(label)] = None for label in labels: update(label) diff --git a/tests/test_filtergraph_presets.py b/tests/test_filtergraph_presets.py index 696b8e69..eec73e1c 100644 --- a/tests/test_filtergraph_presets.py +++ b/tests/test_filtergraph_presets.py @@ -7,7 +7,7 @@ "kwargs", [ dict(crop=None, flip=None, transpose=None), - dict(scale=1.2, crop=100, flip="both", transpose=90, square_pixels="upscale"), + dict(scale=1.2, crop=100, flip="both", transpose=90), ], ) def test_video_basic_filter(kwargs): @@ -21,5 +21,5 @@ def test_video_basic_filter(kwargs): {"fill_color": "red", "input_label": "in", "output_label": "[out]"}, ], ) -def test_remove_video_alpha(kwargs): - print(presets.remove_video_alpha(**kwargs)) +def test_remove_alpha(kwargs): + print(presets.remove_alpha(**kwargs)) From 55a4d5bbf3b431238baf7225d7430f63403f2850 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 15 Feb 2026 00:26:59 -0600 Subject: [PATCH 324/333] wip 2 - _stack & _connect updated --- src/ffmpegio/_utils.py | 39 --- src/ffmpegio/filtergraph/Chain.py | 40 ++- src/ffmpegio/filtergraph/Filter.py | 39 +-- src/ffmpegio/filtergraph/Graph.py | 294 ++++++++++----------- src/ffmpegio/filtergraph/GraphLinks.py | 340 ++++++++++++++++++------- src/ffmpegio/filtergraph/abc.py | 63 +++-- src/ffmpegio/filtergraph/build.py | 4 +- 7 files changed, 488 insertions(+), 331 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 3e711e24..47fc6a13 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -20,45 +20,6 @@ prod = lambda seq: reduce(mul, seq, 1) -from builtins import zip as builtin_zip - - -def zip(*args, strict=False): - - # backwards compatibility for pre-py3.10 - - try: - return builtin_zip(*args, strict=strict) - except TypeError: - if strict is False: - return builtin_zip(*args) - - def strict_zip(): - # strict=True case, excerpted from PEP618: https://peps.python.org/pep-0618/ - iterators = tuple(iter(iterable) for iterable in args) - try: - while True: - items = [] - for iterator in iterators: - items.append(next(iterator)) - yield tuple(items) - except StopIteration: - pass - - if items: - i = len(items) - plural = " " if i == 1 else "s 1-" - msg = f"zip() argument {i + 1} is shorter than argument{plural}{i}" - raise ValueError(msg) - sentinel = object() - for i, iterator in enumerate(iterators[1:], 1): - if next(iterator, sentinel) is not sentinel: - plural = " " if i == 1 else "s 1-" - msg = f"zip() argument {i + 1} is longer than argument{plural}{i}" - raise ValueError(msg) - - return strict_zip() - def is_non_str_sequence( value: Any, class_excluded: type | tuple[type, ...] = str diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index 665b278c..eb795451 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -510,23 +510,37 @@ def _rconnect( def _stack( self, - other: fgb.abc.FilterGraphObject, + *others: tuple[fgb.abc.FilterGraphObject | str], 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) + replace_sws_flags: bool | int | None = None, + ) -> tuple[fgb.Graph, list[int], list[tuple[str | int, str | int]]]: + """stack filtergraphs and also return the configuration + + :param others: other filtergraphs to be stacked under in the order + appeared + :param auto_link: True to connect matched I/O labels, defaults to None + :param replace_sws_flags: Defines how to set ``sws_flags``: + + * ``True``: to use the first ``sws_flags`` found among the + filtergraphs, chosen in the order of appearance + * ``False``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :return fg: new filtergraph object + :return new_chain_ids: new chain ids of ``others`` input filtergraphs + :return new_link_lookup: new labels of each ``others`` entry keyed by + their old labels. + """ - # 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.Graph([self])._stack( + *others, auto_link=auto_link, replace_sws_flags=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""" diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index c2e2e70a..b12ed7f2 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -819,18 +819,31 @@ def _rconnect( def _stack( self, - other: fgb.abc.FilterGraphObject, + *others: tuple[fgb.abc.FilterGraphObject | str], auto_link: bool = False, replace_sws_flags: bool | None = None, - ) -> fgb.Graph: - """stack another Graph to this Graph + ) -> tuple[fgb.Graph, list[int], list[tuple[str | int, str | int]]]: + """stack filtergraphs and also return the configuration - :param other: other filtergraph + :param others: other filtergraphs to be stacked under in the order + appeared :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 + :param replace_sws_flags: Defines how to set ``sws_flags``: + + * ``True``: to use the first ``sws_flags`` found among the + filtergraphs, chosen in the order of appearance + * ``False``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :return fg: new filtergraph object + :return new_chain_ids: new chain ids of ``others`` input filtergraphs + :return new_link_lookup: new labels of each ``others`` entry keyed by + their old labels. Remarks ------- @@ -841,14 +854,8 @@ def _stack( 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 - ) + return fgb.Graph([[self]])._stack( + *others, auto_link=auto_link, replace_sws_flags=replace_sws_flags ) def apply(self, options, filter_id=None): diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index c4016c8e..a8f276c5 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Generator, Sequence from contextlib import contextmanager from copy import deepcopy -from itertools import chain +from itertools import accumulate, chain from math import floor, log10 from tempfile import NamedTemporaryFile @@ -919,18 +919,32 @@ def is_chain_appendable(self, chain_id: int) -> bool: def _stack( self, - other: fgb.abc.FilterGraphObject, + *others: tuple[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 + replace_sws_flags: bool | int | None = None, + ) -> tuple[fgb.Graph, list[int], list[dict[tuple[int, str | int], str | int]]]: + """stack filtergraphs and also return the configuration + + :param others: other filtergraphs to be stacked under in the order + appeared + :param auto_link: ``True`` to connect matched I/O labels, defaults to + ``False`` + :param replace_sws_flags: Defines how to set ``sws_flags``: + + * ``True``: to use the first ``sws_flags`` found among the + filtergraphs, chosen in the order of appearance + * ``False``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :return fg: new filtergraph object + :return new_chain_ids: new chain ids of ``others`` input filtergraphs + :return new_link_mapping: mapping a pair of ``link_objs`` index and its + old label to its new labels in ``combined_link_obj``. Remarks ------- @@ -941,48 +955,64 @@ def _stack( 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( - "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)), auto_link=auto_link - ) - except Exception as e: - if auto_link: - raise - else: - raise Graph.Error(e) from e + if len(others) == 0: + return self.copy() - fg.data.extend(other) + fgs: list[fgb.abc.FilterGraphObject] = [ + self, + *(fgb.as_filtergraph_object(fg) for fg in others), + ] + old_links = [fg.links for fg in fgs] + chain_offsets = accumulate(fg.get_num_chains() for fg in fgs) + new_links, link_mappings = GraphLinks.combine(old_links, chain_offsets) + + # if requested, link input and output pads with a matching label + if auto_link: + pairs = GraphLinks.pair_unconnected_labels(old_links) + for label, in_fg, out_fg in pairs: + in_label = link_mappings[(in_fg, label)] + out_label = link_mappings[(out_fg, label)] + new_links.link_by_labels(in_label, out_label) + if len(pairs): + new_links = new_links.relabel() + + # pick sws_flags + if replace_sws_flags is False or ( + replace_sws_flags is None and self.sws_flags is not None + ): + sws_flags = self.sws_flags + elif isinstance(replace_sws_flags, int): + fg = fgs[replace_sws_flags] + sws_flags = fg.sws_flags if isinstance(fg, Graph) else None else: - # if other is not filtergraph, copy and append the new chain - fg = Graph(self) - fg.append(other) + # find the first fg with sws_flags + iter_sws_flags = ( + fg.sws_flags + for fg in fgs[1:] + if isinstance(fg, Graph) and fg.sws_flags is not None + ) + other_sws_flags = next(iter_sws_flags, None) + if replace_sws_flags is True: + sws_flags = other_sws_flags + elif next(iter_sws_flags, None) is not None: + raise Graph.Error( + f"{replace_sws_flags=} and more than 1 filtergraphs has sws_flags" + ) + if sws_flags is not None: + sws_flags = deepcopy(sws_flags) - return fg + return ( + Graph(chain(fg.iter_chains() for fg in fgs), new_links, sws_flags), + chain_offsets, + link_mappings, + ) def _connect( self, right: fgb.abc.FilterGraphObject, - fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], - bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + fwd_links: list[tuple[PAD_INDEX | str, PAD_INDEX | str]], + bwd_links: list[tuple[PAD_INDEX | str, PAD_INDEX | str]], chain_siso: bool = True, replace_sws_flags: bool | None = None, ) -> fgb.Graph: @@ -1010,118 +1040,88 @@ def _connect( # 3. add remaining fwd_links # 4. add bwd_links - fg = Graph(self) - - right_links = ( - GraphLinks(right._links) if isinstance(right, Graph) else GraphLinks(None) + new_fg, chain_offsets, new_link_mapping = self._stack( + right, replace_sws_flags=replace_sws_flags ) + new_links = new_fg.links - lut_shift = {} - lut_map = {} + chain_links = [] + stack_links = [] - # scan fwd_links: split fwd_links to be chained and stacked - fwd_chain_links = {} # keyed by input chain idx - fwd_stack_links = [] - for outpad, inpad in fwd_links: - link = ( - self.normalize_pad_index(False, outpad), - right.normalize_pad_index(True, inpad), - ) + for out_pad, in_pad in fwd_links: # self -> right + if isinstance(out_pad, str): + # convert to padidx + out_pad = new_links[new_link_mapping[(0, out_pad)]][1] + else: + # update the padidx + out_pad = self.normalize_pad_index(False, out_pad) + + if isinstance(in_pad, str): + # convert to padidx + in_pad = new_links[new_link_mapping[(1, in_pad)]][0] + else: + # update the padidx + in_pad = right.normalize_pad_index(True, in_pad) + in_pad = (in_pad[0] + chain_offsets[1], *in_pad[1:]) if ( chain_siso - and self._output_pad_is_chainable(link[0]) - and right._input_pad_is_chainable(link[1]) + and new_fg._output_pad_is_chainable(out_pad) + and new_fg._input_pad_is_chainable(in_pad) ): - # there should be only 1 link which is a chaining link for inpad (and also for outpad) - fwd_chain_links[link[1][0]] = link + chain_links.append(out_pad, in_pad) + else: + stack_links.append(out_pad, in_pad) + + for out_pad, in_pad in bwd_links: # right -> self + if isinstance(out_pad, str): + # convert to padidx + out_pad = new_links[new_link_mapping[(1, out_pad)]][1] + else: + # update the padidx + out_pad = right.normalize_pad_index(False, out_pad) + + if isinstance(in_pad, str): + # convert to padidx + in_pad = new_links[new_link_mapping[(1, in_pad)]][0] else: - fwd_stack_links.append(link) - - # drop labels currently exists on these pads - label = fg._links.find_outpad_label(outpad) - if label is not None: - assert isinstance(label, str) - fg._links.remove_label(label) - label = right_links.find_inpad_label(inpad) - if label is not None: - assert isinstance(label, str) - right_links.remove_label(label) - - # scan bwd_links - bwd_links_ = [] - for outpad, inpad in bwd_links: - link = ( - self.normalize_pad_index(False, outpad), - right.normalize_pad_index(True, inpad), + # update the padidx + in_pad = self.normalize_pad_index(True, in_pad) + in_pad = (in_pad[0] + chain_offsets[1], *in_pad[1:]) + + if ( + chain_siso + and new_fg._output_pad_is_chainable(out_pad) + and new_fg._input_pad_is_chainable(in_pad) + ): + chain_links.append(out_pad, in_pad) + else: + stack_links.append(out_pad, in_pad) + + # connect non-chaining links + for out_pad, in_pad in stack_links: + new_links.link(in_pad, out_pad) + + # combine chains if any chain_links specified + if len(chain_links): + new_fg._combine_chains( + [(out_pad[0], in_pad[0]) for out_pad, in_pad in chain_links] ) - bwd_links_.append(link) - - # drop labels currently exists on these pads - label = right_links.find_outpad_label(outpad) - if label is not None: - assert isinstance(label, str) - right_links.remove_label(label) - label = fg._links.find_inpad_label(inpad) - if label is not None: - assert isinstance(label, str) - fg._links.remove_label(label) - - # stack/chain the chains of the right filtergraph to the left fg - n0 = len(fg) # chain index offset - for i, c in right.iter_chains(): - if i in fwd_chain_links: - op, ip = fwd_chain_links[i] - - # all the links on this chain gets mapped to outpad's chain - # and shifted by the length of the chain before chaining - lut_map[ip[0]] = op[0] - lut_shift[ip[0]] = len(fg[op[0]]) - - # chain - fg[op[0]].extend(c) - - else: # stack - # map the right links to the new chain - lut_map[i] = n0 - # increment the chain counter - n0 += 1 - # stack the new chain - fg = fg._stack(c) - - # map the remainig right links to the new fg - right_links = right_links.map_chains(lut_map, lut_shift) - - # make sure labels don't collide - right_links = { - fg._links.resolve_label(label, auto_index=True): link - for label, link in right_links.items() - } - - # transfer the right links to fg (remap chains) - fg._links.update(right_links) - - # add the new links in (input, output) of the combined graph - def adjust_right_pad(pad): - c = pad[0] - if c in lut_shift: - pad = (pad[0], pad[1] + lut_shift[c], pad[2]) - if c in lut_map: - pad = (lut_map[c], *pad[1:]) - return pad - - it_fwd = tuple((adjust_right_pad(r), l) for l, r in fwd_stack_links) - it_bwd = tuple((l, adjust_right_pad(r)) for r, l in bwd_links) - fg._links.update( - {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, - validate=False, - ) - # if commanded, use the right sws flags as the output sws flags - if replace_sws_flags and isinstance(right, Graph) and right.sws_flags: - fg.sws_flags = right.sws_flags + return new_fg + + def _combine_chains(self, chain_pairs: list[tuple[int, int]]): + + # sort chain_pairs in descending order of trailing chain + chain_pairs = sorted(chain_pairs, key=lambda cp: cp[1], reverse=True) + + for cid_out, cid_in in chain_pairs: + # fix the existing links + self._links.combine_chains(cid_out, cid_in, len(self[cid_out])) - return fg + # move the trailing chain to the end of the leading chain + fc = self.pop(cid_in) + self[cid_out].append(fc) def _rconnect( self, diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index d1c7f534..f47937a4 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -1,12 +1,13 @@ from __future__ import annotations import re -from collections import UserDict +from collections import UserDict, defaultdict from collections.abc import Callable, Generator, Mapping, Sequence +from copy import deepcopy from ..errors import FFmpegioError from ..stream_spec import is_map_option -from .typing import PAD_INDEX, PAD_PAIR, Literal +from .typing import PAD_INDEX, PAD_PAIR, Literal, cast """ @@ -262,6 +263,58 @@ def link( return label + def link_by_labels( + self, + in_label: str, + out_label: str, + label: str | None = None, + preserve_label: Literal[False, "input", "output"] = False, + ) -> str | int: + """set a filtergraph link from outpad to inpad + + :param in_label: input pad label + :param out_label: output pad label + :param label: desired new 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. + :return: assigned label of the created link. Unnamed links gets a + unique integer value assigned to it. + + """ + + if not self.is_input(in_label, exclude_stream_specs=True): + raise ValueError(f"{in_label=} is not a valid input label.") + if not self.is_output(out_label): + raise ValueError(f"{out_label=} is not a valid output label.") + + link_value = (self[in_label][0], self[out_label][1]) + + linked = label is None + if linked: + if preserve_label == "input": + label = in_label + self[label] = link_value + del self[out_label] + elif preserve_label == "output": + label = out_label + self[label] = link_value + del self[in_label] + elif preserve_label is False: + label = self.resolve_label(None) + linked = False + else: + raise ValueError(f"{preserve_label=} is not a valid value.") + else: + self.validate_label(label) + + if not linked: + self[label] = link_value + del self[in_label] + del self[out_label] + + return label + def unlink(self, label=None, inpad=None, outpad=None): """unlink specified links @@ -348,93 +401,175 @@ def resolve_label( return label @staticmethod - def duplicates( - *link_objs: tuple[GraphLinks | None, ...], - ) -> dict[str | int, list[tuple[int, str]]]: - """re-label the duplicate label names of multiple ``GraphLink`` objects - - :param link_objs: ``GraphLink``s objects to be re-labeled. ``None`` elements - are ignored - :return: copies of ``link_objs`` with relabeled ``GraphLink``s + def combine( + link_objs: Sequence[GraphLinks | None], cumsum_chains: Sequence[int] + ) -> tuple[GraphLinks, list[dict[tuple[int, str | int], str | int]]]: + """combine ``GraphLinks`` objects into one, resolving duplicate labels + + :params link_objs: ``GraphLinks`` objects to be combined. If ``None``, + the entry is ignored. + :param cumsum_chains: cumulative sum of the number of chains of the + filtergraphs that are associated with ``link_objs`` + :return combined_link_obj: a new ``GraphLinks`` object of all the links + combined. Input streams are not linked, and they are returned + separately as the third output below. + :return mapping: mapping a pair of ``link_objs`` index and its old label + to its new labels in ``combined_link_obj``. """ # accumulate all the labels (remove trailing numbers if exist to match) - labels: dict[str | int, list[tuple[int, str]]] = {} + labels: dict[str | int, list[tuple[int, str]]] = defaultdict(list) + input_streams: dict[str, list[int]] = defaultdict(list) regexp = re.compile(r"\d+$") - for i, obj in enumerate(link_objs): + for i, (obj, cid0) in enumerate(zip(link_objs, cumsum_chains)): if obj is None: continue - for label in obj: + links = cast(GraphLinks, obj) + for label in links: key = label if isinstance(key, str): - m = regexp.search(key) - if m: - key = key[: m.start()] - - if key in labels: - labels[key].append((i, label)) - else: - labels[key] = [(i, label)] - - return {k: v for k, v in labels.items() if len(v) > 1} + if links.is_input_stream(label): + # update the connected input pads + input_streams[label].append( + [ + (cid + cid0, fid, pid) + for cid, fid, pid in links[label][0] + ] + ) + continue + else: + m = regexp.search(key) + if m: + key = key[: m.start()] + + labels[key].append((i, label)) + + # create mapping table + # - generate new labels for duplicated labels + mappings = [obj and {} for obj in link_objs] + int_counter = 0 + for key, matches in labels.items(): + if isinstance(key, int): + # auto-labels (auto-label over all internal links) + for i, old_label in matches: + new_label = int_counter + int_counter += 1 + mappings[i][old_label] = new_label + else: + # explicit labels (append a unique suffix number) + for j, (i, old_label) in enumerate(matches): + new_label = f"{key}{j}" + mappings[i][old_label] = new_label - @staticmethod - def relabel_duplicates( - *link_objs: tuple[GraphLinks | None, ...], - ) -> tuple[GraphLinks | None, ...]: - """re-label the duplicate label names of multiple ``GraphLink`` objects - - :param link_objs: ``GraphLink``s objects to be re-labeled. ``None`` elements - are ignored - :return: copies of ``link_objs`` with relabeled ``GraphLink``s + # create the combined object + combined = GraphLinks() + for obj, cid0 in zip(link_objs, cumsum_chains): + if obj is None: + continue + links = cast(GraphLinks, obj) + for label, (in_pad, out_pad) in links.items(): + in_pad = (in_pad[0] + cid0, *in_pad[1:]) + out_pad = (out_pad[0] + cid0, *out_pad[1:]) + combined[mappings[label]] = (in_pad, out_pad) + + # add the input streams with the updated pad indices + for label, in_pads in input_streams.items(): + combined.create_label(label, in_pads) + + return combined, mappings + + def relabel(self) -> GraphLinks: + """relabel ``GraphLinks`` + :return: a new ``GraphLinks`` object of all the internal int labels + renumbered as well as the trailing numbers of user labels """ # accumulate all the labels (remove trailing numbers if exist to match) - labels: dict[str | int, list[tuple[int, str]]] = {} + labels: dict[str | int, list[str | int]] = defaultdict(list) regexp = re.compile(r"\d+$") - for i, obj in enumerate(link_objs): - if obj is None: - continue - for label in obj: - key = label - if isinstance(key, str): - m = regexp.search(key) - if m: - key = key[: m.start()] - if key in labels: - labels[key].append((i, label)) - else: - labels[key] = [(i, label)] + for label in self: + key = label + if isinstance(key, str): + m = regexp.search(key) + if m: + key = key[: m.start()] - # copy the link objects - new_links = [obj or GraphLinks(obj) for obj in link_objs] + labels[key].append(label) - # generate new labels for duplicated labels + # create mapping table + # - generate new labels for duplicated labels + mappings = {} int_counter = 0 for key, matches in labels.items(): if isinstance(key, int): - # integer label (auto-labels) - for i, old_label in matches: + # auto-labels (auto-label over all internal links) + for old_label in matches: new_label = int_counter int_counter += 1 - if new_label != old_label: - obj = new_links[i] - obj[new_label] = obj.pop(old_label) + mappings[old_label] = new_label else: - # user label's - if len(matches) == 1: - # unique, keep - continue - - for j, (i, old_label) in enumerate(matches): + # explicit labels (append a unique suffix number) + for j, old_label in enumerate(matches): new_label = f"{key}{j}" - if new_label != old_label: - obj = new_links[i] - obj[new_label] = obj.pop(old_label) + mappings[old_label] = new_label + + # create the combined object + new_links = GraphLinks() + for label, link in self.items(): + new_links[mappings[label]] = deepcopy(link) return new_links + @staticmethod + def pair_unconnected_labels( + link_objs: Sequence[GraphLinks | None], + ) -> list[tuple[str, int, int]]: + """pair matched input and output labels and gather matched input streams + + :params link_objs: ``GraphLinks`` objects to be combined. If ``None``, + the entry is ignored. + :return combined_link_obj: a new ``GraphLinks`` object of all links + combined + :return: a list of tuples ``(label, in_index, out_index)`` of the pairs. + ``in_index`` and ``out_index`` are indices to ``link_objs``. + + Note + ---- + + A pairing is only returned if and only if one-to-one match is found. If + a label is used in 3 or more inputs or 3 or more outputs, those ports + will not be reported as pairs. + + """ + + # accumulate all the labels (remove trailing numbers if exist to match) + input_labels = defaultdict(list) + output_labels = defaultdict(list) + for i, obj in enumerate(link_objs): + if obj is None: + continue + links = cast(GraphLinks, obj) + + for label in links: + if isinstance(label, int): + continue + if links.is_input(label, exclude_stream_specs=True): + input_labels[label].append(i) + elif links.is_output(label): + output_labels[label].append(i) + + # remove duplicate labels + input_labels = {k: v[0] for k, v in input_labels.items() if len(v) == 1} + output_labels = {k: v[0] for k, v in output_labels.items() if len(v) == 1} + + # return the matched input & output + return [ + (label, in_obj, output_labels[label]) + for label, in_obj in input_labels.items() + if label in output_labels + ] + def __getitem__(self, key: str | int) -> PAD_PAIR: """get link item by label or by inpad pad id tuple @@ -461,37 +596,48 @@ def __setitem__(self, key: str | int, value: PAD_PAIR): else: self.link(value[0], value[1], label=key, force=True) - def is_linked(self, label): + def is_linked(self, label: str) -> bool: """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): + def is_input(self, label: str, exclude_stream_specs: bool = False) -> bool: """True if label specifies an input :param label: link label - :type label: str - :return: True if label is an input - :rtype: bool + :param exclude_stream_specs: ``True`` to return ``False`` if the label + is an input stream spec. + :return: ``True`` if label is an input + """ + lnk = self.data.get(label, None) + return ( + lnk + and lnk[1] is None + and (not exclude_stream_specs or isinstance(lnk[0], str)) + ) + + def is_input_stream(self, label: str) -> bool: + """``True`` if label specifies an input stream map + + :param label: input stream map specifier + :param exclude_stream_specs: ``True`` to return ``False`` if the label + is an input stream spec. + :return: ``True`` if label is an input """ lnk = self.data.get(label, None) - return lnk and lnk[1] is None + return lnk and lnk[1] is None and not isinstance(lnk[0], str) - def is_output(self, label): - """True if label specifies an output + def is_output(self, label: str) -> bool: + """``True`` if label specifies an output :param label: link label - :type label: str - :return: True if label is an output - :rtype: bool + :return: ``True`` if label is an output If multi-inpad label, True if any inpad is None """ @@ -1081,24 +1227,24 @@ def adjust_pair(inpads, outpad): fglinks.data = data 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 + def combine_chains(self, cid_out: int, cid_in: int, n_out: int): + for label, (inpad, outpad) in self.items(): + if isinstance(inpad, tuple): + if inpad[0] == cid_in: + inpad = (cid_out, inpad[1] + n_out, inpad[2]) + elif inpad[0] > cid_in: + inpad = (inpad[0] - 1, *inpad[1:]) + elif isinstance(inpad, list): + inpad = [ + (cid_out, pad[1] + n_out, pad[2]) + if pad[0] == cid_in + else (pad[0] - 1, *pad[1:]) + if pad[0] > cid_in + else pad + for pad in inpad + ] + if outpad[0] == cid_in: + outpad = (cid_out, outpad[1] + n_out, outpad[2]) + elif outpad[0] > cid_in: + outpad = (outpad[0] - 1, *outpad[1:]) + self[label] = (inpad, outpad) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 65c707ae..a34a02f6 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -4,7 +4,6 @@ from collections.abc import Generator, Sequence from .. import filtergraph as fgb -from .._utils import zip # pre-py310 compatibility from .exceptions import * from .GraphLinks import GraphLinks from .typing import JOIN_HOW, PAD_INDEX, Literal @@ -624,17 +623,28 @@ def rattach( def stack( self, - other: fgb.abc.FilterGraphObject | str, - auto_link: bool = False, - replace_sws_flags: bool | None = None, + *others: tuple[fgb.abc.FilterGraphObject | str], + auto_link: bool | int = False, + replace_sws_flags: bool | int = False, ) -> fgb.Graph: - """stack another Graph to this Graph + """stack filtergraph objects + + :param others: other filtergraphs to be stacked under in the order + appeared + :param auto_link: ``True`` to connect matched I/O labels, defaults to + ``None`` + :param replace_sws_flags: Defines how to set ``sws_flags``: + + * ``True``: to use the first ``sws_flags`` found among the + filtergraphs, chosen in the order of appearance + * ``False``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. - :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 @@ -646,18 +656,37 @@ def stack( 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) + return self._stack(others, auto_link, replace_sws_flags)[0] @abstractmethod def _stack( self, - other: fgb.abc.FilterGraphObject, + *others: tuple[fgb.abc.FilterGraphObject | str], auto_link: bool = False, - replace_sws_flags: bool | None = None, - ) -> fgb.Graph: - """stack another Graph to this Graph (no var check)""" + replace_sws_flags: bool | int | None = None, + ) -> tuple[fgb.Graph, list[int], list[tuple[str | int, str | int]]]: + """stack filtergraphs and also return the configuration + + :param others: other filtergraphs to be stacked under in the order + appeared + :param auto_link: True to connect matched I/O labels, defaults to None + :param replace_sws_flags: Defines how to set ``sws_flags``: + + * ``True``: to use the first ``sws_flags`` found among the + filtergraphs, chosen in the order of appearance + * ``False``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :return fg: new filtergraph object + :return new_chain_ids: new chain ids of ``others`` input filtergraphs + :return new_link_lookup: new labels of each ``others`` entry keyed by + their old labels. + """ @abstractmethod def __getitem__(self, key): ... diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 68ce005b..b52d48d7 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -3,7 +3,6 @@ from copy import copy from .. import filtergraph as fgb -from .._utils import zip # pre-py310 compatibility from .exceptions import FFmpegioError, FiltergraphInvalidExpression from .GraphLinks import GraphLinks from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args @@ -376,7 +375,7 @@ def concatenate(*fgs): def stack( *fgs: fgb.abc.FilterGraphObject, auto_link: bool = False, - use_last_sws_flags: bool | None = None, + use_last_sws_flags: bool | int | None = None, inplace: bool = False, ) -> fgb.Graph: """stack filtergraph objects @@ -411,6 +410,7 @@ def stack( return fgb.as_filtergraph_object(fgs[0], copy=True) # re-label the links + new_links, label_map = GraphLinks.combine([fg.links for fg in fgs]) for fg, links in zip(fgs, GraphLinks.relabel_duplicates([fg.links for fg in fgs])): if links is not None: fg._links = links From a9f6f3a46330a62d2e36f9a3cf5ed2381e184675 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 15 Feb 2026 22:04:56 -0600 Subject: [PATCH 325/333] wip3 - working on FilterGraphObject.attach() --- src/ffmpegio/filtergraph/Chain.py | 205 ++++---- src/ffmpegio/filtergraph/Filter.py | 181 ++++--- src/ffmpegio/filtergraph/Graph.py | 422 +++++++++++----- src/ffmpegio/filtergraph/GraphLinks.py | 13 + src/ffmpegio/filtergraph/abc.py | 640 ++++++++++++++++++------- src/ffmpegio/filtergraph/build.py | 243 ++++------ 6 files changed, 1057 insertions(+), 647 deletions(-) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index eb795451..ba3ed1d5 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -2,7 +2,6 @@ from collections import UserList from collections.abc import Callable, Generator, Sequence -from itertools import chain from .. import filtergraph as fgb from . import utils as filter_utils @@ -33,10 +32,11 @@ def __init__(self, filter_specs=None): # convert str to a list of filter_specs if isinstance(filter_specs, fgb.Graph): - nchains = len(filter_specs) - if nchains != 1: - raise TypeError("Cannot convert a `Graph` object to a `Chain` object") - filter_specs = filter_specs[0] if nchains == 1 else "" + if not filter_specs.is_simple_chain(): + raise TypeError( + "Cannot convert a multi-chain or linked `Graph` object to a `Chain` object" + ) + filter_specs = filter_specs[0] if len(filter_specs) > 0 else "" if isinstance(filter_specs, fgb.Filter): filter_specs = [filter_specs] @@ -421,109 +421,78 @@ def iter_output_pads( else (index, filter, in_index) ) - def _connect( + def connect( self, right: fgb.abc.FilterGraphObject, - fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], - bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + 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: + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain | None: """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 fwd_links: a list of tuples, pairing self's output pad and right's input pad + :param bwd_links: a list of tuples, pairing right's output pad and self's input pad :param chain_siso: True to chain the single-input single-output connection, default: True - :param replace_sws_flags: True to use `right` sws_flags if present, - False to drop `right` sws_flags, - None to throw an exception (default) + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :param inplace: ``True`` to add the ``right`` graph in place. :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))}, + return self._connect( + fgb.Graph.connect, + right, + from_left, + to_right, + from_right, + to_left, + chain_siso, + sws_flags_policy, + inplace, ) - def _rconnect( + def rconnect( self, left: fgb.abc.FilterGraphObject, - fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], - bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + 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: - """combine another filtergraph object and make upstream connections (worker) + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain | None: + """combine another filtergraph object and make downstream 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 left: other filtergraph + :param fwd_links: a list of tuples, pairing self's output pad and right's input pad + :param bwd_links: a list of tuples, pairing right's output pad and self's input pad :param chain_siso: True to chain the single-input single-output connection, default: True - :param replace_sws_flags: True to use `right` sws_flags if present, - False to drop `right` sws_flags, - 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) + :param sws_flags_policy: Defines how to set ``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, *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, - *others: tuple[fgb.abc.FilterGraphObject | str], - auto_link: bool = False, - replace_sws_flags: bool | int | None = None, - ) -> tuple[fgb.Graph, list[int], list[tuple[str | int, str | int]]]: - """stack filtergraphs and also return the configuration - - :param others: other filtergraphs to be stacked under in the order - appeared - :param auto_link: True to connect matched I/O labels, defaults to None - :param replace_sws_flags: Defines how to set ``sws_flags``: - - * ``True``: to use the first ``sws_flags`` found among the - filtergraphs, chosen in the order of appearance - * ``False``: use this filtergraph's ``sws_flags`` (or none used if + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if not set). * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` refers to this object, ``1`` refers to ``others[0]``, etc. @@ -531,16 +500,66 @@ def _stack( ``FFmpegioError`` exception. Otherwise, it uses the only one found or none if none not found. - :return fg: new filtergraph object - :return new_chain_ids: new chain ids of ``others`` input filtergraphs - :return new_link_lookup: new labels of each ``others`` entry keyed by - their old labels. + :param inplace: ``True`` to add the ``right`` graph in place. + :return: new filtergraph object + + * link labels may be auto-renamed if there is a conflict + """ - return fgb.Graph([self])._stack( - *others, auto_link=auto_link, replace_sws_flags=replace_sws_flags + return self._connect( + fgb.Graph.rconnect, + left, + from_left, + to_right, + from_right, + to_left, + chain_siso, + sws_flags_policy, + inplace, ) + def _connect( + self, + graph_connect, # fgb.Graph.connect or fgb.Graph.rconnect + other: fgb.abc.FilterGraphObject, + 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, + to_left: PAD_INDEX | str | list[PAD_INDEX | str] | None, + chain_siso: bool, + sws_flags_policy: Literal["first", "last"] | int | None, + inplace: bool, + ) -> fgb.Graph | fgb.Chain | None: + """helper for connect and rconnect""" + + fg = graph_connect( + fgb.as_filtergraph(self), + other, + from_left, + to_right, + from_right=from_right, + to_left=to_left, + chain_siso=chain_siso, + sws_flags_policy=sws_flags_policy, + inplace=False, + ) + + if not inplace: + return fg[0] if fg.is_simple_chain() else fg + + if isinstance(fg, fgb.Chain): + self.clear() + self.extend(fg) + elif fg.is_simple_chain(): + self.clear() + if len(fg): + self.extend(fg[0]) + else: + raise ValueError( + "'inplace=True' but resulting filtergraph is not a simple chain." + ) + def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified input pad index is available""" diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index b12ed7f2..fd919203 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -3,7 +3,6 @@ import re from collections.abc import Generator, Sequence from functools import partial -from itertools import chain from .. import filtergraph as fgb from ..caps import FilterInfo, filter_info, layouts @@ -734,105 +733,87 @@ def iter_chains( ): yield (0, fgb.Chain([self])) - def _connect( + def connect( self, - right: fgb.abc.FilterGraphObject, - fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], - bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + 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: - """combine another filtergraph object and make downstream connections (worker) + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain | None: + """append another filtergraph object and make downstream connections - :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 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 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 + :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`` (default) to chain the connections instead + of stacking. ``False`` to append all the chains of ``right`` graphs. + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :param inplace: Must be ``False`` as the result is always a ``Graph``. + If ``True``, a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` * 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))}, + if inplace: + raise ValueError("Filter object cannot perform connect() with inplace=True") + + return fgb.as_filterchain(self).connect( + right, + from_left, + to_right=to_right, + from_right=from_right, + to_left=to_left, + chain_siso=chain_siso, + sws_flags_policy=sws_flags_policy, + inplace=False, ) - def _rconnect( + def rconnect( self, - left: fgb.abc.FilterGraphObject, - fwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], - bwd_links: list[tuple[PAD_INDEX, PAD_INDEX]], + 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: - """combine another filtergraph object and make upstream connections (worker) + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain | None: + """append another filtergraph object and make downstream connections - :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, - *others: tuple[fgb.abc.FilterGraphObject | str], - auto_link: bool = False, - replace_sws_flags: bool | None = None, - ) -> tuple[fgb.Graph, list[int], list[tuple[str | int, str | int]]]: - """stack filtergraphs and also return the configuration - - :param others: other filtergraphs to be stacked under in the order - appeared - :param auto_link: True to connect matched I/O labels, defaults to None - :param replace_sws_flags: Defines how to set ``sws_flags``: - - * ``True``: to use the first ``sws_flags`` found among the - filtergraphs, chosen in the order of appearance - * ``False``: use this filtergraph's ``sws_flags`` (or none used if + :param left: 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`` (default) to chain the connections instead + of stacking. ``False`` to append all the chains of ``right`` graphs. + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if not set). * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` refers to this object, ``1`` refers to ``others[0]``, etc. @@ -840,22 +821,26 @@ def _stack( ``FFmpegioError`` exception. Otherwise, it uses the only one found or none if none not found. - :return fg: new filtergraph object - :return new_chain_ids: new chain ids of ``others`` input filtergraphs - :return new_link_lookup: new labels of each ``others`` entry keyed by - their old labels. + :param inplace: Must be ``False`` as the result is always a ``Graph``. + If ``True``, a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` - 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. + * link labels may be auto-renamed if there is a conflict - TO-CHECK/TO-DO: what happens if common link labels are already linked """ - return fgb.Graph([[self]])._stack( - *others, auto_link=auto_link, replace_sws_flags=replace_sws_flags + if inplace: + raise ValueError("Filter object cannot perform connect() with inplace=True") + + return fgb.as_filterchain(self).rconnect( + left, + from_left, + to_right=to_right, + from_right=from_right, + to_left=to_left, + chain_siso=chain_siso, + sws_flags_policy=sws_flags_policy, + inplace=False, ) def apply(self, options, filter_id=None): diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index a8f276c5..5672ad9a 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -10,6 +10,7 @@ from tempfile import NamedTemporaryFile from .. import filtergraph as fgb +from ..errors import FFmpegioError from ..stream_spec import is_map_option from . import utils as filter_utils from .exceptions import * @@ -19,6 +20,63 @@ __all__ = ["Graph"] +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 + + class Graph(fgb.abc.FilterGraphObject, UserList): """List of FFmpeg filterchains in parallel with interchain link specifications @@ -142,6 +200,11 @@ def get_num_filters(self, chain: int | None = None) -> int: raise ValueError(f"{chain=} is invalid.") return len(self[chain]) + def is_simple_chain(self) -> bool: + """``True`` if the filtergraph is a simple chain""" + + return len(self) <= 1 and len(self.links) == 0 and self.sws_flags is None + def resolve_pad_index( self, index_or_label: PAD_INDEX | str | None, @@ -917,11 +980,12 @@ def is_chain_appendable(self, chain_id: int) -> bool: conn_to = self._links.output_dict().get(outpad) return conn_to is None or isinstance(conn_to, str) - def _stack( + def stack( self, - *others: tuple[fgb.abc.FilterGraphObject | str], + others: tuple[fgb.abc.FilterGraphObject | str], auto_link: bool = False, - replace_sws_flags: bool | int | None = None, + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, ) -> tuple[fgb.Graph, list[int], list[dict[tuple[int, str | int], str | int]]]: """stack filtergraphs and also return the configuration @@ -931,9 +995,9 @@ def _stack( ``False`` :param replace_sws_flags: Defines how to set ``sws_flags``: - * ``True``: to use the first ``sws_flags`` found among the - filtergraphs, chosen in the order of appearance - * ``False``: use this filtergraph's ``sws_flags`` (or none used if + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if not set). * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` refers to this object, ``1`` refers to ``others[0]``, etc. @@ -958,10 +1022,27 @@ def _stack( if len(others) == 0: return self.copy() - fgs: list[fgb.abc.FilterGraphObject] = [ - self, - *(fgb.as_filtergraph_object(fg) for fg in others), - ] + new_links, sws_flags, *_ = self._stack_analyze( + others, auto_link, sws_flags_policy + ) + + return self._stack_create(others, inplace, new_links, sws_flags) + + def _stack_analyze( + self, + others: tuple[fgb.abc.FilterGraphObject | str], + auto_link: bool, + sws_flags_policy: Literal["first", "last"] | int | None, + insert_at: int = 0, + ) -> tuple[ + GraphLinks, + Sequence[str] | None, + list[int], + list[dict[tuple[int, str | int], str | int]], + ]: + + fgs = list(others) + fgs.insert(insert_at, self) old_links = [fg.links for fg in fgs] chain_offsets = accumulate(fg.get_num_chains() for fg in fgs) @@ -978,43 +1059,72 @@ def _stack( new_links = new_links.relabel() # pick sws_flags - if replace_sws_flags is False or ( - replace_sws_flags is None and self.sws_flags is not None - ): - sws_flags = self.sws_flags - elif isinstance(replace_sws_flags, int): - fg = fgs[replace_sws_flags] + if isinstance(sws_flags_policy, int): + fg = fgs[sws_flags_policy] sws_flags = fg.sws_flags if isinstance(fg, Graph) else None else: - # find the first fg with sws_flags iter_sws_flags = ( fg.sws_flags - for fg in fgs[1:] + for fg in (fgs if sws_flags_policy != "last" else fgs[::-1]) if isinstance(fg, Graph) and fg.sws_flags is not None ) - other_sws_flags = next(iter_sws_flags, None) - if replace_sws_flags is True: - sws_flags = other_sws_flags - elif next(iter_sws_flags, None) is not None: + sws_flags = next(iter_sws_flags, None) + + if sws_flags_policy is None and next(iter_sws_flags, None) is not None: raise Graph.Error( - f"{replace_sws_flags=} and more than 1 filtergraphs has sws_flags" + f"{sws_flags_policy=} and more than 1 filtergraphs has sws_flags" ) if sws_flags is not None: sws_flags = deepcopy(sws_flags) - return ( - Graph(chain(fg.iter_chains() for fg in fgs), new_links, sws_flags), - chain_offsets, - link_mappings, - ) + return new_links, sws_flags, chain_offsets, link_mappings - def _connect( + def _stack_create( + self, + others: Sequence[fgb.abc.FilterGraphObject], + inplace: bool, + new_links: GraphLinks, + sws_flags: Sequence[str] | None, + insert_at: int = 0, + ) -> Graph: + + if inplace: + # extend self.data and replace its links and sws_flags + self.data = [ + *( + fgb.Chain(fc) + for fc in chain(*(fg.iter_chains() for fg in others[:insert_at])) + ), + *self.data, + *( + fgb.Chain(fc) + for fc in chain(*(fg.iter_chains() for fg in others[insert_at:])) + ), + ] + self._links = new_links + self.sws_flags = sws_flags + return self + else: + return Graph( + chain( + fg.iter_chains() + for fg in (*others[:insert_at], self, *others[insert_at:]) + ), + new_links, + sws_flags, + ) + + def connect( self, right: fgb.abc.FilterGraphObject, - fwd_links: list[tuple[PAD_INDEX | str, PAD_INDEX | str]], - bwd_links: list[tuple[PAD_INDEX | str, PAD_INDEX | 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, + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, ) -> fgb.Graph: """combine another filtergraph object and make downstream connections (worker) @@ -1022,81 +1132,167 @@ def _connect( :param fwd_links: a list of tuples, pairing self's output pad and right's input pad :param bwd_links: a list of tuples, pairing right's output pad and self's input pad :param chain_siso: True to chain the single-input single-output connection, default: True - :param replace_sws_flags: True to use `right` sws_flags if present, - False to drop `right` sws_flags, - None to throw an exception (default) + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :param inplace: ``True`` to add the ``right`` graph in place. :return: new filtergraph object * link labels may be auto-renamed if there is a conflict """ - # procedure outline - # 0. analyze fwd_links whether they can be chained or not - # 1. chain or stack each chain of the right filtergraph object - # - chain if there is a responsible fwd_link else stack - # - drop chained fwd_link from the list - # 2. if right is a Graph, add its links to the output fg with adjustments - # 3. add remaining fwd_links - # 4. add bwd_links - - new_fg, chain_offsets, new_link_mapping = self._stack( - right, replace_sws_flags=replace_sws_flags + return self._connect( + right, + from_left, + to_right, + from_right, + to_left, + chain_siso, + sws_flags_policy, + inplace, + 0, ) - new_links = new_fg.links - chain_links = [] - stack_links = [] + def rconnect( + self, + left: fgb.abc.FilterGraphObject, + 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, + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph: + """combine another filtergraph object and make upstream connections - for out_pad, in_pad in fwd_links: # self -> right - if isinstance(out_pad, str): - # convert to padidx - out_pad = new_links[new_link_mapping[(0, out_pad)]][1] - else: - # update the padidx - out_pad = self.normalize_pad_index(False, out_pad) + :param left: other filtergraph + :param fwd_links: a list of tuples, pairing self's output pad and right's input pad + :param bwd_links: a list of tuples, pairing right's output pad and self's input pad + :param chain_siso: True to chain the single-input single-output connection, default: True + :param sws_flags_policy: Defines how to set ``sws_flags``: - if isinstance(in_pad, str): - # convert to padidx - in_pad = new_links[new_link_mapping[(1, in_pad)]][0] - else: - # update the padidx - in_pad = right.normalize_pad_index(True, in_pad) - in_pad = (in_pad[0] + chain_offsets[1], *in_pad[1:]) + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. - if ( - chain_siso - and new_fg._output_pad_is_chainable(out_pad) - and new_fg._input_pad_is_chainable(in_pad) - ): - chain_links.append(out_pad, in_pad) - else: - stack_links.append(out_pad, in_pad) + :param inplace: ``True`` to add the ``right`` graph in place. + :return: new filtergraph object - for out_pad, in_pad in bwd_links: # right -> self - if isinstance(out_pad, str): - # convert to padidx - out_pad = new_links[new_link_mapping[(1, out_pad)]][1] - else: - # update the padidx - out_pad = right.normalize_pad_index(False, out_pad) + * link labels may be auto-renamed if there is a conflict - if isinstance(in_pad, str): - # convert to padidx - in_pad = new_links[new_link_mapping[(1, in_pad)]][0] + """ + + return self._connect( + left, + from_left, + to_right, + from_right, + to_left, + chain_siso, + sws_flags_policy, + inplace, + 1, + ) + + def _connect( + self, + other: fgb.abc.FilterGraphObject, + 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, + to_left: PAD_INDEX | str | list[PAD_INDEX | str] | None, + chain_siso: bool, + sws_flags_policy: Literal["first", "last"] | int | None, + inplace: bool, + insert_at: Literal[0, 1], # 0:connect, 1:rconnect + ) -> fgb.Graph: + """helper function for self.connect() & self.rconnect()""" + if not isinstance(from_left, list): + from_left = [from_left] + if not isinstance(to_right, list): + to_right = [to_right] + if len(from_left) != len(to_right): + raise ValueError("Mismatched number of pads in 'from_right' and 'to_left'") + + if from_right is None: + from_right = [] + elif not isinstance(from_right, list): + from_right = [from_right] + if to_left is None: + to_left = [] + elif not isinstance(to_left, list): + to_left = [to_left] + if len(from_right) != len(to_left): + raise ValueError("Mismatched number of pads in 'from_left' and 'to_right'") + + new_links, sws_flags, chain_offsets, link_mappings = self._stack_analyze( + [other], False, sws_flags_policy, insert_at + ) + + # convert the connecting pads to the new stacked graph to be created + + left, right = (self, other) if insert_at == 0 else (other, self) + + def get_pads( + fg: fgb.abc.FilterGraphObject, pad: PAD_INDEX | str, is_input: bool + ) -> tuple[int, int, int] | str: + + is_right = fg == right + if isinstance(pad, str): + pad = new_links[link_mappings[(is_right, pad)]][not is_input] else: - # update the padidx - in_pad = self.normalize_pad_index(True, in_pad) - in_pad = (in_pad[0] + chain_offsets[1], *in_pad[1:]) + pad = fg.normalize_pad_index(is_input, pad) + pad = (pad[0] + chain_offsets[is_right], *pad[1:]) + return pad + + out_pads = [ + get_pads(fg, pad, False) + for fg, pads in [(left, from_left), (right, from_right or [])] + for pad in pads + ] + in_pads = [ + get_pads(fg, pad, True) + for fg, pads in [(right, to_right), (left, to_left or [])] + for pad in pads + ] + + # create new graph (note: new_fg==self if inplace=True) + new_fg = self._stack_create([right], inplace, new_links, sws_flags, insert_at) + new_links = new_fg._links + + # pair the links (separate links to be chained instead) + + stack_links = [] + chain_links = [] + + for out_pad, in_pad in zip(out_pads, in_pads): # self -> right if ( chain_siso and new_fg._output_pad_is_chainable(out_pad) and new_fg._input_pad_is_chainable(in_pad) ): - chain_links.append(out_pad, in_pad) + chain_links.append((out_pad, in_pad)) else: - stack_links.append(out_pad, in_pad) + stack_links.append((out_pad, in_pad)) # connect non-chaining links for out_pad, in_pad in stack_links: @@ -1104,51 +1300,23 @@ def _connect( # combine chains if any chain_links specified if len(chain_links): - new_fg._combine_chains( - [(out_pad[0], in_pad[0]) for out_pad, in_pad in chain_links] + # sort chain_pairs in descending order of trailing chain + chain_pairs = sorted( + ((out_pad[0], in_pad[0]) for out_pad, in_pad in chain_links), + key=lambda cp: cp[1], + reverse=True, ) - return new_fg - - def _combine_chains(self, chain_pairs: list[tuple[int, int]]): - - # sort chain_pairs in descending order of trailing chain - chain_pairs = sorted(chain_pairs, key=lambda cp: cp[1], reverse=True) - - for cid_out, cid_in in chain_pairs: - # fix the existing links - self._links.combine_chains(cid_out, cid_in, len(self[cid_out])) - - # move the trailing chain to the end of the leading chain - fc = self.pop(cid_in) - self[cid_out].append(fc) - - 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) + # joining chains + for cid_out, cid_in in chain_pairs: + # fix the existing links + self._links.combine_chains(cid_out, cid_in, len(self[cid_out])) - :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 + # move the trailing chain to the end of the leading chain + fc = self.pop(cid_in) + self[cid_out].append(fc) - * 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 - ) + return new_fg def _iter_io_pads(self, is_input, how, ignore_labels=False): """Iterates input/output pads of the filtergraph diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index f47937a4..17bb9248 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -1228,6 +1228,19 @@ def adjust_pair(inpads, outpad): return fglinks def combine_chains(self, cid_out: int, cid_in: int, n_out: int): + """adjust pad indices as two chains are combined + + :param cid_out: id of the host chain + :param cid_in: id of the moving chain + :param n_out: number of filters of the host chain + + .. warning:: + this operation does not check for the existence of a link between the + last output pad of the last filter of the ``cid_out`` chain and the + last input pad of the first filter of the ``cid_in`` chain. The + caller must remove such link if it exists. + + """ for label, (inpad, outpad) in self.items(): if isinstance(inpad, tuple): if inpad[0] == cid_in: diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index a34a02f6..c630a942 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -363,64 +363,63 @@ def remove_label(self, label: str, inpad: PAD_INDEX | None = None): stream, defaults to `None` to delete all input pads. """ - @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 + ### main graph manipulation routines - * link labels may be auto-renamed if there is a conflict + def stack( + self, + *others: tuple[fgb.abc.FilterGraphObject | str], + auto_link: bool | int = False, + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | None: + """stack filtergraph objects - """ + :param others: other filtergraphs to be stacked under in the order + appeared + :param auto_link: ``True`` to connect matched I/O labels, defaults to + ``None`` + :param sws_flags_policy: Defines how to set ``sws_flags``: - @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) + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. - :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) + :param inplace: Must be ``False`` as the result is always a ``Graph``. + If ``True``, a ``ValueError` exception will be raised. :return: new filtergraph object - * link labels may be auto-renamed if there is a conflict + 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. """ + if inplace: + raise ValueError("Filter object cannot perform stack() with inplace=True") + + return fgb.as_filtergraph(self).stack(*others, auto_link, sws_flags_policy) + + @abstractmethod 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: + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain | None: """append another filtergraph object and make downstream connections :param right: receiving filtergraph object @@ -428,37 +427,42 @@ def connect( :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 + :param chain_siso: ``True`` (default) to chain the connections instead + of stacking. ``False`` to append all the chains of ``right`` graphs. + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` * 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, - ) - + @abstractmethod 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: + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain | None: """append another filtergraph object and make upstream connections :param left: transmitting filtergraph object @@ -467,125 +471,252 @@ def rconnect( :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 + :param chain_siso: ``True`` (default) to chain the connections instead + of stacking. ``False`` to append all the chains of ``right`` graphs. + :param replace_sws_flags: Defines how to set ``sws_flags``: + + * ``True``: to use the first ``sws_flags`` found among the + filtergraphs, chosen in the order of appearance + * ``False``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` * link labels may be auto-renamed if there is a conflict """ - return fgb.connect( - left, - self, - from_left, - to_right, - from_right, - to_left, - chain_siso, - replace_sws_flags, - ) - def join( self, right: fgb.abc.FilterGraphObject | str, - how: JOIN_HOW = "per_chain", - n_links: int | Literal["all"] = "all", + *, + 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: + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain: """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'``. + - ``'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) + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + :return: Graph with the appended filter chains or None if inplace=True. """ - return fgb.join( + right = fgb.as_filtergraph_object(right) + + # if one of the filtergraphs is empty, return the other (or a copy thereof) + if right.get_num_filters() == 0: + return None if inplace else self.copy() + if self.get_num_filters() == 0: + if inplace: + self.__init__(right) + return + else: + return right.copy() + + from_left, to_right = self._join_analyze( self, right, how, n_links, strict, unlabeled_only, - chain_siso, - replace_sws_flags, + ) + + return self.connect( + right, + from_left=from_left, + to_right=to_right, + chain_siso=chain_siso, + sws_flags_policy=sws_flags_policy, + inplace=inplace, ) def rjoin( self, left: fgb.abc.FilterGraphObject | str, - how: JOIN_HOW = "per_chain", - n_links: int | Literal["all"] = "all", + *, + 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: + sws_flags_policy: Literal["first", "last"] | int | None = None, + inplace: bool = False, + ) -> fgb.Graph | fgb.Chain: """filtergraph auto-connector - :param left: transmitting filtergraph object + :param left: feeding 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'``. + - ``'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) + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + :return: Graph with the appended filter chains or None if inplace=True. """ - return fgb.join( + left = fgb.as_filtergraph_object(left) + + # if one of the filtergraphs is empty, return the other (or a copy thereof) + if left.get_num_filters() == 0: + return None if inplace else self.copy() + if self.get_num_filters() == 0: + if inplace: + self.__init__(left) + return + else: + return left.copy() + + from_left, to_right = self._join_analyze( + left, self, how, n_links, strict, unlabeled_only + ) + + return self.rconnect( left, - self, - how, - n_links, - strict, - unlabeled_only, - chain_siso, - replace_sws_flags, + from_left=from_left, + to_right=to_right, + chain_siso=chain_siso, + sws_flags_policy=sws_flags_policy, + inplace=inplace, ) + @staticmethod + def _join_analyze( + left: FilterGraphObject, + right: FilterGraphObject, + how: JOIN_HOW | None, + n_links: int | Literal["all"] | None, + strict: bool, + unlabeled_only: bool, + ): + 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 not a valid option") + + # handle joining empty graph + nright = right.get_num_chains() + nleft = left.get_num_chains() + + 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 + + from_left = [] + to_right = [] + + if how in ("per_chain", "auto") and nright == nleft: + try: + from_left = [ + next(left.iter_output_pads(chain=c, **iter_kws))[0] + for c in range(nleft) + ] + to_right = [ + next(right.iter_input_pads(chain=c, **iter_kws))[0] + for c in range(nleft) + ] + + except StopIteration: + if how == "auto": + how = "all" + else: + raise + + if how in ("all", "chainable") or nright != nleft: + left_pads = [out[0] for out in left.iter_output_pads(**iter_kws)] + right_pads = [out[0] for out in right.iter_input_pads(**iter_kws)] + + nleft, nright = len(left_pads), len(right_pads) + if strict and nleft != nright: + raise FFmpegioError( + "`[stict=True] number of unconnected pads must match." + ) + n_max = min(nleft, nright) + n_links = n_max if n_links <= 0 else min(n_links, n_max) + + from_left = left_pads[:n_links] + to_right = right_pads[:n_links] + return from_left, to_right + 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, + inplace: bool = False, ) -> 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 + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` One and only one of ``left`` or ``right`` may be a list or a label. @@ -595,6 +726,112 @@ def attach( """ + if not isinstance(right, list): + right = [right] + + objs = [] + labels = [] + for obj in right: + try: + objs.append(fgb.as_filtergraph_object(obj)) + except FiltergraphInvalidExpression: + if isinstance(obj, str): + labels.append(obj) + else: + raise ValueError( + f"{type(right)} could not be converted to a filtergraph object or a label string." + ) + + 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 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 fgb.as_filtergraph_object( + left_objs_labels, copy=not inplace + )._attach(right_objs_labels, left_on, right_on) + else: + return fgb.as_filtergraph_object( + right_objs_labels, copy=not inplace + )._rattach(left_objs_labels, left_on, right_on) + return fgb.attach(self, right, left_on, right_on) def rattach( @@ -602,6 +839,7 @@ def rattach( 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, + inplace: bool = False, ) -> fgb.Graph: """attach filter(s), chain(s), or label(s) to a filtergraph object @@ -609,7 +847,10 @@ def rattach( :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 + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` One and only one of ``left`` or ``right`` may be a list or a label. @@ -621,72 +862,133 @@ def rattach( return fgb.attach(left, self, left_on, right_on) - def stack( - self, - *others: tuple[fgb.abc.FilterGraphObject | str], - auto_link: bool | int = False, - replace_sws_flags: bool | int = False, - ) -> fgb.Graph: - """stack filtergraph objects - - :param others: other filtergraphs to be stacked under in the order - appeared - :param auto_link: ``True`` to connect matched I/O labels, defaults to - ``None`` - :param replace_sws_flags: Defines how to set ``sws_flags``: - - * ``True``: to use the first ``sws_flags`` found among the - filtergraphs, chosen in the order of appearance - * ``False``: use this filtergraph's ``sws_flags`` (or none used if - not set). - * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` - refers to this object, ``1`` refers to ``others[0]``, etc. - * ``None``: if more than one have the ``sws_flags`` set, raises - ``FFmpegioError`` exception. Otherwise, it uses the only one found - or none if none not found. + @staticmethod + def _attach_analyze( + left: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], + right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], + left_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None, + right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None, + inplace: bool, + ) -> fgb.Graph | fgb.Chain: + """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 filtergraph object, filtergraph expression, or label, or list thereof. + :param left_on: pad_index, specify the pad on left, default to None (first available) + :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) :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. + 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. - TO-CHECK/TO-DO: what happens if common link labels are already linked """ - return self._stack(others, auto_link, replace_sws_flags)[0] + def check_obj(obj): + try: + obj_label = fgb.as_filtergraph_object(obj, copy=not inplace) + 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 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." + ) - @abstractmethod - def _stack( - self, - *others: tuple[fgb.abc.FilterGraphObject | str], - auto_link: bool = False, - replace_sws_flags: bool | int | None = None, - ) -> tuple[fgb.Graph, list[int], list[tuple[str | int, str | int]]]: - """stack filtergraphs and also return the configuration + 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 + ) - :param others: other filtergraphs to be stacked under in the order - appeared - :param auto_link: True to connect matched I/O labels, defaults to None - :param replace_sws_flags: Defines how to set ``sws_flags``: + # 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) + ] - * ``True``: to use the first ``sws_flags`` found among the - filtergraphs, chosen in the order of appearance - * ``False``: use this filtergraph's ``sws_flags`` (or none used if - not set). - * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` - refers to this object, ``1`` refers to ``others[0]``, etc. - * ``None``: if more than one have the ``sws_flags`` set, raises - ``FFmpegioError`` exception. Otherwise, it uses the only one found - or none if none not found. + return base_indices, branch_indices - :return fg: new filtergraph object - :return new_chain_ids: new chain ids of ``others`` input filtergraphs - :return new_link_lookup: new labels of each ``others`` entry keyed by - their old labels. - """ + 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 fgb.as_filtergraph_object( + left_objs_labels, copy=not inplace + )._attach(right_objs_labels, left_on, right_on) + else: + return fgb.as_filtergraph_object( + right_objs_labels, copy=not inplace + )._rattach(left_objs_labels, left_on, right_on) + + ### main graph manipulation routines @abstractmethod def __getitem__(self, key): ... diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index b52d48d7..8b9285df 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -4,67 +4,44 @@ from .. import filtergraph as fgb from .exceptions import FFmpegioError, FiltergraphInvalidExpression -from .GraphLinks import GraphLinks from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args -__all__ = ["connect", "join", "attach", "stack", "concatenate"] +__all__ = ["connect", "join", "attach", "stack"] -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 +def stack( + *fgs: fgb.abc.FilterGraphObject, + auto_link: bool = False, + sws_flags_policy: Literal["first", "last"] | int | None = None, +) -> fgb.Graph: + """stack filtergraph objects - :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. - """ + :param fgs: filtergraph objects + :param auto_link: ``True`` to connect matched I/O labels, defaults to None + :param sws_flags_policy: Defines how to set ``sws_flags``: - # 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." - ) + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. - 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." - ) + :return: a new filtergraph object - # 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 - ] + Remarks + ------- + + - If `auto-link=False`, duplicate labels may be renamed with unique trailing + digits. + - For more explicit linking rather than the auto-linking, use `connect()` instead. + + """ - return fwd_links, bwd_links + return fgs[0].stack(*fgs[1:], auto_link, sws_flags_policy) def connect( @@ -72,26 +49,33 @@ def connect( 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, - inplace: bool = False, + sws_flags_policy: Literal["first", "last"] | int | 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 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 `ri`ght` 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) - :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). - False (default) to make a new filtergraph object + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + :return: new filtergraph object Notes @@ -101,26 +85,10 @@ def connect( """ - # make sure right is a Graph object - left = fgb.as_filtergraph_object(left, copy=not inplace) - right = fgb.as_filtergraph_object(right, copy=not inplace) - - # present as a list of pad indices - if not isinstance(from_left, list): - 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, from_left, to_right, from_right, to_left, chain_siso, sws_flags_policy ) - return left._connect(right, fwd_links, bwd_links, chain_siso, replace_sws_flags) - def join( left: fgb.abc.FilterGraphObject | str, @@ -130,31 +98,38 @@ def join( strict: bool = False, unlabeled_only: bool = False, chain_siso: bool = True, - replace_sws_flags: bool = None, - inplace: bool = False, -) -> fgb.Graph | None: + sws_flags_policy: Literal["first", "last"] | int | None = None, +) -> fgb.Graph | fgb.Chain: """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'``. + - ``'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) - :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). - False (default) to make a new filtergraph object + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + :return: Graph with the appended filter chains or None if inplace=True. """ @@ -250,16 +225,26 @@ def attach( right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], left_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, - inplace: bool = False, -) -> fgb.Graph: + sws_flags_policy: Literal["first", "last"] | int | None = None, +) -> fgb.Graph | fgb.Chain: """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 filtergraph object, filtergraph expression, or label, or list thereof. :param left_on: pad_index, specify the pad on left, default to None (first available) :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) - :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). - False (default) to make a new filtergraph object + :param sws_flags_policy: Defines how to set ``sws_flags``: + + * ``'first'``: to use the first ``sws_flags`` found among the + filtergraphs (searched ``self`` first then ``others``) + * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if + not set). + * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` + refers to this object, ``1`` refers to ``others[0]``, etc. + * ``None``: if more than one have the ``sws_flags`` set, raises + ``FFmpegioError`` exception. Otherwise, it uses the only one found + or none if none not found. + :return: new filtergraph object One and only one of ``left`` or ``right`` may be a list or a label. @@ -365,65 +350,3 @@ def resolve_indices(base, branches, base_indices, branch_indices, base_is_input) return fgb.as_filtergraph_object(right_objs_labels, copy=not inplace)._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 | int | None = None, - inplace: bool = False, -) -> 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) - :param inplace: True to connect right filtergraph object to left filtergraph object (or vice versa). - False (default) to make a new filtergraph object - :return: new filtergraph object - - Remarks - ------- - - 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 (fgb.as_filtergraph_object(fg1) for fg1 in fgs) - if fg.get_num_filters() - ] - n = len(fgs) - if not n: - return fgb.Graph() - if len(fgs) == 1: - return fgb.as_filtergraph_object(fgs[0], copy=True) - - # re-label the links - new_links, label_map = GraphLinks.combine([fg.links for fg in fgs]) - for fg, links in zip(fgs, GraphLinks.relabel_duplicates([fg.links for fg in fgs])): - if links is not None: - fg._links = links - - fg = fgb.as_filtergraph(fgs[0], copy=not inplace) - - if n == 1: - return fg - - 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(other, auto_link, replace_sws_flags) - - return fg From aaa7d28d0671559ec3cd3cd929b84b52800adbe7 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 16 Feb 2026 23:05:25 -0600 Subject: [PATCH 326/333] wip 4 - attach complete, test next --- pyproject.toml | 2 +- src/ffmpegio/filtergraph/Chain.py | 126 +++++++++++- src/ffmpegio/filtergraph/Filter.py | 91 +++++++++ src/ffmpegio/filtergraph/Graph.py | 187 +++++++++++++++++ src/ffmpegio/filtergraph/abc.py | 313 +++++------------------------ src/ffmpegio/filtergraph/build.py | 134 +----------- 6 files changed, 454 insertions(+), 399 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index eeda756d..e699ec38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,4 +49,4 @@ testpaths = ["tests"] # addopts = "-ra -q" [tool.ruff] -typing-modules = ["ffmpegio._typing"] \ No newline at end of file +typing-modules = ["ffmpegio._typing", "ffmpegio.filtergraph.typing"] \ No newline at end of file diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index ba3ed1d5..4722ee4d 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -5,7 +5,11 @@ from .. import filtergraph as fgb from . import utils as filter_utils -from .exceptions import * +from .exceptions import ( + FFmpegioError, + FiltergraphInvalidExpression, + FiltergraphInvalidIndex, +) from .typing import PAD_INDEX, Literal __all__ = ["Chain"] @@ -545,6 +549,9 @@ def _connect( inplace=False, ) + return self._convert_graph(inplace, fg) + + def _convert_graph(self, inplace, fg): if not inplace: return fg[0] if fg.is_simple_chain() else fg @@ -560,6 +567,123 @@ def _connect( "'inplace=True' but resulting filtergraph is not a simple chain." ) + def attach( + self, + right: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, + inplace: bool = False, + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available output pads + + :param right: output filtergraph or labels. If ``str``, the expression + is first attempted to be converted to a filtergraph object. If the + attempt fails, it is treated as a label. + :param left_on: pad_index, specify the output pad to connect ``right`` + to, defaults to auto-detect (first available) + :param right_on: pad index, specifies the input pad of ``right`` to + connect to the ``left_on`` pad, defaults to auto-detect (first + available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` + + """ + + if chainable_only == "auto": + chainable_only = True + + try: + assert left_on is None and right_on is None and chainable_only is True + right_obj = fgb.as_filterchain(right) + except (AssertionError, FiltergraphInvalidExpression): + return self._convert_graph( + inplace, + fgb.as_filtergraph(self).attach( + right, + left_on, + right_on, + chainable_only=chainable_only, + chain_siso=chain_siso, + inplace=False, + ), + ) + + left_pad = next(self.iter_output_pads(chainable_only=True)) + right_pad = next(right_obj.iter_input_pads(chainable_only=True)) + return self.connect( + right_obj, left_pad, right_pad, chain_siso=chain_siso, inplace=inplace + ) + + def rattach( + self, + left: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, + inplace: bool = False, + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available input pads + + :param left: input filtergraph or labels + :param left_on: pad_index, specify the output pad of ``left``, + defaults to auto-detect (first available) + :param right_on: pad index, specifies which input pad to connect + ``left`` to, defaults to auto-detect (first available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` + + """ + + if chainable_only == "auto": + chainable_only = True + + try: + assert left_on is None and right_on is None and chainable_only is True + left_obj = fgb.as_filterchain(left) + except (AssertionError, FiltergraphInvalidExpression): + return self._convert_graph( + inplace, + fgb.as_filtergraph(self).rattach( + left, + left_on, + right_on, + chainable_only=chainable_only, + chain_siso=chain_siso, + inplace=False, + ), + ) + + left_pad = next(left_obj.iter_output_pads(chainable_only=True)) + right_pad = next(self.iter_input_pads(chainable_only=True)) + return self.connect( + left_obj, left_pad, right_pad, chain_siso=chain_siso, inplace=inplace + ) + def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified input pad index is available""" diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index fd919203..d8f0a5f5 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -843,6 +843,97 @@ def rconnect( inplace=False, ) + def attach( + self, + right: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, + inplace: bool = False, + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available output pads + + :param right: output filtergraph or labels. If ``str``, the expression + is first attempted to be converted to a filtergraph object. If the + attempt fails, it is treated as a label. + :param left_on: pad_index, specify the output pad to connect ``right`` + to, defaults to auto-detect (first available) + :param right_on: pad index, specifies the input pad of ``right`` to + connect to the ``left_on`` pad, defaults to auto-detect (first + available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` + + """ + + if inplace: + raise ValueError("Filter object cannot perform connect() with inplace=True") + + return fgb.as_filterchain(self).attach( + right, + left_on, + right_on, + chainable_only=chainable_only, + chain_siso=chain_siso, + inplace=False, + ) + + def rattach( + self, + left: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, + inplace: bool = False, + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available input pads + + :param left: input filtergraph or labels + :param left_on: pad_index, specify the output pad of ``left``, + defaults to auto-detect (first available) + :param right_on: pad index, specifies which input pad to connect + ``left`` to, defaults to auto-detect (first available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` + + """ + + if inplace: + raise ValueError("Filter object cannot perform connect() with inplace=True") + + return fgb.as_filterchain(self).rattach( + left, + left_on, + right_on, + chainable_only=chainable_only, + chain_siso=chain_siso, + inplace=False, + ) + def apply(self, options, filter_id=None): """apply new filter options diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 5672ad9a..a0c48022 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -1318,6 +1318,193 @@ def get_pads( return new_fg + def attach( + self, + right: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, + inplace: bool = False, + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available output pads + + :param right: output filtergraph or labels. If ``str``, the expression + is first attempted to be converted to a filtergraph object. If the + attempt fails, it is treated as a label. + :param left_on: pad_index, specify the output pad to connect ``right`` + to, defaults to auto-detect (first available) + :param right_on: pad index, specifies the input pad of ``right`` to + connect to the ``left_on`` pad, defaults to auto-detect (first + available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` + + """ + + if chainable_only == "auto": + chainable_only = False + + try: + right_is_label = False + right_obj = fgb.as_filtergraph_object(right) + except FiltergraphInvalidExpression: + right_is_label = True + + # get output pads + left_pads = ( + list( + self.iter_output_pads( + chainable_only=chainable_only is True or chainable_only == "left", + full_pad_index=True, + ) + ) + if left_on is None + else [ + self.get_output_pad(pad)[0] + for pad in (left_on if isinstance(left_on, list) else [left_on]) + ] + ) + + # resolve the label attachment first + if right_is_label: + left = self if inplace else Graph(self) + + for pad, label in zip( + left_pads, right if isinstance(right, list) else [right] + ): + left.add_label(label, pad) + + return None if inplace else left + + # get pads to be connected + right_pads = ( + list( + right_obj.iter_input_pads( + chainable_only=chainable_only is True or chainable_only == "right", + full_pad_index=True, + ) + ) + if left_on is None + else [ + right_obj.get_input_pad(pad)[0] + for pad in (right_on if isinstance(right_on, list) else [right_on]) + ] + ) + + nconn = min(len(left_pads), len(right_pads)) + return self.connect( + right_obj, + left_pads[:nconn], + right_pads[:nconn], + inplce=inplace, + chain_siso=chain_siso, + sws_flags_policy="first", + ) + + def rattach( + self, + left: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, + inplace: bool = False, + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available input pads + + :param left: input filtergraph or labels + :param left_on: pad_index, specify the output pad of ``left``, + defaults to auto-detect (first available) + :param right_on: pad index, specifies which input pad to connect + ``left`` to, defaults to auto-detect (first available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. + :param inplace: ``True`` to store the output filtergraph in place. + If ``'inplace=True`` but the output is not of the same class type, + a ``ValueError` exception will be raised. + :return: new filtergraph object or ``None`` if ``inplace=True`` + + """ + + if chainable_only == "auto": + chainable_only = False + + try: + left_is_label = False + left_obj = fgb.as_filtergraph_object(left) + except FiltergraphInvalidExpression: + left_is_label = True + + # get output pads + right_pads = ( + list( + self.iter_input_pads( + chainable_only=chainable_only is True or chainable_only == "right", + full_pad_index=True, + ) + ) + if right_on is None + else [ + self.get_input_pad(pad)[0] + for pad in (right_on if isinstance(right_on, list) else [right_on]) + ] + ) + + # resolve the label attachment first + if left_is_label: + right = self if inplace else Graph(self) + + for pad, label in zip( + right_pads, left if isinstance(left, list) else [left] + ): + right.add_label(label, pad) + + return None if inplace else right + + # get pads to be connected + left_pads = ( + list( + left_obj.iter_output_pads( + chainable_only=chainable_only is True or chainable_only == "left", + full_pad_index=True, + ) + ) + if left_on is None + else [ + left_obj.get_input_pad(pad)[0] + for pad in (left_on if isinstance(left_on, list) else [left_on]) + ] + ) + + nconn = min(len(left_pads), len(right_pads)) + return self.rconnect( + left_obj, + left_pads[:nconn], + right_pads[:nconn], + inplce=inplace, + chain_siso=chain_siso, + sws_flags_policy="first", + ) + def _iter_io_pads(self, is_input, how, ignore_labels=False): """Iterates input/output pads of the filtergraph diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index c630a942..7f138513 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -596,7 +596,8 @@ def rjoin( 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 chain_siso: ``True`` to chain the single-input single-output + connection, default: True :param sws_flags_policy: Defines how to set ``sws_flags``: * ``'first'``: to use the first ``sws_flags`` found among the @@ -701,293 +702,77 @@ def _join_analyze( to_right = right_pads[:n_links] return from_left, to_right + @abstractmethod def attach( self, - right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], + right: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, inplace: bool = False, - ) -> 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) + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available output pads + + :param right: output filtergraph or labels. If ``str``, the expression + is first attempted to be converted to a filtergraph object. If the + attempt fails, it is treated as a label. + :param left_on: pad_index, specify the output pad to connect ``right`` + to, defaults to auto-detect (first available) + :param right_on: pad index, specifies the input pad of ``right`` to + connect to the ``left_on`` pad, defaults to auto-detect (first + available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. :param inplace: ``True`` to store the output filtergraph in place. If ``'inplace=True`` but the output is not of the same class type, a ``ValueError` exception will be raised. :return: new filtergraph object or ``None`` if ``inplace=True`` - 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. - """ - if not isinstance(right, list): - right = [right] - - objs = [] - labels = [] - for obj in right: - try: - objs.append(fgb.as_filtergraph_object(obj)) - except FiltergraphInvalidExpression: - if isinstance(obj, str): - labels.append(obj) - else: - raise ValueError( - f"{type(right)} could not be converted to a filtergraph object or a label string." - ) - - 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 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 fgb.as_filtergraph_object( - left_objs_labels, copy=not inplace - )._attach(right_objs_labels, left_on, right_on) - else: - return fgb.as_filtergraph_object( - right_objs_labels, copy=not inplace - )._rattach(left_objs_labels, left_on, right_on) - - return fgb.attach(self, right, left_on, right_on) - + @abstractmethod def rattach( self, - left: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], + left: fgb.abc.FilterGraphObject | str | list[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, + *, + chainable_only: bool | Literal["left", "right", "auto"] = "auto", + chain_siso: bool = True, inplace: bool = False, - ) -> fgb.Graph: - """attach filter(s), chain(s), or label(s) to a filtergraph object - - :param left: input filtergraph object, filtergraph expression, or label, or list thereof - :param right: output filterchain, filtergraph expression, or label, or list thereof - :param 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) + ) -> fgb.Chain | fgb.Graph: + """attach filter, chain, graph, or labels to available input pads + + :param left: input filtergraph or labels. If ``str``, the expression + is first attempted to be converted to a filtergraph object. If the + attempt fails, it is treated as a label. + :param left_on: pad_index, specify the output pad of ``left``, + defaults to auto-detect (first available) + :param right_on: pad index, specifies which input pad to connect + ``left`` to, defaults to auto-detect (first available) + :param chainable_only: ``True`` to limit auto-detecting ``left_on`` and + ``righ_on`` pads to be only those that can extend the existing + chains. To force this condition only on one side, use ``'left'`` or + ``'right'``. If ``"auto"`` (default) depends on this filtergraph + object type: ``Filter`` and ``Chain`` defaults to ``True`` while + ``Graph`` defaults to ``False`` + :param chain_siso: ``True`` (default) to chain the new connection, + ``False`` to stack attached filtergraph. :param inplace: ``True`` to store the output filtergraph in place. If ``'inplace=True`` but the output is not of the same class type, a ``ValueError` exception will be raised. :return: new filtergraph object or ``None`` if ``inplace=True`` - 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) - - @staticmethod - def _attach_analyze( - left: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], - right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], - left_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None, - right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None, - inplace: bool, - ) -> fgb.Graph | fgb.Chain: - """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 filtergraph object, filtergraph expression, or label, or list thereof. - :param left_on: pad_index, specify the pad on left, default to None (first available) - :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) - :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, copy=not inplace) - 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 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 fgb.as_filtergraph_object( - left_objs_labels, copy=not inplace - )._attach(right_objs_labels, left_on, right_on) - else: - return fgb.as_filtergraph_object( - right_objs_labels, copy=not inplace - )._rattach(left_objs_labels, left_on, right_on) - ### main graph manipulation routines @abstractmethod diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 8b9285df..4438060a 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -3,7 +3,7 @@ from copy import copy from .. import filtergraph as fgb -from .exceptions import FFmpegioError, FiltergraphInvalidExpression +from .exceptions import FFmpegioError from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args __all__ = ["connect", "join", "attach", "stack"] @@ -218,135 +218,3 @@ def join( replace_sws_flags, ) return fg - - -def attach( - left: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], - right: fgb.abc.FilterGraphObject | str | list[fgb.abc.FilterGraphObject | str], - left_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, - right_on: PAD_INDEX | str | list[PAD_INDEX | str | None] | None = None, - sws_flags_policy: Literal["first", "last"] | int | None = None, -) -> fgb.Graph | fgb.Chain: - """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 filtergraph object, filtergraph expression, or label, or list thereof. - :param left_on: pad_index, specify the pad on left, default to None (first available) - :param right_on: pad index, specifies which pad on the right graph, defaults to None (first available) - :param sws_flags_policy: Defines how to set ``sws_flags``: - - * ``'first'``: to use the first ``sws_flags`` found among the - filtergraphs (searched ``self`` first then ``others``) - * ``'last'``: use this filtergraph's ``sws_flags`` (or none used if - not set). - * ``int``: specify which filtergraph's ``sws_flags`` to use. ``0`` - refers to this object, ``1`` refers to ``others[0]``, etc. - * ``None``: if more than one have the ``sws_flags`` set, raises - ``FFmpegioError`` exception. Otherwise, it uses the only one found - or none if none not found. - - :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, copy=not inplace) - 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 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 fgb.as_filtergraph_object(left_objs_labels, copy=not inplace)._attach( - right_objs_labels, left_on, right_on - ) - else: - return fgb.as_filtergraph_object(right_objs_labels, copy=not inplace)._rattach( - left_objs_labels, left_on, right_on - ) From 8160c1721a6c361bbd8e96ab9ad91dfd4d948e82 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 17 Feb 2026 22:39:55 -0600 Subject: [PATCH 327/333] wip 5 - debugging fgb... --- src/ffmpegio/caps.py | 2 +- src/ffmpegio/filtergraph/Chain.py | 103 +++++---- src/ffmpegio/filtergraph/Filter.py | 281 ++++++++++++------------- src/ffmpegio/filtergraph/Graph.py | 210 +++++++++--------- src/ffmpegio/filtergraph/GraphLinks.py | 2 +- src/ffmpegio/filtergraph/__init__.py | 4 +- src/ffmpegio/filtergraph/abc.py | 113 +++------- src/ffmpegio/filtergraph/build.py | 111 ++-------- src/ffmpegio/filtergraph/convert.py | 4 +- src/ffmpegio/filtergraph/utils.py | 174 +++++++++------ tests/test_filtergraph.py | 88 +++++--- tests/test_filtergraph_chain.py | 119 ++++++++--- tests/test_filtergraph_filter.py | 68 +++--- 13 files changed, 641 insertions(+), 638 deletions(-) diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index f45c519b..ea05066d 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -150,7 +150,7 @@ def parse_line(s): # fmt:on -def filters(type=None): +def filters(type=None) -> dict[str, FilterSummary]: """get FFmpeg filters :param type: specify input or output stream type, defaults to None diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index 4722ee4d..6610e807 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -10,7 +10,7 @@ FiltergraphInvalidExpression, FiltergraphInvalidIndex, ) -from .typing import PAD_INDEX, Literal +from .typing import PAD_INDEX, Iterable, Literal __all__ = ["Chain"] @@ -32,38 +32,55 @@ class Chain(fgb.abc.FilterGraphObject, UserList): class Error(FFmpegioError): pass - def __init__(self, filter_specs=None): - # convert str to a list of filter_specs + def __init__( + self, + filter_specs: str + | fgb.abc.FilterGraphObject + | Iterable[str | fgb.Filter] = None, + ): + """FFmpeg filterchain + + :param filter_specs: filtergraph specification, defaults to create an + empty graph. Acceptable formats include: + + * ``str`` of a single-chain FFmpeg filtergraph expression without + any labels or the ``'[sws_flags=flags;]'`` clause. + * ``Chain`` object to copy-construct + * ``Filter`` object to create a single-filter chain + * ``Graph`` object with only one chain without any labels or + ``sws_flags`` + * An iterable of ``Filter`` constructor arguments + """ - if isinstance(filter_specs, fgb.Graph): + if isinstance(filter_specs, str): + filter_specs, links, sws_flags = filter_utils.parse_graph( + filter_specs, False + ) + if links: + raise ValueError( + "filter_specs with link labels cannot be represented by the Chain class. Use Graph instead." + ) + if sws_flags: + raise ValueError( + "filter_specs with sws_flags cannot be represented by the Chain class. Use Graph instead." + ) + if len(filter_specs) != 1: + raise ValueError( + "filter_specs str must resolve to a single-chain filtergraph. Use Graph instead." + ) + filter_specs = filter_specs[0] + elif isinstance(filter_specs, fgb.Graph): if not filter_specs.is_simple_chain(): raise TypeError( - "Cannot convert a multi-chain or linked `Graph` object to a `Chain` object" + "Cannot convert only a 'simple-chain' `Graph` object can be converted to a `Chain` object" ) - filter_specs = filter_specs[0] if len(filter_specs) > 0 else "" - - if isinstance(filter_specs, fgb.Filter): + filter_specs = filter_specs[0] if len(filter_specs) > 0 else [] + elif 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) + elif filter_specs is None: + filter_specs = [] - UserList.__init__(self, () if filter_specs is None else filter_specs) + super().__init__([fgb.as_filter(spec) for spec in filter_specs]) def compose( self, @@ -222,32 +239,14 @@ def __irshift__(self, other): 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]]: + def iter_chains(self) -> Generator[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 + :yields: 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) + if len(self): + yield self def _iter_pads( self, @@ -538,7 +537,7 @@ def _connect( """helper for connect and rconnect""" fg = graph_connect( - fgb.as_filtergraph(self), + fgb.Graph(self), other, from_left, to_right, @@ -621,8 +620,8 @@ def attach( ), ) - left_pad = next(self.iter_output_pads(chainable_only=True)) - right_pad = next(right_obj.iter_input_pads(chainable_only=True)) + left_pad = next(self.iter_output_pads(chainable_only=True))[0] + right_pad = next(right_obj.iter_input_pads(chainable_only=True))[0] return self.connect( right_obj, left_pad, right_pad, chain_siso=chain_siso, inplace=inplace ) diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index d8f0a5f5..3e928d8b 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -2,11 +2,10 @@ import re from collections.abc import Generator, Sequence -from functools import partial +from functools import cached_property, partial from .. import filtergraph as fgb from ..caps import FilterInfo, filter_info, layouts -from ..caps import filters as list_filters from ..stream_spec import parse_stream_spec from . import utils as filter_utils from .exceptions import * @@ -48,110 +47,129 @@ 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: + @cached_property + def info(self) -> FilterInfo: try: - info = Filter._info[name] + return filter_info(self.name) + # return list_filters()[self.name] # summary except KeyError: - try: - info = Filter._info[name] = list_filters()[name] - except: - raise Filter.InvalidName(name) - return info + raise Filter.InvalidName(self.name) - def __new__(self, filter_spec, *args, filter_id=None, **kwargs): - """_summary_""" + def __new__( + self, + filter_spec: str | fgb.abc.FilterGraphObject, + *args, + filter_id: str = None, + **kwargs, + ): + """FFmpeg filter object (immutable) - if isinstance(filter_spec, fgb.Graph): - if len(filter_spec) != 1: - raise TypeError( - "Cannot convert a `Graph` object with more than one filter to a `Filter` object" - ) - filter_spec = filter_spec[0] + :param filter_spec: FFmpeg filter specification. Acceptable formats + include: - if isinstance(filter_spec, fgb.Chain): - if len(filter_spec) != 1: - raise TypeError( - "Cannot convert a `Chain` or `Graph` object to a `Filter` object if it does not have exactly one filter." - ) - filter_spec = filter_spec[0] + * ``str`` of a single-chain FFmpeg filter filtergraph expression + without any labels or the ``'[sws_flags=flags;]'`` clause. + * ``Filter`` object (returns the same object) + * ``Chain`` Must be a single-filter chain. + * ``Graph`` Must be a single-filter graph without any labels or + ``sws_flags`` - proto = [] + :param filter_id: Optional id string to distinguish multiple instances + of a same filter. - 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)) + FFmpeg filter class arguments can be specified by position and keyword + arguments. - proto.extend(opts) + Examples + ^^^^^^^^ - # create named options dict - proto_dict = proto.pop() if isinstance(proto[-1], dict) else {} + .. code:: python - # 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 + import ffmpegio.filtergraph as fgb - # add additional ordered options if present - proto.extend(args[nord:]) + # "scale=w=200:h=100" can be constructed by any of the following: - # update named options - if len(kwargs): - proto_dict.update(kwargs) + fgb.Filter('scale=200:100') + fgb.Filter('scale=w=200:h=100') + fgb.Filter('scale', 200, 100) + fgb.Filter('scale', w=200, h=100) - # 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." - ) + """ + + # parse if str given + if isinstance(filter_spec, str): + name, _args, _kwargs = filter_utils.parse_filter(filter_spec) + + if isinstance(name, tuple): + name, _filter_id = name + if filter_id is None: + # + filter_id = _filter_id + if len(_args) > 0 or len(_kwargs) > 0: + if len(args) or len(kwargs): + raise TypeError( + "Filter arguments can only be passed via either in a Filter expression or the function arguments" + ) + args = _args + kwargs = _kwargs + proto = [name, args, kwargs] + else: + no_id = filter_id is None + if isinstance(filter_spec, fgb.Graph): + if not filter_spec.is_simple_chain() or len(filter_spec[0]) != 1: + raise TypeError( + "Cannot convert a multi-filter `Graph` object to a `Filter` object" + ) + filter_spec = filter_spec[0][0] + if no_id: + return filter_spec + elif isinstance(filter_spec, fgb.Chain): + if len(filter_spec) != 1: + raise TypeError( + "Cannot convert a `Chain` or `Graph` object to a `Filter` object if it does not have exactly one filter." + ) + filter_spec = filter_spec[0] + elif not isinstance(filter_spec, fgb.Filter): + raise ValueError("Invalid filterspec type.") + + proto = [*filter_spec] + + # check id: if matched, no change needed + if filter_spec.id == filter_id: + return filter_spec + elif filter_id is None: + proto[0] = filter_spec.name + else: + proto[0] = (filter_spec.name, filter_id) - # add the named option dict to the prototype list - if len(proto_dict): - proto.append(proto_dict) + # convert kwargs dict to tuple of tuples of key and values (immutable) + proto[-1] = tuple(proto[-1].items()) # create the final tuple return tuple.__new__(Filter, proto) + def copy(self) -> Filter: + """returns itself (immutable)""" + return self + + def __iter__(self): + """iterates to be compatible with compose_filter()""" + + for i, v in enumerate(super().__iter__()): + yield dict(v) if i == 2 else v + def __getitem__(self, key): + """make sure the last + + :param key: _description_ + :return: _description_ + """ 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:])) + if key in (2, -1): + value = dict(value) + elif isinstance(key, slice) and isinstance(value[-1], tuple): + value = (*value[:-1], dict(value[-1])) return value def compose( @@ -180,36 +198,27 @@ def __repr__(self): """ @property - def name(self): + def name(self) -> str: name = self[0] return name if isinstance(name, str) else name[0] @property - def fullname(self): + def fullname(self) -> str: name = self[0] return name if isinstance(name, str) else f"{name[0]}@{name[1]}" @property - def id(self): + def id(self) -> str | None: 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 + def ordered_options(self) -> tuple: + return self[1] @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 named_options(self) -> dict: + return dict(self[-1]) def get_pad_media_type( self, port: Literal["input", "output"], pad_id: int @@ -353,12 +362,10 @@ def get_num_inputs(self): # name@id name = name[0] - try: - nin = self._info[name].num_inputs - except: - raise Filter.InvalidName(name) + nin = self.info.inputs + if nin is not None: # fixed number - return nin + return len(nin) def _inplace(): return 1 if self.get_option_value("inplace") else 2 @@ -435,12 +442,9 @@ def get_num_outputs(self): """ name = self.name - try: - nout = self._info[name].num_outputs - except: - raise Filter.InvalidName(name) + nout = self.info.outputs if nout is not None: # arbitrary number allowed - return nout + return len(nout) def _concat(): return int(self.get_option_value("a")) + int(self.get_option_value("v")) @@ -712,26 +716,13 @@ def iter_output_pads( 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]]: + def iter_chains(self) -> Generator[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 + :yields: 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])) + yield fgb.Chain([self]) def connect( self, @@ -934,18 +925,15 @@ def rattach( inplace=False, ) - def apply(self, options, filter_id=None): + def apply(self, options: dict, filter_id: str | None = None) -> Filter: """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 + :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. + :param filter_id: new filter id, defaults to clear existing filter id :return: new filter with modified options - :rtype: Filter .. note:: @@ -954,25 +942,16 @@ def apply(self, options, filter_id=None): """ - 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 = [] + name, opts, kwopts = self + if isinstance(name, tuple): + name = name[0] + opts = list(opts) nopts = len(opts) delopts = set() for k, v in options.items(): - if type(k) == int: + if isinstance(k, int): if k < 0 or k > nopts: raise Filter.Error(f"invalid positional index [{k}]") if v is not None: @@ -999,7 +978,7 @@ def apply(self, options, filter_id=None): ) opts = opts[:o1] - return Filter(self[0], *opts, filter_id=filter_id, **kwopts) + return Filter(name, *opts, filter_id=filter_id, **kwopts) def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: pad_pos = index[2] diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index a0c48022..8e691415 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -4,7 +4,6 @@ from collections import UserList from collections.abc import Callable, Generator, Sequence from contextlib import contextmanager -from copy import deepcopy from itertools import accumulate, chain from math import floor, log10 from tempfile import NamedTemporaryFile @@ -15,7 +14,7 @@ from . import utils as filter_utils from .exceptions import * from .GraphLinks import GraphLinks -from .typing import PAD_INDEX, Literal +from .typing import PAD_INDEX, Iterable, Literal __all__ = ["Graph"] @@ -78,33 +77,6 @@ def resolve_connect_pad_indices( 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 @@ -123,9 +95,9 @@ def __init__(self, type, index): def __init__( self, filter_specs: ( - Sequence[fgb.Chain | str | Sequence[fgb.Filter]] - | str + str | fgb.abc.FilterGraphObject + | Iterable[fgb.Chain | str | Iterable[fgb.Filter]] | None ) = None, links: ( @@ -133,47 +105,98 @@ def __init__( str | int, tuple[ PAD_INDEX | Sequence[PAD_INDEX] | None, - PAD_INDEX | Sequence[PAD_INDEX] | None, + PAD_INDEX | None, ], ] | GraphLinks | None ) = None, - sws_flags: Sequence[str] | None = None, + sws_flags: str | None = None, ): + """FFmpeg filtergraph + + :param filter_specs: filtergraph specification, defaults to create an + empty graph. Acceptable formats include: + + * ``str`` FFmpeg filtergraph expression. Note: ``links`` and + ``sws_flags`` arguments are ignored. + * Another ``Graph`` object to copy + * A ``Chain`` object as the first and only chain of the filtergraph + * A ``Filter`` object to place the filter on the first chain + * An iterable of ``Chain`` constructor arguments + + :param links: a dict specifying the inter-chain connections and input + and output labels, defaults to none specified. + + Dict keys specify the link labels, each of which may be: + + * A ``str`` with or without the bracket (e.g., ``'[in]'`` or + ``'in'``). To specify connections to an input stream, the key + must be a valid map option, e.g. ``'0:v:0'`` or ``'0:a:0'``. + * An ``int`` value which will be converted to ``'L0'``, ``'L1'``, + etc. when ``Graph`` object is converted to ``str``. + + These labels may change during the lifespan of a ``Graph`` object. + As it is combining with another ``Graph`` object duplicate labels + may be assigned. Duplicates ``str`` labels are resolved by trailing + numbers. E.g., ``'in0'``, ``'in1'``, ... Duplicate ``int`` labels + are simply renumbered. + + Dict values are two-element tuples and must follow one of 4 formats: + + * Link label ``(to_pad, from_pad)``: connecting the output pad + ``from_pad`` to the input pad ``to_pad``. + * Input label ``(in_pad, None)``: Only defining the ``in_pad`` + * Output label ``(None, out_pad)``: Only defining the ``out_pad`` + * Input streams ``([in_pad0, in_pad1, ...], None)``: Multiple + input pads can be specified only for input stream connection. + + :param sws_flags: ``flags`` option of automatically inserted ``scale`` + filters, defaults to use the FFmpeg default (``='bicubic'``). FFmpeg + automatically inserts ``scale`` filters where format conversion is + required. + + Examples + ^^^^^^^^ + + .. code:: python + + import ffmpegio.filtergraph as fgb - # convert str to a list of filter_specs - if isinstance(filter_specs, fgb.Graph): + # from FFmpeg filtergraph expression + fg = fgb.Graph('[in]yadif=0:0:0[middle];[middle]scale=iw/2:-1[out]') + + # same graph but specify separate chains + fg = fgb.Graph(['yadif=0:0:0', 'scale=iw/2:-1'], + links={'in': ((0,0,0), None)), + 'middle': ((0,0,0), (1,0,0)), + 'out': (None, (1,0,0))}) + + """ + if isinstance(filter_specs, str): + # parse ffmpeg filtergraph expression + filter_specs, links, sws_flags = filter_utils.parse_graph( + filter_specs, False + ) + elif filter_specs is None: + # empty graph + filter_specs = () + elif isinstance(filter_specs, fgb.Graph): + # copy constructor, pull links and sws_flags aside links = filter_specs._links - sws_flags = filter_specs.sws_flags and [*filter_specs.sws_flags[1:]] + sws_flags = filter_specs.sws_flags and [*filter_specs.sws_flags] + elif isinstance(filter_specs, fgb.Filter): + filter_specs = [[filter_specs]] elif isinstance(filter_specs, fgb.Chain): filter_specs = [filter_specs] if len(filter_specs) else () - elif filter_specs is not None: - if isinstance(filter_specs, fgb.Filter): - filter_specs = [[filter_specs]] - elif not len(filter_specs): - filter_specs = [] - links = sws_flags = None - elif isinstance(filter_specs, str): - filter_specs, links, sws_flags = filter_utils.parse_graph(filter_specs) - - if any(not len(fspec) for fspec in filter_specs): - raise ValueError( - "An empty filterchain found. All chains must be populated." - ) - UserList.__init__( - self, - () if filter_specs is None else iter(fgb.Chain(c) for c in filter_specs), - ) + super().__init__((fgb.Chain(c) for c in 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]) - ) + self.sws_flags = None if sws_flags is None else str(sws_flags) """Filter|None: swscale flags for automatically inserted scalers """ @@ -345,7 +368,7 @@ def compose( 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:]) + return filter_utils.compose_graph(fg, links, fg.sws_flags) def __repr__(self): type_ = type(self) @@ -456,39 +479,14 @@ def __delitem__(self, i: int): # 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]]: + def iter_chains(self) -> Generator[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 + :yields: 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 + for c in self: + yield c def _iter_pads( self, @@ -982,7 +980,7 @@ def is_chain_appendable(self, chain_id: int) -> bool: def stack( self, - others: tuple[fgb.abc.FilterGraphObject | str], + *others: tuple[fgb.abc.FilterGraphObject | str], auto_link: bool = False, sws_flags_policy: Literal["first", "last"] | int | None = None, inplace: bool = False, @@ -1036,7 +1034,7 @@ def _stack_analyze( insert_at: int = 0, ) -> tuple[ GraphLinks, - Sequence[str] | None, + str | None, list[int], list[dict[tuple[int, str | int], str | int]], ]: @@ -1045,7 +1043,7 @@ def _stack_analyze( fgs.insert(insert_at, self) old_links = [fg.links for fg in fgs] - chain_offsets = accumulate(fg.get_num_chains() for fg in fgs) + chain_offsets = [0, *accumulate(fg.get_num_chains() for fg in fgs[:-1])] new_links, link_mappings = GraphLinks.combine(old_links, chain_offsets) # if requested, link input and output pads with a matching label @@ -1074,8 +1072,6 @@ def _stack_analyze( raise Graph.Error( f"{sws_flags_policy=} and more than 1 filtergraphs has sws_flags" ) - if sws_flags is not None: - sws_flags = deepcopy(sws_flags) return new_links, sws_flags, chain_offsets, link_mappings @@ -1107,8 +1103,10 @@ def _stack_create( else: return Graph( chain( - fg.iter_chains() - for fg in (*others[:insert_at], self, *others[insert_at:]) + *( + fg.iter_chains() + for fg in (*others[:insert_at], self, *others[insert_at:]) + ) ), new_links, sws_flags, @@ -1313,8 +1311,8 @@ def get_pads( self._links.combine_chains(cid_out, cid_in, len(self[cid_out])) # move the trailing chain to the end of the leading chain - fc = self.pop(cid_in) - self[cid_out].append(fc) + fc = new_fg.pop(cid_in) + new_fg[cid_out].extend(fc) return new_fg @@ -1365,14 +1363,15 @@ def attach( # get output pads left_pads = ( list( - self.iter_output_pads( + c[0] + for c in self.iter_output_pads( chainable_only=chainable_only is True or chainable_only == "left", full_pad_index=True, ) ) if left_on is None else [ - self.get_output_pad(pad)[0] + self.get_output_pad(self.normalize_pad_index(False, pad))[0] for pad in (left_on if isinstance(left_on, list) else [left_on]) ] ) @@ -1391,14 +1390,15 @@ def attach( # get pads to be connected right_pads = ( list( - right_obj.iter_input_pads( + c[0] + for c in right_obj.iter_input_pads( chainable_only=chainable_only is True or chainable_only == "right", full_pad_index=True, ) ) - if left_on is None + if right_on is None else [ - right_obj.get_input_pad(pad)[0] + right_obj.get_input_pad(self.normalize_pad_index(True, pad))[0] for pad in (right_on if isinstance(right_on, list) else [right_on]) ] ) @@ -1408,7 +1408,7 @@ def attach( right_obj, left_pads[:nconn], right_pads[:nconn], - inplce=inplace, + inplace=inplace, chain_siso=chain_siso, sws_flags_policy="first", ) @@ -1457,7 +1457,8 @@ def rattach( # get output pads right_pads = ( list( - self.iter_input_pads( + c[0] + for c in self.iter_input_pads( chainable_only=chainable_only is True or chainable_only == "right", full_pad_index=True, ) @@ -1483,7 +1484,8 @@ def rattach( # get pads to be connected left_pads = ( list( - left_obj.iter_output_pads( + c[0] + for c in left_obj.iter_output_pads( chainable_only=chainable_only is True or chainable_only == "left", full_pad_index=True, ) @@ -1500,7 +1502,7 @@ def rattach( left_obj, left_pads[:nconn], right_pads[:nconn], - inplce=inplace, + inplace=inplace, chain_siso=chain_siso, sws_flags_policy="first", ) diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 17bb9248..5b5b7e7e 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -403,7 +403,7 @@ def resolve_label( @staticmethod def combine( link_objs: Sequence[GraphLinks | None], cumsum_chains: Sequence[int] - ) -> tuple[GraphLinks, list[dict[tuple[int, str | int], str | int]]]: + ) -> tuple[GraphLinks, list[dict[tuple[int, str | int], str | int]] | None]: """combine ``GraphLinks`` objects into one, resolving duplicate labels :params link_objs: ``GraphLinks`` objects to be combined. If ``None``, diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index be35ef1e..7100401b 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -101,8 +101,8 @@ from .. import path from ..caps import filters as list_filters -from . import abc -from .build import attach, concatenate, connect, join, stack +from . import abc, presets +from .build import connect, join, stack from .Chain import Chain from .convert import ( as_filter, diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 7f138513..056190a0 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -6,7 +6,7 @@ from .. import filtergraph as fgb from .exceptions import * from .GraphLinks import GraphLinks -from .typing import JOIN_HOW, PAD_INDEX, Literal +from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args __all__ = ["FilterGraphObject"] @@ -144,20 +144,10 @@ def next_output_pad( 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]]: + def iter_chains(self) -> Generator[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 + :yields chain: ``Chain`` object """ @abstractmethod @@ -819,30 +809,30 @@ def __repr__(self) -> str: ... # Filtergraph math operators def __add__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: - return fgb.join(self, other, inplace=False) + return self.join(other) def __radd__(self, other: FilterGraphObject | str) -> fgb.Chain | fgb.Graph: - return fgb.join(other, self, inplace=False) + return fgb.as_filtergraph_object(other).join(self) def __mul__(self, __n: int) -> fgb.Graph: """duplicate-n-stack""" if not isinstance(__n, int): return NotImplemented - return fgb.stack(*((self,) * __n), inplace=False) + 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), inplace=False) + return fgb.stack(*((self,) * __n)) def __or__(self, other: FilterGraphObject | str) -> fgb.Graph: """stack""" - return fgb.stack(self, other, inplace=False) + return self.stack(other) def __ror__(self, other: FilterGraphObject | str) -> fgb.Graph: """stack""" - return fgb.stack(other, self, inplace=False) + return fgb.stack(other, self) def __rshift__( self, @@ -850,53 +840,34 @@ def __rshift__( 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] - ] + | tuple[FilterGraphObject, PAD_INDEX | str | None, PAD_INDEX | str] ), ) -> fgb.Graph: - """make one-to-one connections + """make one-to-one attachment 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): - if len(other) == 0: - raise ValueError("At least one `other` filtergraph must be specified.") + left_on = right_on = None + if isinstance(other, tuple): + n = len(other) + if n == 0 or n > 3: + return NotImplemented + right = other[-1] - # match the pad indices first - right, left_on, right_on = [ - [*t] for t in zip(*(parse_other(o) for o in other)) - ] + if n > 1: + left_on = other[0] + right_on = other[1] if n > 2 else None else: - # parse other argument, separate the indices if given - right, left_on, right_on = parse_other(other) + right = other - return fgb.attach(self, right, left_on, right_on, inplace=False) + return self.attach(right, left_on, right_on, inplace=False) def __rrshift__( self, @@ -905,12 +876,6 @@ def __rrshift__( | 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 @@ -924,32 +889,20 @@ def __rrshift__( 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 + left_on = right_on = None + if isinstance(other, tuple): + n = len(other) + if n == 0 or n > 3: + return NotImplemented + left = other[0] - return other, index, other_index - - # if output is a list - if isinstance(other, list): - if len(other) == 0: - raise ValueError("At least one `other` filtergraph must be specified.") - - # match the pad indices first - left, right_on, left_on = [ - [*t] for t in zip(*(parse_other(o) for o in other)) - ] + if n > 1: + right_on = other[-1] + left_on = other[1] if n > 2 else None else: - # parse other argument, separate the indices if given - left, right_on, left_on = parse_other(other) + left = other - return fgb.attach(left, self, left_on, right_on, inplace=False) + return self.rattach(left, left_on, right_on, inplace=False) def resolve_pad_index( self, diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 4438060a..cf28a013 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -1,12 +1,9 @@ from __future__ import annotations -from copy import copy - from .. import filtergraph as fgb -from .exceptions import FFmpegioError -from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args +from .typing import JOIN_HOW, PAD_INDEX, Literal -__all__ = ["connect", "join", "attach", "stack"] +__all__ = ["connect", "join", "stack"] def stack( @@ -41,7 +38,9 @@ def stack( """ - return fgs[0].stack(*fgs[1:], auto_link, sws_flags_policy) + return fgb.as_filtergraph_object(fgs[0]).stack( + *fgs[1:], auto_link=auto_link, sws_flags_policy=sws_flags_policy + ) def connect( @@ -85,8 +84,14 @@ def connect( """ - return left.connect( - right, from_left, to_right, from_right, to_left, chain_siso, sws_flags_policy + return fgb.as_filtergraph_object(left).connect( + right, + from_left, + to_right, + from_right=from_right, + to_left=to_left, + chain_siso=chain_siso, + sws_flags_policy=sws_flags_policy, ) @@ -133,88 +138,12 @@ def join( :return: Graph with the appended filter chains or None if inplace=True. """ - # if one of the filtergraphs is empty, return the other (or a copy thereof) - if not fgb.as_filtergraph_object(right).get_num_filters(): - if inplace: - return left - else: - return fgb.as_filtergraph_object(left).copy() - if not fgb.as_filtergraph_object(left).get_num_filters(): - if inplace: - return right - else: - return copy(fgb.as_filtergraph_object(right)) - - if how is None: - how = "auto" - if n_links is None: - 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, copy=not inplace) - right = fgb.as_filtergraph_object(right, copy=not inplace) - - # handle joining empty graph - nright = right.get_num_chains() - if not nright: - return left - nleft = left.get_num_chains() - if not nleft: - 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 - - if how in ("per_chain", "auto") and nright == nleft: - # - try: - links = [None] * nleft - for c in range(nleft): - # get the first available pad to join - left_pad, *_ = next(left.iter_output_pads(chain=c, **iter_kws)) - right_pad, *_ = next(right.iter_input_pads(chain=c, **iter_kws)) - links[c] = (left_pad, right_pad) - except: - if how == "auto": - how = "all" - else: - raise - - if how in ("all", "chainable") or nright != nleft: - left_pads = [out[0] for out in left.iter_output_pads(**iter_kws)] - right_pads = [out[0] for out in right.iter_input_pads(**iter_kws)] - - nleft, nright = len(left_pads), len(right_pads) - if strict and nleft != nright: - raise FFmpegioError("`[stict=True] number of unconnected pads must match.") - n_max = min(nleft, nright) - n_links = n_max if n_links <= 0 else min(n_links, n_max) - - links = [None] * n_links - for i, (left_pad, right_pad) in enumerate( - zip(left_pads[:n_links], right_pads[:n_links]) - ): - links[i] = (left_pad, right_pad) - - fg = left._connect( + return fgb.as_filtergraph_object(left).join( right, - links, - [], - chain_siso, - replace_sws_flags, + how=how, + n_links=n_links, + strict=strict, + unlabeled_only=unlabeled_only, + chain_siso=chain_siso, + sws_flags_policy=sws_flags_policy, ) - if fg == NotImplemented: - fg = right._rconnect( - left, - links, - chain_siso, - replace_sws_flags, - ) - return fg diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py index 5a31139d..6741cd85 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -120,7 +120,7 @@ def as_filtergraph_object( return type(filter_specs)(filter_specs) if copy else filter_specs try: - specs, links, sws_flags = filter_utils.parse_graph(filter_specs) + specs, links, sws_flags = filter_utils.parse_graph(filter_specs, False) return ( fgb.Graph(specs, links, sws_flags) if links or sws_flags or len(specs) > 1 @@ -172,7 +172,7 @@ def atleast_filterchain( return fgb.Chain([filter_specs]) try: - specs, links, sws_flags = filter_utils.parse_graph(filter_specs) + specs, links, sws_flags = filter_utils.parse_graph(filter_specs, False) return ( fgb.Graph(specs, links, sws_flags) if links or sws_flags or len(specs) > 1 diff --git a/src/ffmpegio/filtergraph/utils.py b/src/ffmpegio/filtergraph/utils.py index eaa6e7c9..d91a892c 100644 --- a/src/ffmpegio/filtergraph/utils.py +++ b/src/ffmpegio/filtergraph/utils.py @@ -4,12 +4,13 @@ import re from collections.abc import Sequence from fractions import Fraction +from typing import Literal, overload # Filter string parser/composer # For FilterGraph class, see ../filtergraph.py # various regexp objects used in the module -_re_name_id = re.compile(r"\s*([a-zA-Z0-9_]+)(?:\s*@\s*([a-zA-Z0-9_]+))?\s*(?:=|$)") +_re_name_id = re.compile(r"\s*([a-z0-9_]+)(?:\s*@\s*([a-zA-Z0-9_]+))?\s*(?:=|$)") _re_labels = re.compile(r"\s*\[\s*(.+?)\s*\]") _re_graph = re.compile(r"(? tuple[ + tuple[str | int | float | Fraction, ...], + dict[str, str, str | int | float | Fraction], +]: """parse filter argument string :param expr: filter argument string - :type expr: str - :return: list of argument strings; last element may be a dict of key-value pairs - :rtype: list of str + dict + :return args: tuple of positional arguments + :return kwargs: dict of keyword arguments """ def conv_val(s): # convert a numeric option value try: return int(s) - except: + except ValueError: try: return float(s) - except: + except ValueError: try: return Fraction(s) - except: + except ValueError: return s # remove escaped single quotes @@ -73,21 +78,24 @@ def conv_val(s): ) # gather ordered options - args = [conv_val(s.rstrip()) for s in (all_args if ikw is None else all_args[:ikw])] + args = tuple( + conv_val(s.rstrip()) for s in (all_args if ikw is None else all_args[:ikw]) + ) - if ikw is not None: + if ikw is None: + kwargs = {} + else: # if named options are given, form a dict def get_kw(arg): m = _re_args_kw.match(arg) return m[1], conv_val(m[2].rstrip()) kwargs = {k: v for k, v in (get_kw(arg) for arg in all_args[ikw:])} - args = [*args, kwargs] - return args + return args, kwargs -def compose_filter_args(*args: tuple[str, ...]) -> str: +def compose_filter_args(args: tuple, kwargs: dict) -> str: """compose once-escaped filter argument string :param args: list of argument strings; last element may be a dict of key-value pairs @@ -120,10 +128,6 @@ def finalize_option_value(value): return s - kwargs = args[-1] if len(args) > 0 and isinstance(args[-1], dict) else None - if kwargs is not None: - args = args[:-1] - args = ":".join([finalize_option_value(i) for i in args]) if kwargs: kwargs = ":".join( @@ -136,14 +140,20 @@ def finalize_option_value(value): ################################################################################################### -def parse_filter(expr): +def parse_filter( + expr: str, +) -> tuple[ + str | tuple[str, str], + tuple[str | int | float | Fraction], + dict[str, str | int | float | Fraction], +]: """Parse FFmpeg filter expression :param expr: filter expression, escaped special characters once - :type expr: str - :return: filter name followed by arguments, followed by a dict containing id string - (empty if id not given) - :rtype: tuple(str, *args, {['id':str]}) + :return name: filter name. If filter id is specified (i.e., ``'name@id'``) + a tuple of the name and id are returned instead. + :return args: positional arguments + :return kwargs: keyword arguments """ m = _re_name_id.match(expr, 0) @@ -153,33 +163,50 @@ def parse_filter(expr): f'"{expr}" does not start with a valid filter name or not terminated "=" character.' ) - name, id = m.groups() + filter_name, filter_id = m.groups() s_args = expr[m.end() :] try: - args = parse_filter_args(s_args) if s_args else [] - except: - raise ValueError(f'"{expr}" is not a valid filter expression.') + args, kwargs = parse_filter_args(s_args) if s_args else ((), {}) + except Exception as e: + raise ValueError(f'"{expr}" is not a valid filter expression.') from e + + return filter_name if filter_id is None else (filter_name, filter_id), args, kwargs + - return (((name, id) if id else name), *args) +@overload +def compose_filter( + name: str | tuple[str, str], + args: tuple[str | int | float | Fraction], + kwargs: dict[str, str | int | float | Fraction], +) -> str: + """compose filter expression from args and kwargs + + :param name: filter name or a pair of filter name and id strings + :param args: tuple of positional arguments + :param kwargs: dict of keyword arguments + :return: filter expression, once escaped + """ -def compose_filter(name, *args): - """Compose FFmpeg filter expression +def compose_filter( + name: str | tuple[str, str], + *args: *tuple[str | int | float | Fraction], + **kwargs: dict[str, str | int | float | Fraction], +) -> str: + """compose filter expression from expanded positional & keyword arguments - :param name: filter name, optionally seq of name & id - :type name: str or (str, str) - :param args: option value sequence - :type args: seq of stringifyable items + last item may be a dict to hold - key-value pairs + :param name: filter name or a pair of filter name and id strings :return: filter expression, once escaped - :rtype: str """ + if len(args) == 2 and isinstance(args[0], tuple) and isinstance(args[1], dict): + args, kwargs = args + expr = name if isinstance(name, str) else f"{name[0]}@{name[1]}" - if len(args): - expr = f"{expr}={compose_filter_args(*args)}" + if len(args) or len(kwargs): + expr = f"{expr}={compose_filter_args(args, kwargs)}" return expr @@ -188,16 +215,46 @@ def compose_filter(name, *args): # FILTERGRAPH PARSER/COMPOSER - -def parse_graph(expr): +FilterSpecTuple = tuple[str | tuple[str, str], tuple, dict] + + +@overload +def parse_graph( + expr: str, parse_filters: Literal[True] +) -> tuple[ + list[list[FilterSpecTuple]], + dict[str, tuple[tuple | list[tuple] | None, tuple | None]], + str | None, +]: ... +@overload +def parse_graph( + expr: str, parse_filters: Literal[False] +) -> tuple[ + list[list[str]], + dict[str, tuple[tuple | list[tuple] | None, tuple | None]], + str | None, +]: ... +def parse_graph( + expr: str, parse_filters: bool = True +) -> tuple[ + list[list], + dict[str, tuple[tuple | list[tuple] | None, tuple | None]], + str | None, +]: """parse filter graph expression :param expr: twice-escaped filter graph string - :type expr: str - :return: tuple of unescaped filter graph blob, input labels, output labels, chain links, and sws_flags list - :rtype: (list of list of (name, args, id), dict, dict, dict, list) - :return: tuple of unescaped filter graph blob, pad link map, and sws_flags list - :rtype: (list of list of (name, args, id), dict, list) + :param parse_filters: ``True`` (default) to convert individual filter + expressions to ``FilterSpecTuple``. ``False`` to keep the filter + expressions as (escaped) strings. + :return filter_specs: list of lists of filters. If ``parse_filters=True``, + filters are parsed to a tuple of name (or a tuple of name and id), + a tuple of positional options, and a dict of keyword options. If + ``parsed_filters=False``, escaped filter expressions are returned. + :return link_specs: a mapping of link labels and pairs of output and input + pads. + :return sws_flags: optional ``flags`` option for automatically inserted + scale filters (FFmpeg defaults to ``'bicubic'``). Note ---- @@ -259,7 +316,7 @@ def parse_labels(expr, i, output, *cidfid): # get scale flags if given m = re.match(r"\s*sws_flags=(.+?);", expr) if m: - sws_flags = parse_filter_args(m[1]) + sws_flags = m[1] i = m.end() else: sws_flags = None @@ -283,7 +340,7 @@ def parse_labels(expr, i, output, *cidfid): i = parse_labels(expr, j - 1, bool(fs), cid, fid) # grab all labels if i == n: # add new filter to the chain - fc.append(parse_filter(fs)) + fc.append(parse_filter(fs) if parse_filters else fs) # if new chain, add it to the graph if not fid: @@ -304,7 +361,7 @@ def parse_labels(expr, i, output, *cidfid): else: # add new filter to the chain - fc.append(parse_filter(fs)) + fc.append(parse_filter(fs) if parse_filters else fs) # if new chain, add it to the graph if not fid: @@ -322,20 +379,19 @@ def parse_labels(expr, i, output, *cidfid): return (fg, links, sws_flags) -def compose_graph(filter_specs, links=None, sws_flags=None): +def compose_graph( + filter_specs: Sequence[Sequence[str | FilterSpecTuple]], + links: dict[str, tuple[tuple | list[tuple] | None, tuple | None]] | None = None, + sws_flags: str | None = None, +) -> str: """Compose complex filter graph - :param filter_specs: a nested sequence of argument sequences to compose_filter() to define - a filter graph. The last element of each filter argument sequence - may be a dict, defining its keyword arguments. - :type filter_specs: seq(seq(filter_args)) + :param filter_specs: a nested sequence of argument sequences to + ``compose_filter()`` to define a filter graph. The last element of each + filter argument sequence may be a dict, defining its keyword arguments. :param links: specifies how non-sequential filters are linked. See below for the specification. - :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 :returns: filter graph expression - :rtype: str Note ---- @@ -507,11 +563,7 @@ def set_link_label(k): # COMPOSE FILTER GRAPH # add optional auto-scaling filter arguments - expr = ( - "" - if sws_flags is None - else f"sws_flags={escape(compose_filter_args(*sws_flags))};" - ) + expr = "" if sws_flags is None else f"sws_flags={sws_flags};" # form individual filters, form chains, then comine them into graphs expr += ";".join( diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index da7563f8..a835cb23 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -1,10 +1,13 @@ from os import path -from tempfile import TemporaryDirectory -from ffmpegio import ffmpegprocess, filtergraph as fgb -from ffmpegio.filtergraph import Chain from pprint import pprint +from tempfile import TemporaryDirectory + import pytest +from ffmpegio import ffmpegprocess +from ffmpegio import filtergraph as fgb +from ffmpegio.filtergraph import Chain + @pytest.mark.parametrize( "expr, pad, filter, chain, exclude_chainable, chainable_first, include_connected, unlabeled_only, ret", @@ -164,22 +167,12 @@ def test_iter_output_pads( @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), - ], + "expr, ret", + [(None, 0), ("fps;scale", 2)], ) -def test_iter_chains(expr, skip_if_no_input, skip_if_no_output, chainable_only, ret): +def test_iter_chains(expr, ret): f = fgb.Graph(expr) - chains = [*f.iter_chains(skip_if_no_input, skip_if_no_output, chainable_only)] + chains = [*f.iter_chains()] assert len(chains) == ret @@ -305,8 +298,18 @@ def test_attach(fg, fc, left_on, right_on, out): ("fps;crop", "trim", (1, 0, 0), "[UNC0]trim,crop[UNC2];[UNC1]fps[UNC3]"), ("fps;[in]crop", "trim", "in", "[UNC0]trim,crop[UNC2];[UNC1]fps[UNC3]"), ("[L]fps;crop[L]", "trim", None, "[UNC0]trim,crop[L];[L]fps[UNC1]"), - ("[C]overlay;crop[C]", "trim", None, "[UNC0]trim,[C]overlay[UNC2];[UNC1]crop[C]"), - ("[C][in]overlay;crop[C]", "trim", "in", "[UNC0]trim,[C]overlay[UNC2];[UNC1]crop[C]"), + ( + "[C]overlay;crop[C]", + "trim", + None, + "[UNC0]trim,[C]overlay[UNC2];[UNC1]crop[C]", + ), + ( + "[C][in]overlay;crop[C]", + "trim", + "in", + "[UNC0]trim,[C]overlay[UNC2];[UNC1]crop[C]", + ), # fmt: on ], ) @@ -432,9 +435,30 @@ def test_get_output_pad(fg, id, out): "fg, r, to_l,to_r,chain, out", [ # fmt: off - ("[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[la1]"), - ("[a1]fps;crop[b]", "[c]trim;scale[d1]", ['b'], ['c'], True, "[a1]fps[UNC2];[UNC0]crop,trim[UNC3];[UNC1]scale[d1]"), + ( + "[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[la1]", + ), + ( + "[a1]fps;crop[b]", + "[c]trim;scale[d1]", + ["b"], + ["c"], + True, + "[a1]fps[UNC2];[UNC0]crop,trim[UNC3];[UNC1]scale[d1]", + ), # fmt: on ], ) @@ -453,9 +477,21 @@ def test_connect(fg, r, to_l, to_r, chain, out): "fg, r, how, unlabeled_only, out", [ # fmt: off - ("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,scale[out2];[UNC0]crop[ou1];[in2]trim[UNC1]"), - ("fps", "overlay", 'per_chain', False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), + ( + "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,scale[out2];[UNC0]crop[ou1];[in2]trim[UNC1]", + ), + ("fps", "overlay", "per_chain", False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), # fmt: on ], ) @@ -589,7 +625,7 @@ def test_readme(): if __name__ == "__main__": from pprint import pprint - from ffmpegio.filtergraph import Graph, filter_info, FFmpegioError, list_filters + from ffmpegio.filtergraph import FFmpegioError, Graph, filter_info, list_filters for k, v in list_filters().items(): if v.num_inputs is None or v.num_inputs: diff --git a/tests/test_filtergraph_chain.py b/tests/test_filtergraph_chain.py index 2a83f207..74bf12da 100644 --- a/tests/test_filtergraph_chain.py +++ b/tests/test_filtergraph_chain.py @@ -3,9 +3,10 @@ logging.basicConfig(level=logging.INFO) -from ffmpegio import filtergraph as fgb import pytest +from ffmpegio import filtergraph as fgb + def test_fchain(): fchain = fgb.Chain("fps=30,format=pix_fmt=rgb24,trim=0.5:12.4") @@ -142,41 +143,99 @@ def test_iter_output_pads( 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 +def test_iter_chains(): + assert len([*fgb.Chain("fps,scale").iter_chains()]) == 1 + assert len([*fgb.Chain().iter_chains()]) == 0 @pytest.mark.parametrize( "op, lhs,rhs,expected", [ # fmt:off - (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,[L0]hstack[UNC2]"), - (operator.__rshift__, fgb.Chain("split"), ["[v1]","[v2]"], "[UNC0]split[v1][v2]"), + ( + 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,[L0]hstack[UNC2]", + ), + ( + operator.__rshift__, + fgb.Chain("split"), + ["[v1]", "[v2]"], + "[UNC0]split[v1][v2]", + ), # (operator.__rshift__, fgb.Graph("split[out1][out2]"), ('[out1]', '[over]', "[base][over]overlay"), "split[out1][out2];[base][out1]overlay"), # fmt:on ], diff --git a/tests/test_filtergraph_filter.py b/tests/test_filtergraph_filter.py index 6b618509..709ecc63 100644 --- a/tests/test_filtergraph_filter.py +++ b/tests/test_filtergraph_filter.py @@ -2,10 +2,12 @@ logging.basicConfig(level=logging.INFO) -from ffmpegio import filtergraph as fgb -import pytest import operator +import pytest + +from ffmpegio import filtergraph as fgb + def test_Filter(): f = fgb.Filter("concat") @@ -22,9 +24,9 @@ def test_Filter(): @pytest.mark.parametrize( "filter_spec,option_name,expected", [ - (("concat", {"n": 3}), "n", 3), - (("concat", 3), "n", 3), - (("concat",), "n", 2), + ("concat=n=3", "n", 3), + ("concat=3", "n", 3), + ("concat", "n", 2), ], ) def test_filter_get_option_value(filter_spec, option_name, expected): @@ -39,21 +41,21 @@ def test_filter_get_option_value(filter_spec, option_name, expected): "filter_spec,expected", [ ("overlay", 2), - (["overlay", "no1"], 2), - (("hstack", {"inputs": 4}), 4), - (("afir", {"nbirs": 1}), 2), - (("concat", {"n": 3}), 3), - (("decimate", {"ppsrc": 1}), 2), - (("fieldmatch", {"ppsrc": 1}), 2), - (("headphone", "FL|FR|FC|LFE|BL|BR|SL|SR"), 9), - (("headphone", ["FL", "FR"]), 3), - (("headphone", {"map": "FL|FR|FC|LFE|BL|BR|SL|SR", "hrir": "multich"}), 2), - (("interleave", {"nb_inputs": 2}), 2), - (("mergeplanes", "0x001020", "yuv444p"), 3), - (("mergeplanes", "0x00010210", "yuv444p"), 2), - (("premultiply", {"inplace": 1}), 1), - (("unpremultiply", {"inplace": 0}), 2), - (("signature", {"nb_inputs": 2}), 2), + ("overlay=no1", 2), + ("hstack=inputs=4", 4), + ("afir=nbirs=1", 2), + ("concat=n=3", 3), + ("decimate=ppsrc=1", 2), + ("fieldmatch=ppsrc=1", 2), + ("headphone=FL|FR|FC|LFE|BL|BR|SL|SR", 9), + ("headphone=FL|FR", 3), + ("headphone=map=FL|FR|FC|LFE|BL|BR|SL|SR:hrir=multich", 2), + ("interleave=nb_inputs=2", 2), + ("mergeplanes=0x001020:yuv444p", 3), + ("mergeplanes=0x00010210:yuv444p", 2), + ("premultiply=inplace=1", 1), + ("unpremultiply=inplace=0", 2), + ("signature=nb_inputs=2", 2), ], ) def test_filter_get_num_inputs(filter_spec, expected): @@ -67,7 +69,7 @@ def test_filter_get_num_inputs(filter_spec, expected): @pytest.mark.parametrize( "filter_spec,expected", [ - ("split", 2), + (["split"], 2), (["split", 3], 3), (("acrossover", {"split": "1500 8000", "order": "8th"}), 3), (("afir", {"response": 0}), 1), @@ -89,7 +91,12 @@ def test_filter_get_num_inputs(filter_spec, expected): ) def test_filter_get_num_outputs(filter_spec, expected): - f = fgb.Filter(filter_spec) + has_kws = isinstance(filter_spec[-1], dict) + if has_kws: + *filter_spec, kwargs = filter_spec + else: + kwargs = {} + f = fgb.Filter(*filter_spec, **kwargs) try: assert f.get_num_outputs() == expected except fgb.Filter.InvalidName: @@ -190,21 +197,8 @@ def test_iter_output_pads( 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_iter_chains(): + assert len([*fgb.Filter("fps").iter_chains()]) == 1 def test_apply(): From 2f806186cf4f7b456dadbc3aeba5b5404dc7bc87 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 18 Feb 2026 22:59:38 -0600 Subject: [PATCH 328/333] wip 6 - still debugging... --- src/ffmpegio/filtergraph/Chain.py | 11 ++- src/ffmpegio/filtergraph/Filter.py | 6 +- src/ffmpegio/filtergraph/Graph.py | 14 ++-- src/ffmpegio/filtergraph/GraphLinks.py | 42 ++++++---- src/ffmpegio/filtergraph/__init__.py | 9 +- src/ffmpegio/filtergraph/abc.py | 8 +- src/ffmpegio/filtergraph/convert.py | 112 ++++++++----------------- src/ffmpegio/filtergraph/utils.py | 14 +++- tests/test_filtergraph.py | 30 ++++--- tests/test_filtergraph_convert.py | 105 +++++++++++++++++++++++ tests/test_filtergraph_fglinks.py | 77 +++++++++++++++++ tests/test_filtergraph_filter.py | 2 +- tests/test_utils_filter.py | 28 +++---- 13 files changed, 316 insertions(+), 142 deletions(-) create mode 100644 tests/test_filtergraph_convert.py diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index 6610e807..feef51fd 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -57,21 +57,21 @@ def __init__( filter_specs, False ) if links: - raise ValueError( + raise fgb.FiltergraphInvalidExpression( "filter_specs with link labels cannot be represented by the Chain class. Use Graph instead." ) if sws_flags: - raise ValueError( + raise fgb.FiltergraphInvalidExpression( "filter_specs with sws_flags cannot be represented by the Chain class. Use Graph instead." ) if len(filter_specs) != 1: - raise ValueError( + raise fgb.FiltergraphInvalidExpression( "filter_specs str must resolve to a single-chain filtergraph. Use Graph instead." ) filter_specs = filter_specs[0] elif isinstance(filter_specs, fgb.Graph): if not filter_specs.is_simple_chain(): - raise TypeError( + raise fgb.FiltergraphConversionError( "Cannot convert only a 'simple-chain' `Graph` object can be converted to a `Chain` object" ) filter_specs = filter_specs[0] if len(filter_specs) > 0 else [] @@ -82,6 +82,9 @@ def __init__( super().__init__([fgb.as_filter(spec) for spec in filter_specs]) + def copy(self) -> Chain: + return Chain(self) + def compose( self, show_unconnected_inputs: bool = False, diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 3e928d8b..532e7c32 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -107,7 +107,7 @@ def __new__( filter_id = _filter_id if len(_args) > 0 or len(_kwargs) > 0: if len(args) or len(kwargs): - raise TypeError( + raise fgb.FiltergraphInvalidExpression( "Filter arguments can only be passed via either in a Filter expression or the function arguments" ) args = _args @@ -117,7 +117,7 @@ def __new__( no_id = filter_id is None if isinstance(filter_spec, fgb.Graph): if not filter_spec.is_simple_chain() or len(filter_spec[0]) != 1: - raise TypeError( + raise fgb.FiltergraphConversionError( "Cannot convert a multi-filter `Graph` object to a `Filter` object" ) filter_spec = filter_spec[0][0] @@ -125,7 +125,7 @@ def __new__( return filter_spec elif isinstance(filter_spec, fgb.Chain): if len(filter_spec) != 1: - raise TypeError( + raise fgb.FiltergraphConversionError( "Cannot convert a `Chain` or `Graph` object to a `Filter` object if it does not have exactly one filter." ) filter_spec = filter_spec[0] diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 8e691415..820cc0a2 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -1018,7 +1018,9 @@ def stack( """ if len(others) == 0: - return self.copy() + return self.copy() if inplace else None + + others = list(fgb.as_filtergraph_object(obj) for obj in others) new_links, sws_flags, *_ = self._stack_analyze( others, auto_link, sws_flags_policy @@ -1028,7 +1030,7 @@ def stack( def _stack_analyze( self, - others: tuple[fgb.abc.FilterGraphObject | str], + others: tuple[fgb.abc.FilterGraphObject], auto_link: bool, sws_flags_policy: Literal["first", "last"] | int | None, insert_at: int = 0, @@ -1050,11 +1052,9 @@ def _stack_analyze( if auto_link: pairs = GraphLinks.pair_unconnected_labels(old_links) for label, in_fg, out_fg in pairs: - in_label = link_mappings[(in_fg, label)] - out_label = link_mappings[(out_fg, label)] - new_links.link_by_labels(in_label, out_label) - if len(pairs): - new_links = new_links.relabel() + in_label = link_mappings[in_fg][label] + out_label = link_mappings[out_fg][label] + new_links.link_by_labels(in_label, out_label, label=label) # pick sws_flags if isinstance(sws_flags_policy, int): diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 5b5b7e7e..cced8eaf 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -308,10 +308,11 @@ def link_by_labels( else: self.validate_label(label) + # delete old labels and create a new link with the chosen label if not linked: - self[label] = link_value del self[in_label] del self[out_label] + self[label] = link_value return label @@ -413,8 +414,11 @@ def combine( :return combined_link_obj: a new ``GraphLinks`` object of all the links combined. Input streams are not linked, and they are returned separately as the third output below. - :return mapping: mapping a pair of ``link_objs`` index and its old label - to its new labels in ``combined_link_obj``. + :return mapping: list of mapping pairs for each element of + ``link_objs``. Each mapping links ``link_objs`` item's old labels to + its new labels in ``combined_link_obj``. If a ``link_objs`` element + is a ``None``, a ``None`` is returned in ``mapping`` instead of a + ``dict``. """ # accumulate all the labels (remove trailing numbers if exist to match) @@ -430,7 +434,7 @@ def combine( if isinstance(key, str): if links.is_input_stream(label): # update the connected input pads - input_streams[label].append( + input_streams[label].extend( [ (cid + cid0, fid, pid) for cid, fid, pid in links[label][0] @@ -457,20 +461,28 @@ def combine( mappings[i][old_label] = new_label else: # explicit labels (append a unique suffix number) - for j, (i, old_label) in enumerate(matches): - new_label = f"{key}{j}" - mappings[i][old_label] = new_label + if len(matches) == 1: # no duplicate, use as is + i, old_label = matches[0] + mappings[i][old_label] = key + else: # duplicates, append auto-# + for j, (i, old_label) in enumerate(matches): + new_label = f"{key}{j}" + mappings[i][old_label] = new_label # create the combined object combined = GraphLinks() - for obj, cid0 in zip(link_objs, cumsum_chains): + for obj, cid0, mapping in zip(link_objs, cumsum_chains, mappings): if obj is None: continue links = cast(GraphLinks, obj) for label, (in_pad, out_pad) in links.items(): - in_pad = (in_pad[0] + cid0, *in_pad[1:]) - out_pad = (out_pad[0] + cid0, *out_pad[1:]) - combined[mappings[label]] = (in_pad, out_pad) + if label not in mapping: + continue + if in_pad is not None: + in_pad = (in_pad[0] + cid0, *in_pad[1:]) + if out_pad is not None: + out_pad = (out_pad[0] + cid0, *out_pad[1:]) + combined[mapping[label]] = (in_pad, out_pad) # add the input streams with the updated pad indices for label, in_pads in input_streams.items(): @@ -619,19 +631,17 @@ def is_input(self, label: str, exclude_stream_specs: bool = False) -> bool: return ( lnk and lnk[1] is None - and (not exclude_stream_specs or isinstance(lnk[0], str)) + and not (exclude_stream_specs and self.is_input_stream(label)) ) def is_input_stream(self, label: str) -> bool: """``True`` if label specifies an input stream map :param label: input stream map specifier - :param exclude_stream_specs: ``True`` to return ``False`` if the label - is an input stream spec. :return: ``True`` if label is an input """ - lnk = self.data.get(label, None) - return lnk and lnk[1] is None and not isinstance(lnk[0], str) + + return label in self and is_map_option(label, allow_missing_file_id=True) def is_output(self, label: str) -> bool: """``True`` if label specifies an output diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index 7100401b..c8d4524a 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -112,7 +112,12 @@ as_filtergraph_object_like, atleast_filterchain, ) -from .exceptions import FiltergraphInvalidIndex, FiltergraphPadNotFoundError +from .exceptions import ( + FiltergraphConversionError, + FiltergraphInvalidExpression, + FiltergraphInvalidIndex, + FiltergraphPadNotFoundError, +) from .Filter import Filter from .Graph import Graph @@ -137,6 +142,8 @@ "Graph", "FiltergraphInvalidIndex", "FiltergraphPadNotFoundError", + "FiltergraphConversionError", + "FiltergraphInvalidExpression", ] diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 056190a0..4dcf0994 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -6,7 +6,7 @@ from .. import filtergraph as fgb from .exceptions import * from .GraphLinks import GraphLinks -from .typing import JOIN_HOW, PAD_INDEX, Literal, get_args +from .typing import JOIN_HOW, PAD_INDEX, Literal, Self, get_args __all__ = ["FilterGraphObject"] @@ -29,6 +29,10 @@ def get_num_pads(self, input: bool) -> int: """ return self.get_num_inputs() if input else self.get_num_outputs() + @abstractmethod + def copy(self) -> Self: + """(deep) copy filtergraph object""" + @abstractmethod def get_num_inputs(self) -> int: """get the number of input pads of the filter @@ -682,7 +686,7 @@ def _join_analyze( nleft, nright = len(left_pads), len(right_pads) if strict and nleft != nright: - raise FFmpegioError( + raise FiltergraphMismatchError( "`[stict=True] number of unconnected pads must match." ) n_max = min(nleft, nright) diff --git a/src/ffmpegio/filtergraph/convert.py b/src/ffmpegio/filtergraph/convert.py index 6741cd85..68a83d45 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -2,16 +2,12 @@ from .. import filtergraph as fgb from . import utils as filter_utils -from .exceptions import FiltergraphConversionError, FiltergraphInvalidExpression -def as_filter( - filter_spec: str | fgb.abc.FilterGraphObject, copy: bool = False -) -> fgb.Filter: +def as_filter(filter_spec: str | fgb.abc.FilterGraphObject) -> 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``. @@ -20,29 +16,9 @@ def as_filter( 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 + return ( + filter_spec if isinstance(filter_spec, fgb.Filter) else fgb.Filter(filter_spec) + ) def as_filterchain( @@ -60,23 +36,12 @@ def as_filterchain( 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 + + return ( + filter_specs + if not copy and isinstance(filter_specs, fgb.Chain) + else fgb.Chain(filter_specs) + ) def as_filtergraph( @@ -91,14 +56,11 @@ def as_filtergraph( 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 + return ( + filter_specs + if not copy and isinstance(filter_specs, fgb.Graph) + else fgb.Graph(filter_specs) + ) def as_filtergraph_object( @@ -119,17 +81,14 @@ def as_filtergraph_object( 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, False) - 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 + specs, links, sws_flags = filter_utils.parse_graph(filter_specs, False) + 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]) + ) def as_filtergraph_object_like( @@ -141,9 +100,10 @@ def as_filtergraph_object_like( :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. + :param copy: ``True`` to copy if the input is a ``Chain`` or a ``Graph``. + ``Filter`` objects are immutable so they are always returned as is. :return: Filtergraph object of the same type as ``like`` object. - No copy is performed if the input is already a ``Graph`` and ``copy=False``. + No copy is performed if the input is already a ``Graph`` and ``copy=False``. """ otype = type(like) return ( @@ -169,14 +129,12 @@ def atleast_filterchain( 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, False) - 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 + return fgb.Chain(filter_specs) + + # str input + specs, links, sws_flags = filter_utils.parse_graph(filter_specs, False) + return ( + fgb.Graph(specs, links, sws_flags) + if links or sws_flags or len(specs) > 1 + else fgb.Chain(specs[0]) + ) diff --git a/src/ffmpegio/filtergraph/utils.py b/src/ffmpegio/filtergraph/utils.py index d91a892c..372d7d17 100644 --- a/src/ffmpegio/filtergraph/utils.py +++ b/src/ffmpegio/filtergraph/utils.py @@ -6,6 +6,8 @@ from fractions import Fraction from typing import Literal, overload +from .exceptions import FiltergraphInvalidExpression + # Filter string parser/composer # For FilterGraph class, see ../filtergraph.py @@ -56,7 +58,9 @@ def conv_val(s): s = next(arg_iter, None) if not in_quote and any((c in s for c in ",;[]")): - raise ValueError("filter specification includes reserved characters ',;[]'") + raise FiltergraphInvalidExpression( + "filter specification includes reserved characters ',;[]'" + ) if s is None: break @@ -169,7 +173,9 @@ def parse_filter( try: args, kwargs = parse_filter_args(s_args) if s_args else ((), {}) except Exception as e: - raise ValueError(f'"{expr}" is not a valid filter expression.') from e + raise FiltergraphInvalidExpression( + f'"{expr}" is not a valid filter expression.' + ) from e return filter_name if filter_id is None else (filter_name, filter_id), args, kwargs @@ -296,7 +302,7 @@ def add_pad(label, output, *padspec): # more matching labels padspecs.append(padspec) else: - raise ValueError( + raise FiltergraphInvalidExpression( f"Filter graph specifies multiple '{label}' {'output' if output else 'input'} pads." ) @@ -353,7 +359,7 @@ def parse_labels(expr, i, output, *cidfid): # add quoted text to fs unchanged j = expr.find("'", i) + 1 if j <= 0: - raise ValueError( + raise FiltergraphInvalidExpression( "a quote in the filter graph string not terminated properly" ) fs += expr[i - 1 : j] diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index a835cb23..93d5a527 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -345,7 +345,7 @@ def test_rattach(right, left, right_on, out): "[lb]trim;scale[la]", False, None, - None, + "[la0]fps[UNC2];[UNC0]crop[lb0];[lb1]trim[UNC3];[UNC1]scale[la1]", ), ( "[la]fps;crop[lb]", @@ -354,20 +354,26 @@ def test_rattach(right, left, right_on, out): None, "[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), ( - "sws_flags=w=200;fps;crop", - "sws_flags=h=400;trim;scale", + "sws_flags=fast_bilinear;fps;crop", + "sws_flags=bicubic;trim;scale", False, + None, + None, + ), + ( + "sws_flags=fast_bilinear;fps;crop", + "sws_flags=bicubic;trim;scale", False, - "sws_flags=w=200;[UNC0]fps[UNC4];[UNC1]crop[UNC5];[UNC2]trim[UNC6];[UNC3]scale[UNC7]", + "first", + "sws_flags=fast_bilinear;[UNC0]fps[UNC4];[UNC1]crop[UNC5];[UNC2]trim[UNC6];[UNC3]scale[UNC7]", ), ( - "sws_flags=w=200;fps;crop", - "sws_flags=h=400;trim;scale", + "sws_flags=fast_bilinear;fps;crop", + "sws_flags=bicubic;trim;scale", False, - True, - "sws_flags=h=400;[UNC0]fps[UNC4];[UNC1]crop[UNC5];[UNC2]trim[UNC6];[UNC3]scale[UNC7]", + "last", + "sws_flags=bicubic;[UNC0]fps[UNC4];[UNC1]crop[UNC5];[UNC2]trim[UNC6];[UNC3]scale[UNC7]", ), ], ) @@ -376,9 +382,11 @@ def test_stack(fg, other, auto_link, replace_sws_flags, out): fg = fgb.Graph(fg) if out is None: with pytest.raises(fgb.Graph.Error): - fg = fg.stack(other, auto_link, replace_sws_flags) + fg = fg.stack( + other, auto_link=auto_link, sws_flags_policy=replace_sws_flags + ) else: - fg = fg.stack(other, auto_link, replace_sws_flags) + fg = fg.stack(other, auto_link=auto_link, sws_flags_policy=replace_sws_flags) assert fg.compose() == out diff --git a/tests/test_filtergraph_convert.py b/tests/test_filtergraph_convert.py new file mode 100644 index 00000000..f77804ab --- /dev/null +++ b/tests/test_filtergraph_convert.py @@ -0,0 +1,105 @@ +import pytest + +from ffmpegio import filtergraph as fgb + + +@pytest.mark.parametrize("filter_spec", ["scale", fgb.scale()]) +def test_as_filter(filter_spec): + assert isinstance(fgb.as_filter(filter_spec), fgb.Filter) + + +@pytest.mark.parametrize( + "filter_spec,copy", + [ + ("scale,fps", True), + (fgb.Chain("scale,fps"), False), + (fgb.Chain("scale,fps"), True), + ], +) +def test_as_filterchain(filter_spec, copy): + obj = fgb.as_filterchain(filter_spec, copy) + assert isinstance(obj, fgb.Chain) + if copy: + assert id(obj) != id(filter_spec) + else: + assert id(obj) == id(filter_spec) + + +@pytest.mark.parametrize( + "filter_spec,copy", + [ + ("scale,fps", True), + (fgb.Graph("scale,fps"), False), + (fgb.Graph("scale,fps"), True), + ], +) +def test_as_filtergraph(filter_spec, copy): + obj = fgb.as_filtergraph(filter_spec, copy) + assert isinstance(obj, fgb.Graph) + if copy: + assert id(obj) != id(filter_spec) + else: + assert id(obj) == id(filter_spec) + + +@pytest.mark.parametrize( + "filter_spec,copy,res", + [ + ("", True, fgb.Chain), + ("scale", True, fgb.Filter), + ("scale,fps", True, fgb.Chain), + ("scale;fps", True, fgb.Graph), + (fgb.scale(), False, fgb.Filter), + (fgb.Chain("scale,fps"), False, fgb.Chain), + (fgb.Graph("scale,fps"), False, fgb.Graph), + (fgb.Graph("scale,fps"), True, fgb.Graph), + ], +) +def test_as_filtergraph_object(filter_spec, copy, res): + obj = fgb.as_filtergraph_object(filter_spec, copy) + assert isinstance(obj, res) + if copy: + assert id(obj) != id(filter_spec) + else: + assert id(obj) == id(filter_spec) + + +@pytest.mark.parametrize( + "filter_spec,like,copy", + [ + (fgb.Chain("scale,fps"), fgb.Chain("scale,fps"), False), + (fgb.Chain("scale,fps"), fgb.Chain("scale,fps"), True), + (fgb.Chain("scale,fps"), fgb.Graph("scale,fps"), True), + ], +) +def test_as_filtergraph_object_like(filter_spec, like, copy): + obj = fgb.as_filtergraph_object_like(filter_spec, like, copy) + assert isinstance(obj, type(like)) + if copy: + assert id(obj) != id(filter_spec) + else: + assert id(obj) == id(filter_spec) + + +@pytest.mark.parametrize( + "filter_spec,copy,res", + [ + ("scale", True, fgb.Chain), + ("[in]scale[out]", True, fgb.Graph), + ("sws_flags=linear;scale", True, fgb.Graph), + ("scale;fps", True, fgb.Graph), + (fgb.scale(), True, fgb.Chain), + (fgb.Chain("scale,fps"), False, fgb.Chain), + (fgb.Chain("scale,fps"), True, fgb.Chain), + (fgb.Graph("scale,fps"), False, fgb.Graph), + (fgb.Graph("scale,fps"), True, fgb.Graph), + ], +) +def test_atleast_filterchain(filter_spec, copy, res): + + obj = fgb.atleast_filterchain(filter_spec, copy) + assert isinstance(obj, res) + if copy: + assert id(obj) != id(filter_spec) + else: + assert id(obj) == id(filter_spec) diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index ea2b993f..749b5842 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -424,3 +424,80 @@ def test_remove_label(links, n, nin): # "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 + + +@pytest.mark.parametrize( + ("link_objs,cumsum_chains,res"), + [ + ([], [], ({}, [])), + ([None], [0], ({}, [None])), + ( + [ + GraphLinks( + { + "in": ((0, 0, 0), None), + "out": (None, (1, 0, 0)), + 0: ((1, 0, 1), (0, 1, 0)), + } + ), + GraphLinks( + { + "in": ((0, 0, 0), None), + "link": ((1, 0, 0), (0, 0, 0)), + 0: ((1, 0, 1), (0, 1, 0)), + 1: ((0, 1, 0), (2, 0, 0)), + } + ), + ], + [0, 2], + ( + { + "in0": ((0, 0, 0), None), + "in1": ((2, 0, 0), None), + "out": (None, (1, 0, 0)), + "link": ((3, 0, 0), (2, 0, 0)), + 0: ((1, 0, 1), (0, 1, 0)), + 1: ((3, 0, 1), (2, 1, 0)), + 2: ((2, 1, 0), (4, 0, 0)), + }, + [ + {"in": "in0", "out": "out", 0: 0}, + {"in": "in1", "link": "link", 0: 1, 1: 2}, + ], + ), + ), + ( + [ + GraphLinks({"0:v:0": ((0, 0, 0), None)}), + GraphLinks({"0:v:0": (((0, 0, 0), (1, 0, 0)), None)}), + ], + [0, 2], + ( + { + "0:v:0": (((0, 0, 0), (2, 0, 0), (3, 0, 0)), None), + }, + [{}, {}], + ), + ), + ], +) +def test_combine(link_objs, cumsum_chains, res): + out = GraphLinks.combine(link_objs, cumsum_chains) + assert out[1] == res[1] + assert out[0] == res[0] + + +@pytest.mark.parametrize( + "links,res", + [ + ( + [ + GraphLinks({"la": ((0, 0, 0), None), "lb": (None, (1, 0, 0))}), + GraphLinks({"lb": ((0, 0, 0), None), "la": (None, (1, 0, 0))}), + ], + [("la", 0, 1), ("lb", 1, 0)], + ), + ], +) +def test_pair_unconnected_labels(links, res): + assert res == GraphLinks.pair_unconnected_labels(links) diff --git a/tests/test_filtergraph_filter.py b/tests/test_filtergraph_filter.py index 709ecc63..275ea69a 100644 --- a/tests/test_filtergraph_filter.py +++ b/tests/test_filtergraph_filter.py @@ -216,7 +216,7 @@ def test_apply(): # fmt:off ( operator.__add__, - fgb.Filter("scale"), + fgb.scale(), "overlay", "[UNC0]scale[L0];[L0][UNC1]overlay[UNC2]", ), diff --git a/tests/test_utils_filter.py b/tests/test_utils_filter.py index 2a2c76e5..029bbb2f 100644 --- a/tests/test_utils_filter.py +++ b/tests/test_utils_filter.py @@ -2,10 +2,12 @@ logging.basicConfig(level=logging.INFO) -from ffmpegio.filtergraph import utils as filter_utils from pprint import pprint + import pytest +from ffmpegio.filtergraph import utils as filter_utils + def test_parse_filter(): f = "loudnorm" @@ -44,11 +46,7 @@ def test_compose_filter(): print(filter_utils.compose_filter("loudnorm")) f = "loudnorm=print_format=summary:linear=true" - print( - filter_utils.compose_filter( - "loudnorm", dict(print_format="summary", linear=True) - ) - ) + print(filter_utils.compose_filter("loudnorm", print_format="summary", linear=True)) f = "scale=iw/2:-1" print(filter_utils.compose_filter("scale", "iw/2", -1)) @@ -64,16 +62,14 @@ def test_compose_filter(): print( filter_utils.compose_filter( "drawtext", - dict( - fontfile="/usr/share/fonts/truetype/DroidSans.ttf", - timecode="09:57:00:00", - r=25, - x="(w-tw)/2", - y="h-(2*lh)", - fontcolor="white", - box=1, - boxcolor="0x00000000@1", - ), + fontfile="/usr/share/fonts/truetype/DroidSans.ttf", + timecode="09:57:00:00", + r=25, + x="(w-tw)/2", + y="h-(2*lh)", + fontcolor="white", + box=1, + boxcolor="0x00000000@1", ) ) From b8fc2705620b9240a086adce9ab0d7a5a4104ac9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 19 Feb 2026 22:28:25 -0600 Subject: [PATCH 329/333] wip 7 - debugging filtergraph.abc.get_input_pad --- src/ffmpegio/filtergraph/Graph.py | 20 +++-- src/ffmpegio/filtergraph/GraphLinks.py | 103 ++++++++++++++----------- src/ffmpegio/filtergraph/abc.py | 2 +- src/ffmpegio/stream_spec.py | 16 ++-- tests/test_filtergraph.py | 64 +-------------- tests/test_filtergraph_abc.py | 58 ++++++++++++++ 6 files changed, 144 insertions(+), 119 deletions(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 820cc0a2..e5ff5209 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -603,7 +603,9 @@ def iter_input_pads( chainable_only, ): # exclude a pad connected to an input stream - is_stream_spec = is_map_option(other_pidx, allow_missing_file_id=True) + is_stream_spec = is_map_option( + other_pidx, allow_missing_file_id=True, unique_stream=True + ) if (is_stream_spec and exclude_stream_specs) or ( not is_stream_spec and only_stream_specs ): @@ -1241,13 +1243,15 @@ def _connect( if len(from_right) != len(to_left): raise ValueError("Mismatched number of pads in 'from_left' and 'to_right'") + other_fg = fgb.as_filtergraph_object(other) + new_links, sws_flags, chain_offsets, link_mappings = self._stack_analyze( - [other], False, sws_flags_policy, insert_at + [other_fg], False, sws_flags_policy, insert_at ) # convert the connecting pads to the new stacked graph to be created - left, right = (self, other) if insert_at == 0 else (other, self) + left, right = (self, other_fg) if insert_at == 0 else (other_fg, self) def get_pads( fg: fgb.abc.FilterGraphObject, pad: PAD_INDEX | str, is_input: bool @@ -1255,7 +1259,7 @@ def get_pads( is_right = fg == right if isinstance(pad, str): - pad = new_links[link_mappings[(is_right, pad)]][not is_input] + pad = new_links[link_mappings[is_right][pad]][not is_input] else: pad = fg.normalize_pad_index(is_input, pad) pad = (pad[0] + chain_offsets[is_right], *pad[1:]) @@ -1300,15 +1304,17 @@ def get_pads( if len(chain_links): # sort chain_pairs in descending order of trailing chain chain_pairs = sorted( - ((out_pad[0], in_pad[0]) for out_pad, in_pad in chain_links), + ((out_pad, in_pad) for out_pad, in_pad in chain_links), key=lambda cp: cp[1], reverse=True, ) # joining chains - for cid_out, cid_in in chain_pairs: + for out_pad, in_pad in chain_pairs: + cid_out, cid_in = out_pad[0], in_pad[0] + # fix the existing links - self._links.combine_chains(cid_out, cid_in, len(self[cid_out])) + new_links.combine_chains(out_pad, in_pad, len(self[cid_out])) # move the trailing chain to the end of the leading chain fc = new_fg.pop(cid_in) diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index cced8eaf..27100f83 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -152,26 +152,6 @@ def validate(data: dict[str | int, PAD_PAIR]): 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+?$") @@ -187,7 +167,6 @@ def __init__( 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( @@ -362,7 +341,7 @@ def resolve_label( self, label: str | int | None, force: bool = False, - check_stream_spec: bool = True, + check_stream_spec: bool = False, auto_index: bool = False, auto_index_sep: str = "", ) -> str | int: @@ -915,7 +894,7 @@ def create_label( if (outpad is None) == (inpad is None): raise ValueError("outpad or inpad (but not both) must be given.") - is_stspec = is_map_option(label, allow_missing_file_id=True) + is_stspec = outpad is None and is_map_option(label, allow_missing_file_id=True) if not is_stspec: label = self.resolve_label(label, force=force, check_stream_spec=False) @@ -946,7 +925,7 @@ def create_label( inpad0 = () elif isinstance(inpad0[0], int): inpad0 = (inpad0,) - inpad = (*inpad0, *(inpad if isinstance(inpad[0], tuple) else (inpad,))) + inpad = (*inpad0, *inpad) label_in_use = False # OK to overwrite if pad_in_use == label: pad_in_use = None @@ -985,7 +964,7 @@ def remove_label(self, label: str, inpad: PAD_INDEX | None = None): try: inpads, outpad = self.data[label] - except: + except KeyError: raise GraphLinks.Error(f"{label} is not a valid link label.") if inpads is None or (outpad is None and inpad is None): @@ -1021,7 +1000,7 @@ def rename(self, old_label: str, new_label: str, force: bool = False) -> str: :return: renamed label name """ v = self.data[old_label] - label = self.resolve_label(new_label, force) + label = self.resolve_label(new_label, force, check_stream_spec=v[1] is None) del self.data[old_label] self.data[label] = v return label @@ -1051,7 +1030,8 @@ def update( assert isinstance(other, Mapping) except Exception: raise GraphLinks.Error("Other must be a dict-like mapping object") - self.validate(other) + + # self.validate(other) # set aside labels labels = { @@ -1237,7 +1217,7 @@ def adjust_pair(inpads, outpad): fglinks.data = data return fglinks - def combine_chains(self, cid_out: int, cid_in: int, n_out: int): + def combine_chains(self, out_pad: PAD_INDEX, in_pad: PAD_INDEX, n_out: int): """adjust pad indices as two chains are combined :param cid_out: id of the host chain @@ -1251,23 +1231,56 @@ def combine_chains(self, cid_out: int, cid_in: int, n_out: int): caller must remove such link if it exists. """ + + # check that the pads to be chained are available + # (no go if either pads are already connected) + label_out = self.find_outpad_label(out_pad) + label_in = self.find_inpad_label(in_pad) + if label_in == label_out: + if label_in is not None: + self.remove_label(label_in) + else: + if label_out is not None: + if self.is_linked(label_out): + raise ValueError( + f"cannot combine chains because {out_pad=} is already linked" + ) + self.remove_label(label_out) + + if label_in is not None: # in_pad already used + if self.is_linked(label_in) and not self.are_linked(in_pad, out_pad): + raise ValueError( + f"cannot combine chains because {in_pad=} is already linked" + ) + self.remove_label(label_in) + + cid_out, cid_in = out_pad[0], in_pad[0] + + # update all the pad indices appearing after the input chain for label, (inpad, outpad) in self.items(): + cid_out, cid_in = out_pad[0], in_pad[0] + if isinstance(inpad, tuple): - if inpad[0] == cid_in: - inpad = (cid_out, inpad[1] + n_out, inpad[2]) - elif inpad[0] > cid_in: - inpad = (inpad[0] - 1, *inpad[1:]) - elif isinstance(inpad, list): - inpad = [ - (cid_out, pad[1] + n_out, pad[2]) - if pad[0] == cid_in - else (pad[0] - 1, *pad[1:]) - if pad[0] > cid_in - else pad - for pad in inpad - ] - if outpad[0] == cid_in: - outpad = (cid_out, outpad[1] + n_out, outpad[2]) - elif outpad[0] > cid_in: - outpad = (outpad[0] - 1, *outpad[1:]) + if isinstance(inpad[0], int): + # input label + if inpad[0] == cid_in: + inpad = (cid_out, inpad[1] + n_out, inpad[2]) + elif inpad[0] > cid_in: + inpad = (inpad[0] - 1, *inpad[1:]) + else: + # pads connected to an input stream are always in a nested tuple + inpad = [ + (cid_out, pad[1] + n_out, pad[2]) + if pad[0] == cid_in + else (pad[0] - 1, *pad[1:]) + if pad[0] > cid_in + else pad + for pad in inpad + ] + + if outpad is not None: + if outpad[0] == cid_in: + outpad = (cid_out, outpad[1] + n_out, outpad[2]) + elif outpad[0] > cid_in: + outpad = (outpad[0] - 1, *outpad[1:]) self[label] = (inpad, outpad) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 4dcf0994..54e7c6bf 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -491,9 +491,9 @@ def rconnect( def join( self, 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, diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 7e53fce1..de1ae510 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -391,21 +391,27 @@ def parse_map_option( return out -def is_map_option(spec: str, allow_missing_file_id: bool = False) -> bool: +def is_map_option( + spec: str, allow_missing_file_id: bool = False, unique_stream: bool = False +) -> bool: """True if valid map option string :param spec: map option string to be tested - :param allow_missing_file_id: True to allow missing input file id - :return: True if valid map option. The validity of stream_specifier is also tested. + :param allow_missing_file_id: ``True`` to allow missing input file id + :param unique_stream: ``True`` to require ``'stream_index'`` + :return: ``True`` if valid map option. The validity of stream_specifier is also tested. """ try: - parse_map_option( + opt = parse_map_option( spec, input_file_id=0 if allow_missing_file_id else None, parse_stream=True ) + if unique_stream: + return "stream_id" in opt["stream_specifier"] except Exception: return False - return True + else: + return True def map_option( diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index 93d5a527..ad2360e1 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -390,31 +390,6 @@ def test_stack(fg, other, auto_link, replace_sws_flags, out): assert fg.compose() == out -@pytest.mark.parametrize( - "fg, id, out", - [ - # fmt: off - ("fps;crop", (0, 0, 0), ((0, 0, 0), None)), - ("fps;crop", (1, 0, 0), ((1, 0, 0), None)), - ("fps;crop", (0, 1, 0), None), - ("fps;crop", "fake", None), - ("[la]fps;crop[lb]", "la", ((0, 0, 0), "la")), - ("[la]fps;crop[lb]", "lb", None), - ("[0:v]fps;[0:v]crop", (0, 0, 0), None), - ("[0:v]fps;[0:v]crop", "0:v", None), - # fmt: on - ], -) -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.FiltergraphPadNotFoundError): - fg.get_input_pad(id) - else: - assert fg.get_input_pad(id) == out - - @pytest.mark.parametrize( "fg, id, out", [ @@ -449,7 +424,7 @@ def test_get_output_pad(fg, id, out): ["b"], ["c"], None, - "[a1]fps[UNC2];[UNC0]crop[L0];[L0]trim[UNC3];[UNC1]scale[d1]", + "[a]fps[UNC2];[UNC0]crop[L0];[L0]trim[UNC3];[UNC1]scale[d]", ), ( "[la]fps;crop[lb]", @@ -457,7 +432,7 @@ def test_get_output_pad(fg, id, out): ["lb"], ["lb"], None, - "[la]fps[UNC2];[UNC0]crop[L0];[L0]trim[UNC3];[UNC1]scale[la1]", + "[la0]fps[UNC2];[UNC0]crop[L0];[L0]trim[UNC3];[UNC1]scale[la1]", ), ( "[a1]fps;crop[b]", @@ -465,7 +440,7 @@ def test_get_output_pad(fg, id, out): ["b"], ["c"], True, - "[a1]fps[UNC2];[UNC0]crop,trim[UNC3];[UNC1]scale[d1]", + "[a]fps[UNC2];[UNC0]crop,trim[UNC3];[UNC1]scale[d]", ), # fmt: on ], @@ -481,39 +456,6 @@ def test_connect(fg, r, to_l, to_r, chain, out): assert fg.compose() == out -@pytest.mark.parametrize( - "fg, r, how, unlabeled_only, out", - [ - # fmt: off - ( - "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,scale[out2];[UNC0]crop[ou1];[in2]trim[UNC1]", - ), - ("fps", "overlay", "per_chain", False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), - # fmt: on - ], -) -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, unlabeled_only=unlabeled_only) - else: - 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)] diff --git a/tests/test_filtergraph_abc.py b/tests/test_filtergraph_abc.py index e4a77136..22c99fb0 100644 --- a/tests/test_filtergraph_abc.py +++ b/tests/test_filtergraph_abc.py @@ -132,3 +132,61 @@ def test_resolve_pad_index( ) == ret ) + + +@pytest.mark.parametrize( + "fg, r, how, unlabeled_only, out", + [ + # fmt: off + ( + "fps;crop", + "trim;scale", + None, + False, + "[UNC0]fps,trim[UNC2];[UNC1]crop,scale[UNC3]", + ), + ( + "[in1]fps;crop[out1]", + "[in2]trim;scale[out2]", + None, + True, + "[in0]fps,scale[out1];[UNC0]crop[out0];[in1]trim[UNC1]", + ), + ("fps", "overlay", "per_chain", False, "[UNC0]fps[L0];[L0][UNC1]overlay[UNC2]"), + # fmt: on + ], +) +def test_join(fg, r, how, unlabeled_only, out): + # other, auto_link=False, replace_sws_flags=None, + fg = fgb.as_filtergraph_object(fg) + if out is None: + with pytest.raises(fgb.Graph.Error): + fg = fg.join(r, how, unlabeled_only=unlabeled_only) + else: + fg = fg.join(r, how, unlabeled_only=unlabeled_only) + assert fg.compose() == out + + +@pytest.mark.parametrize( + "fg, id, out", + [ + # fmt: off + ("fps;crop", (0, 0, 0), ((0, 0, 0), None)), + ("fps;crop", (1, 0, 0), ((1, 0, 0), None)), + ("fps;crop", (0, 1, 0), None), + ("fps;crop", "fake", None), + ("[la]fps;crop[lb]", "la", ((0, 0, 0), "la")), + ("[la]fps;crop[lb]", "lb", None), + ("[0:v]fps;[0:v]crop", (0, 0, 0), None), + ("[0:v]fps;[0:v]crop", "0:v", None), + # fmt: on + ], +) +def test_get_input_pad(fg, id, out): + # other, auto_link=False, replace_sws_flags=None, + fg = fgb.as_filtergraph_object(fg) + if out is None: + with pytest.raises(fgb.FiltergraphPadNotFoundError): + fg.get_input_pad(id) + else: + assert fg.get_input_pad(id) == out From 6d2ebb358e2fa72e6afd6f4e283371a4b976e22e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 22 Feb 2026 22:06:50 -0600 Subject: [PATCH 330/333] wip 8 - debugging GraphLinks --- src/ffmpegio/filtergraph/Chain.py | 4 +- src/ffmpegio/filtergraph/Graph.py | 20 +- src/ffmpegio/filtergraph/GraphLinks.py | 1740 ++++++++++++++---------- src/ffmpegio/filtergraph/abc.py | 6 +- src/ffmpegio/filtergraph/typing.py | 16 +- tests/test_filtergraph_fglinks.py | 82 +- 6 files changed, 1041 insertions(+), 827 deletions(-) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index feef51fd..5e184dd4 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -3,6 +3,8 @@ from collections import UserList from collections.abc import Callable, Generator, Sequence +from typing_extensions import Iterable, Literal + from .. import filtergraph as fgb from . import utils as filter_utils from .exceptions import ( @@ -10,7 +12,7 @@ FiltergraphInvalidExpression, FiltergraphInvalidIndex, ) -from .typing import PAD_INDEX, Iterable, Literal +from .typing import PAD_INDEX __all__ = ["Chain"] diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index e5ff5209..8819faf0 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -8,13 +8,15 @@ from math import floor, log10 from tempfile import NamedTemporaryFile +from typing_extensions import Iterable, Literal + from .. import filtergraph as fgb from ..errors import FFmpegioError from ..stream_spec import is_map_option from . import utils as filter_utils from .exceptions import * from .GraphLinks import GraphLinks -from .typing import PAD_INDEX, Iterable, Literal +from .typing import PAD_INDEX __all__ = ["Graph"] @@ -654,7 +656,7 @@ def iter_output_pads( ): yield v - def get_num_inputs(self, chainable_only=False): + def get_num_inputs(self, chainable_only: bool = False) -> int: return len( list( self.iter_input_pads( @@ -667,12 +669,11 @@ 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, only_stream_specs: bool = False + 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 - :param only_stream_specs: True to only include input streams :yield: a tuple of 3-tuple pad index and the pad index of the connected output pad if connected """ for label_index in self._links.iter_inputs( @@ -950,10 +951,8 @@ def is_chain_prependable(self, chain_id: int) -> bool: if nin == 0: return False - inpad = (chain_id, 0, nin - 1) - conn_from = self._links.input_dict().get(inpad) - - return conn_from is None or isinstance(conn_from, str) + # make sure the chaining input pad is not already linked + return not self._links.are_linked(inpad=(chain_id, 0, nin - 1)) def is_chain_appendable(self, chain_id: int) -> bool: """True if another chain can be appended to the specified filter chain @@ -975,10 +974,7 @@ def is_chain_appendable(self, chain_id: int) -> bool: # the last output pad must not be already connected filter_id = len(chain) - 1 - outpad = (chain_id, filter_id, nout - 1) - - conn_to = self._links.output_dict().get(outpad) - return conn_to is None or isinstance(conn_to, str) + return not self._links.are_linked(outpad=(chain_id, filter_id, nout - 1)) def stack( self, diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 27100f83..ba06c3e4 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -3,14 +3,44 @@ import re from collections import UserDict, defaultdict from collections.abc import Callable, Generator, Mapping, Sequence -from copy import deepcopy + +from typing_extensions import Literal, cast from ..errors import FFmpegioError from ..stream_spec import is_map_option -from .typing import PAD_INDEX, PAD_PAIR, Literal, cast +from .typing import PAD_INDEX, PAD_PAIR -""" +PAD_INDEX_ = tuple[int, int, int] +"""proper pad index, exclusively used in GraphLinks """ + +PAD_PAIR = tuple[PAD_INDEX | tuple[PAD_INDEX], PAD_INDEX] +"""user-provided pad index pair + + A tuple pair of (input pad index, output pad index). The first item, + input pad index, may be a tuple of pad indices ONLY when the associated + label is an input stream. + + Only one of input or output pad maybe ``None``, indicating the associated + pad label is an input/output label. + + Unlike ``PAD_PAIR_`` the pad indices in this tuple may contain a partial pad + index. + """ + + +PAD_PAIR_ = tuple[PAD_INDEX_ | tuple[PAD_INDEX_] | None, PAD_INDEX_ | None] +"""concrete pad index pair mapped to every filter pad label + A tuple pair of (input pad index, output pad index). The first item, + input pad index, may be a tuple of pad indices ONLY when the associated + label is an input stream. + + Only one of input or output pad maybe ``None``, indicating the associated + pad label is an input/output label. + + """ + +""" 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 @@ -38,11 +68,9 @@ def iter_inpad_ids( """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: @@ -56,105 +84,348 @@ def iter_inpad_ids( yield inpad @staticmethod - def validate_label( - label: str | int, is_link: bool = False, no_stream_spec: bool = False - ): + def validate_label(label: str | int, is_link: bool | None = None) -> str | int: + if isinstance(label, int): - if not is_link: + if is_link is False: raise GraphLinks.Error( "A pad label without a link must be a string label." ) - else: - if not (isinstance(label, str) and len(label)): - raise GraphLinks.Error( - "Pad label must be a string and has at least one character." - ) - if no_stream_spec and is_map_option(label, allow_missing_file_id=True): + elif isinstance(label, str) and len(label): + # remove the square bracket if present + if label[0] == "[" and label[-1] == "]": + label = label[1:-1] + + if not re.match(r"[a-zA-Z0-9_]+$", label): raise GraphLinks.Error( - f"Pad label cannot be an input stream specifier ({label})." + "Pad label must be a string of alphanumeric characters and '_'" ) + else: + raise GraphLinks.Error( + f"{type(label)} is not a supported pad label data type" + ) + + return label @staticmethod - def validate_pad_idx(id: PAD_INDEX | None, none_ok: bool = True): + def validate_input_stream(label: str) -> bool: + + if not isinstance(label, str): + raise GraphLinks.Error( + "Input stream specifier must be an input stream specifier." + ) + elif isinstance(label, str) and len(label): + # remove the square bracket if present + if label[0] == "[" and label[-1] == "]": + label = label[1:-1] + + # check for input stream specifier + if not is_map_option(label, allow_missing_file_id=True): + raise GraphLinks.Error("Pad label is not an input stream specifier.") - if id is None: + return label + + @staticmethod + def validate_pad_idx( + index: PAD_INDEX | None, + none_ok: bool = False, + default_chain_pos: int = 0, + default_filter_pos: int = 0, + ) -> PAD_INDEX_: + + if index is None: if none_ok: return raise GraphLinks.Error("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)" - ) + if isinstance(index, int): + values = [None, None, index] + else: + try: + n = len(index) + assert ( + n > 0 + and n <= 3 + and all( + (isinstance(pos, int) and pos >= 0) or (pos is None and none_ok) + for pos in index + ) + ) + except (TypeError, AssertionError) as e: + raise GraphLinks.Error( + "pad index must be a nonnegative int or a sequence of up to 3 ints" + ) from e + + if n == 1: + values = [None, None, *index] + elif n == 2: + values = [None, *index] + else: + values = [*index] + + if not none_ok: + if values[2] is None: + raise GraphLinks.Error("filter pad position cannot be None") + + # use chain and filter default positions if not provided + for i, default in enumerate((default_chain_pos, default_filter_pos, None)): + pos = values[i] + if pos is None: + values[i] = default + + return (*values,) @staticmethod - def validate_pad_idx_pair(ids: PAD_PAIR): + def validate_pad_idx_pair( + ids: tuple[PAD_INDEX | Sequence[PAD_INDEX] | None, PAD_INDEX | None], + none_pos_ok: bool = False, + default_chain_pos: int = 0, + default_filter_pos: int = 0, + ) -> tuple[PAD_INDEX_ | tuple[PAD_INDEX_] | None, PAD_INDEX_ | None]: try: assert len(ids) == 2 - except: - raise GraphLinks.Error( - "Link value must be a 2-element tuple with inpad and outpad pad ids" - ) + except (TypeError, AssertionError): + raise GraphLinks.Error("Link index pair must be a 2-element sequence.") (inpad, outpad) = ids - GraphLinks.validate_pad_idx(outpad) - inpad_is_none = inpad is None - if inpad_is_none and outpad is None: + if inpad is None and outpad is None: raise GraphLinks.Error("Both input and output pads cannot be None.") - i = -1 - for i, d in enumerate(GraphLinks.iter_inpad_ids(inpad, True)): - if d is None and not inpad_is_none: - raise GraphLinks.Error("multi-id input label item cannot be None.") - GraphLinks.validate_pad_idx(d) + if inpad is not None: + try: + inpad = GraphLinks.validate_pad_idx( + inpad, none_pos_ok, default_chain_pos, default_filter_pos + ) + except GraphLinks.Error: + inpad = tuple( + GraphLinks.validate_pad_idx( + item, False, default_chain_pos, default_filter_pos + ) + for item in inpad + ) + + if len(inpad) == 0: + raise GraphLinks.Error( + "At least one input pad must be connected to an input stream." + ) + + if outpad is not None: + outpad = GraphLinks.validate_pad_idx( + outpad, none_pos_ok, default_chain_pos, default_filter_pos + ) + + return inpad, outpad @staticmethod - def validate_item(label: str | int, pads: PAD_PAIR): + def validate_item( + label: str | int | None, + pads: PAD_PAIR, + none_label_ok: bool = False, + item_type: Literal["link", "label"] | None = None, + default_chain_pos: int = 0, + default_filter_pos: int = 0, + ) -> tuple[str | int | None, PAD_PAIR_]: + """validate and format a graph link pair of a label and a pair of pads + + :param label: link label or ``None`` to be auto-numbered + :param pads: input-output pad pair. One of the pad may be ``None`` to + specify an input/output label. If ``label`` is an input stream + specifier string, multiple input pads may be provided with output + pad as ``None``. + :param none_label_ok: ``True`` to allow ``label`` to be ``None``, + defaults to ``False`` + :param link_only: ``True`` to raise an exception if the item is not a + link + :param label_only: ``True`` to raise an exception if the item is a + link + :param default_chain_pos: default chain position to use if chain + position is missing in a pad index, defaults to 0 + :param default_filter_pos: default filter position to use if filter + position is missing in a pad index, defaults to 0 + :return: input label and pads with the latter in a tuple-only format + """ + + if item_type is None: + item_type = "label" if any(pad is None for pad in pads) else "link" + + return ( + GraphLinks.validate_link_item( + label, pads, none_label_ok, default_chain_pos, default_filter_pos + ) + if item_type == "link" + else GraphLinks.validate_label_item( + label, pads, default_chain_pos, default_filter_pos + ) + ) - GraphLinks.validate_pad_idx_pair(pads) # this fails if None-None pair + @staticmethod + def validate_link_item( + label: str | int | None, + pads: PAD_PAIR, + none_label_ok: bool = False, + default_chain_pos: int = 0, + default_filter_pos: int = 0, + ) -> tuple[str | int | None, PAD_PAIR_]: + """validate and format a graph link pair of a label and a pair of pads + + :param label: link label or ``None`` to be auto-numbered + :param pads: input-output pad pair. One of the pad may be ``None`` to + specify an input/output label. If ``label`` is an input stream + specifier string, multiple input pads may be provided with output + pad as ``None``. + :param none_label_ok: ``True`` to allow ``label`` to be ``None``, + defaults to ``False`` + :param link_only: ``True`` to raise an exception if the item is not a + link + :param label_only: ``True`` to raise an exception if the item is a + link + :param default_chain_pos: default chain position to use if chain + position is missing in a pad index, defaults to 0 + :param default_filter_pos: default filter position to use if filter + position is missing in a pad index, defaults to 0 + :return: input label and pads with the latter in a tuple-only format + """ - inpad_given = pads[0] is not None - outpad_given = pads[1] is not None + if label is None: + if not none_label_ok: + raise GraphLinks.Error("label cannot be None.") + else: + # check label and whether it can be an input stream specifier + label = GraphLinks.validate_label(label, is_link=True) - GraphLinks.validate_label( - label, is_link=inpad_given and outpad_given, no_stream_spec=outpad_given + # check the pad pair + inpad, outpad = GraphLinks.validate_pad_idx_pair( + pads, False, default_chain_pos, default_filter_pos ) + if inpad is None or outpad is None or isinstance(inpad[0], tuple): + raise GraphLinks.Error( + "a link item must specify one-to-one output to input connection." + ) + + return label, (inpad, outpad) + @staticmethod - def validate(data: dict[str | int, PAD_PAIR]): + def validate_label_item( + label: str | int | None, + pads: PAD_PAIR, + default_chain_pos: int = 0, + default_filter_pos: int = 0, + ) -> tuple[str, PAD_PAIR_]: + """validate and format a graph link pair of a label and a pair of pads + + :param label: link label or ``None`` to be auto-numbered + :param pads: input-output pad pair. One of the pad may be ``None`` to + specify an input/output label. If ``label`` is an input stream + specifier string, multiple input pads may be provided with output + pad as ``None``. + :param none_label_ok: ``True`` to allow ``label`` to be ``None``, + defaults to ``False`` + :param link_only: ``True`` to raise an exception if the item is not a + link + :param label_only: ``True`` to raise an exception if the item is a + link + :param default_chain_pos: default chain position to use if chain + position is missing in a pad index, defaults to 0 + :param default_filter_pos: default filter position to use if filter + position is missing in a pad index, defaults to 0 + :return: input label and pads with the latter in a tuple-only format + """ + + if not isinstance(label, str): + raise GraphLinks.Error("label must be a string.") + + # check label and whether it can be an input stream specifier + try: + label_ = GraphLinks.validate_label(label, is_link=False) + except GraphLinks.Error: + label_ = GraphLinks.validate_input_stream(label) + is_stream_spec = True + else: + is_stream_spec = False + + # check the pad pair + inpad, outpad = GraphLinks.validate_pad_idx_pair( + pads, False, default_chain_pos, default_filter_pos + ) + + if (inpad is None) and (outpad is None): + raise GraphLinks.Error( + "Only one of input and output pads can be specified." + ) + + if inpad is None: + if is_stream_spec: + raise GraphLinks.Error("ouput label cannot be a stream specifier") - inpads = set() # inpad cannot be repeated + # output pad - completed + return label_, (inpad, outpad) + + # input pad - check input stream specifier + if is_stream_spec: + if isinstance(inpad[0], int): + inpad = (inpad,) + + return label_, (inpad, outpad) + + @staticmethod + def validate( + data: dict[str | int, PAD_PAIR], + default_chain_pos: int = 0, + default_filter_pos: int = 0, + ) -> dict[int | str, PAD_PAIR_]: + """validate and format a user-defined GraphLinks compatible map + + :param data: user-defined map of pad labels and input and output pads + :param default_chain_pos: default chain position to use if chain + position is missing in a pad index, defaults to 0 + :param default_filter_pos: default filter position to use if filter + position is missing in a pad index, defaults to 0 + :return: equivalent dict of ``data`` but its content are guaranteed to + be used as ``GraphLinks`` items. + """ + # pads must be unique for both input and output + used_inpads = set() + used_outpads = set() # validate each link - for label, pads in data.items(): - if ( - not is_map_option(label, allow_missing_file_id=True) - and pads[0] is not None - and isinstance(pads[0][0], tuple) - ): - raise GraphLinks.Error( - "Only map specifier labels can have multiple input pads." - ) + out = {} + for label, pad_pair in data.items(): + key, value = GraphLinks.validate_item( + label, + pad_pair, + default_chain_pos=default_chain_pos, + default_filter_pos=default_filter_pos, + ) - GraphLinks.validate_item(label, pads) - for d in GraphLinks.iter_inpad_ids(pads[0]): - # inpad pad id must be unique - if d in inpads: + # check the uniqueness of input pads + for pad in GraphLinks.iter_inpad_ids(value[0]): + if pad in used_inpads: raise GraphLinks.Error( - f"Duplicate entries of inpad pad id {d} found (must be unique)" + f"Input filter pad {value[0]} is used multiple times" ) - if d is not None: - inpads.add(d) + used_inpads.add(pad) + + # check the uniqueness of output pads + if value[1] in used_outpads: + raise GraphLinks.Error( + f"Out filter pad {value[1]} is used multiple times" + ) + if value[1] is not None: + used_outpads.add(value[1]) + + # all good, add to the formatted dict + out[key] = value + + return out # regex pattern to identify a label with a trailing number AutoLabelPattern = re.compile(r"^L\d+?$") + _auto_count: int = 0 + def __init__( self, links: dict[str | int, PAD_PAIR] | GraphLinks | None = None, @@ -164,10 +435,20 @@ def __init__( super().__init__() # validate input arg - if isinstance(links, GraphLinks): - self.data = links.data.copy() - elif links is not None: - self.update(links) + if links is not None: + if not isinstance(links, GraphLinks): + links = self.validate(links) + + # re-number all int labels + self.data = { + k if isinstance(k, str) else self._auto_label(): v + for k, v in links.items() + } + + def _auto_label(self) -> int: + i = self._auto_count + self._auto_count = self._auto_count + 1 + return i def link( self, @@ -181,11 +462,13 @@ def link( :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 + :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. @@ -200,47 +483,148 @@ def link( """ - self.validate_pad_idx(inpad, none_ok=False) - self.validate_pad_idx(outpad, none_ok=False) + label_, (inpad_, outpad_) = self.validate_link_item( + None if isinstance(label, int) else label, + (inpad, outpad), + none_label_ok=True, + ) + + pads_to_unlink = [] # 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 + inlabel = self._is_inpad_used(inpad_) + if inlabel is True: + if not force: + raise GraphLinks.Error( + f"The specified input pad {inpad} is already linked." + ) + pads_to_unlink.append({"inpad": inpad_}) + inlabel = False + elif inlabel is not False and self.is_input_stream(inlabel): + if not ( + force + or (len(self.data[inlabel][0]) == 1 and self.validate_label(inlabel)) + ): + raise GraphLinks.Error( + "specified input pad is already connected to an input stream." + ) + pads_to_unlink.append({"label": inlabel}) + inlabel = False # 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 + outlabel = self._is_outpad_used(outpad_) + if outlabel is True: + if not force: + raise GraphLinks.Error( + f"The specified output pad {outpad_} is already linked." + ) + pads_to_unlink.append({"inpad": outpad_}) + outlabel = False + + if label_ in self and (label_ != inlabel and label_ != outlabel): + if not force: + raise GraphLinks.Error( + f"The specified label {label} is already in use." + ) + pads_to_unlink.append({"label": label_}) + + # good to proceed: unlink the existing pads/labels + for kws in pads_to_unlink: + self.unlink(**kws) + + # if label is not set, try to use input/output label if exists + if label_ is None and preserve_label is not False: + label_ = ( + outlabel if preserve_label == "output" or inlabel is None else inlabel ) - if not (in_label or out_label): - # new label, resolve - label = self.resolve_label(label, force) + # new auto-label + if not label_: + label_ = self._auto_label() # create the new link (overwrite if forced) - self.data[label] = (inpad, outpad) + self.data[label_] = (inpad_, outpad_) - return label + return label_ + + 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. + + """ + + label_, (inpad_, outpad_) = self.validate_label_item(label, (inpad, outpad)) + + label_in_use = label_ in self + + pads_to_unlink = [] + + if not force and label_in_use and not self.is_input_stream(label_): + raise GraphLinks.Error(f"{label=} is already in use.") + + # make sure no input pad is already in use + if inpad_ is not None: + for pad in self.iter_inpad_ids(inpad_): + inlabel = self._is_inpad_used(pad) + if inlabel is True: + # pad is already connected + if force: + pads_to_unlink.append({"inpad": pad}) + else: + raise GraphLinks.Error(f"input pad {pad} is already in use.") + elif inlabel is not False and inlabel != label_: + # pad already has an input label + if force: # or not self.is_input_stream(inlabel): + # allow renaming as long as not input stream + pads_to_unlink.append({"label": inlabel}) + else: + raise GraphLinks.Error( + f"input pad {pad} is already connected to another input stream ({inlabel})." + ) + + # make sure output pad is already in use + if outpad_ is not None: + outlabel = self._is_outpad_used(outpad_) + if outlabel is True: + # pad is already connected + if force: + pads_to_unlink.append({"outpad": outpad_}) + else: + raise GraphLinks.Error(f"output pad {outpad_} is already in use.") + + elif outlabel is not False and inlabel != label_: + # is an input label + pads_to_unlink.append({"label": outlabel}) + + # all good, remove existing items + for cfg in pads_to_unlink: + self.unlink(**cfg) + + if isinstance(inpad_, tuple) and label_ in self: + # extend input stream specifier connections + self.data[label_] = ((*self[label_][0], *inpad_), None) + else: + self.data[label_] = (inpad_, outpad_) + + return label_ def link_by_labels( self, @@ -280,12 +664,12 @@ def link_by_labels( self[label] = link_value del self[in_label] elif preserve_label is False: - label = self.resolve_label(None) + label = self._auto_label() linked = False else: raise ValueError(f"{preserve_label=} is not a valid value.") else: - self.validate_label(label) + label = self.validate_label(label, is_link=True) # delete old labels and create a new link with the chosen label if not linked: @@ -313,125 +697,242 @@ def unlink(self, label=None, inpad=None, outpad=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 + + if not self.is_linked(label) and self.is_input_stream(label): + inpads, outpad = self.data[label] + if len(inpads) == 1: + del self.data[label] + else: + self.data[label] = ( + tuple(pad for pad in inpads if pad != inpad), + outpad, + ) + else: 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 int label removed, refresh auto-labels 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 rename(self, old_label: str, new_label: str, force: bool = False) -> str: + """rename a label - def resolve_label( - self, - label: str | int | None, - force: bool = False, - check_stream_spec: bool = False, - auto_index: bool = False, - auto_index_sep: str = "", - ) -> 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 - :param auto_index: True to append a number to a string label until a unique - label is found, defaults to False to error out. - :param auto_index_sep: a string to separate the label and the auto-index number, - defaults to '' - :return: validated label name/id + :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 """ - 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 old_label not in self: + raise ValueError(f"{old_label=} does not exist.") - if check_stream_spec and is_map_option(label, allow_missing_file_id=True): - return label + label_ = self.validate_label(new_label, is_link=self.is_linked(old_label)) + + if label_ in self: + if force: + del self.data[label_] + else: + raise ValueError(f"{new_label} is already used.") - if not force and label in self: - if not auto_index: - raise GraphLinks.Error(f"{label=} is already in use.") - i = 0 - label_ = f"{label}{auto_index_sep}" - while label in self: - i += 1 - label = f"{label_}{i}" + v = self.data[old_label] + del self.data[old_label] + self.data[label_] = v + return label_ - self.validate_label(label) + def remove_label(self, label: str, inpad: PAD_INDEX | None = None): + """remove an input/output label - return label + :param label: unconnected link label + :param inpad: (multi-input label only) specify the input filter pad id - @staticmethod - def combine( - link_objs: Sequence[GraphLinks | None], cumsum_chains: Sequence[int] - ) -> tuple[GraphLinks, list[dict[tuple[int, str | int], str | int]] | None]: - """combine ``GraphLinks`` objects into one, resolving duplicate labels + Removing an input label by default removes all associated filter pad ids + unless `inpad` is specified. - :params link_objs: ``GraphLinks`` objects to be combined. If ``None``, - the entry is ignored. - :param cumsum_chains: cumulative sum of the number of chains of the - filtergraphs that are associated with ``link_objs`` - :return combined_link_obj: a new ``GraphLinks`` object of all the links - combined. Input streams are not linked, and they are returned - separately as the third output below. - :return mapping: list of mapping pairs for each element of - ``link_objs``. Each mapping links ``link_objs`` item's old labels to - its new labels in ``combined_link_obj``. If a ``link_objs`` element - is a ``None``, a ``None`` is returned in ``mapping`` instead of a - ``dict``. """ - # accumulate all the labels (remove trailing numbers if exist to match) - labels: dict[str | int, list[tuple[int, str]]] = defaultdict(list) - input_streams: dict[str, list[int]] = defaultdict(list) - regexp = re.compile(r"\d+$") - for i, (obj, cid0) in enumerate(zip(link_objs, cumsum_chains)): - if obj is None: - continue - links = cast(GraphLinks, obj) - for label in links: - key = label - if isinstance(key, str): - if links.is_input_stream(label): - # update the connected input pads - input_streams[label].extend( - [ - (cid + cid0, fid, pid) - for cid, fid, pid in links[label][0] - ] - ) - continue - else: - m = regexp.search(key) - if m: - key = key[: m.start()] + if isinstance(label, int): + raise ValueError( + f"{label=} must be str. Use `unlink` to remove auto-numbered links." + ) - labels[key].append((i, label)) + try: + inpads, outpad = self.data[label] + except KeyError: + raise GraphLinks.Error(f"{label} is not a valid link label.") - # create mapping table - # - generate new labels for duplicated labels - mappings = [obj and {} for obj in link_objs] - int_counter = 0 - for key, matches in labels.items(): + if isinstance(inpads, tuple) and inpad is not None: # input streams + if inpad not in inpads: + raise GraphLinks.Error(f"input pad {(inpad)} is not found") + + if len(inpads) > 1: + # no other input pad indices assigne to the input stream + del self.data[label] + else: + # remove only the requested input pad index + self.data[label] = ( + tuple(pad for pad in inpads if pad != inpad), + outpad, + ) + else: + # simple in/out label + del self.data[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: + raise GraphLinks.Error("Other must be a dict-like mapping object") + + other = 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 _is_inpad_used(self, pad: PAD_INDEX_) -> bool | str: + """check if given input pad index is already linked + + :param pad: output pad index to check + :return: ``True`` if ``pad`` is already connected to an input pad, or + the output label ``str`` if the pad has a label but not connected + yet, or ``False`` if no record is found. + """ + + for label, (inpads, outpad) in self.items(): + for inpad in self.iter_inpad_ids(inpads): + if pad == inpad: + return label if outpad is None else True + return False + + def _is_outpad_used(self, pad: PAD_INDEX_) -> bool | str: + """check if given output pad index is already linked + + :param pad: output pad index to check + :return: ``True`` if ``pad`` is already connected to an input pad, or + the output label ``str`` if the pad has a label but not connected + yet, or ``False`` if no record is found. + """ + for label, (inpad, outpad) in self.items(): + if pad == outpad: + return label if inpad is None else True + return False + + 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] + + ############################################################################ + ### LINK manipulation + ############################################################################ + + @staticmethod + def combine( + link_objs: Sequence[GraphLinks | None], cumsum_chains: Sequence[int] + ) -> tuple[GraphLinks, list[dict[tuple[int, str | int], str | int]] | None]: + """combine ``GraphLinks`` objects into one, resolving duplicate labels + + :params link_objs: ``GraphLinks`` objects to be combined. If ``None``, + the entry is ignored. + :param cumsum_chains: cumulative sum of the number of chains of the + filtergraphs that are associated with ``link_objs`` + :return combined_link_obj: a new ``GraphLinks`` object of all the links + combined. Input streams are not linked, and they are returned + separately as the third output below. + :return mapping: list of mapping pairs for each element of + ``link_objs``. Each mapping links ``link_objs`` item's old labels to + its new labels in ``combined_link_obj``. If a ``link_objs`` element + is a ``None``, a ``None`` is returned in ``mapping`` instead of a + ``dict``. + """ + + # accumulate all the labels (remove trailing numbers if exist to match) + labels: dict[str | int, list[tuple[int, str]]] = defaultdict(list) + input_streams: dict[str, list[int]] = defaultdict(list) + regexp = re.compile(r"\d+$") + for i, (obj, cid0) in enumerate(zip(link_objs, cumsum_chains)): + if obj is None: + continue + links = cast(GraphLinks, obj) + for label in links: + key = label + if isinstance(key, str): + if links.is_input_stream(label): + # update the connected input pads + input_streams[label].extend( + [ + (cid + cid0, fid, pid) + for cid, fid, pid in links[label][0] + ] + ) + continue + else: + m = regexp.search(key) + if m: + key = key[: m.start()] + + labels[key].append((i, label)) + + # create mapping table + # - generate new labels for duplicated labels + mappings = [obj and {} for obj in link_objs] + int_counter = 0 + for key, matches in labels.items(): if isinstance(key, int): # auto-labels (auto-label over all internal links) for i, old_label in matches: @@ -469,49 +970,6 @@ def combine( return combined, mappings - def relabel(self) -> GraphLinks: - """relabel ``GraphLinks`` - :return: a new ``GraphLinks`` object of all the internal int labels - renumbered as well as the trailing numbers of user labels - """ - - # accumulate all the labels (remove trailing numbers if exist to match) - labels: dict[str | int, list[str | int]] = defaultdict(list) - regexp = re.compile(r"\d+$") - - for label in self: - key = label - if isinstance(key, str): - m = regexp.search(key) - if m: - key = key[: m.start()] - - labels[key].append(label) - - # create mapping table - # - generate new labels for duplicated labels - mappings = {} - int_counter = 0 - for key, matches in labels.items(): - if isinstance(key, int): - # auto-labels (auto-label over all internal links) - for old_label in matches: - new_label = int_counter - int_counter += 1 - mappings[old_label] = new_label - else: - # explicit labels (append a unique suffix number) - for j, old_label in enumerate(matches): - new_label = f"{key}{j}" - mappings[old_label] = new_label - - # create the combined object - new_links = GraphLinks() - for label, link in self.items(): - new_links[mappings[label]] = deepcopy(link) - - return new_links - @staticmethod def pair_unconnected_labels( link_objs: Sequence[GraphLinks | None], @@ -561,105 +1019,294 @@ def pair_unconnected_labels( if label in output_labels ] - def __getitem__(self, key: str | int) -> PAD_PAIR: - """get link item by label or by inpad pad id tuple + def remove_chains(self, chains: Sequence[int]): + """insert/delete contiguous chains from fg - :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 + :param chains: positions of the chains that are removed """ - 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) + if not len(chains): + return # nothing to remove - def is_linked(self, label: str) -> bool: - """True if label specifies a link + chains = list(enumerate(sorted(set(chains))))[::-1] - :param label: link label - :return: True if label is a link + def adj(pid): + return ( + pid[0] - next((i + 1 for i, v in chains if v < pid[0]), 0), + *pid[1:], + ) - 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])) + select = lambda pid: pid[0] >= chains[0][1] # select all chains at or above pos + self._modify_pad_ids(select, adj) - def is_input(self, label: str, exclude_stream_specs: bool = False) -> bool: - """True if label specifies an input + def map_chains( + self, + mapper: int | Mapping[int, int] | None, + shifter: Mapping[int, int] | None = None, + ) -> GraphLinks: + """Generate a new GraphLink object with a chain id mapper - :param label: link label - :param exclude_stream_specs: ``True`` to return ``False`` if the label - is an input stream spec. - :return: ``True`` if label is an input - """ - lnk = self.data.get(label, None) - return ( - lnk - and lnk[1] is None - and not (exclude_stream_specs and self.is_input_stream(label)) - ) + :param mapper: the current chain id as a key and the new chain id as its + value. If an int value, all the chains are offset by the value. + :param shifter: keyed chain links are shifted by the given value if specified + + Note: if a chain is indexed in both `mapper` and `shifter`, its links + are first shifted then mapped. - def is_input_stream(self, label: str) -> bool: - """``True`` if label specifies an input stream map - :param label: input stream map specifier - :return: ``True`` if label is an input """ - return label in self and is_map_option(label, allow_missing_file_id=True) + if shifter is not None and len(shifter): - def is_output(self, label: str) -> bool: - """``True`` if label specifies an output + def shift_padidx(pad): + if pad[0] in shifter: + pad = (pad[0], pad[1] + shifter[pad[0]], pad[2]) + return pad - :param label: link label - :return: ``True`` if label is an output + def shift_pair(inpads, outpad): + if outpad is not None: + outpad = shift_padidx(outpad) + if inpads is not None: + if isinstance(inpads[0], int): # single-input + inpads = shift_padidx(inpads) + else: # multiple-inputs (an input stream) + inpads = tuple(shift_padidx(d) for d in inpads) + return (inpads, outpad) - 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))) + data = {label: shift_pair(*value) for label, value in self.items()} + else: + data = self.data - 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 + # check for duplicate value + if isinstance(mapper, int): - :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 - """ + class OffsetMapper: + nmap = len(self) - def iter(label, inpad, outpad): + def __init__(self, offset): + self._off = offset + + def __len__(self): + return self.nmap + + 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) + + if mapper is not None and len(mapper): + + def map_padidx(pad): + if pad[0] in mapper: + pad = (mapper[pad[0]], *pad[1:]) + return pad + + def adjust_pair(inpads, outpad): + if outpad is not None: + outpad = map_padidx(outpad) + if inpads is not None: + if isinstance(inpads[0], int): # single-input + inpads = map_padidx(inpads) + else: # multiple-inputs (an input stream) + inpads = tuple(map_padidx(d) for d in inpads) + return (inpads, outpad) + + data = {label: adjust_pair(*value) for label, value in data.items()} + + fglinks = GraphLinks() + fglinks.data = data + return fglinks + + def combine_chains(self, out_pad: PAD_INDEX, in_pad: PAD_INDEX, n_out: int): + """adjust pad indices as two chains are combined + + :param cid_out: id of the host chain + :param cid_in: id of the moving chain + :param n_out: number of filters of the host chain + + .. warning:: + this operation does not check for the existence of a link between the + last output pad of the last filter of the ``cid_out`` chain and the + last input pad of the first filter of the ``cid_in`` chain. The + caller must remove such link if it exists. + + """ + + # check that the pads to be chained are available + # (no go if either pads are already connected) + label_out = self.find_outpad_label(out_pad) + label_in = self.find_inpad_label(in_pad) + if label_in == label_out: + if label_in is not None: + self.remove_label(label_in) + else: + if label_out is not None: + if self.is_linked(label_out): + raise ValueError( + f"cannot combine chains because {out_pad=} is already linked" + ) + self.remove_label(label_out) + + if label_in is not None: # in_pad already used + if self.is_linked(label_in) and not self.are_linked(in_pad, out_pad): + raise ValueError( + f"cannot combine chains because {in_pad=} is already linked" + ) + self.remove_label(label_in) + + cid_out, cid_in = out_pad[0], in_pad[0] + + # update all the pad indices appearing after the input chain + for label, (inpad, outpad) in self.items(): + cid_out, cid_in = out_pad[0], in_pad[0] + + if isinstance(inpad, tuple): + if isinstance(inpad[0], int): + # input label + if inpad[0] == cid_in: + inpad = (cid_out, inpad[1] + n_out, inpad[2]) + elif inpad[0] > cid_in: + inpad = (inpad[0] - 1, *inpad[1:]) + else: + # pads connected to an input stream are always in a nested tuple + inpad = [ + (cid_out, pad[1] + n_out, pad[2]) + if pad[0] == cid_in + else (pad[0] - 1, *pad[1:]) + if pad[0] > cid_in + else pad + for pad in inpad + ] + + if outpad is not None: + if outpad[0] == cid_in: + outpad = (cid_out, outpad[1] + n_out, outpad[2]) + elif outpad[0] > cid_in: + outpad = (outpad[0] - 1, *outpad[1:]) + self[label] = (inpad, outpad) + + 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 __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: str) -> bool: + """True if label specifies a link + + :param label: link label + :return: True if label is a link + + If multi-inpad label, True if any inpad is not None + """ + inpad, outpad = self.data.get(label, (None, None)) + return inpad is not None and outpad is not None + + def is_input(self, label: str, exclude_stream_specs: bool = False) -> bool: + """True if label specifies an input + + :param label: link label + :param exclude_stream_specs: ``True`` to return ``False`` if the label + is an input stream spec. + :return: ``True`` if label is an input + """ + lnk = self.data.get(label, None) + return ( + lnk + and lnk[1] is None + and not (exclude_stream_specs and isinstance(lnk[0], tuple)) + ) + + def is_input_stream(self, label: str) -> bool: + """``True`` if label specifies an input stream map + + :param label: input stream map specifier + :return: ``True`` if label is an input + """ + + return ( + label in self + and isinstance(label, str) + and self.data[label][0] is not None + and isinstance(self.data[label][0][0], tuple) + ) + + def is_output(self, label: str) -> bool: + """``True`` if label specifies an output + + :param label: link label + :return: ``True`` if label is an output + + If multi-inpad label, True if any inpad is None + """ + lnk = self.data.get(label, None) + return lnk and lnk[0] is None + + 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) + :yield: a full link definition (inpad or outpad may be None if input or output label, respectively) + """ + + def iter(label, inpad, outpad): for d in self.iter_inpad_ids(inpad, True): yield (label, d, outpad) if label is None: + # all input pads for label, (inpad, outpad) in self.data.items(): for v in iter(label, inpad, outpad): yield v else: + # only specified label 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]]: + ) -> Generator[tuple[str, PAD_INDEX_, PAD_INDEX_ | None]]: """Iterate over only actual links, separating inpad ids with the same input stream @@ -669,9 +1316,8 @@ def iter_links( """ def iter(label, inpad, outpad): - if outpad is not None or ( - include_input_stream - and is_map_option(label, allow_missing_file_id=True) + if outpad is not None and ( + inpad is not None or (include_input_stream and isinstance(inpad, tuple)) ): for d in self.iter_inpad_ids(inpad): yield (label, d, outpad) @@ -685,8 +1331,8 @@ def iter(label, inpad, outpad): yield v def iter_inputs( - self, exclude_stream_specs: bool = True, only_stream_specs: bool = False - ) -> Generator[tuple[str, PAD_INDEX]]: + 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 @@ -695,29 +1341,27 @@ def iter_inputs( :yield: label and pad index """ for label, (inpad, outpad) in self.data.items(): - is_stream = is_map_option(label, allow_missing_file_id=True) - if outpad is None and ( - (is_stream and not exclude_stream_specs) - or not (is_stream or only_stream_specs) + if outpad is None and not ( + exclude_stream_specs and isinstance(inpad, tuple) ): for d in self.iter_inpad_ids(inpad): yield (label, d) - def iter_input_streams(self) -> Generator[tuple[str, PAD_INDEX]]: + 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_map_option(label, allow_missing_file_id=True): + for label, (inpad, _) in self.data.items(): + if isinstance(inpad, tuple): for d in self.iter_inpad_ids(inpad): yield (label, d) - def iter_outputs(self) -> Generator[tuple[str, PAD_INDEX]]: + def iter_outputs(self) -> Generator[tuple[str, PAD_INDEX_]]: """Iterate over only output labels - :yield: a full output definition + :yield: output label and pad index """ # iterate over all labels @@ -725,7 +1369,7 @@ def iter_outputs(self) -> Generator[tuple[str, PAD_INDEX]]: if inpad is None: yield (label, outpad) - def input_dict(self) -> dict[PAD_INDEX, PAD_INDEX | str]: + 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 @@ -743,7 +1387,7 @@ def input_dict(self) -> dict[PAD_INDEX, PAD_INDEX | str]: for d in self.iter_inpad_ids(inpad) } - def output_dict(self) -> dict[PAD_INDEX, PAD_INDEX | str]: + 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 @@ -864,423 +1508,23 @@ def chain_has_link( 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. + def _modify_pad_ids(self, select: Callable, adjust: Callable): + """generic pad id modifier - If label has a trailing number, the number will be dropped and replaced with an - internally assigned label number. + :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 """ - 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 = outpad is None and is_map_option(label, allow_missing_file_id=True) - if not is_stspec: - label = self.resolve_label(label, force=force, check_stream_spec=False) - - label_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) - 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 KeyError: - 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, check_stream_spec=v[1] is None) - 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: - raise GraphLinks.Error("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) + 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] | None, - shifter: Mapping[int, int] | None = None, - ) -> GraphLinks: - """Generate a new GraphLink object with a chain id mapper - - :param mapper: the current chain id as a key and the new chain id as its - value. If an int value, all the chains are offset by the value. - :param shifter: keyed chain links are shifted by the given value if specified - - Note: if a chain is indexed in both `mapper` and `shifter`, its links - are first shifted then mapped. - - - """ - - if shifter is not None and len(shifter): - - def shift_padidx(pad): - if pad[0] in shifter: - pad = (pad[0], pad[1] + shifter[pad[0]], pad[2]) - return pad - - def shift_pair(inpads, outpad): - if outpad is not None: - outpad = shift_padidx(outpad) - if inpads is not None: - if isinstance(inpads[0], int): # single-input - inpads = shift_padidx(inpads) - else: # multiple-inputs (an input stream) - inpads = tuple(shift_padidx(d) for d in inpads) - return (inpads, outpad) - - data = {label: shift_pair(*value) for label, value in self.items()} - else: - data = self.data - - # check for duplicate value - if isinstance(mapper, int): - - class OffsetMapper: - nmap = len(self) - - def __init__(self, offset): - self._off = offset - - def __len__(self): - return self.nmap - - def __contains__(self, _): - # applies to all - return True - - def __getitem__(self, i): - return i + self._off - - def get(self, k, defaults=None): - return k + self._off - - mapper = OffsetMapper(mapper) - - if mapper is not None and len(mapper): - - def map_padidx(pad): - if pad[0] in mapper: - pad = (mapper[pad[0]], *pad[1:]) - return pad - - def adjust_pair(inpads, outpad): - if outpad is not None: - outpad = map_padidx(outpad) - if inpads is not None: - if isinstance(inpads[0], int): # single-input - inpads = map_padidx(inpads) - else: # multiple-inputs (an input stream) - inpads = tuple(map_padidx(d) for d in inpads) - return (inpads, outpad) - - data = {label: adjust_pair(*value) for label, value in data.items()} - - fglinks = GraphLinks() - fglinks.data = data - return fglinks - - def combine_chains(self, out_pad: PAD_INDEX, in_pad: PAD_INDEX, n_out: int): - """adjust pad indices as two chains are combined - - :param cid_out: id of the host chain - :param cid_in: id of the moving chain - :param n_out: number of filters of the host chain - - .. warning:: - this operation does not check for the existence of a link between the - last output pad of the last filter of the ``cid_out`` chain and the - last input pad of the first filter of the ``cid_in`` chain. The - caller must remove such link if it exists. - - """ - - # check that the pads to be chained are available - # (no go if either pads are already connected) - label_out = self.find_outpad_label(out_pad) - label_in = self.find_inpad_label(in_pad) - if label_in == label_out: - if label_in is not None: - self.remove_label(label_in) - else: - if label_out is not None: - if self.is_linked(label_out): - raise ValueError( - f"cannot combine chains because {out_pad=} is already linked" - ) - self.remove_label(label_out) - - if label_in is not None: # in_pad already used - if self.is_linked(label_in) and not self.are_linked(in_pad, out_pad): - raise ValueError( - f"cannot combine chains because {in_pad=} is already linked" - ) - self.remove_label(label_in) - - cid_out, cid_in = out_pad[0], in_pad[0] - - # update all the pad indices appearing after the input chain - for label, (inpad, outpad) in self.items(): - cid_out, cid_in = out_pad[0], in_pad[0] - - if isinstance(inpad, tuple): - if isinstance(inpad[0], int): - # input label - if inpad[0] == cid_in: - inpad = (cid_out, inpad[1] + n_out, inpad[2]) - elif inpad[0] > cid_in: - inpad = (inpad[0] - 1, *inpad[1:]) - else: - # pads connected to an input stream are always in a nested tuple - inpad = [ - (cid_out, pad[1] + n_out, pad[2]) - if pad[0] == cid_in - else (pad[0] - 1, *pad[1:]) - if pad[0] > cid_in - else pad - for pad in inpad - ] - - if outpad is not None: - if outpad[0] == cid_in: - outpad = (cid_out, outpad[1] + n_out, outpad[2]) - elif outpad[0] > cid_in: - outpad = (outpad[0] - 1, *outpad[1:]) - self[label] = (inpad, outpad) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 54e7c6bf..880979a9 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -3,10 +3,12 @@ from abc import ABC, abstractmethod from collections.abc import Generator, Sequence +from typing_extensions import Literal, Self, get_args + from .. import filtergraph as fgb from .exceptions import * from .GraphLinks import GraphLinks -from .typing import JOIN_HOW, PAD_INDEX, Literal, Self, get_args +from .typing import JOIN_HOW, PAD_INDEX __all__ = ["FilterGraphObject"] @@ -15,8 +17,6 @@ class FilterGraphObject(ABC): @staticmethod def relabel_duplicates(*fgs: tuple[FilterGraphObject]): ... - def relabel(self, labels: dict[str | int, str | int]): ... - @property def links(self) -> GraphLinks | None: """filtergraph link definition only if filtergraph""" diff --git a/src/ffmpegio/filtergraph/typing.py b/src/ffmpegio/filtergraph/typing.py index 66d5121b..a831d111 100644 --- a/src/ffmpegio/filtergraph/typing.py +++ b/src/ffmpegio/filtergraph/typing.py @@ -1,13 +1,11 @@ from __future__ import annotations -from typing import * - -from typing_extensions import * +from typing_extensions import Literal, Union PAD_INDEX = Union[ - Tuple[Union[int, None], Union[int, None], int], - Tuple[Union[int, None], Union[int, None]], - Tuple[Union[int, None]], + tuple[Union[int, None], Union[int, None], int], + tuple[Union[int, None], Union[int, None]], + tuple[Union[int, None]], int, ] """Filter pad index. @@ -22,9 +20,9 @@ """ PAD_PAIR = Union[ - Tuple[PAD_INDEX, PAD_INDEX], - Tuple[Union[PAD_INDEX, List[PAD_INDEX]], None], - Tuple[None, PAD_INDEX], + tuple[PAD_INDEX, PAD_INDEX], + tuple[Union[PAD_INDEX, list[PAD_INDEX]], None], + tuple[None, PAD_INDEX], ] """Specifies a filter pad linkage or labeling diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index 749b5842..7bbe4753 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -22,7 +22,6 @@ def test_iter_inpad_ids(dsts, expects): @pytest.mark.parametrize( ("args", "ok"), [ - (("0:v",), True), (("label",), True), ((0, True), True), ((0.0, True), False), @@ -38,20 +37,37 @@ def test_validate_label(args, ok): @pytest.mark.parametrize( - ("id", "ok"), + ("args", "ok"), [ - (None, True), - ((0, 0, 0), True), - ((0, 0, 0, 0), False), - ((0, 0, "0"), False), + (("0:v",), True), + (("a",), True), + (("in",), False), ], ) -def test_validate_pad_idx(id, ok): +def test_validate_input_stream(args, ok): if ok: - GraphLinks.validate_pad_idx(id) + GraphLinks.validate_input_stream(*args) else: with pytest.raises(GraphLinks.Error): - GraphLinks.validate_pad_idx(id) + GraphLinks.validate_input_stream(*args) + + +@pytest.mark.parametrize( + ("args", "ok"), + [ + ((None, True), True), + ((None, False), False), + (((0, 0, 0),), True), + (((0, 0, 0, 0),), False), + (((0, 0, "0"),), False), + ], +) +def test_validate_pad_idx(args, ok): + if ok: + GraphLinks.validate_pad_idx(*args) + else: + with pytest.raises(GraphLinks.Error): + GraphLinks.validate_pad_idx(*args) @pytest.mark.parametrize( @@ -104,28 +120,8 @@ def test_validate(data, ok): GraphLinks.validate(data) -@pytest.mark.parametrize( - ("args", "expects"), - [ - (((0, 0, 0), None), ((0, 0, 0), None)), - (([(0, 0, 0), (1, 0, 0)], None), (((0, 0, 0), (1, 0, 0)), None)), - (((0, 0, 0), None, lambda id: (id[0] + 1, *id[1:])), ((1, 0, 0), None)), - ( - ((0, 0, 0), (0, 0, 0), lambda id: (id[0] + 1, *id[1:])), - ((1, 0, 0), (1, 0, 0)), - ), - ], -) -def test_format_value(args, expects): - if expects is None: - with pytest.raises(GraphLinks.Error): - GraphLinks.format_value(*args) - else: - assert GraphLinks.format_value(*args) == expects - - # fixture links with one of each type of link items -@pytest.fixture() +@pytest.fixture(scope="function") def base_links(): yield GraphLinks( { @@ -145,25 +141,6 @@ def test_init(base_links): base_links -@pytest.mark.parametrize( - ("labels", "expects"), - [ - ([0, 3, None], [0, 1, 2]), - (["a", "b"], ["a", "b"]), - ], -) -def test_resolve_label(labels, expects): - links = GraphLinks() - - def update(label): - links.data[links.resolve_label(label)] = None - - for label in labels: - update(label) - - assert list(links.keys()) == expects - - def test_iter_links(base_links): res = { ("l", (0, 0, 0), (0, 0, 0)), # regular link @@ -226,10 +203,7 @@ def test_iter_input_pads(base_links): @pytest.mark.parametrize( ("key", "expects"), - [ - ("l", ((0, 0, 0), (0, 0, 0))), - ((1, 1, 0), (0, (0, 1, 0))), - ], + [("l", ((0, 0, 0), (0, 0, 0)))], ) def test__getitem__(key, expects, base_links): @@ -333,7 +307,7 @@ def test_unlink(base_links): 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), + (((3, 0, 0), (4, 0, 0), None, None, True), 1, None), # links to inherit 'in' input label # fmt:on ], From 4802da2af1ed1ac242c2fa956b1dee88a91a6aba Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 23 Feb 2026 09:07:22 -0600 Subject: [PATCH 331/333] wip 9 - onto Graph... --- src/ffmpegio/filtergraph/Graph.py | 37 ++++------ src/ffmpegio/filtergraph/GraphLinks.py | 94 +++++++++++++++++--------- tests/test_filtergraph_fglinks.py | 4 ++ 3 files changed, 78 insertions(+), 57 deletions(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 8819faf0..28678319 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -269,31 +269,20 @@ def resolve_pad_index( # 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 - ) + if self._links.is_linked(index_or_label, include_stream_specs=True): + raise FiltergraphPadNotFoundError( + f"Pad with label='{index_or_label}' is already linked" + ) + # input/output label (input streams excluded) 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: + inpad, outpad = self._links[index_or_label] + except KeyError: raise FiltergraphPadNotFoundError( - f"{index_or_label=} is not defined on the filtergraph." - ) from exc + f"Pad with label='{index_or_label}' does not exist" + ) + + return inpad if is_input else outpad # obtain 3-element tuple index (unvalidated) return super().resolve_pad_index( @@ -676,9 +665,7 @@ def iter_input_labels( :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, only_stream_specs - ): + for label_index in self._links.iter_inputs(exclude_stream_specs): yield label_index def iter_output_labels(self) -> Generator[tuple[str, PAD_INDEX]]: diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index ba06c3e4..1fb19379 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -450,6 +450,28 @@ def _auto_label(self) -> int: self._auto_count = self._auto_count + 1 return i + def __getitem__(self, key: str | int) -> PAD_PAIR_: + + try: + try: + key_ = self.validate_label(key) + except GraphLinks.Error: + key_ = self.validate_input_stream(key) + except GraphLinks.Error as e: + raise KeyError("Unknown label") from e + + return super().__getitem__(key_) + + 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 link( self, inpad: PAD_INDEX, @@ -679,26 +701,38 @@ def link_by_labels( return label - def unlink(self, label=None, inpad=None, outpad=None): + def unlink( + self, + *, + label: str | int | 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 + + Only one of ``label``, ``inpad``, ``outpad`` should be specified. If + more than one input argument is given, the preference is given in the + order listed. + + If the specified link item does not exist, this function exits quietly. + """ 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: + if label in self.data: del self.data[label] - if inpad is not None: + # if int label removed, refresh auto-labels + if isinstance(label, int): + self._refresh_autolabels() + elif inpad is not None: label = self.find_inpad_label(inpad) - - if not self.is_linked(label) and self.is_input_stream(label): + if label is None: + return + if self.is_input_stream(label): + # if input stream with multiple connections, only unlink the requested inpads, outpad = self.data[label] if len(inpads) == 1: del self.data[label] @@ -709,10 +743,11 @@ def unlink(self, label=None, inpad=None, outpad=None): ) else: del self.data[label] - - # if int label removed, refresh auto-labels - if isinstance(label, int): - self._refresh_autolabels() + elif outpad is not None: + label = self.find_outpad_label(outpad) + if label is None: + return + del self.data[label] def rename(self, old_label: str, new_label: str, force: bool = False) -> str: """rename a label @@ -1219,17 +1254,7 @@ def adjust_filters(self, chain_id: int, pos: int, len: int): ############################################################################ - 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: str) -> bool: + def is_linked(self, label: str, include_stream_specs: bool = False) -> bool: """True if label specifies a link :param label: link label @@ -1238,7 +1263,10 @@ def is_linked(self, label: str) -> bool: If multi-inpad label, True if any inpad is not None """ inpad, outpad = self.data.get(label, (None, None)) - return inpad is not None and outpad is not None + is_link = inpad is not None and outpad is not None + if not is_link and include_stream_specs: + is_link = self.is_input_stream(label) + return is_link def is_input(self, label: str, exclude_stream_specs: bool = False) -> bool: """True if label specifies an input @@ -1251,8 +1279,9 @@ def is_input(self, label: str, exclude_stream_specs: bool = False) -> bool: lnk = self.data.get(label, None) return ( lnk + and lnk[0] is not None and lnk[1] is None - and not (exclude_stream_specs and isinstance(lnk[0], tuple)) + and not (exclude_stream_specs and isinstance(lnk[0][0], tuple)) ) def is_input_stream(self, label: str) -> bool: @@ -1262,11 +1291,12 @@ def is_input_stream(self, label: str) -> bool: :return: ``True`` if label is an input """ + lnk = self.data.get(label, None) return ( - label in self - and isinstance(label, str) - and self.data[label][0] is not None - and isinstance(self.data[label][0][0], tuple) + lnk is not None + and lnk[0] is not None + and lnk[1] is None + and isinstance(lnk[0][0], tuple) ) def is_output(self, label: str) -> bool: diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index 7bbe4753..0583247e 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -126,6 +126,7 @@ def base_links(): yield GraphLinks( { "l": ((0, 0, 0), (0, 0, 0)), # regular link + "d": (1, None), # regular link 0: ((1, 1, 0), (0, 1, 0)), # unnamed link "in": ((2, 1, 0), None), # named input "0:v": ([(3, 0, 0), (3, 1, 0)], None), # named inputs @@ -158,6 +159,7 @@ def test_iter_links(base_links): def test_iter_inputs(base_links): res = { ("in", (2, 1, 0)), # regular link + ("d", (0, 0, 1)), # regular link ("0:v", (3, 0, 0)), # unnamed link ("0:v", (3, 1, 0)), # split output label#2 } @@ -185,6 +187,7 @@ def test_iter_outputs(base_links): def test_iter_input_pads(base_links): res = { ("l", (0, 0, 0), (0, 0, 0)), # regular link + ("d", (0, 0, 1), None), # regular link (0, (1, 1, 0), (0, 1, 0)), # unnamed link ("in", (2, 1, 0), None), # named input ("0:v", (3, 0, 0), None), # named inputs @@ -308,6 +311,7 @@ def test_unlink(base_links): ), # links to inherit 'out' output label (((4, 0, 0), (1, 1, 0), None, True), 1, None), # new label (((3, 0, 0), (4, 0, 0), None, None, True), 1, None), + (((0, 0, 1), (1, 0, 2)), 1, None), # links to inherit 'in' input label # fmt:on ], From 642ab03c73855cdc989ab865ef69e6ba8cade106 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 23 Feb 2026 21:56:08 -0600 Subject: [PATCH 332/333] wip 10 - debugging Graph --- src/ffmpegio/filtergraph/Graph.py | 17 ++-- src/ffmpegio/filtergraph/GraphLinks.py | 107 ++++++++++++++----------- 2 files changed, 70 insertions(+), 54 deletions(-) diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 28678319..68f1946a 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -683,17 +683,18 @@ def are_linked( self, inpad: PAD_INDEX | None, outpad: PAD_INDEX | None, - check_input_stream: bool | str = False, + check_input_stream: bool | None = None, ) -> 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``. + :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 the default (``None``) behavior to return ``False`` + for an ambiguous label such as ``'a'`` or ``'v'``, and ``True`` for + definitive label such as ``'0:v:0'``. ``ValueError`` will be raised if both ``inpad`` and ``outpad`` ``None`` or if ``include_input_stream!=False`` and ``outpad`` is ``None``. diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 1fb19379..1f3eeda3 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -195,10 +195,12 @@ def validate_pad_idx_pair( if inpad is not None: try: + # try pad index first inpad = GraphLinks.validate_pad_idx( inpad, none_pos_ok, default_chain_pos, default_filter_pos ) except GraphLinks.Error: + # if failed, try as a sequence of pad indices (linking to an input stream) inpad = tuple( GraphLinks.validate_pad_idx( item, False, default_chain_pos, default_filter_pos @@ -339,12 +341,12 @@ def validate_label_item( # check label and whether it can be an input stream specifier try: - label_ = GraphLinks.validate_label(label, is_link=False) - except GraphLinks.Error: label_ = GraphLinks.validate_input_stream(label) - is_stream_spec = True - else: + except GraphLinks.Error: + label_ = GraphLinks.validate_label(label, is_link=False) is_stream_spec = False + else: + is_stream_spec = True # check the pad pair inpad, outpad = GraphLinks.validate_pad_idx_pair( @@ -357,16 +359,15 @@ def validate_label_item( ) if inpad is None: - if is_stream_spec: + if is_stream_spec and not re.match(r"[a-zA-Z0-9_]+$", label): raise GraphLinks.Error("ouput label cannot be a stream specifier") # output pad - completed return label_, (inpad, outpad) # input pad - check input stream specifier - if is_stream_spec: - if isinstance(inpad[0], int): - inpad = (inpad,) + if is_stream_spec and isinstance(inpad[0], int): + inpad = (inpad,) return label_, (inpad, outpad) @@ -632,7 +633,7 @@ def create_label( else: raise GraphLinks.Error(f"output pad {outpad_} is already in use.") - elif outlabel is not False and inlabel != label_: + elif outlabel is not False and outlabel != label_: # is an input label pads_to_unlink.append({"label": outlabel}) @@ -1334,6 +1335,24 @@ def iter(label, inpad, outpad): for v in iter(label, *self.data[label]): yield v + def iter_output_pads( + self, label: str | None = None + ) -> Generator[str, PAD_INDEX_, PAD_INDEX_ | None]: + """Iterate over all ``GraphLinks`` items with an assigned output pad + + :param label: to iterate only on this label, defaults to None (all frames) + :yield label: a full link definition (inpad or outpad may be None if input or output label, respectively) + :yield inpad: input pad index + :yield outpad: output pad index + """ + + if label is None: # all output pads + for label, (inpad, outpad) in self.data.items(): + if outpad is not None: + yield (label, inpad, outpad) + else: + yield label, *self.data[label] + def iter_links( self, label: str | None = None, include_input_stream: bool = False ) -> Generator[tuple[str, PAD_INDEX_, PAD_INDEX_ | None]]: @@ -1468,57 +1487,53 @@ def are_linked( self, inpad: PAD_INDEX | None, outpad: PAD_INDEX | None, - check_input_stream: bool | str = False, + check_input_stream: bool | None = None, ) -> 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``. + :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 the default (``None``) behavior to return ``False`` + for an ambiguous label such as ``'a'`` or ``'v'``, and ``True`` for + definitive label such as ``'0:v:0'``. ``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("At least one of inpad or outpad must be specified.") + if inpad is None and outpad is None: + raise ValueError("At least one of inpad or outpad must be specified.") - # check internal links first - it_links = self.iter_links() + inpad = self.validate_pad_idx(inpad, none_ok=True) + outpad = self.validate_pad_idx(outpad, none_ok=True) - # 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) - ) + if inpad is None: # any link with outpad + return any(inp is not None for _, inp, __ in self.iter_output_pads()) + elif outpad is None: # any link with inpad + for label, inp, outp in self.iter_input_pads(): + if inp is None: + continue + + if inp == inpad and outp is not None: + return True - # possible 2-step check for an arbitrary ouput + # check input stream link + if check_input_stream is not False and isinstance(inp[0], tuple): + if check_input_stream is True and any(p == inpad for p in inp[0]): + return True - # 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 + if check_input_stream is None and len(inp) == 1 and inp[0] == inpad: + # ambiguous input stream connection if it is also a valid link label + return re.match(r"[a-zA-Z0-9_]+$", label) is not None + else: # specific pairing + return any( + inp == inpad and outp == outpad for _, inp, outp in self.iter_links() ) + return False def chain_has_link( self, chain_id: int, check_input: bool = True, check_output: bool = True From c327fb7f422a069b15ac28190f399733fcf9c775 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 18 Apr 2026 12:38:51 -0500 Subject: [PATCH 333/333] wip 11 --- src/ffmpegio/filtergraph/Chain.py | 5 +- src/ffmpegio/filtergraph/Filter.py | 5 +- src/ffmpegio/filtergraph/Graph.py | 80 +++------- src/ffmpegio/filtergraph/GraphLinks.py | 139 +++++++++++------ src/ffmpegio/filtergraph/abc.py | 200 ++----------------------- src/ffmpegio/utils/__init__.py | 2 +- tests/test_filtergraph_fglinks.py | 54 ++++--- 7 files changed, 167 insertions(+), 318 deletions(-) diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index 5e184dd4..bd4ecdd6 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -330,11 +330,8 @@ def iter_input_pads( filter: int | None = None, chain: Literal[0] | None = None, *, - exclude_stream_specs: bool = False, - only_stream_specs: bool = False, - exclude_chainable: bool = False, chainable_first: bool = False, - include_connected: bool = False, + include_connected: bool | None = False, unlabeled_only: bool = False, chainable_only: bool = False, full_pad_index: bool = False, diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 532e7c32..161c23ed 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -634,11 +634,8 @@ def iter_input_pads( filter: Literal[0] | None = None, chain: Literal[0] | None = None, *, - exclude_stream_specs: bool = False, - only_stream_specs: bool = False, - exclude_chainable: bool = False, chainable_first: bool = False, - include_connected: bool = False, + include_connected: bool | None = False, unlabeled_only: bool = False, chainable_only: bool = False, full_pad_index: bool = False, diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 68f1946a..573d5eb1 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -12,7 +12,6 @@ from .. import filtergraph as fgb from ..errors import FFmpegioError -from ..stream_spec import is_map_option from . import utils as filter_utils from .exceptions import * from .GraphLinks import GraphLinks @@ -556,11 +555,8 @@ def iter_input_pads( filter: int | None = None, chain: int | None = None, *, - exclude_stream_specs: bool = True, - only_stream_specs: bool = False, - exclude_chainable: bool = False, chainable_first: bool = False, - include_connected: bool = False, + include_connected: bool | None = False, unlabeled_only: bool = False, chainable_only: bool = False, full_pad_index: bool = False, @@ -570,38 +566,39 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None - :param exclude_stream_specs: True to not include input streams - :param only_stream_specs: True to only include input streams - :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False :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 + :yield input_pad: filter pad index + :yield filter_obj: connected filter object + :yield output_pad:, output pad index of the connected filter or + connected input stream specifier or ``None`` if not connected """ + # get a dict mapping linked inputs to the output pads (or input streams) + # include_connected = True, input streams as available pads (exclude streams in link_inputs) + # False, input streams as connected (include streams in link_inputs) + # None, only definitive streams as connected treat definitive streams as streams + + excluded_inputs = self._links.input_dict( + only_links=True, + exclude_input_streams=not include_connected, + input_streams_as_links=None if include_connected is None else True, + ) + for index, f, other_pidx in self._iter_pads( fgb.Chain.iter_input_pads, - self._links.input_dict(), + excluded_inputs, pad, filter, chain, - exclude_chainable, chainable_first, include_connected, unlabeled_only, chainable_only, ): - # exclude a pad connected to an input stream - is_stream_spec = is_map_option( - other_pidx, allow_missing_file_id=True, unique_stream=True - ) - if (is_stream_spec and exclude_stream_specs) or ( - not is_stream_spec and only_stream_specs - ): - continue - yield index, f, other_pidx def iter_output_pads( @@ -649,7 +646,7 @@ def get_num_inputs(self, chainable_only: bool = False) -> int: return len( list( self.iter_input_pads( - exclude_stream_specs=True, chainable_only=chainable_only + include_connected=False, chainable_only=chainable_only ) ) ) @@ -1497,47 +1494,6 @@ def rattach( sws_flags_policy="first", ) - 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 diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 1f3eeda3..7376bf48 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -2,9 +2,9 @@ import re from collections import UserDict, defaultdict -from collections.abc import Callable, Generator, Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence -from typing_extensions import Literal, cast +from typing_extensions import Iterator, Literal, cast from ..errors import FFmpegioError from ..stream_spec import is_map_option @@ -64,7 +64,7 @@ class Error(FFmpegioError): @staticmethod def iter_inpad_ids( inpads: PAD_INDEX | list[PAD_INDEX] | None, include_labels: bool = False - ) -> Generator[PAD_INDEX]: + ) -> Iterator[PAD_INDEX]: """helper generator to work inpads ids :param inpads: inpads pad id or ids @@ -1312,32 +1312,61 @@ def is_output(self, label: str) -> bool: return lnk and lnk[0] is None 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) - :yield: a full link definition (inpad or outpad may be None if input or output label, respectively) + self, + only_labels: bool = False, + only_links: bool = False, + input_streams_as_links: bool | None = None, + only_input_streams: bool = False, + exclude_input_streams: bool = False, + ) -> Iterator[tuple[str, PAD_INDEX_, PAD_INDEX_ | None]]: + """Iterate over all link elements with assigned input pad index + + :param only_labels: ``True`` to exclude links + :param only_links: ``True`` to exclude unconnected input labels + :param input_stream_as_links: ``None`` (default) to treat only + undisputable input stream specifier as links, e.g., ``'a'`` or + ``'v'`` is treated as a label if it is assigned to only one pad + while ``'0:a:0'`` is treated as a link. Set ``False`` to treat all + input stream labels as unconnected labels, and ``True`` to treat all + likely input streams as links. + :param only_input_streams: ``True`` to include only input stream labels + :param exclude_input_streams: ``True`` to exclude input stream labels + (unless it is disputable) + :yield label: link label + :yield inpad: input pad index + :yield outpad: output pad index or ``None`` if input label """ - def iter(label, inpad, outpad): - for d in self.iter_inpad_ids(inpad, True): - yield (label, d, outpad) + if only_input_streams and exclude_input_streams: + return - if label is None: - # all input pads - for label, (inpad, outpad) in self.data.items(): - for v in iter(label, inpad, outpad): - yield v - else: - # only specified label - for v in iter(label, *self.data[label]): - yield v + # all input pads + for label, (inpad, outpad) in self.data.items(): + if inpad is None: + continue + if isinstance(inpad[0], int): # not input stream + if ( + not only_input_streams + and (not only_links or outpad is not None) + and (not only_labels or outpad is None) + ): + yield label, inpad, outpad + elif len(inpad) == 1 and re.match(r"[a-zA-Z0-9_]+$", label): + # can be either link label or input stream + # (input_streams_as_links is None == is_label) + if (input_streams_as_links is not True and not only_links) or ( + input_streams_as_links is True and not only_labels + ): + yield label, inpad[0], outpad + elif ( + not exclude_input_streams + and (input_streams_as_links is False and not only_links) + or (input_streams_as_links is not False and not only_labels) + ): + for inp in inpad: + yield label, inp, outpad - def iter_output_pads( - self, label: str | None = None - ) -> Generator[str, PAD_INDEX_, PAD_INDEX_ | None]: + def iter_output_pads(self) -> Iterator[tuple[str, PAD_INDEX_, PAD_INDEX_ | None]]: """Iterate over all ``GraphLinks`` items with an assigned output pad :param label: to iterate only on this label, defaults to None (all frames) @@ -1346,16 +1375,13 @@ def iter_output_pads( :yield outpad: output pad index """ - if label is None: # all output pads - for label, (inpad, outpad) in self.data.items(): - if outpad is not None: - yield (label, inpad, outpad) - else: - yield label, *self.data[label] + for label, (inpad, outpad) in self.data.items(): + if outpad is not None: + yield (label, inpad, outpad) def iter_links( self, label: str | None = None, include_input_stream: bool = False - ) -> Generator[tuple[str, PAD_INDEX_, PAD_INDEX_ | None]]: + ) -> Iterator[tuple[str, PAD_INDEX_, PAD_INDEX_ | None]]: """Iterate over only actual links, separating inpad ids with the same input stream @@ -1381,11 +1407,15 @@ def iter(label, inpad, outpad): def iter_inputs( self, exclude_stream_specs: bool = True - ) -> Generator[tuple[str, PAD_INDEX_]]: + ) -> Iterator[tuple[str, PAD_INDEX_]]: """Iterate over only input labels, possibly repeating the same label if shared among multiple input pad ids - :param exclude_stream_specs: True to not include input streams + :param exclude_input_streams: ``False`` (default) to include all pads, + ``True`` to exclude input pads (possibly) connected to an input + stream, and ``None`` to exclude input pads definitively connected to + an input stream. For example, ``'a'`` or ``'v'`` may be an input + stream specifier but can also be a link label. :param only_stream_specs: True to only include input streams :yield: label and pad index """ @@ -1396,7 +1426,7 @@ def iter_inputs( for d in self.iter_inpad_ids(inpad): yield (label, d) - def iter_input_streams(self) -> Generator[tuple[str, PAD_INDEX_]]: + def iter_input_streams(self) -> Iterator[tuple[str, PAD_INDEX_]]: """Iterate over input stream labels, possibly repeating the same label if shared among multiple input pad ids @@ -1407,7 +1437,7 @@ def iter_input_streams(self) -> Generator[tuple[str, PAD_INDEX_]]: for d in self.iter_inpad_ids(inpad): yield (label, d) - def iter_outputs(self) -> Generator[tuple[str, PAD_INDEX_]]: + def iter_outputs(self) -> Iterator[tuple[str, PAD_INDEX_]]: """Iterate over only output labels :yield: output label and pad index @@ -1418,22 +1448,43 @@ def iter_outputs(self) -> Generator[tuple[str, PAD_INDEX_]]: if inpad is None: yield (label, outpad) - def input_dict(self) -> dict[PAD_INDEX_, PAD_INDEX_ | str]: + def input_dict( + self, + only_labels: bool = False, + only_links: bool = False, + input_streams_as_links: bool | None = None, + only_input_streams: bool = False, + exclude_input_streams: bool = False, + ) -> 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. + :param only_labels: ``True`` to exclude links + :param only_links: ``True`` to exclude unconnected input labels + :param input_stream_as_links: ``None`` (default) to treat only + undisputable input stream specifier as links, e.g., ``'a'`` or + ``'v'`` is treated as a label if it is assigned to only one pad + while ``'0:a:0'`` is treated as a link. Set ``False`` to treat all + input stream labels as unconnected labels, and ``True`` to treat all + likely input streams as links. + :param only_input_streams: ``True`` to include only input stream labels + :param exclude_input_streams: ``True`` to exclude input stream labels + (unless it is disputable) + :return: a dict mapping input pad index to either the connected output + pad index if linked or a string if input pad is unconnected. :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) + inpad: label if outpad is None else outpad + for label, inpad, outpad in self.iter_input_pads( + only_labels, + only_links, + input_streams_as_links, + only_input_streams, + exclude_input_streams, + ) } def output_dict(self) -> dict[PAD_INDEX_, PAD_INDEX_ | str]: diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 880979a9..1466e77f 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -6,7 +6,7 @@ from typing_extensions import Literal, Self, get_args from .. import filtergraph as fgb -from .exceptions import * +from .exceptions import FiltergraphMismatchError, FiltergraphPadNotFoundError from .GraphLinks import GraphLinks from .typing import JOIN_HOW, PAD_INDEX @@ -57,96 +57,6 @@ def get_num_filters(self, chain: int | None = None) -> int: of filters across all chains """ - 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) -> Generator[fgb.Chain]: """iterate over chains of the filtergraphobject @@ -161,11 +71,8 @@ def iter_input_pads( filter: int | None = None, chain: int | None = None, *, - exclude_stream_specs: bool = False, - only_stream_specs: bool = False, - exclude_chainable: bool = False, chainable_first: bool = False, - include_connected: bool = False, + include_connected: bool | None = False, unlabeled_only: bool = False, chainable_only: bool = False, full_pad_index: bool = False, @@ -175,9 +82,6 @@ def iter_input_pads( :param pad: pad id, defaults to None :param filter: filter index, defaults to None :param chain: chain index, defaults to None - :param exclude_stream_specs: True to not include input streams - :param only_stream_specs: True to only include input streams - :param exclude_chainable: True to leave out the last input pads, defaults to False (all avail pads) :param chainable_first: True to yield the last input first then the rest, defaults to False :param include_connected: True to include pads connected to input streams, defaults to False :param unlabeled_only: True to leave out named inputs, defaults to False to return all inputs @@ -186,6 +90,18 @@ def iter_input_pads( :yield: filter pad index, link label, filter object, output pad index of connected filter if connected """ + # used by: join (unlabeled_only, full_pad_index, chainable_only) + # resolve_pad_index (chainable_first, chainable_only) + # Chain.attach (chainable_only) + # Graph.compose (unlabeled_only) + # Graph.iter_input_pads/_iter_pads (exclude_chainable, chainable_first, include_connected, chainable_only) + # Graph.get_num_inputs (exclude_stream_specs, chainable_only) + # Graph.attach/rattach (chainable_only, full_pad_index) + # analyze_complex_filtergraph (full_pad_index, exclude_stream_specs) + # + # iter_input_pads used by: + # + @abstractmethod def iter_output_pads( self, @@ -1034,94 +950,6 @@ def get_value(id_type, id_value, omittable, fill_value): 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""" diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 060272af..a11e62dd 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -700,7 +700,7 @@ def analyze_complex_filtergraphs( # for a filter or a filterchain, no labels. Connect all its inputs for i, (padidx, filt, _) in enumerate( - fg.iter_input_pads(full_pad_index=True, exclude_stream_specs=False) + fg.iter_input_pads(full_pad_index=True, include_connected=True) ): label = fg.get_label(inpad=padidx) media_type = filt.get_pad_media_type("input", padidx[-1]) diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index 0583247e..726bd4d1 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -184,24 +184,44 @@ def test_iter_outputs(base_links): assert not len(res) -def test_iter_input_pads(base_links): - res = { - ("l", (0, 0, 0), (0, 0, 0)), # regular link - ("d", (0, 0, 1), None), # regular link - (0, (1, 1, 0), (0, 1, 0)), # unnamed link - ("in", (2, 1, 0), None), # named input - ("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, (6, 0, 0)), # split output label#1 - ("sout2", (2, 0, 0), (1, 0, 0)), # split output label#2 - } - - for v in base_links.iter_input_pads(): - assert v in res - res.discard(v) +@pytest.mark.parametrize( + ("only_labels,only_links,input_streams_as_links,ret"), + [ + (False, False, None, {"link", "label", "0:v", "a", "v"}), + (False, False, False, {"link", "label", "0:v", "a", "v"}), + (False, False, True, {"link", "label", "0:v", "a", "v"}), + (False, True, None, {"link", "0:v"}), + (False, True, False, {"link"}), + (False, True, True, {"link", "0:v", "a", "v"}), + (True, False, None, {"label", "a", "v"}), + (True, False, False, {"label", "0:v", "a", "v"}), + (True, False, True, {"label"}), + (True, True, None, set()), + (True, True, False, set()), + (True, True, True, set()), + ], +) +def test_iter_input_pads(only_labels, only_links, input_streams_as_links, ret): + links = GraphLinks( + { + "link": ((0, 0, 0), (0, 0, 0)), # regular link + "label": ((1, 0, 0), None), # regular input label + "out": (None, (1, 0, 0)), # regular output label + "0:v": ((3, 0, 0), None), # input stream connection + "a": (4, None), # possible input stream + "v": ((5, 6), None), # input stream with two connections + } + ) + out = set( + ( + l + for l, *_ in links.iter_input_pads( + only_labels, only_links, input_streams_as_links + ) + ) + ) - assert not len(res) + assert ret == out @pytest.mark.parametrize(