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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] - 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] `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/344] 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/344] 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/344] 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/344] `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/344] `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/344] 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/344] 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/344] 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/344] `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/344] 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/344] 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/344] `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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] `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/344] 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/344] `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/344] `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/344] 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/344] 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/344] 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/344] `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/344] `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/344] 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/344] `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/344] 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/344] `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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `_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/344] 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/344] 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/344] `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/344] `__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/344] 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/344] 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/344] 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/344] 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/344] 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/344] `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/344] `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/344] `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/344] `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/344] 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/344] 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/344] 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/344] `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/344] `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/344] `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/344] 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/344] `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/344] `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/344] `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/344] `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/344] 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/344] 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/344] `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/344] `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/344] 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/344] `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/344] `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/344] 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/344] `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/344] `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/344] 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/344] 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/344] `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/344] `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/344] `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/344] 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/344] "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/344] 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/344] `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/344] 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/344] `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/344] 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/344] `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/344] 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/344] `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/344] 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/344] 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/344] `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/344] `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/344] 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/344] 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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] 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/344] `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/344] 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/344] `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/344] `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/344] 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/344] 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/344] `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/344] 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/344] 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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] 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/344] `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/344] 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/344] `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/344] 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/344] 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/344] 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/344] `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/344] 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/344] 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/344] 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/344] 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/344] `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/344] `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/344] `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/344] 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/344] `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/344] 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/344] 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/344] `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/344] `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/344] `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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] 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/344] `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/344] `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/344] `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/344] 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/344] 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/344] 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/344] 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/344] 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/344] `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/344] `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/344] 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/344] 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/344] `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/344] `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/344] 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/344] `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/344] `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/344] `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/344] 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/344] 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/344] 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/344] 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/344] `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/344] 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/344] - `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/344] 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/344] 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/344] 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/344] 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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] 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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] `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/344] 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/344] `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/344] 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/344] 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/344] `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/344] 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/344] 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/344] `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/344] 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/344] 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 9e35108bab3cf2e05beabe57e866f331800acabb Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Tue, 15 Jul 2025 20:59:14 -0400 Subject: [PATCH 306/344] wip --- docsrc/analysis.rst | 6 +- docsrc/conf.py | 58 +- docsrc/index.rst | 54 +- docsrc/install.rst | 43 +- docsrc/options.rst | 152 +-- docsrc/requirements.txt | 9 +- src/ffmpegio/_open.py | 968 ++++++++++++---- src/ffmpegio/_typing.py | 7 +- src/ffmpegio/configure.py | 273 ++--- src/ffmpegio/media.py | 9 +- src/ffmpegio/plugins/hookspecs.py | 42 +- src/ffmpegio/stream_spec.py | 28 + src/ffmpegio/streams/BaseFFmpegRunner.py | 607 ++++++++++ src/ffmpegio/streams/PipedStreams.py | 399 +------ src/ffmpegio/streams/SimpleStreams.py | 1287 +++++++--------------- src/ffmpegio/streams/StdStreams.py | 7 +- src/ffmpegio/streams/__init__.py | 7 +- tests/test_open.py | 36 +- tests/test_pipedstreams.py | 20 +- tests/test_simplestreams.py | 66 +- tests/test_stdstreams.py | 8 +- 21 files changed, 2275 insertions(+), 1811 deletions(-) create mode 100644 src/ffmpegio/streams/BaseFFmpegRunner.py diff --git a/docsrc/analysis.rst b/docsrc/analysis.rst index 122b345b..a4c791c7 100644 --- a/docsrc/analysis.rst +++ b/docsrc/analysis.rst @@ -8,7 +8,7 @@ There are a number of `FFmpeg filters `_ which analyze video and audio streams and inject per-frame results into frame metadata to be used in a later stage of -a filtergraph. :py:mod:`ffmpegio.analyze.run` retrieves the injected metadata by appending ``metadata`` +a filtergraph. :py:mod:`run` retrieves the injected metadata by appending ``metadata`` and ``ametadata`` filters and logs the frame metadata outputs. You can use either the supplied Python classes or a custom class, which conforms to :py:class:`MetadataLogger` interface to specify the FFmpeg filter and to log its output. @@ -88,10 +88,10 @@ Analyze API Reference :nosignatures: :recursive: - ffmpegio.analyze.run + run ffmpegio.video.detect ffmpegio.audio.detect - ffmpegio.analyze.MetadataLogger + MetadataLogger .. autofunction:: ffmpegio.analyze.run .. autofunction:: ffmpegio.video.detect diff --git a/docsrc/conf.py b/docsrc/conf.py index 294b1ac6..2288c479 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-2025, 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", + "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/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..cd21c18e 100644 --- a/docsrc/options.rst +++ b/docsrc/options.rst @@ -125,101 +125,101 @@ 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'`. + :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 +.. digraph:: video_manipulation + :caption: Video Manipulation Order - blockdiag { - square_pixels -> crop -> flip -> transpose; - crop -> flip [folded] - } + rankdir=LR + node [margin=0.1 width=1.5 shape=box]; + + "square_pixels" -> "crop" -> "flip" -> "transpose"; 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 + :class: tight-table - * - .. plot:: + * - .. 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') + 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 + .. code-block:: python - ffmpegio.image.read('ffmpeg-logo.png') + ffmpegio.image.read('ffmpeg-logo.png') - * - .. plot:: + * - .. 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') + 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 + .. code-block:: python - ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), transpose=0) + ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), transpose=0) - * - .. plot:: + * - .. 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') + 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 + .. code-block:: python - ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), flip='both', size=(200,-1)) + ffmpegio.image.read('ffmpeg-logo.png', crop=(100,100,0,0), flip='both', size=(200,-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/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index 26709191..8a79e26a 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -1,21 +1,551 @@ +"""`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 v", "e->a"], + *, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> streams.SimpleAudioReader | streams.SimpleVideoReader: + """open a single-source reader (`mode = "rv" | "ra" | "e->v" | "e->a"`) + + :param urls_fgs: URL of the file or format/device object to obtain a media stream from. + It can also be an input filtergraph object or string. The input + could also be fed by a buffered bytes-like data object or a readable file object. + :param mode: `'rv'` or `'e->v'` to read video data, `'ra'` or `'e->a'` to read audio data + :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 queue's item size in bytes, defaults to `None` (auto-set) + :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. + :return: reader stream object + """ + + +@overload +def open( + urls_fgs: FFmpegUrlType | IO | Buffer, + mode: Literal["wv", "wa", "v->e", "a->e"], + input_rate: int | Fraction, + *, + input_shape: ShapeTuple = None, + input_dtype: DTypeString = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool = False, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> streams.SimpleAudioWriter | streams.SimpleVideoReader: + """open a single-destination writer (`mode = "wv" | "wa" | "v->e" | "a->e"`) + + :param urls_fgs: URL of the file or format/device object to write media stream to. The output + could also be written to a bytes object or a writable file object. + :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file + :param input_rate: Input frame rate (video) or sampling rate (audio) + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) + :param extra_inputs: extra media source files/urls, defaults to None + :param overwrite: True to overwrite output URL, defaults to False. + :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 queue's item size in bytes, defaults to `None` (auto-set) + :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. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: None | Literal["pipe", "-", "pipe:0"], + mode: Literal["e->v", "e->a"], + *, + f_in: str, + 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], +) -> streams.StdAudioDecoder | streams.StdVideoDecoder: + """open a piped single-source reader (`mode = "rv" | "ra" | "e->v" | "e->a"`) + + :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. + :param mode: `'rv'` or `'e->v'` to read video data, `'ra'` or `'e->a'` to read audio data + :param f_in: FFmpeg format option for the input stream + :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 queue's item size in bytes, defaults to `None` (auto-set) + :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. + :return: reader stream object + """ + + +@overload +def open( + urls_fgs: Literal["-", "pipe", "pipe:1"] | None, + mode: Literal["wv", "wa", "v->e", "a->e"], + input_rate: int | Fraction, + *, + f: str, + input_shape: ShapeTuple = None, + input_dtype: DTypeString = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool = False, + 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], +) -> streams.StdAudioEncoder | streams.StdVideoEncoder: + """open a piped single-destination writer (`mode = "wv" | "wa" | "v->e" | "a->e"`) + + :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. + :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file + :param f: FFmpeg format option for the output stream + :param input_rate: Input frame rate (video) or sampling rate (audio) + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) + :param extra_inputs: _description_, defaults to None + :param overwrite: True to overwrite output URL, defaults to False. + :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 queue's item size in bytes, defaults to `None` (auto-set) + :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. + :return: writer stream object + + """ + + +@overload +def open( + urls_fgs: str | FilterGraphObject, + mode: Literal["fv", "fa", "v->v", "a->a"], + input_rate: int | Fraction, + *, + input_shape: ShapeTuple = None, + input_dtype: DTypeString = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + queuesize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> streams.StdAudioFilter | streams.StdVideoFilter: + """open a single-input, single-output (SISO) filter + + :param urls_fgs: a filtergraph expression + :param mode: `"fv"` or `"v->v"` to specify video filter, and `"fa"` or `"a->a"` to specify audio filter + :param input_rate: input frame rate (video) or sampling rate (audio) + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, 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 queue's item size in bytes, defaults to `None` (auto) + :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. + :return: filter stream object + """ + + +@overload +def open( + urls_fgs: Literal[None], + mode: LiteralString, + *, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: 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, + **options: Unpack[FFmpegOptionDict], +) -> streams.StdMediaTranscoder: + """open a single-input, single-output streamed transcoder + + :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` + and `read()` operations. + :param mode: transcoding mode is activated by setting `mode = 't'` + :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 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 queue's item size in bytes, defaults to `None` (64 kB) + :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. + :return: transcoder stream object + """ -from .filtergraph import Graph as FilterGraph +@overload 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, + urls_fgs: Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + mode: LiteralString, + *, + 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], +) -> streams.PipedMediaReader: + """open a multi-stream reader + + :param urls_fgs: a list of input sources + :param mode: `'r'` + an optional sequence of `'v'`s and `'a'`s for each output streams. Alternately, + `eee->vva` format could be used with the left hand side repeating the `'e'`s to indicate + the number of inputs.) + :param map: a list of FFmpeg stream specifiers to specify the streams to retrieve, defaults to `None` + to retrieve all streams if `mode='r'` or as many streams as `mode` specifies in the order + of appearances. + :param ref_stream: index of the output stream, which is used as a reference stream to pace the read + operations, defaults to 0 + :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 queue's item size in bytes, defaults to `None` (64 kB) + :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. + :return: _description_ + """ + + +@overload +def open( + urls_fgs: ( + FFmpegOutputUrlComposite + | list[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ] + ), + mode: LiteralString, + rates_or_opts_in: Sequence[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, + merge_audio_outpad: str | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwite: bool = False, + 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], +) -> streams.PipedMediaWriter: + """open a multi-stream writer + + :param urls_fgs: a list of output encoded streams. Specific FFmpeg output options could be specified for + an output by providing a pair of the url and its option `dict`. + :param mode: `'w'` followed by a sequence of input stream types, e.g., `'vav'` if video, audio, and video + raw data streams will be written (in that order). Alternately, `vav->ee` format could be used. + (The right hand side has `'e'` repeated for as many outputs as written.) + :param rates_or_opts_in: _description_ + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) + :param merge_audio_streams: _description_, defaults to False + :param merge_audio_ar: _description_, defaults to None + :param merge_audio_sample_fmt: _description_, defaults to None + :param merge_audio_outpad: _description_, defaults to None + :param extra_inputs: extra media source files/urls, defaults to None + :param overwrite: True to overwrite destination file. Ignored if any of the + output is streamed. + :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 queue's item size in bytes, defaults to `None` (64 kB) + :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. + :return: _description_ + """ + + +@overload +def open( + urls_fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], + mode: LiteralString, + input_rates_or_opts: Sequence[int | Fraction | FFmpegOptionDict], + *, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + ref_output: int = 0, + output_options: dict[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 = None, + **options: Unpack[FFmpegOptionDict], +) -> streams.PipedMediaFilter: + """open a multi-stream filter + + :param urls_fgs: _description_ + :param mode: _description_ + :param input_rates_or_opts: _description_ + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) + :param extra_inputs: extra media source files/urls, defaults to None + :param ref_output: index of the output stream, which is used as a reference stream to pace the read + operations, defaults to 0 + :param output_options: _description_, 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 queue's item size in bytes, defaults to `None` (64 kB) + :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. + :return: transcoder stream object + """ + + +@overload +def open( + urls_fgs: Literal[None], + mode: LiteralString, + 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, + 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, + **options: Unpack[FFmpegOptionDict], +) -> streams.PipedMediaTranscoder: + """open a streamed transcoder + + :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` + and `read()` operations. + :param mode: transcoding mode is activated by setting `mode = 't'` or '`ee->e'` The `'->'` + operator optionally specifies the number of input and output files. + :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 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 queue's item size in bytes, defaults to `None` (64 kB) + :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. + :return: transcoder stream object + """ + + +def open( + urls_fgs: ( + FFmpegInputUrlComposite + | FFmpegOutputUrlComposite + | Sequence[FFmpegInputUrlComposite | FFmpegOutputUrlComposite] + | None + ), + mode: LiteralString, + *args, + **kwargs, ): """Open a multimedia file/stream for read/write @@ -24,48 +554,16 @@ 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 - (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. + 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:: @@ -81,7 +579,7 @@ def open( 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: + with ffmpegio.open('video_dst.flac','wa',input_rate=fs) as wr: frame = rd.read() while frame: wr.write(frame) @@ -89,188 +587,250 @@ def open( :Additional Notes: - `url_fg` can be a string specifying either the pathname (absolute or relative to the current + `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 url - 'w' write to url + ==== ======================================================= + 'r' read from encoded url/file/stream + 'w' write to encoded url/file/stream '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 ' 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'`. """ - is_fg = isinstance(url_fg, FilterGraph) - if isinstance(url_fg, str): - is_fg = kwds.get("f_in", None) == "lavfi" - url_fg = (url_fg,) + try: + op_mode, in_types, out_types = _parse_mode(mode) + if op_mode == "r": + runner = _create_reader(out_types, urls_fgs, args, kwargs) + elif op_mode == "w": + runner = _create_writer(in_types, urls_fgs, args, kwargs) + elif op_mode == "f": + runner = _create_filter(in_types, out_types, urls_fgs, args, kwargs) + else: + runner = _create_transcoder(urls_fgs, args, kwargs) + + # TODO - check io types, display warning if mismatched - unk = set(mode) - set("avrwf") - if unk: - raise ValueError( - f"Invalid FFmpeg streaming mode: {mode}. Unknown mode {unk} specified." - ) + except: + raise - read = "r" in mode - write = "w" in mode - filter = "f" in mode + return runner - if read + write + filter != 1: - raise ValueError( - f"Invalid FFmpeg streaming mode argument: {mode}. It must contain one and only one of 'rwf'." - ) - audio = sum(1 for m in mode if m == "a") - video = sum(1 for m in mode if m == "v") +def _parse_mode(mode: str) -> tuple[str, str, str]: - if audio + video == 0: + it = re.finditer(r"([rwft])|(-\>)", mode) + try: + m = next(it) + except StopIteration as e: raise ValueError( - f"Invalid FFmpeg streaming mode argument: {mode}. Stream type not specified. Mode must contain 'v' or 'a' at least once." + f'{mode=} is missing the operation specifier ("r", "w", "f", "t", or "->")' + ) from e + try: + next(it) + raise ValueError( + f'{mode=} specifies multiple the operation specifiers ("r", "w", "f", "t", or "->")' + ) + except StopIteration as e: + pass + + inputs = mode[: m.start()] + outputs = mode[m.end() :] + + op_mode = m[1] + if op_mode: + if op_mode == "r": + inputs = "" + outputs = inputs + outputs + else: + inputs = inputs + outputs + outputs = "" + in_encoded = out_encoded = None + else: + in_encoded = all(c == "e" for c in inputs) + out_encoded = all(c == "e" for c in outputs) + op_mode = ( + ("t" if out_encoded else "r") + if in_encoded + else ("w" if out_encoded else "f") ) - if read: - vars = [] - if rate_in is not None: - vars.append("rate_in") - if rate is not None: - vars.append("rate") - if len(vars): - vars = ", ".join(vars) + if op_mode in "rt": # encoded in + if not in_encoded and any(c != "e" for c in inputs): raise ValueError( - f"Invalid argument for a read stream: {vars}. To change rate, use FFmpeg 'r' argument for video stream or 'ar' argument for audio stream." + f"{mode=} specifies a raw input, which is not valid for the specified operation." ) - vars = [] - if shape_in is not None: - vars.append("shape_in") - if shape is not None: - vars.append("shape") - if len(vars): - vars = ", ".join(vars) + else: # raw in + if not all(c in "av" for c in inputs): raise ValueError( - f"Invalid argument for a read stream: {vars}. To change shape, use FFmpeg 's' argument for video frame or 'ac' for the number of audio channels." + f"{mode=} specifies an encoded input, which is not valid for the specified operation." ) - if dtype_in is not None: - raise ValueError("Invalid argument for a read stream: dtype_in.") - else: - if audio + video > 1: + if op_mode in "wt": # encoded out + if not out_encoded and any(c != "e" for c in outputs): + raise ValueError( + f"{mode=} specifies a raw output, which is not valid for the specified operation." + ) + else: # raw out + if not all(c in "av" for c in outputs): raise ValueError( - f"Too many streams specified: {mode}. A {'write' if write else 'filter'} stream can only process one stream at a time." + f"{mode=} specifies an encoded output, which is not valid for the specified operation." ) - 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." - ) + return op_mode, inputs, outputs - try: + +def _create_reader( + out_types: str, + urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], + args: tuple, + kwargs: dict, +) -> ( + streams.PipedMediaReader + | streams.StdAudioDecoder + | streams.StdVideoDecoder + | streams.SimpleAudioReader + | streams.SimpleVideoReader +): + + if len(args): + raise TypeError( + f"ffmpegio.open() takes two arguments ({2+len(args)} given) to open a reader" + ) + + single_url = utils.is_valid_input_url(urls) # else a sequence of urls + if single_url: + urls = [urls] + elif len(urls) == 1 and utils.is_valid_input_url(urls[0]): + single_url = True + + map_option = utils.as_multi_option(kwargs.get("map", None)) + if map_option is None: + map_option = out_types + + is_audio = out_types == "a" + is_siso = single_url and len(map_option) == 1 + + if is_siso and utils.is_pipe(urls[0]): + StreamClass = streams.StdAudioDecoder if is_audio else streams.StdVideoDecoder + reader = StreamClass(**kwargs) + else: 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 + streams.PipedMediaReader + if not is_siso + else streams.SimpleAudioReader if is_audio else streams.SimpleVideoReader ) - 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) + reader = StreamClass(*urls, **kwargs) + + return reader + + +def _create_writer( + in_types: str, + urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], + args: tuple, + kwargs: dict, +) -> ( + streams.PipedMediaWriter + | streams.StdAudioEncoder + | streams.StdVideoEncoder + | streams.SimpleAudioWriter + | streams.SimpleVideoWriter +): + + if len(args) > 1: + raise TypeError( + f"ffmpegio.open() takes two arguments ({2+len(args)} given) to open a writer" + ) + + single_output = utils.is_valid_output_url(urls) # else a sequence of urls + if single_output: + urls = [urls] + elif len(urls) == 1 and utils.is_valid_output_url(urls[0]): + single_output = True + + single_input = len(in_types) > 1 + + is_siso = single_output and single_input + is_audio = in_types == "a" + + if not is_siso: + rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") + writer = streams.PipedMediaWriter(urls, in_types, *rates, **kwargs) + elif utils.is_pipe(urls[0]): + StreamClass = streams.StdAudioEncoder if is_audio else streams.StdVideoEncoder + writer = StreamClass(*args, **kwargs) + else: + StreamClass = ( + streams.SimpleAudioWriter if is_audio else streams.SimpleVideoWriter + ) + writer = StreamClass(*urls, *args, **kwargs) + return writer + + +def _create_filter( + in_types: str, + out_types: str, + fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], + args: tuple, + kwargs: dict, +) -> streams.PipedMediaFilter | streams.StdAudioFilter | streams.StdVideoFilter: + + if len(args) > 1: + raise TypeError( + f"ffmpegio.open() takes two arguments ({2+len(args)} given) to open a writer" + ) + + single_input = len(in_types) > 1 + single_output = len(out_types) > 1 + matched_io = in_types == out_types + + is_siso = single_output and single_input and matched_io + is_audio = in_types == "a" + + if is_siso: + StreamClass = streams.StdAudioFilter if is_audio else streams.StdVideoFilter + filter = StreamClass(fgs, *args, **kwargs) + else: + rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") + filter = streams.PipedMediaFilter(fgs, in_types, *rates, **kwargs) + + return filter + + +def _create_transcoder( + urls: None, args: tuple, kwargs: dict +) -> streams.PipedMediaTranscoder | streams.StdMediaTranscoder: + + if urls is not None: + raise TypeError("urls_fgs argument for a filter must be None.") + + nargs = len(args) + if nargs not in (0, 2) or (nargs == 3 and "output_options" in kwargs): + raise TypeError( + f"ffmpegio.open() takes two or four arguments ({2+len(args)} given) to open a filter." + ) + + use_piped = args[0] if nargs else kwargs.get("input_options", None) + + return ( + streams.PipedMediaTranscoder(*args, **kwargs) + if use_piped + else streams.StdMediaTranscoder(*args, **kwargs) + ) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 897a9b37..80cd49d6 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from namedpipe import NPopen - from .threading import WriterThread, ReaderThread + from .threading import WriterThread, ReaderThread, CopyFileObjThread # from typing_extensions import * @@ -139,7 +139,8 @@ class InputSourceDict(TypedDict): fileobj: NotRequired[IO] # file object media_type: NotRequired[MediaType] # media type if input pipe raw_info: NotRequired[RawStreamInfoTuple] - writer: NotRequired[WriterThread] # pipe + pipe: NotRequired[NPopen] # named pipe + writer: NotRequired[WriterThread | CopyFileObjThread] # pipe class OutputDestinationDict(TypedDict): @@ -153,6 +154,6 @@ class OutputDestinationDict(TypedDict): linklabel: NotRequired[str] raw_info: NotRequired[RawStreamInfoTuple] pipe: NotRequired[NPopen] - reader: NotRequired[ReaderThread] + reader: NotRequired[ReaderThread | CopyFileObjThread] itemsize: NotRequired[int] nmin: NotRequired[int] diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index deb456ca..df75f80a 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -9,6 +9,7 @@ TypedDict, Unpack, Callable, + BinaryIO, ) from ._typing import ( @@ -1658,7 +1659,13 @@ def init_media_read( ], map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, options: FFmpegOptionDict, -) -> tuple[FFmpegArgs, list[InputSourceDict], list[OutputDestinationDict]]: +) -> tuple[ + FFmpegArgs, + list[InputSourceDict], + list[bool], + list[OutputDestinationDict], + list[FFmpegOptionDict | None], +]: """Initialize FFmpeg arguments for media read :param *urls: URLs of the media files to read. @@ -2154,190 +2161,133 @@ def init_media_transcoder( return args, input_info, output_info -def init_named_pipes( +######################################## + + +def assign_output_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 + sp_kwargs: dict | None = None, + use_std_pipes: bool = False, +) -> dict: + """initialize 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']` :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. + :param sp_kwargs: Specify the subprocess.Popen keyword arguments. + :param use_std_pipes: True to assign the first piped output to stdout + :returns sp_kwargs: Modified Popen keyword arguments - - if any output is a piped, overwrite flag (-y) is automatically inserted """ - stack = ExitStack() - wr_kws = {"queuesize": queue_size} if queue_size else {} + if sp_kwargs is None: + sp_kwargs = {} + + if output_info is None: + return sp_kwargs # configure output pipes + use_stdout = False has_pipeout = False - for i, (output, info) in enumerate(zip(args["outputs"], output_info)): - if output[0] is None: - has_pipeout = True + for i, (info, arg) in enumerate(zip(output_info, args["outputs"])): + + if arg[0]: + # url already configured + continue + + has_pipeout = True + if use_std_pipes and not use_stdout: + use_stdout = True + pipe_path = "pipe:1" - # 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) + assert "fileobj" in info + sp_kwargs["stdout"] = info["fileobj"] 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 - else: - raise FFmpegioError(f"{src_type=} is an unknown input data type.") + sp_kwargs["stdout"] = fp.PIPE + else: + # if fileobj or buffer output, use pipe + pipe = NPopen("r", bufsize=0) + pipe_path = pipe.path + info["pipe"] = pipe + assign_output_url(args, i, pipe_path) if has_pipeout: # if any output is piped, must run in the overwrite mode args["global_options"].pop("n", None) args["global_options"]["y"] = None - return stack if len(input_info) or len(output_info) else None + return sp_kwargs -def assign_std_pipes( +def assign_input_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 + sp_kwargs: dict | None = None, + use_std_pipes: bool = False, + set_sp_kwargs_input: bool = False, +) -> dict: + """initialize named pipes for 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 + :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 sp_kwargs: Specify the subprocess.Popen keyword arguments. + :param set_sp_kwargs_input: True to assign 'input' instead of 'stdin' for sp_kwargs + :returns sp_kwargs: Modified Popen keyword arguments - 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 sp_kwargs is None: + sp_kwargs = {} + if input_info is None: + return sp_kwargs - if any output is a piped, overwrite flag (-y) is automatically inserted - """ + # configure input pipes + use_stdin = False - # 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") + # configure input pipes (if needed) + for i, (info, arg) in enumerate(zip(input_info, args["inputs"])): - 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.") + if arg[0]: + # url already configured + continue - # 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." - ) + if use_std_pipes and not use_stdin: use_stdin = True - assign_input_url(args, i, "pipe:0") + pipe_path = "pipe:0" + src_type = info["src_type"] if src_type == "fileobj": - stdin = info["fileobj"] + assert "fileobj" in info + sp_kwargs["input"] = info["fileobj"] elif src_type == "buffer": - if "buffer" in info: - pinput = info["buffer"] - if not use_sp_run: - stdin = fp.PIPE + if ( + set_sp_kwargs_input and "buffer" in info + ): # given data to send to subprocess + sp_kwargs["input"] = info["buffer"] 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 + sp_kwargs["stdin"] = fp.PIPE + else: + pipe = NPopen("w", bufsize=0) + pipe_path = pipe.path + info["pipe"] = pipe + assign_input_url(args, i, pipe_path) - return stdin, stdout, pinput + return sp_kwargs -def init_std_pipes( - stdin: IO | None, - stdout: IO | None, +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: + stack: ExitStack | None = None, +) -> ExitStack: """initialize named pipes for read & write operations with FFmpeg :param args: FFmpeg option arguments (modified) @@ -2346,12 +2296,12 @@ def init_std_pipes( :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) + :param stack: ExitStack context manager object to handle __exit__() of NOpen and Thread objects :returns: a list of indices of the FFmpeg outputs that are raw data streams In addition to the retured list, this function modifies the dicts in its arguements. - - The pipe names are assigned to the URLs of FFmpeg input and output (`args['inputs'][][0]` - and `args['outputs'][][0]`) + - 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. @@ -2360,14 +2310,24 @@ def init_std_pipes( if any output is a piped, overwrite flag (-y) is automatically inserted """ - stack = ExitStack() - in_use = False + if stack is None: + stack = ExitStack() wr_kws = {"queuesize": queue_size} if queue_size else {} # configure output pipes for info in output_info: + if "pipe" not in info: + continue + + pipe = info["pipe"] + stack.enter_context(pipe) + dst_type = info["dst_type"] - if dst_type == "buffer": + if dst_type == "fileobj": + assert "fileobj" in info + reader = CopyFileObjThread(pipe, info["fileobj"]) + else: + assert dst_type == "buffer" kws = {**wr_kws} if "raw_info" in info: dtype, shape, rate = info["raw_info"] @@ -2378,19 +2338,28 @@ def init_std_pipes( # 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 + reader = ReaderThread(pipe, **kws) + + info["reader"] = reader + stack.enter_context(reader) # starts thread & wait for pipe connection # configure input pipes (if needed) for info in input_info: + if "pipe" not in info: + continue + + pipe = info["pipe"] + stack.enter_context(pipe) + src_type = info["src_type"] - if src_type == "buffer": - writer = WriterThread(stdin, **wr_kws) + if src_type == "fileobj": + assert "fileobj" in info + writer = CopyFileObjThread(info["fileobj"], pipe, auto_close=True) + # starts thread & wait for pipe connection + else: + assert src_type == "buffer" + writer = WriterThread(pipe, **wr_kws) # starts thread & wait for pipe connection - stack.enter_context(writer) - in_use = True if "buffer" in info: # data buffer given, feed the data and terminate writer.write(info["buffer"]) @@ -2398,6 +2367,6 @@ def init_std_pipes( else: # if no data given, provide the access to the writer info["writer"] = writer - break + stack.enter_context(writer) - return stack if in_use else None + return stack diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 91a9a59b..85fbd4f8 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -44,15 +44,18 @@ 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 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) + if len(input_info): + sp_kwargs = configure.assign_input_pipes(args, input_info, False, sp_kwargs) + if len(output_info): + sp_kwargs = configure.assign_output_pipes(args, output_info, False, sp_kwargs) + stack = configure.init_named_pipes(input_info, output_info) def on_exit(rc): stack.close() diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index e809fdec..bf07b3fd 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -1,15 +1,19 @@ from __future__ import annotations import pluggy -from typing import Callable +from typing import Protocol, Callable 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""" + ... + +class GetInfoCallable(Protocol): + def __call__(self, *, obj: object) -> tuple[ShapeTuple, DTypeString]: ... @hookspec(firstresult=True) @@ -17,9 +21,10 @@ 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 """ + ... @hookspec(firstresult=True) @@ -27,9 +32,14 @@ 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 """ + ... + + +class ToBytesCallable(Protocol): + def __call__(self, *, obj: object) -> memoryview: ... @hookspec(firstresult=True) @@ -39,6 +49,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 +59,14 @@ 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 """ + ... + + +class CountDataCallable(Protocol): + def __call__( + self, *, b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool + ) -> int: ... + @hookspec(firstresult=True) def video_frames(obj: object) -> int: @@ -56,6 +75,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) @@ -65,6 +85,14 @@ 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 """ + ... + + +class FromBytesCallable(Protocol): + def __call__( + self, *, b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool + ) -> object: ... + @hookspec(firstresult=True) def bytes_to_video( @@ -81,7 +109,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 @@ -104,6 +134,7 @@ def device_source_api() -> tuple[str, dict[str, Callable]]: Partial definition is OK """ + ... @hookspec @@ -118,3 +149,4 @@ def device_sink_api() -> tuple[str, dict[str, Callable]]: Partial definition is OK """ + ... diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 256476d5..8e308150 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -449,3 +449,31 @@ def map_option( map = f"{map}?" return map + + +def stream_spec_to_map_option( + stream_spec_or_link_label: str | StreamSpecDict, + input_file_id: int = 0, +) -> str: + """Form map option string from stream_spec/link_label + + :param stream_spec_or_link_label: stream_spec or link_label string or + stream_spec dict + :param input_file_id: id of the file, defaults to "0" + """ + + link_label = None + if isinstance(stream_spec_or_link_label, str): + try: + stream_spec_dict = parse_stream_spec(stream_spec_or_link_label) + except ValueError: + stream_spec_dict = None + link_label = stream_spec_or_link_label + else: + stream_spec_dict = stream_spec_or_link_label + + return map_option( + None if stream_spec_dict is None else input_file_id, + link_label, + stream_spec_dict, + ) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py new file mode 100644 index 00000000..588d2c9f --- /dev/null +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -0,0 +1,607 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger("ffmpegio") + +from typing_extensions import Callable, Literal, override +from .._typing import ( + ProgressCallable, + InputSourceDict, + OutputDestinationDict, + FFmpegOptionDict, + RawDataBlob, + ShapeTuple, + DTypeString, + MediaType, +) +from ..configure import FFmpegArgs, InitMediaOutputsCallable +from ..plugins.hookspecs import CountDataCallable, FromBytesCallable, ToBytesCallable +from contextlib import ExitStack +from fractions import Fraction + +import sys +from time import time + +from .. import ffmpegprocess, configure +from ..threading import LoggerThread +from ..errors import FFmpegError, FFmpegioError +from .. import probe + +__all__ = ["BaseFFmpegRunner"] + + +class BaseFFmpegRunner: + """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], + input_ready: Literal[True] | list[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, + sp_kwargs: dict | None = None, + **_, + ): + """Base FFmpeg runner + + :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: True to start FFmpeg, if not provide a list of per-stream readiness + :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 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._stack: ExitStack = ExitStack() + 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 = [] + + # create logger without assigning the source stream + self._logger = LoggerThread(None, bool(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 reference stream + self.default_timeout = default_timeout + 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 or all(self._input_ready): + self._open(False) + + def _assign_pipes(self): + """assign pipes (pre-popen) + """ + pass + + def _init_pipes(self): + """initialize pipes (post-popen)""" + pass + + def _write_deferred_data(self): + pass + + def _open(self, deferred: bool): + + if deferred: + + assert self._init_deferred_outputs is not None + + # 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 named pipes and read/write threads + self._assign_pipes() + + # run the FFmpeg + try: + self._proc = ffmpegprocess.Popen( + **self._args, on_exit=(lambda _: self._stack.close()) + ) + except: + if self._stack is not None: + self._stack.close() + raise + + # set up and activate standard pipes and read/write threads + self._init_pipes() + + # set the log source and start the logger + if self._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 + + if self._logger: + self._logger.join() + self._logger = None + + def __exit__(self, *exc_details) -> bool: + try: + self.close() + return False + except: + if not exc_details[0]: + exc_details = sys.exc_info() + finally: + if self._logger is not None: + try: + self._logger.join() + except RuntimeError: + pass + return False + + @property + def closed(self) -> bool: + """True if the stream is closed.""" + return self._proc is None or self._proc.poll() is not None + + @property + def lasterror(self) -> FFmpegError | None: + """Last error FFmpeg posted""" + if self._proc and self._proc.poll(): + return self._logger and 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 self._logger is None: + return "" + + 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 + self._proc.wait(None if timeout is None else timeout - time()) + + rc = self._proc.returncode + if rc is not None: + self._proc = None + else: + rc = None + return rc + + +class BaseRawInputsMixin: + """write a raw media data to a specified stream (backend)""" + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + _args: dict + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # 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"] + for data in src: + writer.write(data, self.default_timeout) + self._deferred_data = [] + self._input_ready = True + + def _write_stream_bytes( + self, + converter: ToBytesCallable, + stream_id: int, + data: RawDataBlob, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + b = converter(obj=data) + if not len(b): + return + + if self._input_ready is True: + logger.debug("[writer main] writing...") + + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (KeyError, BrokenPipeError, OSError): + if self._logger: + 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, 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) + + @property + def input_types(self) -> dict[int, MediaType | None]: + """media type associated with the input streams""" + return { + i: v["media_type"] if "media_type" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_rates(self) -> dict[int, int | Fraction | None]: + """sample or frame rates associated with the input streams""" + return { + i: v["raw_info"][2] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_dtypes(self) -> dict[int, DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + return { + i: v["raw_info"][0] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_shapes(self) -> dict[int, ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + return { + i: v["raw_info"][1] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + +class BaseEncodedInputsMixin: + + # FFmpegRunner's properties accessed + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + + 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 is True: + 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[index] + if len(data0): + 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) + + +class BaseRawOutputsMixin: + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread | None + + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(**kwargs) + + # 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_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_pipes() + + def _read_stream_bytes( + self, + converter: FromBytesCallable, + counter: CountDataCallable, + dtype: DTypeString, + shape: ShapeTuple, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze + ) + + # update the frame/sample counter + n = counter(obj=data) # actual number read + self._n0[stream_id] += n + + return data + + +class BaseEncodedOutputsMixin: + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread | None + + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) + + # set the default read block size + self._blocksize = blocksize + + def _init_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_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) + + +class BaseRawInputMixin(BaseRawInputsMixin): + """write a raw media data to a specified stream (backend)""" + + @property + def input_type(self) -> MediaType | None: + """media type associated with the input stream""" + info = self._input_info[0] + return info["media_type"] if "media_type" in info else None + + @property + def input_rate(self) -> int | Fraction | None: + """sample or frame rates associated with the input streams""" + + info = self._input_info[0] + return info["raw_info"][2] if "raw_info" in info else None + + @property + def input_dtype(self) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + info = self._input_info[0] + return info["raw_info"][0] if "raw_info" in info else None + + @property + def input_shape(self) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + info = self._input_info[0] + return info["raw_info"][1] if "raw_info" in info else None + + +class BaseRawOutputMixin(BaseRawOutputsMixin): + + @property + def output_label(self) -> str | None: + """FFmpeg/custom labels of output streams""" + return self._output_info[0]["user_map"] + + @property + def output_type(self) -> dict[str, MediaType | None]: + """media type associated with the output streams (key)""" + return self._output_info[0]["media_type"] + + @property + def output_rate(self) -> int | Fraction | None: + """sample or frame rates associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][2] if "raw_info" in info else None + + @property + def output_dtype(self) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][0] if "raw_info" in info else None + + @property + def output_shape(self) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][1] if "raw_info" in info else None + + @property + def output_count(self) -> int: + """number of frames/samples read""" + return self._n0[0] diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 1d8e6cba..3ce524b6 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -25,7 +25,6 @@ InitMediaOutputsCallable, ) from ..filtergraph.abc import FilterGraphObject -from ..configure import OutputDestinationDict from contextlib import ExitStack import sys @@ -36,12 +35,20 @@ from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError +from .BaseFFmpegRunner import ( + BaseFFmpegRunner, + BaseRawInputsMixin, + BaseRawOutputsMixin, + BaseEncodedInputsMixin, + BaseEncodedOutputsMixin, +) + # fmt:off __all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] # fmt:on -class _PipedFFmpegRunner: +class _PipedFFmpegRunner(BaseFFmpegRunner): """Base class to run FFmpeg and manage its multiple I/O's""" def __init__( @@ -49,7 +56,7 @@ def __init__( ffmpeg_args: FFmpegArgs, input_info: list[InputSourceDict], output_info: list[OutputDestinationDict] | None, - input_ready: True | list[bool] | None, + input_ready: Literal[True] | list[bool] | None, init_deferred_outputs: InitMediaOutputsCallable | None, deferred_output_args: list[FFmpegOptionDict | None], *, @@ -57,7 +64,7 @@ def __init__( progress: ProgressCallable | None = None, show_log: bool | None = None, queuesize: int | None = None, - sp_kwargs: dict = None, + sp_kwargs: dict | None = None, ): """Encoded media stream transcoder @@ -87,186 +94,47 @@ def __init__( 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, - } + super().__init__( + ffmpeg_args, + input_info, + output_info, + input_ready, + init_deferred_outputs, + deferred_output_args, + default_timeout, + progress, + show_log, + 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. + def _assign_pipes(self): + """pre-popen pipe assignment and initialization + All named pipes must be """ - - 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( + if len(self._input_info): + configure.assign_input_pipes( self._args["ffmpeg_args"], self._input_info, - self._deferred_output_options, - self._deferred_data, + self._args["sp_kwargs"], ) - # 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() + if len(self._output_info): + configure.assign_output_pipes( + self._args["ffmpeg_args"], + self._output_info, + self._args["sp_kwargs"], ) - 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 + configure.init_named_pipes( + self._input_info, self._output_info, **self._pipe_kws, stack=self._stack + ) -class _RawInputMixin: +class _RawInputMixin(BaseRawInputsMixin): _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} _array_to_opts = { @@ -276,24 +144,13 @@ class _RawInputMixin: 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, @@ -304,34 +161,7 @@ def _write_stream( """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() + self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) def write_stream( self, stream_id: int, data: RawDataBlob, timeout: float | None = None @@ -404,55 +234,7 @@ def write( ) -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.") +class _EncodedInputMixin(BaseEncodedInputsMixin): def write_encoded_stream( self, stream_id: int, data: bytes, timeout: float | None = None @@ -525,87 +307,31 @@ def write_encoded( ) -class _RawOutputMixin: +class _RawOutputMixin(BaseRawOutputsMixin): def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(**kwargs) + super().__init__(blocksize=blocksize, ref_output=ref_output, **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, + squeeze: bool = False, ) -> RawDataBlob: """read selected output stream (shared backend)""" converter = self._converters[info["media_type"]] dtype, shape, _ = info["raw_info"] + counter = self._get_num[info["media_type"]] - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=False + return self._read_stream_bytes( + converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze ) - # 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: @@ -706,31 +432,7 @@ def read(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob]: 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) +class _EncodedOutputMixin(BaseEncodedOutputsMixin): def read_encoded_stream( self, stream_id: int, n: int, timeout: float | None = None @@ -819,7 +521,7 @@ def __init__( 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 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 @@ -953,8 +655,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( diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 86f38b87..6f1af8bb 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -7,170 +7,226 @@ logger = logging.getLogger("ffmpegio") -from typing import Literal +from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable +from typing import Literal, Self from fractions import Fraction from .._typing import RawDataBlob + +from typing_extensions import Unpack, Callable +from collections.abc import Sequence +from .._typing import ( + DTypeString, + ShapeTuple, + ProgressCallable, + RawDataBlob, + FFmpegOptionDict, + InputSourceDict, + OutputDestinationDict, +) + from ..filtergraph.abc import FilterGraphObject from ..errors import FFmpegioError -from .. import utils, configure, ffmpegprocess, plugins -from ..threading import LoggerThread, ReaderThread, WriterThread +from .. import configure, ffmpegprocess as fp, plugins, utils, probe +from .. import utils, configure, plugins +from ..threading import LoggerThread + +from ..utils import FFmpegInputUrlComposite, FFmpegOutputUrlComposite +from ..configure import OutputDestinationDict +from contextlib import ExitStack +from ..stream_spec import stream_spec_to_map_option, StreamSpecDict + +import sys +from time import time +from fractions import Fraction +from math import prod + +from ..threading import LoggerThread +from ..errors import FFmpegError, FFmpegioError + +from ..configure import ( + FFmpegArgs, + MediaType, + InitMediaOutputsCallable, + FFmpegUrlType, +) + +from .BaseFFmpegRunner import ( + BaseFFmpegRunner, + BaseRawInputMixin, + BaseRawOutputMixin, + BaseEncodedInputsMixin, + BaseEncodedOutputsMixin, +) # fmt:off __all__ = [ "SimpleVideoReader", "SimpleAudioReader", "SimpleVideoWriter", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter"] + "SimpleAudioWriter"] # fmt:on -class SimpleReaderBase: - """base class for SISO media read stream classes""" +class RawOutputsMixin: - 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 + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread | None - # 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) + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) - # abstract method to finalize the options => sets self.dtype and self.shape if known - self._finalize(ffmpeg_args) + # set the default read block size for the reference stream + self._blocksize = blocksize + self._rate = None + self._n0: int = 0 # timestamps of the last read sample - # 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} + def _read_stream_bytes( + self, + converter: FromBytesCallable, + counter: CountDataCallable, + dtype: DTypeString, + shape: ShapeTuple, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze ) - # start FFmpeg - self._proc = ffmpegprocess.Popen(ffmpeg_args, **kwargs) + # update the frame/sample counter + n = counter(obj=data) # actual number read + self._n0[stream_id] += n - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() + return data - # 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") +class SimpleReaderBase(BaseFFmpegRunner): + """base class for SISO media read stream classes""" - 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. + def __init__( + self, + *, + ffmpeg_args: FFmpegArgs, + input_info: list[InputSourceDict], + output_info: list[OutputDestinationDict], + from_bytes: FromBytesCallable, + counter: CountDataCallable, + to_memoryview: ToBytesCallable, + show_log: bool | None, + progress: ProgressCallable | None, + blocksize: int, + default_timeout: float | None, + sp_kwargs: dict | None, + ): + """Queue-less simple media io runner + + :param ffmpeg_args: (Mostly) populated FFmpeg argument dict + :param input_info: FFmpeg input option dicts with zero or one streaming pipe. (only one in input or output) + :param output_info: FFmpeg output option dicts with zero or one any streaming pipe. (only one in input or output) + :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 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) + """ - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. + super().__init__( + ffmpeg_args=ffmpeg_args, + input_info=input_info, + output_info=output_info, + input_ready=True, + init_deferred_outputs=None, + deferred_output_args=[], + default_timeout=default_timeout, + progress=progress, + show_log=show_log, + sp_kwargs={**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, + blocksize=blocksize, + ref_output=0, + ) - """ + self._converter = from_bytes + self._get_num = counter + self._memoryviewer = to_memoryview - if self._proc is None: - return + # set the default read block size for the reference stream + self._blocksize = blocksize - self._proc.stdout.close() - self._proc.stderr.close() + # set the default read block size for the referenc stream + info = self._output_info[0] + assert "raw_info" in info - 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 + self._rate = info["raw_info"][2] + self._n0 = 0 # timestamps of the last read sample - logger.debug(f"[reader main] FFmpeg closed? {self._proc.poll()}") + @property + def output_label(self) -> str | None: + """FFmpeg/custom labels of output streams""" + return self._output_info[0]["user_map"] - try: - self._proc.stdin.close() - except: - pass - self._logger.join() + @property + def output_type(self) -> MediaType | None: + """media type associated with the output streams (key)""" + return self._output_info[0]["media_type"] @property - def closed(self): - """:bool: True if the stream is closed.""" - return self._proc.poll() is not None + def output_rate(self) -> int | Fraction | None: + """sample or frame rates associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][2] if "raw_info" in info else None @property - def lasterror(self): - """:FFmpegError: Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None + def output_dtype(self) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][0] if "raw_info" in info else None - def __enter__(self): - return self + @property + def output_shape(self) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][1] if "raw_info" in info else None - def __exit__(self, exc_type, exc_value, traceback): - self.close() + @property + def output_count(self) -> int: + """number of frames/samples read""" + return self._n0 + + def _assign_pipes(self): + + configure.assign_output_pipes( + self._args["ffmpeg_args"], + self._output_info, + self._args["sp_kwargs"], + use_std_pipes=True, + ) def __iter__(self): return self def __next__(self): - F = self.read(self.blocksize) + F = self.read(self._blocksize, self.default_timeout) 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): + def read( + self, n: int, timeout: float | None = None, squeeze: bool = False + ) -> 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 @@ -184,15 +240,26 @@ 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): + 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=squeeze) + + # update the frame/sample counter + n = self._get_num( + b=b, dtype=dtype, shape=shape, squeeze=squeeze + ) # actual number read + self._n0 += n + + return data + + def readinto(self, array: RawDataBlob) -> int: """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. @@ -201,145 +268,208 @@ def readinto(self, array): A BlockingIOError is raised if the underlying raw stream is in non blocking-mode, and has no data available at the moment.""" + info = self._output_info[0] + shape = info["raw_info"][1] - return ( - self._proc.stdout.readinto(self._memoryviewer(obj=array)) // self.samplesize + return self._proc.stdout.readinto(self._memoryviewer(obj=array)) // prod( + shape[1:] ) class SimpleVideoReader(SimpleReaderBase): - readable = True - writable = False - multi_read = False - multi_write = False def __init__( self, - url, + url: FFmpegUrlType, *, - show_log=None, - progress=None, - blocksize=1, - sp_kwargs=None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int = 1, + sp_kwargs: dict | None = None, + stream: str | StreamSpecDict | None = None, + default_timeout: float | None = None, **options, ): - hook = plugins.get_hook() - super().__init__( - hook.bytes_to_video, - hook.video_bytes, - url, - show_log, - progress, - blocksize, - sp_kwargs, - **options, - ) + # assign the input stream + map = "0:V:0" if stream is None else stream_spec_to_map_option(stream) - 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" - ) - } - ], + args, input_info, ready, output_info, _ = configure.init_media_read( + [url], [map], options ) - 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.") + if len(output_info) != 1 or output_info[0]["media_type"] != "video": + raise FFmpegioError(f'no output video stream found in "{url}" ({map=})') - # construct basic video filter if options specified - configure.build_basic_vf( - ffmpeg_args, utils.alpha_change(pix_fmt_in, pix_fmt, -1) - ) + if not all(ready): + raise RuntimeError( + "Given file/url does not pre-provide the media information. Use media.read instead." + ) - def _finalize_array(self, info): - # finalize array setup from FFmpeg log + hook = plugins.get_hook() - self.rate = info["r"] - self.dtype, self.shape = utils.get_video_format(info["pix_fmt"], info["s"]) + super().__init__( + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + show_log=show_log, + progress=progress, + blocksize=blocksize, + sp_kwargs=sp_kwargs, + from_bytes=hook.bytes_to_video, + counter=hook.video_frames, + to_memoryview=hook.video_bytes, + default_timeout=default_timeout, + ) class SimpleAudioReader(SimpleReaderBase): - readable = True - writable = False - multi_read = False - multi_write = False def __init__( self, - url, + url: FFmpegUrlType, *, - show_log=None, - progress=None, - blocksize=None, - sp_kwargs=None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int = 1, + sp_kwargs: dict | None = None, + stream: str | StreamSpecDict | None = None, + default_timeout: float | None = None, **options, ): + # assign the input stream + map = "0:a:0" if stream is None else stream_spec_to_map_option(stream) + + args, input_info, ready, output_info, _ = configure.init_media_read( + [url], [map], options + ) + + if len(output_info) != 1 or output_info[0]["media_type"] != "audio": + raise FFmpegioError(f'no output audio stream found in "{url}" ({map=})') + + if not all(ready): + raise RuntimeError( + "Given file/url does not pre-provide the media information. Use media.read instead." + ) + hook = plugins.get_hook() + super().__init__( - hook.bytes_to_audio, - hook.audio_bytes, - url, - show_log, - progress, - blocksize, - sp_kwargs, - **options, + ffmpeg_args=args, + input_info=input_info, + output_info=output_info, + show_log=show_log, + progress=progress, + blocksize=blocksize, + sp_kwargs=sp_kwargs, + from_bytes=hook.bytes_to_audio, + counter=hook.audio_frames, + to_memoryview=hook.audio_bytes, + default_timeout=default_timeout, ) - 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) - ) +class BaseRawInputsMixin: + """write a raw media data to a specified stream (backend)""" + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + _args: dict + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # 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"] + for data in src: + writer.write(data, self.default_timeout) + self._deferred_data = [] + self._input_ready = True + + def _write_stream_bytes( + self, + converter: ToBytesCallable, + stream_id: int, + data: RawDataBlob, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + b = converter(obj=data) + if not len(b): + return + + if self._input_ready is True: + logger.debug("[writer main] writing...") + + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (KeyError, BrokenPipeError, OSError): + if self._logger: + 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, 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) @property - def channels(self): - return self.shape[-1] + def input_types(self) -> dict[int, MediaType | None]: + """media type associated with the input streams""" + return { + i: v["media_type"] if "media_type" in v else None + for i, v in enumerate(self._input_info) + } + @property + def input_rates(self) -> dict[int, int | Fraction | None]: + """sample or frame rates associated with the input streams""" + return { + i: v["raw_info"][2] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } -########################################################################### + @property + def input_dtypes(self) -> dict[int, DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + return { + i: v["raw_info"][0] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_shapes(self) -> dict[int, ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + return { + i: v["raw_info"][1] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } -class SimpleWriterBase: +class SimpleWriterBase(BaseEncodedOutputsMixin, BaseRawInputMixin, BaseFFmpegRunner): def __init__( self, viewer, @@ -394,13 +524,47 @@ def __init__( if ready: self._open() + def _assign_pipes(self): + + configure.assign_input_pipes( + self._args["ffmpeg_args"], + self._output_info, + self._args["sp_kwargs"], + use_std_pipes=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 _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._proc = fp.Popen(**self._cfg) self._cfg = False # set the log source and start the logger @@ -623,666 +787,3 @@ def _finalize_with_data(self, data): 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 as e: - if self._proc.poll() is None: - raise self._logger.Exception - else: - raise ValueError("failed retrieve output data format") - - self._finalize_output(info) - self._reader_needs_info = False - - def _start_reader(self): - self._bps_out = utils.get_samplesize(self.shape, self.dtype) - self._bps_in = utils.get_samplesize(self.shape_in, self.dtype_in) - self._out2in = self.rate / self.rate_in - - # start the FFmpeg output reader - self._reader.itemsize = self._bps_out - self._reader.stdout = self._proc.stdout - self._reader.start() - - self._reader_needs_info = False - - def close(self): - """Close the stream. - - This method has no effect if the stream is already closed. Once the - stream is closed, any read operation on the stream will raise a ThreadNotActive. - - As a convenience, it is allowed to call this method more than once; only the first call, - however, will have an effect. - """ - - if self._proc is None: - return - - 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 index 190ff7b9..3e89b66b 100644 --- a/src/ffmpegio/streams/StdStreams.py +++ b/src/ffmpegio/streams/StdStreams.py @@ -32,6 +32,7 @@ from .. import configure, ffmpegprocess, plugins, utils, probe from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError +from .BaseFFmpegRunner import BaseFFmpegRunner # fmt:off __all__ = ["StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", @@ -39,7 +40,7 @@ # fmt:on -class _StdFFmpegRunner: +class _StdFFmpegRunner(BaseFFmpegRunner): """Base class to run FFmpeg and manage its multiple I/O's""" def __init__( @@ -56,7 +57,7 @@ def __init__( progress: ProgressCallable | None = None, show_log: bool | None = None, queuesize: int | None = None, - sp_kwargs: dict = None, + sp_kwargs: dict | None = None, ): """Encoded media stream transcoder @@ -199,6 +200,8 @@ def close(self): if self._proc.poll() is None: self._proc.kill() self._proc = None + self._logger.join() + self._logger = None def __exit__(self, *exc_details) -> bool: try: diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index de5065fe..417b3766 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -2,9 +2,7 @@ SimpleVideoReader, SimpleVideoWriter, SimpleAudioReader, - SimpleAudioWriter, - SimpleVideoFilter, - SimpleAudioFilter, + SimpleAudioWriter ) from .StdStreams import ( StdAudioDecoder, @@ -27,8 +25,7 @@ # TODO Buffered reverse video read # fmt: off -__all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", - "SimpleAudioWriter", "SimpleVideoFilter", "SimpleAudioFilter", +__all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter" "StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", "StdVideoDecoder", "StdVideoEncoder", "StdVideoFilter", "StdMediaTranscoder", "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder", diff --git a/tests/test_open.py b/tests/test_open.py index 712af36a..8eb1842f 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -1,17 +1,35 @@ -from logging import DEBUG -import ffmpegio +import pytest +import ffmpegio as ff +import ffmpegio.streams as ff_streams def test_fg(): - with ffmpegio.open( - "color=c=red:d=1:r=10", "rv", f_in="lavfi", pix_fmt="rgb24" - ) as f: + with ff.open("color=c=red:d=1:r=10", "rv", f_in="lavfi", pix_fmt="rgb24") as f: I = f.read(-1) assert I["shape"][0] == 10 -if __name__ == "__main__": - import logging +url = "tests/assets/testmulti-1m.mp4" - logging.basicConfig(level=DEBUG) - test_fg() + +@pytest.mark.parametrize( + "mode,Cls", + [ + (url, "rv", ff_streams.SimpleVideoReader), + (url, "ra", ff_streams.SimpleAudioReader), + (url, "e->v", ff_streams.SimpleVideoReader), + (url, "e->a", ff_streams.SimpleAudioReader), + ], +) +def test_readers(src, mode, Cls): + + assert isinstance(ff.open(url, mode), Cls) + + +def test_writers(): ... + + +def test_filters(): ... + + +def test_transcoders(): ... diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index d69fc731..7aefbaf8 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -56,13 +56,25 @@ def test_PipedMediaWriter(): stream_types = [spec.split(":", 2)[1] for spec in data] with streams.PipedMediaWriter( - "pipe", stream_types, *rates.values(), show_log=True, f="matroska" + "pipe", stream_types, *rates.values(), show_log=True, f="matroska", ) as writer: + # write full audio streams + video_frames = {} for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): - if mtype == "v": - writer.write_stream(i, frame[0]) - else: + if mtype == "a": writer.write_stream(i, frame) + 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) + writer.write_stream(i, frame[j]) + frame_count[i] = j + 1 writer.wait(10) b = writer.read_encoded_stream(0, -1, 10) diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py index fa68432e..bfe3507e 100644 --- a/tests/test_simplestreams.py +++ b/tests/test_simplestreams.py @@ -15,14 +15,13 @@ def test_read_video(): w = 420 h = 360 with streams.SimpleVideoReader( - url, vf="transpose", pix_fmt="gray", s=(w, h), show_log=True + url, vf="transpose", pix_fmt="gray", s=(w, h), show_log=True, r=30 ) as f: F = f.read(10) - print(f.rate) - assert f.shape == (h, w, 1) - assert f.samplesize == w * h + assert f.output_rate == 30 + assert f.output_shape == (h, w, 1) assert F["shape"] == (10, h, w, 1) - assert F["dtype"] == f.dtype + assert F["dtype"] == f.output_dtypes def test_read_write_video(): @@ -101,63 +100,6 @@ def test_read_write_audio(): 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" diff --git a/tests/test_stdstreams.py b/tests/test_stdstreams.py index b41006b5..ec17dbd3 100644 --- a/tests/test_stdstreams.py +++ b/tests/test_stdstreams.py @@ -91,7 +91,7 @@ def test_video_filter(): with ( streams.SimpleVideoReader(url, blocksize=30, t=30) as src, - streams.StdVideoFilter("scale=200:100", src.rate, r=fps, show_log=True) as f, + streams.StdVideoFilter("scale=200:100", src.output_rate, r=fps, show_log=True) as f, ): def process(i, frames): @@ -101,7 +101,7 @@ def process(i, frames): for i, frames in enumerate(src): process(i, f.filter(frames)) - assert f.input_rate == src.rate + assert f.input_rate == src.output_rate assert f.output_rate == fps f.wait() process("end", f.read(-1)) @@ -114,7 +114,7 @@ def test_audio_filter(): 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, + streams.StdAudioFilter("lowpass", src.output_rate, ar=sps, show_log=True) as f, ): samples = src.read(src.blocksize) @@ -134,7 +134,7 @@ def process(i, samples): process(i, f.filter(samples)) except TimeoutError: pass - assert f.input_rate == src.rate + assert f.input_rate == src.output_rate assert f.output_rate == sps f.wait() process("end", f.read(-1)) From de85cfb369e6f2fa8e3452ae84b7024c183d1bb2 Mon Sep 17 00:00:00 2001 From: Takeshi Ikuma Date: Sun, 27 Jul 2025 09:27:13 -0500 Subject: [PATCH 307/344] wip --- src/ffmpegio/_utils.py | 9 + src/ffmpegio/configure.py | 33 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 391 +------------- src/ffmpegio/streams/PipedStreams.py | 373 ++++++++++++- src/ffmpegio/streams/SimpleStreams.py | 652 ++++++++--------------- src/ffmpegio/streams/StdStreams.py | 2 +- src/ffmpegio/utils/__init__.py | 11 +- 7 files changed, 645 insertions(+), 826 deletions(-) diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index b62b7b46..3893c9dc 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -11,6 +11,7 @@ import urllib.parse import re +import numpy as np try: from math import prod @@ -258,3 +259,11 @@ def unescape(txt: str) -> str: in_quote = not in_quote return "".join(blks) + + +def get_bytesize(shape: ShapeTuple | None, dtype: DTypeString | None) -> int | None: + return ( + None + if shape is None or dtype is None + else prod(shape) * np.dtype(dtype).itemsize + ) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index df75f80a..54e39bee 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1377,12 +1377,18 @@ def process_raw_inputs( stream_types: Sequence[Literal["a", "v"]], stream_args: Sequence[RawStreamDef], inopts_default: FFmpegOptionDict, - dtypes: list[DTypeString] | None = None, - shapes: list[ShapeTuple] | None = None, + dtypes: list[DTypeString | None] | None = None, + shapes: list[ShapeTuple | None] | None = None, ) -> list[InputSourceDict]: input_info: list[InputSourceDict] = [] - for i, (mtype, arg) in enumerate(zip(stream_types, stream_args)): + if dtypes is None: + dtypes = [None] * len(stream_types) + if shapes is None: + shapes = [None] * len(stream_types) + for i, (mtype, arg, dtype, shape) in enumerate( + zip(stream_types, stream_args, dtypes, shapes) + ): try: a1, a2 = arg @@ -1398,23 +1404,24 @@ def process_raw_inputs( ) else: assert isinstance(a2, dict) + data, opts = a1, a2 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 + raise FFmpegioError("unknown input stream media type") + except FFmpegioError: raise - except: + except Exception as e: 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 """ - ) + ) from e opts = {**inopts_default, **opts} more_opts = None @@ -1425,7 +1432,7 @@ def process_raw_inputs( more_opts, raw_info = utils.array_to_audio_options(data) data = plugins.get_hook().audio_bytes(obj=data) - elif dtypes and shapes: + elif dtypes and shapes and shapes[i] is not None and dtypes[i] is not None: 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) @@ -1436,8 +1443,8 @@ def process_raw_inputs( 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] + elif dtype and shape: + raw_info = shape, dtype pix_fmt, s = utils.guess_video_format(*raw_info) more_opts = { "f": "rawvideo", @@ -1776,14 +1783,14 @@ def init_media_write( | None ), options: dict[str, Any], - dtypes: list[DTypeString] | None = None, - shapes: list[ShapeTuple] | None = None, + dtypes: list[DTypeString | None] | None = None, + shapes: list[ShapeTuple | None] | None = None, ) -> tuple[ FFmpegArgs, list[InputSourceDict], + list[bool], list[OutputDestinationDict] | None, tuple | None, - list[bool], ]: """write multiple streams to a url/file diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 588d2c9f..496d53f6 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -4,29 +4,22 @@ logger = logging.getLogger("ffmpegio") -from typing_extensions import Callable, Literal, override +from typing_extensions import Literal from .._typing import ( ProgressCallable, InputSourceDict, OutputDestinationDict, FFmpegOptionDict, - RawDataBlob, - ShapeTuple, - DTypeString, - MediaType, ) from ..configure import FFmpegArgs, InitMediaOutputsCallable -from ..plugins.hookspecs import CountDataCallable, FromBytesCallable, ToBytesCallable from contextlib import ExitStack -from fractions import Fraction import sys from time import time -from .. import ffmpegprocess, configure +from .. import ffmpegprocess from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError -from .. import probe +from ..errors import FFmpegError __all__ = ["BaseFFmpegRunner"] @@ -34,6 +27,9 @@ class BaseFFmpegRunner: """Base class to run FFmpeg and manage its multiple I/O's""" + default_timeout: float | None = None + _proc: ffmpegprocess.Popen + def __init__( self, ffmpeg_args: FFmpegArgs, @@ -45,6 +41,7 @@ def __init__( default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, + overwrite: bool | None = None, sp_kwargs: dict | None = None, **_, ): @@ -92,10 +89,12 @@ def __init__( "capture_log": True, "sp_kwargs": sp_kwargs, } + if overwrite is not None: + self._args["overwrite"] = overwrite # set the default read block size for the reference stream - self.default_timeout = default_timeout - self._proc = None + if default_timeout is not None: + self.default_timeout = default_timeout def __enter__(self): @@ -118,8 +117,7 @@ def open(self): self._open(False) def _assign_pipes(self): - """assign pipes (pre-popen) - """ + """assign pipes (pre-popen)""" pass def _init_pipes(self): @@ -160,9 +158,8 @@ def _open(self, deferred: bool): self._init_pipes() # set the log source and start the logger - if self._logger: - self._logger.stderr = self._proc.stderr - self._logger.start() + self._logger.stderr = self._proc.stderr + self._logger.start() # if any pending data, queue them if deferred: @@ -178,11 +175,8 @@ def close(self): self._proc.terminate() if self._proc.poll() is None: self._proc.kill() - self._proc = None - if self._logger: - self._logger.join() - self._logger = None + self._logger.join() def __exit__(self, *exc_details) -> bool: try: @@ -192,11 +186,10 @@ def __exit__(self, *exc_details) -> bool: if not exc_details[0]: exc_details = sys.exc_info() finally: - if self._logger is not None: - try: - self._logger.join() - except RuntimeError: - pass + try: + self._logger.join() + except RuntimeError: + pass return False @property @@ -219,9 +212,6 @@ def readlog(self, n: int) -> str: :return: logged messages """ - if self._logger is None: - return "" - if n is not None: self._logger.index(n) with self._logger._newline_mutex: @@ -262,346 +252,3 @@ def wait(self, timeout: float | None = None) -> int | None: else: rc = None return rc - - -class BaseRawInputsMixin: - """write a raw media data to a specified stream (backend)""" - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - _args: dict - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - # 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"] - for data in src: - writer.write(data, self.default_timeout) - self._deferred_data = [] - self._input_ready = True - - def _write_stream_bytes( - self, - converter: ToBytesCallable, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - b = converter(obj=data) - if not len(b): - return - - if self._input_ready is True: - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (KeyError, BrokenPipeError, OSError): - if self._logger: - 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, 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) - - @property - def input_types(self) -> dict[int, MediaType | None]: - """media type associated with the input streams""" - return { - i: v["media_type"] if "media_type" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_rates(self) -> dict[int, int | Fraction | None]: - """sample or frame rates associated with the input streams""" - return { - i: v["raw_info"][2] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_dtypes(self) -> dict[int, DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - return { - i: v["raw_info"][0] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_shapes(self) -> dict[int, ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - return { - i: v["raw_info"][1] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - -class BaseEncodedInputsMixin: - - # FFmpegRunner's properties accessed - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - - 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 is True: - 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[index] - if len(data0): - 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) - - -class BaseRawOutputsMixin: - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread | None - - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(**kwargs) - - # 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_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_pipes() - - def _read_stream_bytes( - self, - converter: FromBytesCallable, - counter: CountDataCallable, - dtype: DTypeString, - shape: ShapeTuple, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze - ) - - # update the frame/sample counter - n = counter(obj=data) # actual number read - self._n0[stream_id] += n - - return data - - -class BaseEncodedOutputsMixin: - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread | None - - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - - # set the default read block size - self._blocksize = blocksize - - def _init_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_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) - - -class BaseRawInputMixin(BaseRawInputsMixin): - """write a raw media data to a specified stream (backend)""" - - @property - def input_type(self) -> MediaType | None: - """media type associated with the input stream""" - info = self._input_info[0] - return info["media_type"] if "media_type" in info else None - - @property - def input_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the input streams""" - - info = self._input_info[0] - return info["raw_info"][2] if "raw_info" in info else None - - @property - def input_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - info = self._input_info[0] - return info["raw_info"][0] if "raw_info" in info else None - - @property - def input_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - info = self._input_info[0] - return info["raw_info"][1] if "raw_info" in info else None - - -class BaseRawOutputMixin(BaseRawOutputsMixin): - - @property - def output_label(self) -> str | None: - """FFmpeg/custom labels of output streams""" - return self._output_info[0]["user_map"] - - @property - def output_type(self) -> dict[str, MediaType | None]: - """media type associated with the output streams (key)""" - return self._output_info[0]["media_type"] - - @property - def output_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][2] if "raw_info" in info else None - - @property - def output_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][0] if "raw_info" in info else None - - @property - def output_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][1] if "raw_info" in info else None - - @property - def output_count(self) -> int: - """number of frames/samples read""" - return self._n0[0] diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 3ce524b6..0bb94397 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -4,18 +4,20 @@ logger = logging.getLogger("ffmpegio") -from typing_extensions import Unpack -from collections.abc import Sequence +from typing_extensions import Callable, Literal, Unpack from .._typing import ( - DTypeString, - ShapeTuple, ProgressCallable, - RawDataBlob, - Literal, InputSourceDict, OutputDestinationDict, FFmpegOptionDict, + RawDataBlob, + ShapeTuple, + DTypeString, + MediaType, ) + +from collections.abc import Sequence + from ..configure import ( FFmpegArgs, FFmpegInputUrlComposite, @@ -25,23 +27,17 @@ InitMediaOutputsCallable, ) from ..filtergraph.abc import FilterGraphObject -from contextlib import ExitStack -import sys +from contextlib import ExitStack from time import time from fractions import Fraction -from .. import configure, ffmpegprocess, plugins, utils, probe +from .. import configure, plugins, utils, probe from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError +from ..configure import FFmpegArgs, InitMediaOutputsCallable -from .BaseFFmpegRunner import ( - BaseFFmpegRunner, - BaseRawInputsMixin, - BaseRawOutputsMixin, - BaseEncodedInputsMixin, - BaseEncodedOutputsMixin, -) +from .BaseFFmpegRunner import BaseFFmpegRunner # fmt:off __all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] @@ -113,7 +109,7 @@ def __init__( def _assign_pipes(self): """pre-popen pipe assignment and initialization - All named pipes must be + All named pipes must be """ if len(self._input_info): configure.assign_input_pipes( @@ -134,6 +130,349 @@ def _assign_pipes(self): ) +class BaseRawInputsMixin: + """write a raw media data to a specified stream (backend)""" + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + _args: dict + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # 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"] + for data in src: + writer.write(data, self.default_timeout) + self._deferred_data = [] + self._input_ready = True + + def _write_stream_bytes( + self, + converter: ToBytesCallable, + stream_id: int, + data: RawDataBlob, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + b = converter(obj=data) + if not len(b): + return + + if self._input_ready is True: + logger.debug("[writer main] writing...") + + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (KeyError, BrokenPipeError, OSError): + if self._logger: + 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, 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) + + @property + def input_types(self) -> dict[int, MediaType | None]: + """media type associated with the input streams""" + return { + i: v["media_type"] if "media_type" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_rates(self) -> dict[int, int | Fraction | None]: + """sample or frame rates associated with the input streams""" + return { + i: v["raw_info"][2] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_dtypes(self) -> dict[int, DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + return { + i: v["raw_info"][0] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_shapes(self) -> dict[int, ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + return { + i: v["raw_info"][1] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + +class BaseEncodedInputsMixin: + + # FFmpegRunner's properties accessed + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + + 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 is True: + 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[index] + if len(data0): + 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) + + +class BaseRawOutputsMixin: + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread | None + + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(**kwargs) + + # 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_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_pipes() + + def _read_stream_bytes( + self, + converter: FromBytesCallable, + counter: CountDataCallable, + dtype: DTypeString, + shape: ShapeTuple, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze + ) + + # update the frame/sample counter + n = counter(obj=data) # actual number read + self._n0[stream_id] += n + + return data + + +class BaseEncodedOutputsMixin: + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread + + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) + + # set the default read block size + self._blocksize = blocksize + + def _init_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_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) + + +class BaseRawInputMixin(BaseRawInputsMixin): + """write a raw media data to a specified stream (backend)""" + + @property + def input_type(self) -> MediaType | None: + """media type associated with the input stream""" + info = self._input_info[0] + return info["media_type"] if "media_type" in info else None + + @property + def input_rate(self) -> int | Fraction | None: + """sample or frame rates associated with the input streams""" + + info = self._input_info[0] + return info["raw_info"][2] if "raw_info" in info else None + + @property + def input_dtype(self) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + info = self._input_info[0] + return info["raw_info"][0] if "raw_info" in info else None + + @property + def input_shape(self) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + info = self._input_info[0] + return info["raw_info"][1] if "raw_info" in info else None + + +class BaseRawOutputMixin(BaseRawOutputsMixin): + + @property + def output_label(self) -> str | None: + """FFmpeg/custom labels of output streams""" + return self._output_info[0]["user_map"] + + @property + def output_type(self) -> dict[str, MediaType | None]: + """media type associated with the output streams (key)""" + return self._output_info[0]["media_type"] + + @property + def output_rate(self) -> int | Fraction | None: + """sample or frame rates associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][2] if "raw_info" in info else None + + @property + def output_dtype(self) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][0] if "raw_info" in info else None + + @property + def output_shape(self) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][1] if "raw_info" in info else None + + @property + def output_count(self) -> int: + """number of frames/samples read""" + return self._n0[0] + + class _RawInputMixin(BaseRawInputsMixin): _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 6f1af8bb..9d2e5da2 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -2,17 +2,13 @@ from __future__ import annotations -from time import time import logging logger = logging.getLogger("ffmpegio") from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable -from typing import Literal, Self -from fractions import Fraction -from .._typing import RawDataBlob -from typing_extensions import Unpack, Callable +from typing_extensions import Unpack from collections.abc import Sequence from .._typing import ( DTypeString, @@ -24,40 +20,19 @@ OutputDestinationDict, ) -from ..filtergraph.abc import FilterGraphObject -from ..errors import FFmpegioError - -from .. import configure, ffmpegprocess as fp, plugins, utils, probe -from .. import utils, configure, plugins -from ..threading import LoggerThread - -from ..utils import FFmpegInputUrlComposite, FFmpegOutputUrlComposite -from ..configure import OutputDestinationDict -from contextlib import ExitStack -from ..stream_spec import stream_spec_to_map_option, StreamSpecDict - -import sys -from time import time from fractions import Fraction from math import prod -from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError - +from .. import configure, plugins +from ..stream_spec import stream_spec_to_map_option, StreamSpecDict +from ..errors import FFmpegioError from ..configure import ( FFmpegArgs, MediaType, - InitMediaOutputsCallable, - FFmpegUrlType, -) - -from .BaseFFmpegRunner import ( - BaseFFmpegRunner, - BaseRawInputMixin, - BaseRawOutputMixin, - BaseEncodedInputsMixin, - BaseEncodedOutputsMixin, + FFmpegUrlType ) +from .BaseFFmpegRunner import BaseFFmpegRunner +from .._utils import get_bytesize # fmt:off __all__ = [ "SimpleVideoReader", "SimpleAudioReader", "SimpleVideoWriter", @@ -65,49 +40,6 @@ # fmt:on -class RawOutputsMixin: - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread | None - - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - - # set the default read block size for the reference stream - self._blocksize = blocksize - self._rate = None - self._n0: int = 0 # timestamps of the last read sample - - - def _read_stream_bytes( - self, - converter: FromBytesCallable, - counter: CountDataCallable, - dtype: DTypeString, - shape: ShapeTuple, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze - ) - - # update the frame/sample counter - n = counter(obj=data) # actual number read - self._n0[stream_id] += n - - return data - - class SimpleReaderBase(BaseFFmpegRunner): """base class for SISO media read stream classes""" @@ -118,7 +50,6 @@ def __init__( input_info: list[InputSourceDict], output_info: list[OutputDestinationDict], from_bytes: FromBytesCallable, - counter: CountDataCallable, to_memoryview: ToBytesCallable, show_log: bool | None, progress: ProgressCallable | None, @@ -160,7 +91,6 @@ def __init__( ) self._converter = from_bytes - self._get_num = counter self._memoryviewer = to_memoryview # set the default read block size for the reference stream @@ -206,6 +136,11 @@ def output_count(self) -> int: """number of frames/samples read""" return self._n0 + @property + def output_bytesize(self) -> int|None: + """number of bytes per output sample/pixel""" + return get_bytesize(self.output_shape,self.output_dtype) + def _assign_pipes(self): configure.assign_output_pipes( @@ -225,7 +160,7 @@ def __next__(self): return F def read( - self, n: int, timeout: float | None = None, squeeze: bool = False + self, n: int, squeeze: bool = False ) -> RawDataBlob: """Read and return numpy.ndarray with up to n frames/samples. If the argument is omitted, None, or negative, data is read and @@ -243,18 +178,16 @@ def read( info = self._output_info[0] converter = self._converter - dtype, shape, _ = info["raw_info"] + nbytes = self.output_bytesize + assert nbytes is not None - if timeout is None: - timeout = self.default_timeout + dtype, shape, _ = info["raw_info"] # type: ignore - b = info["reader"].read(n, timeout) + b = self._proc.stdout.read(n*nbytes) # type: ignore data = converter(b=b, dtype=dtype, shape=shape, squeeze=squeeze) # update the frame/sample counter - n = self._get_num( - b=b, dtype=dtype, shape=shape, squeeze=squeeze - ) # actual number read + n = len(b)//nbytes # actual number read self._n0 += n return data @@ -268,12 +201,14 @@ def readinto(self, array: RawDataBlob) -> int: A BlockingIOError is raised if the underlying raw stream is in non blocking-mode, and has no data available at the moment.""" + info = self._output_info[0] + assert 'raw_info' in info shape = info["raw_info"][1] - return self._proc.stdout.readinto(self._memoryviewer(obj=array)) // prod( + return self._proc.stdout.readinto(self._memoryviewer(obj=array)) // prod( # type: ignore shape[1:] - ) + ) class SimpleVideoReader(SimpleReaderBase): @@ -316,7 +251,6 @@ def __init__( blocksize=blocksize, sp_kwargs=sp_kwargs, from_bytes=hook.bytes_to_video, - counter=hook.video_frames, to_memoryview=hook.video_bytes, default_timeout=default_timeout, ) @@ -362,7 +296,6 @@ def __init__( blocksize=blocksize, sp_kwargs=sp_kwargs, from_bytes=hook.bytes_to_audio, - counter=hook.audio_frames, to_memoryview=hook.audio_bytes, default_timeout=default_timeout, ) @@ -371,20 +304,93 @@ def __init__( ########################################################################### -class BaseRawInputsMixin: - """write a raw media data to a specified stream (backend)""" +class SimpleWriterBase(BaseFFmpegRunner): + def __init__( + self, + media_type: MediaType, + counter: CountDataCallable, + to_memoryview: ToBytesCallable, + url: FFmpegUrlType, + input_rate: int | Fraction, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, + options: Unpack[FFmpegOptionDict], + ): + """Write video data to a video file + + :param url: output url + :param input_rate: video frame rate + :param input_dtype: numpy-style data type string of input frames, defaults + to `None` (auto-detect). + :param input_shape: shapes of each video frame, 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 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 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) + """ - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - _args: dict + options = {"probesize_in": 32, **options, "r_in": input_rate} + if overwrite: + if "n" in options: + raise FFmpegioError( + "cannot specify both `overwrite=True` and `n=ff.FLAG`." + ) + options["y"] = None + + args, input_info, input_ready, output_info, output_args = ( + configure.init_media_write( + [url], + [media_type[0]], + [(input_rate, None)], + False, + None, + None, + None, + extra_inputs, + options, + [input_dtype], + [input_shape], + ) + ) - def __init__(self, **kwargs): - super().__init__(**kwargs) + super().__init__( + ffmpeg_args=args, + input_info=input_info, + output_info=output_info or [], + input_ready=input_ready, + init_deferred_outputs=configure.init_media_write_outputs, + deferred_output_args=output_args, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs={**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, + ) + + self._get_bytes = to_memoryview + self._get_num = counter + + # set the default read block size for the referenc stream + info = self._input_info[0] + assert "raw_info" in info + + self._rate = info["raw_info"][2] + self._n0 = 0 # timestamps of the last read sample # input data must be initially buffered self._deferred_data = [[] for _ in range(len(self._input_info))] @@ -398,222 +404,48 @@ def _write_deferred_data(self): self._deferred_data = [] self._input_ready = True - def _write_stream_bytes( - self, - converter: ToBytesCallable, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - b = converter(obj=data) - if not len(b): - return - - if self._input_ready is True: - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (KeyError, BrokenPipeError, OSError): - if self._logger: - 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, stream_id, data - ) - - self._deferred_data[stream_id].append(b) - self._input_ready[stream_id] = True + def _assign_pipes(self): - if all(self._input_ready): - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) + configure.assign_input_pipes( + self._args["ffmpeg_args"], + self._input_info, + self._args["sp_kwargs"], + use_std_pipes=True, + ) @property - def input_types(self) -> dict[int, MediaType | None]: + def input_type(self) -> MediaType | None: """media type associated with the input streams""" - return { - i: v["media_type"] if "media_type" in v else None - for i, v in enumerate(self._input_info) - } + info = self._input_info[0] + return info.get("media_type", None) @property - def input_rates(self) -> dict[int, int | Fraction | None]: + def input_rate(self) -> int | Fraction | None: """sample or frame rates associated with the input streams""" - return { - i: v["raw_info"][2] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } + info = self._input_info[0] + return info["raw_info"][2] if "raw_info" in info else None @property - def input_dtypes(self) -> dict[int, DTypeString | None]: + def input_dtype(self) -> DTypeString | None: """frame/sample data type associated with the output streams (key)""" - return { - i: v["raw_info"][0] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } + info = self._input_info[0] + return info["raw_info"][0] if "raw_info" in info else None @property - def input_shapes(self) -> dict[int, ShapeTuple | None]: + def input_shape(self) -> ShapeTuple | None: """frame/sample shape associated with the output streams (key)""" - return { - i: v["raw_info"][1] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - -class SimpleWriterBase(BaseEncodedOutputsMixin, BaseRawInputMixin, BaseFFmpegRunner): - 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 _assign_pipes(self): - - configure.assign_input_pipes( - self._args["ffmpeg_args"], - self._output_info, - self._args["sp_kwargs"], - use_std_pipes=True, - ) + info = self._input_info[0] + return info["raw_info"][1] if "raw_info" in info else None @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] + return self._n0 @property - def input_samplesize(self) -> int: + def input_bytesize(self) -> int|None: """input sample/pixel count per frame""" - return prod(self._input_info[0]["raw_info"][1]) - - 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 = fp.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]) + return get_bytesize(self.input_shape,self.input_dtype) def write(self, data): """Write the given numpy.ndarray object, data, and return the number @@ -629,161 +461,147 @@ def write(self, data): """ - if self._cfg: - # if FFmpeg not yet started, finalize the configuration with - # the data and start - self._open(data) + b = self._get_bytes(obj=data) + if not len(b): + return + + if self._input_ready is True: + logger.debug("[writer main] writing...") + try: + self._proc.stdin.write(b) + 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 + ) - logger.debug("[writer main] writing...") + self._deferred_data[0].append(b) + self._input_ready = True - try: - self._proc.stdin.write(self._viewer(obj=data)) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() + if self._input_ready is True: + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) 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, + url: FFmpegUrlType, + input_rate: int | Fraction, *, - input_shape=None, - input_dtype=None, - extra_inputs=None, - overwrite=None, - show_log=None, - progress=None, - sp_kwargs=None, - **options, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], ): - options["r_in"] = rate_in - if "r" not in options: - options["r"] = rate_in + """Write video data to a video file + + :param url: output url + :param input_rate: video frame rate + :param input_dtype: numpy-style data type string of input frames, defaults + to `None` (auto-detect). + :param input_shape: shapes of each video frame, 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 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 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) + """ + hook = plugins.get_hook() super().__init__( - plugins.get_hook().video_bytes, + 'video', + hook.video_frames, + hook.video_bytes, url, + input_rate, input_shape, input_dtype, + extra_inputs, + overwrite, 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) + options, ) - 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, + url: FFmpegUrlType, + input_rate: int | Fraction, *, - input_shape=None, - input_dtype=None, - extra_inputs=None, - overwrite=None, - show_log=None, - progress=None, - sp_kwargs=None, - **options, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool | None = None, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], ): - options["ar_in"] = rate_in - if "ar" not in options: - options["ar"] = rate_in + """Write video data to a video file + + :param url: output url + :param input_rate: video frame rate + :param input_dtype: numpy-style data type string of input frames, defaults + to `None` (auto-detect). + :param input_shape: shapes of each video frame, 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 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 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) + """ + hook = plugins.get_hook() super().__init__( - plugins.get_hook().audio_bytes, + 'audio', + hook.audio_frames, + hook.audio_bytes, url, + input_rate, input_shape, input_dtype, + extra_inputs, + overwrite, 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 + options, ) - inopts["c:a"], inopts["f"] = utils.get_audio_codec(inopts["sample_fmt"]) diff --git a/src/ffmpegio/streams/StdStreams.py b/src/ffmpegio/streams/StdStreams.py index 3e89b66b..8804579d 100644 --- a/src/ffmpegio/streams/StdStreams.py +++ b/src/ffmpegio/streams/StdStreams.py @@ -327,7 +327,7 @@ def input_shape(self) -> ShapeTuple: return self._input_info[0]["raw_info"][1] @property - def input_samplesize(self) -> int: + def input_bytesize(self) -> int: """input sample/pixel count per frame""" return prod(self._input_info[0]["raw_info"][1]) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index dba06696..c17486e6 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -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( - f"invalid audio data dimension: data shape must be must be 1d or 2d" - ) + ndim = len(shape) + if ndim < 1 or ndim > 2: + raise ValueError( + f"invalid audio data dimension: data shape must be must be 1d or 2d" + ) try: sample_fmt = { From f972c22902ad1abbaa642423b98f008dc20500bd Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 29 Jul 2025 21:41:28 -0500 Subject: [PATCH 308/344] wip --- src/ffmpegio/_typing.py | 18 +- src/ffmpegio/configure.py | 53 +- src/ffmpegio/streams/PipedStreams.py | 120 +-- src/ffmpegio/streams/StdStreams.py | 1197 ++++++++++++++++---------- src/ffmpegio/utils/__init__.py | 3 +- 5 files changed, 815 insertions(+), 576 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 80cd49d6..07688435 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -147,8 +147,8 @@ 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 # + user_map: NotRequired[str] # user specified map option input_file_id: NotRequired[int] input_stream_id: NotRequired[int] linklabel: NotRequired[str] @@ -157,3 +157,19 @@ class OutputDestinationDict(TypedDict): reader: NotRequired[ReaderThread | 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/configure.py b/src/ffmpegio/configure.py index 54e39bee..168cfeae 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -24,6 +24,7 @@ RawStreamDef, RawDataBlob, FFmpegOptionDict, + FilterGraphInfoDict, ) from collections.abc import Sequence from .utils import FFmpegInputUrlComposite, FFmpegOutputUrlComposite @@ -318,13 +319,16 @@ def finalize_video_read_opts( args: FFmpegArgs, ofile: int = 0, input_info: list[InputSourceDict] = [], - fg_info: dict[str, dict] | None = None, + fg_info: dict[str, FilterGraphInfoDict] | 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 [] + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally :return dtype: Numpy-style buffer data type string :return s: video shape tuple (height, width, nb_components) :return r: video framerate @@ -435,7 +439,7 @@ def build_basic_vf( :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'`. + 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 """ @@ -497,13 +501,16 @@ def finalize_audio_read_opts( args: FFmpegArgs, ofile: int = 0, input_info: list[InputSourceDict] = [], - fg_info: dict[str, dict] | None = None, + fg_info: dict[str, FilterGraphInfoDict] | 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 + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally :return dtype: input data type (Numpy style) :return ac: number of channels :return ar: sampling rate @@ -952,13 +959,16 @@ def add_filtergraph( def resolve_raw_output_streams( args: FFmpegArgs, input_info: list[InputSourceDict], - fg_info: dict[str, dict] | None, + fg_info: dict[str, FilterGraphInfoDict] | 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 fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally :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. @@ -1044,14 +1054,17 @@ def parse_map(spec): def auto_map( - args: FFmpegArgs, input_info: list[InputSourceDict], fg_info: dict[str, dict] | None + args: FFmpegArgs, + input_info: list[InputSourceDict], + fg_info: dict[str, FilterGraphInfoDict] | None, ) -> dict[str, OutputDestinationDict]: """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 + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally :return: a map of input/filtergraph output labels and their stream information. Mapping Input Streams vs. Complex Filtergraph Outputs @@ -1064,16 +1077,11 @@ 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 + # 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 + gopts["filter_complex"], fg_info = utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info ) else: fg_info = None @@ -1290,8 +1298,8 @@ def process_raw_outputs( input_info: list[InputSourceDict], streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, options: FFmpegOptionDict, - fg_info: dict[str, dict] | None = None, -) -> tuple[list[OutputDestinationDict], dict[str, dict] | None]: + fg_info: dict[str, FilterGraphInfoDict] | None = None, +) -> tuple[list[OutputDestinationDict], dict[str, FilterGraphInfoDict] | None]: """analyze and process piped raw outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -1299,10 +1307,12 @@ 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 + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, 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 + :return fg_info: dict of filtergraph outputs, keyed by their linklabels; + None if no filtergraph defined """ gopts = args["global_options"] @@ -1514,8 +1524,6 @@ def 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 @@ -1524,7 +1532,6 @@ 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 """ missing_map = False diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 0bb94397..99591a9f 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -15,6 +15,7 @@ DTypeString, MediaType, ) +from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable from collections.abc import Sequence @@ -51,8 +52,8 @@ def __init__( self, ffmpeg_args: FFmpegArgs, input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict] | None, - input_ready: Literal[True] | list[bool] | None, + output_info: list[OutputDestinationDict], + input_ready: Literal[True] | list[bool], init_deferred_outputs: InitMediaOutputsCallable | None, deferred_output_args: list[FFmpegOptionDict | None], *, @@ -198,10 +199,7 @@ def _write_stream_bytes( @property def input_types(self) -> dict[int, MediaType | None]: """media type associated with the input streams""" - return { - i: v["media_type"] if "media_type" in v else None - for i, v in enumerate(self._input_info) - } + return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} @property def input_rates(self) -> dict[int, int | Fraction | None]: @@ -304,34 +302,48 @@ def __init__(self, blocksize, ref_output, **kwargs): self._n0 = None # timestamps of the last read sample @property - def output_labels(self) -> list[str]: + def output_labels(self) -> list[str | None]: """FFmpeg/custom labels of output streams""" - return [v["user_map"] for v in self._output_info] + return [ + v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) + ] @property - def output_types(self) -> dict[str, MediaType]: + def output_types(self) -> list[MediaType | None]: """media type associated with the output streams (key)""" - return {v["user_map"]: v["media_type"] for v in self._output_info} + return [v["media_type"] for v in self._output_info] @property - def output_rates(self) -> dict[str, int | Fraction]: + def output_rates(self) -> list[int | Fraction | None]: """sample or frame rates associated with the output streams (key)""" - return {v["user_map"]: v["raw_info"][2] for v in self._output_info} + + def get_rate(v): + return v and v[2] + + return [get_rate(v) for v in self._output_info] @property - def output_dtypes(self) -> dict[str, DTypeString]: + def output_dtypes(self) -> list[DTypeString | None]: """frame/sample data type associated with the output streams (key)""" - return {v["user_map"]: v["raw_info"][1] for v in self._output_info} + + def get_dtype(v): + return v and v[1] + + return [get_dtype(v) for v in self._output_info] @property - def output_shapes(self) -> dict[str, ShapeTuple]: + def output_shapes(self) -> list[ShapeTuple | None]: """frame/sample shape associated with the output streams (key)""" - return {v["user_map"]: v["raw_info"][0] for v in self._output_info} + + def get_shape(v): + return v and v[0] + + return [get_shape(v) for v in self._output_info] @property - def output_counts(self) -> dict[str, int]: + def output_counts(self) -> list[int]: """number of frames/samples read""" - return {v["user_map"]: n for v, n in zip(self._output_info, self._n0)} + return [0] * len(self._output_info) if self._n0 is None else list(self._n0) def _init_pipes(self) -> ExitStack: @@ -339,7 +351,11 @@ def _init_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["raw_info"][2] for v in self._output_info] + self._rates = self.output_rates + + if any(r is None for r in self._rates): + raise FFmpegioError('There is an output stream without known output rate.') + self._n0 = [0] * len(self._output_info) # timestamps of the last read sample self._pipe_kws = { **self._pipe_kws, @@ -407,72 +423,6 @@ def _read_encoded_stream( return info["reader"].read(n, timeout) - -class BaseRawInputMixin(BaseRawInputsMixin): - """write a raw media data to a specified stream (backend)""" - - @property - def input_type(self) -> MediaType | None: - """media type associated with the input stream""" - info = self._input_info[0] - return info["media_type"] if "media_type" in info else None - - @property - def input_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the input streams""" - - info = self._input_info[0] - return info["raw_info"][2] if "raw_info" in info else None - - @property - def input_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - info = self._input_info[0] - return info["raw_info"][0] if "raw_info" in info else None - - @property - def input_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - info = self._input_info[0] - return info["raw_info"][1] if "raw_info" in info else None - - -class BaseRawOutputMixin(BaseRawOutputsMixin): - - @property - def output_label(self) -> str | None: - """FFmpeg/custom labels of output streams""" - return self._output_info[0]["user_map"] - - @property - def output_type(self) -> dict[str, MediaType | None]: - """media type associated with the output streams (key)""" - return self._output_info[0]["media_type"] - - @property - def output_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][2] if "raw_info" in info else None - - @property - def output_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][0] if "raw_info" in info else None - - @property - def output_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][1] if "raw_info" in info else None - - @property - def output_count(self) -> int: - """number of frames/samples read""" - return self._n0[0] - - class _RawInputMixin(BaseRawInputsMixin): _media_bytes = {"video": "video_bytes", "audio": "audio_bytes"} diff --git a/src/ffmpegio/streams/StdStreams.py b/src/ffmpegio/streams/StdStreams.py index 8804579d..b3cc31fa 100644 --- a/src/ffmpegio/streams/StdStreams.py +++ b/src/ffmpegio/streams/StdStreams.py @@ -4,34 +4,40 @@ logger = logging.getLogger("ffmpegio") -from typing_extensions import Unpack -from collections.abc import Sequence +from typing_extensions import Callable, Literal, Unpack from .._typing import ( - DTypeString, - ShapeTuple, ProgressCallable, - RawDataBlob, - FFmpegOptionDict, InputSourceDict, OutputDestinationDict, + FFmpegOptionDict, + RawDataBlob, + ShapeTuple, + DTypeString, + MediaType, ) +from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable + +from collections.abc import Sequence + 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 contextlib import ExitStack from time import time from fractions import Fraction -from math import prod -from .. import configure, ffmpegprocess, plugins, utils, probe +from .. import configure, plugins, utils, probe from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError +from ..configure import FFmpegArgs, InitMediaOutputsCallable + from .BaseFFmpegRunner import BaseFFmpegRunner # fmt:off @@ -45,14 +51,13 @@ class _StdFFmpegRunner(BaseFFmpegRunner): def __init__( self, - *, - get_num, ffmpeg_args: FFmpegArgs, input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict] | None, - input_ready: bool, + output_info: list[OutputDestinationDict], + input_ready: Literal[True] | list[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, @@ -87,259 +92,384 @@ def __init__( 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}, - } + super().__init__( + ffmpeg_args, + input_info, + output_info, + input_ready, + init_deferred_outputs, + deferred_output_args, + default_timeout, + progress, + show_log, + 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 - self._stack = None - def __enter__(self): - self.open() - return self - - def open(self): - """start FFmpeg processing + def _assign_pipes(self): - 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. + """pre-popen pipe assignment and initialization + All named pipes must be """ + if len(self._input_info): + configure.assign_input_pipes( + self._args["ffmpeg_args"], + self._input_info, + self._args["sp_kwargs"], + use_std_pipes=True, + ) - if self._input_ready is True: - self._open(False) - - def _init_std_pipes(self) -> ExitStack: + if len(self._output_info): + configure.assign_output_pipes( + self._args["ffmpeg_args"], + self._output_info, + self._args["sp_kwargs"], + use_std_pipes=True, + ) - return configure.init_std_pipes( - self._proc.stdin, - self._proc.stdout, - self._input_info, - self._output_info, - **self._pipe_kws, + configure.init_named_pipes( + self._input_info, self._output_info, **self._pipe_kws, stack=self._stack ) - def _write_deferred_data(self): - pass - def _close_io(self, _): - if self._stack: - self._stack.close() - self._stack = None +class BaseRawInputsMixin: + """write a raw media data to a specified stream (backend)""" - def _open(self, deferred: bool): + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + _args: dict - 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], - ) + def __init__(self, **kwargs): + super().__init__(**kwargs) - # get std pipes - stdin, stdout, input = configure.assign_std_pipes( - self._args["ffmpeg_args"], self._input_info, self._output_info - ) + # input data must be initially buffered + self._deferred_data = [[] for _ in range(len(self._input_info))] - # run the FFmpeg - self._proc = ffmpegprocess.Popen( - **self._args, - stdin=stdin, - stdout=stdout, - input=input, - on_exit=self._close_io, - ) + 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"] + for data in src: + writer.write(data, self.default_timeout) + self._deferred_data = [] + self._input_ready = True - # set up and activate pipes and read/write threads - self._stack = self._init_std_pipes() + def _write_stream_bytes( + self, + converter: ToBytesCallable, + stream_id: int, + data: RawDataBlob, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" - # set the log source and start the logger - self._logger.stderr = self._proc.stderr - self._logger.start() + b = converter(obj=data) + if not len(b): + return - # if any pending data, queue them - if deferred: - self._write_deferred_data() + if self._input_ready is True: + logger.debug("[writer main] writing...") - return self + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (KeyError, BrokenPipeError, OSError): + if self._logger: + self._logger.join_and_raise() - def close(self): - """Kill FFmpeg process and close the streams""" + else: + # need to collect input data type and shape from the actual data + # before starting the FFmpeg - 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._logger.join() - self._logger = None + configure.update_raw_input( + self._args["ffmpeg_args"], self._input_info, stream_id, data + ) - 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 + 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) @property - def closed(self) -> bool: - """True if the stream is closed.""" - return self._proc.poll() is not None + def input_types(self) -> dict[int, MediaType | None]: + """media type associated with the input streams""" + return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} @property - def lasterror(self) -> FFmpegError: - """Last error FFmpeg posted""" - if self._proc.poll(): - return self._logger.Exception() - else: - return None + def input_rates(self) -> dict[int, int | Fraction | None]: + """sample or frame rates associated with the input streams""" + return { + i: v["raw_info"][2] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } - def readlog(self, n: int | None = None) -> str: - """read FFmpeg log lines + @property + def input_dtypes(self) -> dict[int, DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + return { + i: v["raw_info"][0] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } - :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 - """ + @property + def input_shapes(self) -> dict[int, ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + return { + i: v["raw_info"][1] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } - if timeout is None: - timeout = self.default_timeout - if self._proc: +class BaseEncodedInputsMixin: - if timeout is not None: - timeout += time() + # FFmpegRunner's properties accessed + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] - # 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() - ) + 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 - # wait until the FFmpeg finishes the job + 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 is True: 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 + info["writer"].write(data, timeout) + except: + raise FFmpegioError("Cannot write to a non-piped input.") + else: - rc = None - return rc + # buffer must be contiguous + data0 = self._deferred_data[index] + if len(data0): + 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 -from collections.abc import Callable + if all(self._input_ready): + # once data is written for all the necessary inputs, + # analyze them and start the FFmpeg + self._open(True) -class _RawInputBaseMixin: +class BaseRawOutputsMixin: - _get_num: Callable - _input_info: InputSourceDict default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread | None - def __init__(self, get_bytes, array_to_opts, **kwargs): + def __init__(self, blocksize, ref_output, **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 + # 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 _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 output_labels(self) -> list[str | None]: + """FFmpeg/custom labels of output streams""" + return [ + v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) + ] @property - def input_count(self) -> int: - """number of input frames/samples written""" - return self._nin + def output_types(self) -> list[MediaType | None]: + """media type associated with the output streams (key)""" + return [v["media_type"] for v in self._output_info] @property - def input_rate(self) -> int | Fraction: - """input sample or frame rates""" - return self._input_info[0]["raw_info"][2] + def output_rates(self) -> list[int | Fraction | None]: + """sample or frame rates associated with the output streams (key)""" + + def get_rate(v): + return v and v[2] + + return [get_rate(v) for v in self._output_info] @property - def input_dtype(self) -> DTypeString: - """input frame/sample data type""" - return self._input_info[0]["raw_info"][0] + def output_dtypes(self) -> list[DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + + def get_dtype(v): + return v and v[1] + + return [get_dtype(v) for v in self._output_info] @property - def input_shape(self) -> ShapeTuple: - """input frame/sample shape""" - return self._input_info[0]["raw_info"][1] + def output_shapes(self) -> list[ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + + def get_shape(v): + return v and v[0] + + return [get_shape(v) for v in self._output_info] @property - def input_bytesize(self) -> int: - """input sample/pixel count per frame""" - return prod(self._input_info[0]["raw_info"][1]) + def output_counts(self) -> list[int]: + """number of frames/samples read""" + return [0] * len(self._output_info) if self._n0 is None else list(self._n0) + + def _init_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 = self.output_rates + + if any(r is None for r in self._rates): + raise FFmpegioError('There is an output stream without known output rate.') + + 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_pipes() + + def _read_stream_bytes( + self, + converter: FromBytesCallable, + counter: CountDataCallable, + dtype: DTypeString, + shape: ShapeTuple, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze + ) + + # update the frame/sample counter + n = counter(obj=data) # actual number read + self._n0[stream_id] += n + + return data + + +class BaseEncodedOutputsMixin: + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread + + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) - def write(self, data: RawDataBlob, timeout: float | None = None): - """write a raw media data + # set the default read block size + self._blocksize = blocksize + + def _init_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_pipes() - :param data: audio data blob (depends on the active data conversion plugin) + 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) + +class _RawInputMixin(BaseRawInputsMixin): + + _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_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"] + self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) + + 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, @@ -351,167 +481,158 @@ def write(self, data: RawDataBlob, timeout: float | None = None): 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) + # 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 -class _AudioInputMixin(_RawInputBaseMixin): + self._write_stream(info, stream_id, data, timeout) - def __init__(self, **kwargs): - super().__init__( - plugins.get_hook().audio_bytes, utils.array_to_audio_options, **kwargs - ) + 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 -class _VideoInputMixin(_RawInputBaseMixin): + """ - def __init__(self, **kwargs): - super().__init__( - plugins.get_hook().video_bytes, utils.array_to_video_options, **kwargs - ) + it_data = data.items() if isinstance(data, dict) else enumerate(data) + if timeout is None: + timeout = self.default_timeout -class _EncodedInputMixin: + if timeout is not None: + timeout += time() - def __init__(self, **kwargs): + 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(), + ) - 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 +class _EncodedInputMixin(BaseEncodedInputsMixin): - def write_encoded(self, data: bytes, timeout: float | None = None): - """write encoded media data to stdout + def write_encoded_stream( + self, stream_id: int, data: bytes, timeout: float | None = None + ): + """write a raw media data to a specified stream - :param data: encoded data byte sequence + :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 - info = self._input_info[0] - - if self._input_ready: + 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). - try: - info["writer"].write(data, timeout) - except: - raise FFmpegioError("Cannot write to a non-piped input.") + 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. - else: - # buffer must be contiguous - data0 = self._deferred_data - if len(data0): - data = data0.append(data) - else: - self._deferred_data = data + The caller may release or mutate data after this method returns, + so the implementation should only access data during the method call. - # 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) + # 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 -class _RawOutputBaseMixin: - def __init__(self, converter, blocksize, **kwargs): - super().__init__(**kwargs) - self._converter = converter + self._write_encoded_stream(stream_id, info, data, timeout) - # set the default read block size for the reference stream - self._blocksize = blocksize - self._n0 = None # timestamps of the last read sample + def write_encoded( + self, + data: Sequence[RawDataBlob] | dict[int, RawDataBlob], + timeout: float | None = None, + ) -> bytes | None: + """write data to all input streams - @property - def output_label(self) -> str: - """FFmpeg/custom label of output stream""" - return self._output_info[0]["user_map"] + :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 - @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] + it_data = data.items() if isinstance(data, dict) else enumerate(data) - @property - def output_dtype(self) -> DTypeString: - """output frame/sample data type""" - return self._output_info[0]["raw_info"][0] + if timeout is None: + timeout = self.default_timeout - @property - def output_shape(self) -> ShapeTuple: - """output frame/sample shape""" - return self._output_info[0]["raw_info"][1] + if timeout is not None: + timeout += time() - @property - def output_samplesize(self) -> int: - """output sample/pixel count per frame""" - return prod(self._output_info[0]["raw_info"][1]) + 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(), + ) - @property - def output_count(self) -> int: - """number of frames/samples read""" - return self._n0 - def _init_std_pipes(self) -> ExitStack: +class _RawOutputMixin(BaseRawOutputsMixin): + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(blocksize=blocksize, ref_output=ref_output, **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 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} + def _read_stream( + self, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + converter = self._converters[info["media_type"]] + dtype, shape, _ = info["raw_info"] + counter = self._get_num[info["media_type"]] - # set up and activate pipes and read/write threads - return super()._init_std_pipes() + return self._read_stream_bytes( + converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze + ) - def read(self, n: int, timeout: float | None = None) -> RawDataBlob: - """read output stream + def read_stream( + self, stream_id: int | str, n: int, timeout: float | None = None + ) -> RawDataBlob: + """read selected output stream - :param n: number of frames/samples to read. Set -1 to read as many as available. + :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 @@ -533,60 +654,92 @@ def read(self, n: int, timeout: float | None = None) -> RawDataBlob: """ - 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) + 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) - # update the frame/sample counter - n = self._get_num(obj=data) # actual number read - self._n0 += n + def read(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob]: + """Read data from all output streams - return data + :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. -class _AudioOutputMixin(_RawOutputBaseMixin): + The returned `dict` is keyed by the output labels. - def __init__(self, **kwargs): - super().__init__(plugins.get_hook().bytes_to_audio, **kwargs) + 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 + === ========= ========================================================================= + """ -class _VideoOutputMixin(_RawOutputBaseMixin): - - def __init__(self, **kwargs): - super().__init__(plugins.get_hook().bytes_to_video, **kwargs) + data = {} # output + if timeout is None: + timeout = self.default_timeout -class _EncodedOutputMixin: - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) + if timeout is not None: + timeout += time() - # set the default read block size - self._blocksize = blocksize + 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 - def _init_std_pipes(self) -> ExitStack: + return data - # 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() +class _EncodedOutputMixin(BaseEncodedOutputsMixin): - def read_encoded(self, n: int, timeout: float | None = None) -> bytes: - """read encoded data from stdout + def read_encoded_stream( + self, stream_id: int, n: int, timeout: float | None = None + ) -> bytes: + """read selected output stream - :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 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 byte sequence + :return: retrieved data Effect of mixing `n` and `timeout` ---------------------------------- @@ -596,20 +749,54 @@ def read_encoded(self, n: int, timeout: float | None = None) -> bytes: === ========= ========================================================================= 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 `float` Retrieve as many bytes up to `n` before `timeout` seconds passes <0 `None` Wait indefinitely until FFmpeg terminates - <0 `float` Retrieve as much data until `timeout` seconds passes + <0 `float` Retrieve as many bytes until `timeout` seconds passes === ========= ========================================================================= + """ - return self._output_info[0]["reader"].read(n, timeout) + 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() -class StdAudioDecoder(_EncodedInputMixin, _AudioOutputMixin, _StdFFmpegRunner): + 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, _StdFFmpegRunner): 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, @@ -618,13 +805,17 @@ def __init__( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): - """Decode audio data from media data stream over std pipes + """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 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 @@ -632,22 +823,33 @@ def __init__( :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 - 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} + urls, 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, + ref_output=ref_stream, blocksize=blocksize, default_timeout=default_timeout, progress=progress, @@ -656,23 +858,43 @@ def __init__( sp_kwargs=sp_kwargs, ) - self._get_bytes = plugins.get_hook().audio_bytes + 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 len(self._get_bytes(obj=F)): + 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 StdVideoDecoder(_EncodedInputMixin, _VideoOutputMixin, _StdFFmpegRunner): +class PipedMediaWriter(_EncodedOutputMixin, _RawInputMixin, _StdFFmpegRunner): 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, @@ -681,13 +903,31 @@ def __init__( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): - """Read audio data from encoded media data stream + """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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :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 @@ -697,49 +937,64 @@ def __init__( 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} + 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__( - 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, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_write_outputs, deferred_output_args=output_args, - blocksize=blocksize, default_timeout=default_timeout, progress=progress, show_log=show_log, + blocksize=blocksize, 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): +class PipedMediaFilter(_RawOutputMixin, _RawInputMixin, _StdFFmpegRunner): def __init__( self, - input_rate: int, - *, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, + 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, @@ -748,122 +1003,132 @@ def __init__( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): - """Write video and audio data from multiple media streams to one or more files + """Filter audio/video data streams with FFmpeg filtergraphs - :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 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 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. + 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, defaults to `None` to use 64-kB blocks + :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[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 **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, 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, - ) + 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__( - 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, + 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, - blocksize=blocksize, queuesize=queuesize, sp_kwargs=sp_kwargs, ) -class StdVideoEncoder(_EncodedOutputMixin, _VideoInputMixin, _StdFFmpegRunner): +class PipedMediaTranscoder(_EncodedOutputMixin, _EncodedInputMixin, _StdFFmpegRunner): + """Class to transcode encoded media streams""" def __init__( self, - input_rate: int, + input_options: Sequence[FFmpegOptionDict], + output_options: Sequence[FFmpegOptionDict], *, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - show_log: bool | 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 = None, + sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], ): - """Write video and audio data from multiple media streams to one or more files + """Encoded media stream transcoder - :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 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 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. + 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: 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 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, 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], - ) + 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__( - 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, + input_ready=None, + init_deferred_outputs=None, + deferred_output_args=None, default_timeout=default_timeout, progress=progress, show_log=show_log, diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index c17486e6..a5e58e2f 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -30,6 +30,7 @@ FFmpegOptionDict, ShapeTuple, DTypeString, + FilterGraphInfoDict, ) from ..filtergraph.abc import FilterGraphObject from .. import filtergraph as fgb @@ -728,7 +729,7 @@ def analyze_complex_filtergraphs( filtergraphs: list[FilterGraphObject | str], inputs: list[tuple[FFmpegUrlType | None, FFmpegOptionDict]], inputs_info: list[InputSourceDict], -) -> tuple[list[FilterGraphObject], dict[str, dict]]: +) -> tuple[list[FilterGraphObject], dict[str, FilterGraphInfoDict]]: """analyze filtergraphs and return requested field values :param fields: a list of stream properties From 7aa2fd928ab8d0a2578b309382ce28f91c0a8615 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 4 Sep 2025 10:27:42 -0400 Subject: [PATCH 309/344] wip (SimpleStreams passing) --- src/ffmpegio/configure.py | 23 +- src/ffmpegio/plugins/hookspecs.py | 12 + src/ffmpegio/plugins/rawdata_bytes.py | 8 + src/ffmpegio/plugins/rawdata_mpl.py | 8 + src/ffmpegio/plugins/rawdata_numpy.py | 9 + src/ffmpegio/streams/BaseFFmpegRunner.py | 15 +- src/ffmpegio/streams/PipedStreams.py | 9 +- src/ffmpegio/streams/SimpleStreams.py | 270 +++-- src/ffmpegio/streams/StdStreams.py | 1401 ---------------------- src/ffmpegio/streams/__init__.py | 16 +- src/ffmpegio/threading.py | 20 +- tests/test_open.py | 2 +- tests/test_simplestreams.py | 30 +- tests/test_stdstreams.py | 188 --- 14 files changed, 257 insertions(+), 1754 deletions(-) delete mode 100644 src/ffmpegio/streams/StdStreams.py delete mode 100644 tests/test_stdstreams.py diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 168cfeae..1afb2b79 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1399,27 +1399,26 @@ def process_raw_inputs( for i, (mtype, arg, dtype, shape) in enumerate( zip(stream_types, stream_args, dtypes, shapes) ): - + ropt = {"v": "r", "a": "ar"}.get(mtype, None) # rate option 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: + opts = {ropt: a1} + if ropt is None: raise FFmpegioError( "stream_type not specified, cannot resolve the `rate` input." ) else: assert isinstance(a2, dict) data, opts = a1, a2 - if mtype not in "av": # unknown + if ropt is None: # unknown if "ar" in opts: mtype = "a" + ropt = "ar" elif "r" in opts: mtype = "v" + ropt = "r" else: raise FFmpegioError("unknown input stream media type") @@ -1438,6 +1437,7 @@ def process_raw_inputs( raw_info = None if mtype == "a": # audio media_type = "audio" + opts[ropt] = round(opts[ropt]) # force int sampling rate if data is not None: more_opts, raw_info = utils.array_to_audio_options(data) data = plugins.get_hook().audio_bytes(obj=data) @@ -1448,6 +1448,8 @@ def process_raw_inputs( acodec, f = utils.get_audio_codec(sample_fmt) more_opts = {"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f} + raw_info = (*raw_info, opts["ar"]) if raw_info else (None, None, opts["ar"]) + else: # video media_type = "video" if data is not None: @@ -1462,12 +1464,15 @@ def process_raw_inputs( "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 raw_info is not None: - info["raw_info"] = raw_info + + info["raw_info"] = ( + (None, None, opts[ropt]) if raw_info is None else (*raw_info, opts[ropt]) + ) if data is not None: info["buffer"] = data diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index bf07b3fd..45ef44fb 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -150,3 +150,15 @@ def device_sink_api() -> tuple[str, dict[str, Callable]]: Partial definition is OK """ ... + +class HasDataCallable(Protocol): + def __call__(self, *, obj: object) -> bool: ... + + +@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 fa129dcb..04f1a7e4 100644 --- a/src/ffmpegio/plugins/rawdata_bytes.py +++ b/src/ffmpegio/plugins/rawdata_bytes.py @@ -167,3 +167,11 @@ 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 ae2cc0cb..b606bd94 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -39,3 +39,11 @@ 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 8ac292fc..bb8e5a42 100644 --- a/src/ffmpegio/plugins/rawdata_numpy.py +++ b/src/ffmpegio/plugins/rawdata_numpy.py @@ -141,3 +141,12 @@ def bytes_to_audio(b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: boo return x.squeeze() if squeeze else x except: return None + + +@hookimpl +def is_empty(obj: bytes) -> bool: + """True if data blob object has no data + + :param obj: object containing media data + """ + return not bool(obj) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 496d53f6..b9ad508e 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -129,6 +129,8 @@ def _write_deferred_data(self): def _open(self, deferred: bool): + logger.info("starting FFmpeg subprocess") + if deferred: assert self._init_deferred_outputs is not None @@ -205,17 +207,15 @@ def lasterror(self) -> FFmpegError | None: else: return None - def readlog(self, n: int) -> str: + def readlog(self, n: int | None = None) -> str: """read FFmpeg log lines - :param n: number of lines to read + :param n: number of lines to read or None to read all currently found in the buffer :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]) + return "\n".join(self._logger.logs if n is None else self._logger.logs[:n]) def wait(self, timeout: float | None = None) -> int | None: """close all input pipes and wait for FFmpeg to exit @@ -238,10 +238,13 @@ def wait(self, timeout: float | None = None) -> int | None: # write the sentinel to each input queue for info in self._input_info: - if "writer" in info: + if "writer" in info: # has writer thread info["writer"].write( None, None if timeout is None else timeout - time() ) + else: # std pipe, no threading + # close the stdout + self._proc.stdin.close() # wait until the FFmpeg finishes the job self._proc.wait(None if timeout is None else timeout - time()) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 99591a9f..ac3c2103 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -861,10 +861,11 @@ def __iter__(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) - ): + # if not any( + # len(self._get_bytes[info["media_type"]](obj=f)) + # for f, info in zip(F.values(), self._output_info) + # ): + if plugins.get_hook().is_empty(obj=F): raise StopIteration return F diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 9d2e5da2..0dce2fdc 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -8,7 +8,7 @@ from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable -from typing_extensions import Unpack +from typing_extensions import Unpack, Literal from collections.abc import Sequence from .._typing import ( DTypeString, @@ -26,11 +26,7 @@ from .. import configure, plugins from ..stream_spec import stream_spec_to_map_option, StreamSpecDict from ..errors import FFmpegioError -from ..configure import ( - FFmpegArgs, - MediaType, - FFmpegUrlType -) +from ..configure import FFmpegArgs, MediaType, FFmpegUrlType, InitMediaOutputsCallable from .BaseFFmpegRunner import BaseFFmpegRunner from .._utils import get_bytesize @@ -41,7 +37,7 @@ class SimpleReaderBase(BaseFFmpegRunner): - """base class for SISO media read stream classes""" + """queue-less SISO media reader class""" def __init__( self, @@ -106,7 +102,7 @@ def __init__( @property def output_label(self) -> str | None: """FFmpeg/custom labels of output streams""" - return self._output_info[0]["user_map"] + return self._output_info[0].get("user_map", None) @property def output_type(self) -> MediaType | None: @@ -137,9 +133,9 @@ def output_count(self) -> int: return self._n0 @property - def output_bytesize(self) -> int|None: + def output_bytesize(self) -> int | None: """number of bytes per output sample/pixel""" - return get_bytesize(self.output_shape,self.output_dtype) + return get_bytesize(self.output_shape, self.output_dtype) def _assign_pipes(self): @@ -154,14 +150,12 @@ def __iter__(self): return self def __next__(self): - F = self.read(self._blocksize, self.default_timeout) - if F is None: + F = self.read(self._blocksize) + if plugins.get_hook().is_empty(obj=F): raise StopIteration return F - def read( - self, n: int, squeeze: bool = False - ) -> RawDataBlob: + def read(self, n: int, squeeze: bool = False) -> 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 @@ -181,13 +175,13 @@ def read( nbytes = self.output_bytesize assert nbytes is not None - dtype, shape, _ = info["raw_info"] # type: ignore + dtype, shape, _ = info["raw_info"] # type: ignore - b = self._proc.stdout.read(n*nbytes) # type: ignore + b = self._proc.stdout.read(n * nbytes if n >= 0 else -1) # type: ignore data = converter(b=b, dtype=dtype, shape=shape, squeeze=squeeze) # update the frame/sample counter - n = len(b)//nbytes # actual number read + n = len(b) // nbytes # actual number read self._n0 += n return data @@ -203,12 +197,12 @@ def readinto(self, array: RawDataBlob) -> int: blocking-mode, and has no data available at the moment.""" info = self._output_info[0] - assert 'raw_info' in info + assert "raw_info" in info shape = info["raw_info"][1] - return self._proc.stdout.readinto(self._memoryviewer(obj=array)) // prod( # type: ignore + return self._proc.stdout.readinto(self._memoryviewer(obj=array)) // prod( # type: ignore shape[1:] - ) + ) class SimpleVideoReader(SimpleReaderBase): @@ -307,100 +301,76 @@ def __init__( class SimpleWriterBase(BaseFFmpegRunner): def __init__( self, - media_type: MediaType, - counter: CountDataCallable, + ffmpeg_args: FFmpegArgs, + input_info: list[InputSourceDict], + output_info: list[OutputDestinationDict], + input_ready: Literal[True] | list[bool], + init_deferred_outputs: InitMediaOutputsCallable | None, + deferred_output_args: list[FFmpegOptionDict | None], + from_bytes: FromBytesCallable, to_memoryview: ToBytesCallable, - url: FFmpegUrlType, - input_rate: int | Fraction, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - overwrite: bool | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - sp_kwargs: dict | None = None, - options: Unpack[FFmpegOptionDict], + show_log: bool | None, + progress: ProgressCallable | None, + default_timeout: float | None, + sp_kwargs: dict | None, ): - """Write video data to a video file + """Queue-less simple media writer - :param url: output url - :param input_rate: video frame rate - :param input_dtype: numpy-style data type string of input frames, defaults - to `None` (auto-detect). - :param input_shape: shapes of each video frame, 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 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 ffmpeg_args: (Mostly) populated FFmpeg argument dict + :param input_info: FFmpeg input option dicts with zero or one streaming pipe. (only one in input or output) + :param output_info: FFmpeg output option dicts with zero or one any streaming pipe. (only one in input or output) + :param input_ready: True to start FFmpeg, if not provide a list of per-stream readiness + :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 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 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) + :param blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks + :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) """ - options = {"probesize_in": 32, **options, "r_in": input_rate} - if overwrite: - if "n" in options: - raise FFmpegioError( - "cannot specify both `overwrite=True` and `n=ff.FLAG`." - ) - options["y"] = None - - args, input_info, input_ready, output_info, output_args = ( - configure.init_media_write( - [url], - [media_type[0]], - [(input_rate, None)], - False, - None, - None, - None, - extra_inputs, - options, - [input_dtype], - [input_shape], - ) - ) + # add std writer super().__init__( - ffmpeg_args=args, + ffmpeg_args=ffmpeg_args, input_info=input_info, - output_info=output_info or [], + output_info=output_info, input_ready=input_ready, - init_deferred_outputs=configure.init_media_write_outputs, - deferred_output_args=output_args, + init_deferred_outputs=init_deferred_outputs, + deferred_output_args=deferred_output_args, + default_timeout=default_timeout, progress=progress, show_log=show_log, - overwrite=overwrite, sp_kwargs={**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, + ref_output=0, ) - self._get_bytes = to_memoryview - self._get_num = counter + self._converter = from_bytes + self._memoryviewer = to_memoryview - # set the default read block size for the referenc stream + # set the default read block size for the reference stream info = self._input_info[0] assert "raw_info" in info self._rate = info["raw_info"][2] self._n0 = 0 # timestamps of the last read sample + ############ + + self._get_bytes = to_memoryview + # input data must be initially buffered - self._deferred_data = [[] for _ in range(len(self._input_info))] + self._deferred_data = [] 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"] - for data in src: - writer.write(data, self.default_timeout) + self._proc.stdin.write(self._deferred_data[0]) self._deferred_data = [] self._input_ready = True @@ -443,9 +413,9 @@ def input_count(self) -> int: return self._n0 @property - def input_bytesize(self) -> int|None: + def input_bytesize(self) -> int | None: """input sample/pixel count per frame""" - return get_bytesize(self.input_shape,self.input_dtype) + return get_bytesize(self.input_shape, self.input_dtype) def write(self, data): """Write the given numpy.ndarray object, data, and return the number @@ -480,7 +450,7 @@ def write(self, data): self._args["ffmpeg_args"], self._input_info, 0, data ) - self._deferred_data[0].append(b) + self._deferred_data.append(b) self._input_ready = True if self._input_ready is True: @@ -506,6 +476,8 @@ def __init__( show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, + stream: str | StreamSpecDict | None = None, + default_timeout: float | None = None, **options: Unpack[FFmpegOptionDict], ): """Write video data to a video file @@ -532,23 +504,53 @@ def __init__( the preference (see :doc:`options` for custom options) """ + # assign the input stream + st_map = options.pop("map", None) + if st_map is None: + st_map = "0:v:0" if stream is None else stream_spec_to_map_option(stream) + hook = plugins.get_hook() + + options = {"probesize_in": 32, **options, "map": st_map} + if overwrite: + if "n" in options: + raise FFmpegioError( + "cannot specify both `overwrite=True` and `n=ff.FLAG`." + ) + options["y"] = None + + args, input_info, input_ready, output_info, output_args = ( + configure.init_media_write( + [url], + ["v"], + [(None, {"r": input_rate})], + False, + None, + None, + None, + extra_inputs, + options, + [input_dtype], + [input_shape], + ) + ) + super().__init__( - 'video', - hook.video_frames, - hook.video_bytes, - url, - input_rate, - input_shape, - input_dtype, - extra_inputs, - overwrite, - show_log, - progress, - sp_kwargs, - options, + ffmpeg_args=args, + input_info=input_info, + output_info=[{}] if output_info is None else output_info, + input_ready=input_ready, + init_deferred_outputs=configure.init_media_write_outputs, + deferred_output_args=output_args, + from_bytes=hook.bytes_to_video, + to_memoryview=hook.video_bytes, + show_log=show_log, + progress=progress, + default_timeout=default_timeout, + sp_kwargs=sp_kwargs, ) + class SimpleAudioWriter(SimpleWriterBase): def __init__( @@ -563,6 +565,8 @@ def __init__( show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, + stream: str | StreamSpecDict | None = None, + default_timeout: float | None = None, **options: Unpack[FFmpegOptionDict], ): """Write video data to a video file @@ -589,19 +593,47 @@ def __init__( the preference (see :doc:`options` for custom options) """ + # assign the input stream + st_map = options.pop("map", None) + if st_map is None: + st_map = "0:a:0" if stream is None else stream_spec_to_map_option(stream) hook = plugins.get_hook() + + options = {"probesize_in": 32, **options, "map": st_map} + if overwrite: + if "n" in options: + raise FFmpegioError( + "cannot specify both `overwrite=True` and `n=ff.FLAG`." + ) + options["y"] = None + + args, input_info, input_ready, output_info, output_args = ( + configure.init_media_write( + [url], + ["a"], + [(None, {"ar": input_rate})], + False, + None, + None, + None, + extra_inputs, + options, + [input_dtype], + [input_shape], + ) + ) + super().__init__( - 'audio', - hook.audio_frames, - hook.audio_bytes, - url, - input_rate, - input_shape, - input_dtype, - extra_inputs, - overwrite, - show_log, - progress, - sp_kwargs, - options, + 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, + from_bytes=hook.bytes_to_audio, + to_memoryview=hook.audio_bytes, + show_log=show_log, + progress=progress, + default_timeout=default_timeout, + sp_kwargs=sp_kwargs, ) diff --git a/src/ffmpegio/streams/StdStreams.py b/src/ffmpegio/streams/StdStreams.py deleted file mode 100644 index b3cc31fa..00000000 --- a/src/ffmpegio/streams/StdStreams.py +++ /dev/null @@ -1,1401 +0,0 @@ -from __future__ import annotations - -import logging - -logger = logging.getLogger("ffmpegio") - -from typing_extensions import Callable, Literal, Unpack -from .._typing import ( - ProgressCallable, - InputSourceDict, - OutputDestinationDict, - FFmpegOptionDict, - RawDataBlob, - ShapeTuple, - DTypeString, - MediaType, -) -from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable - -from collections.abc import Sequence - -from ..configure import ( - FFmpegArgs, - FFmpegInputUrlComposite, - FFmpegUrlType, - MediaType, - FFmpegOutputUrlComposite, - InitMediaOutputsCallable, -) -from ..filtergraph.abc import FilterGraphObject - -from contextlib import ExitStack -from time import time -from fractions import Fraction - -from .. import configure, plugins, utils, probe -from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError -from ..configure import FFmpegArgs, InitMediaOutputsCallable - -from .BaseFFmpegRunner import BaseFFmpegRunner - -# fmt:off -__all__ = ["StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", - "StdVideoDecoder", "StdVideoEncoder", "StdVideoFilter", "StdMediaTranscoder"] -# fmt:on - - -class _StdFFmpegRunner(BaseFFmpegRunner): - """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], - input_ready: Literal[True] | list[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 = 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. - """ - - super().__init__( - ffmpeg_args, - input_info, - output_info, - input_ready, - init_deferred_outputs, - deferred_output_args, - default_timeout, - progress, - show_log, - sp_kwargs, - ) - - # set the default read block size for the referenc stream - self._pipe_kws = {"queue_size": queuesize} - - - def _assign_pipes(self): - - """pre-popen pipe assignment and initialization - - All named pipes must be - """ - if len(self._input_info): - configure.assign_input_pipes( - self._args["ffmpeg_args"], - self._input_info, - self._args["sp_kwargs"], - use_std_pipes=True, - ) - - if len(self._output_info): - configure.assign_output_pipes( - self._args["ffmpeg_args"], - self._output_info, - self._args["sp_kwargs"], - use_std_pipes=True, - ) - - configure.init_named_pipes( - self._input_info, self._output_info, **self._pipe_kws, stack=self._stack - ) - - -class BaseRawInputsMixin: - """write a raw media data to a specified stream (backend)""" - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - _args: dict - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - # 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"] - for data in src: - writer.write(data, self.default_timeout) - self._deferred_data = [] - self._input_ready = True - - def _write_stream_bytes( - self, - converter: ToBytesCallable, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - b = converter(obj=data) - if not len(b): - return - - if self._input_ready is True: - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (KeyError, BrokenPipeError, OSError): - if self._logger: - 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, 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) - - @property - def input_types(self) -> dict[int, MediaType | None]: - """media type associated with the input streams""" - return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} - - @property - def input_rates(self) -> dict[int, int | Fraction | None]: - """sample or frame rates associated with the input streams""" - return { - i: v["raw_info"][2] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_dtypes(self) -> dict[int, DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - return { - i: v["raw_info"][0] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_shapes(self) -> dict[int, ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - return { - i: v["raw_info"][1] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - -class BaseEncodedInputsMixin: - - # FFmpegRunner's properties accessed - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - - 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 is True: - 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[index] - if len(data0): - 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) - - -class BaseRawOutputsMixin: - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread | None - - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(**kwargs) - - # 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 | None]: - """FFmpeg/custom labels of output streams""" - return [ - v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) - ] - - @property - def output_types(self) -> list[MediaType | None]: - """media type associated with the output streams (key)""" - return [v["media_type"] for v in self._output_info] - - @property - def output_rates(self) -> list[int | Fraction | None]: - """sample or frame rates associated with the output streams (key)""" - - def get_rate(v): - return v and v[2] - - return [get_rate(v) for v in self._output_info] - - @property - def output_dtypes(self) -> list[DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - - def get_dtype(v): - return v and v[1] - - return [get_dtype(v) for v in self._output_info] - - @property - def output_shapes(self) -> list[ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - - def get_shape(v): - return v and v[0] - - return [get_shape(v) for v in self._output_info] - - @property - def output_counts(self) -> list[int]: - """number of frames/samples read""" - return [0] * len(self._output_info) if self._n0 is None else list(self._n0) - - def _init_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 = self.output_rates - - if any(r is None for r in self._rates): - raise FFmpegioError('There is an output stream without known output rate.') - - 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_pipes() - - def _read_stream_bytes( - self, - converter: FromBytesCallable, - counter: CountDataCallable, - dtype: DTypeString, - shape: ShapeTuple, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze - ) - - # update the frame/sample counter - n = counter(obj=data) # actual number read - self._n0[stream_id] += n - - return data - - -class BaseEncodedOutputsMixin: - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread - - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - - # set the default read block size - self._blocksize = blocksize - - def _init_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_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) - -class _RawInputMixin(BaseRawInputsMixin): - - _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_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"] - self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) - - 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(BaseEncodedInputsMixin): - - 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(BaseRawOutputsMixin): - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(blocksize=blocksize, ref_output=ref_output, **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} - - def _read_stream( - self, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - converter = self._converters[info["media_type"]] - dtype, shape, _ = info["raw_info"] - counter = self._get_num[info["media_type"]] - - return self._read_stream_bytes( - converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze - ) - - 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(BaseEncodedOutputsMixin): - - 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, _StdFFmpegRunner): - - 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 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) - - 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, _StdFFmpegRunner): - - 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, _StdFFmpegRunner): - - 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, _StdFFmpegRunner): - """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, - ) - - -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 417b3766..f28346fc 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -4,15 +4,6 @@ SimpleAudioReader, SimpleAudioWriter ) -from .StdStreams import ( - StdAudioDecoder, - StdAudioEncoder, - StdAudioFilter, - StdVideoDecoder, - StdVideoEncoder, - StdVideoFilter, - StdMediaTranscoder, -) from .PipedStreams import ( PipedMediaReader, PipedMediaWriter, @@ -25,9 +16,6 @@ # TODO Buffered reverse video read # fmt: off -__all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter" - "StdAudioDecoder", "StdAudioEncoder", "StdAudioFilter", - "StdVideoDecoder", "StdVideoEncoder", "StdVideoFilter", "StdMediaTranscoder", - "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder", - "AviMediaReader"] +__all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter", + "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] # fmt: on diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 10c64a27..87e7986c 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -203,7 +203,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 @@ -213,7 +227,7 @@ def index(self, prefix, start=None, block=True, timeout=None): next((i for i, log in enumerate(logs) if log.startswith(prefix))) + start ) - except: + except StopIteration: if not self.is_alive(): raise ThreadNotActive("LoggerThread is not running") @@ -247,7 +261,7 @@ def index(self, prefix, start=None, block=True, timeout=None): ) + start ) - except: + except StopIteration: # still no match, update the starting position start = len(self.logs) diff --git a/tests/test_open.py b/tests/test_open.py index 8eb1842f..59b4b640 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -13,7 +13,7 @@ def test_fg(): @pytest.mark.parametrize( - "mode,Cls", + "src,mode,Cls", [ (url, "rv", ff_streams.SimpleVideoReader), (url, "ra", ff_streams.SimpleAudioReader), diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py index bfe3507e..e2cea9cd 100644 --- a/tests/test_simplestreams.py +++ b/tests/test_simplestreams.py @@ -21,7 +21,7 @@ def test_read_video(): assert f.output_rate == 30 assert f.output_shape == (h, w, 1) assert F["shape"] == (10, h, w, 1) - assert F["dtype"] == f.output_dtypes + assert F["dtype"] == f.output_dtype def test_read_write_video(): @@ -40,9 +40,12 @@ def test_read_write_video(): 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: + with streams.SimpleVideoWriter(out_url, 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(caplog): @@ -67,7 +70,7 @@ def test_read_audio(caplog): 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() + log = f.readlog(-1) shape = sum(shapes) print(log) @@ -86,18 +89,22 @@ def test_read_write_audio(): 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 + fs = f.output_rate + shape = f.output_shape + dtype = f.output_dtype + bps = f.output_bytesize 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 streams.SimpleAudioWriter(out_url, rate_in=fs, show_log=True) as f: + with streams.SimpleAudioWriter(out_url, 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(): @@ -109,13 +116,16 @@ def test_write_extra_inputs(): "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 streams.SimpleVideoWriter( - out_url, fs, extra_inputs=[url_aud], map=["0:v", "1:a"], show_log=True + out_url, fs, extra_inputs=[url_aud], map=["0:v", "1:a"], show_log=True,loglevel='debug' ) as f: f.write(F) + f.wait() + print(f.readlog()) info = ffmpegio.probe.streams_basic(out_url) assert len(info) == 2 @@ -130,6 +140,8 @@ def test_write_extra_inputs(): overwrite=True, ) 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_stdstreams.py b/tests/test_stdstreams.py deleted file mode 100644 index ec17dbd3..00000000 --- a/tests/test_stdstreams.py +++ /dev/null @@ -1,188 +0,0 @@ -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.output_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.output_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.output_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.output_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 0935028df610af2036a55a9c88b010ef8d07c099 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 5 Sep 2025 10:45:39 -0400 Subject: [PATCH 310/344] wip (update caps for v8) --- src/ffmpegio/caps.py | 86 ++++++++++++++++++++++++++++---------------- tests/test_caps.py | 7 +++- 2 files changed, 61 insertions(+), 32 deletions(-) diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index 976aa778..dffef3c6 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -30,7 +30,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() @@ -878,18 +878,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 @@ -903,7 +912,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() @@ -911,19 +920,19 @@ 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") - else partial(_conv_func, Fraction) if type == "rational" else (lambda s: s) + if otype in ("float", "double") + else partial(_conv_func, Fraction) if otype == "rational" else (lambda s: s) ) ) @@ -936,30 +945,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), ) @@ -1047,6 +1069,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 afbaa426..fd615f22 100644 --- a/tests/test_caps.py +++ b/tests/test_caps.py @@ -48,6 +48,11 @@ def test_options(): pprint(caps.options('video',True)) pprint(caps.options('per-file')) +def test_filters(): + for f in caps.filters(): + print(f) + pprint(caps.filter_info(f)) + if __name__ == '__main__': - caps.encoder_info('mpeg1video') + caps.filter_info('aresample') From eac805fac8a7a89b89357b1c520974f085a00e5b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 24 Oct 2025 20:17:34 -0400 Subject: [PATCH 311/344] wip (expand PipedStreams???) --- src/ffmpegio/streams/BaseFFmpegRunner.py | 929 ++++++++++++++++++++++- src/ffmpegio/streams/PipedStreams.py | 680 +---------------- 2 files changed, 931 insertions(+), 678 deletions(-) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index b9ad508e..2861f70b 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -2,26 +2,45 @@ import logging -logger = logging.getLogger("ffmpegio") +import sys +from time import time +from collections.abc import Sequence +from contextlib import ExitStack +from fractions import Fraction + +from typing_extensions import Callable, Literal + +from .. import configure, plugins, utils, probe, ffmpegprocess -from typing_extensions import Literal from .._typing import ( ProgressCallable, InputSourceDict, OutputDestinationDict, FFmpegOptionDict, + RawDataBlob, + ShapeTuple, + DTypeString, + MediaType, ) -from ..configure import FFmpegArgs, InitMediaOutputsCallable -from contextlib import ExitStack - -import sys -from time import time -from .. import ffmpegprocess +from ..configure import FFmpegArgs, MediaType, InitMediaOutputsCallable from ..threading import LoggerThread -from ..errors import FFmpegError +from ..errors import FFmpegError, FFmpegioError +from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable +from .._utils import get_bytesize -__all__ = ["BaseFFmpegRunner"] +logger = logging.getLogger("ffmpegio") + +__all__ = [ + "BaseFFmpegRunner", + "EncodedInputsMixin", + "RawOutputsMixin", + "EncodedOutputsMixin", + "RawInputMixin", + "EncodedInputMixin", + "RawOutputMixin", + "EncodedOutputMixin", +] class BaseFFmpegRunner: @@ -238,11 +257,11 @@ def wait(self, timeout: float | None = None) -> int | None: # write the sentinel to each input queue for info in self._input_info: - if "writer" in info: # has writer thread + if "writer" in info: # has writer thread info["writer"].write( None, None if timeout is None else timeout - time() ) - else: # std pipe, no threading + else: # std pipe, no threading # close the stdout self._proc.stdin.close() @@ -255,3 +274,889 @@ def wait(self, timeout: float | None = None) -> int | None: else: rc = None return rc + + +class BaseRawInputsMixin: + """write a raw media data to a specified stream (backend)""" + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + _args: dict + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # 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"] + for data in src: + writer.write(data, self.default_timeout) + self._deferred_data = [] + self._input_ready = True + + def _write_stream_bytes( + self, + converter: ToBytesCallable, + stream_id: int, + data: RawDataBlob, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + b = converter(obj=data) + if not len(b): + return + + if self._input_ready is True: + logger.debug("[writer main] writing...") + + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (KeyError, BrokenPipeError, OSError): + if self._logger: + 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, 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) + + @property + def input_types(self) -> dict[int, MediaType | None]: + """media type associated with the input streams""" + return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} + + @property + def input_rates(self) -> dict[int, int | Fraction | None]: + """sample or frame rates associated with the input streams""" + return { + i: v["raw_info"][2] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_dtypes(self) -> dict[int, DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + return { + i: v["raw_info"][0] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_shapes(self) -> dict[int, ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + return { + i: v["raw_info"][1] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + +class BaseEncodedInputsMixin: + + # FFmpegRunner's properties accessed + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + + 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 is True: + 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[index] + if len(data0): + 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) + + +class BaseRawOutputsMixin: + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread | None + + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(**kwargs) + + # 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 | None]: + """FFmpeg/custom labels of output streams""" + return [ + v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) + ] + + @property + def output_types(self) -> list[MediaType | None]: + """media type associated with the output streams (key)""" + return [v["media_type"] for v in self._output_info] + + @property + def output_rates(self) -> list[int | Fraction | None]: + """sample or frame rates associated with the output streams (key)""" + + def get_rate(v): + return v and v[2] + + return [get_rate(v) for v in self._output_info] + + @property + def output_dtypes(self) -> list[DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + + def get_dtype(v): + return v and v[1] + + return [get_dtype(v) for v in self._output_info] + + @property + def output_shapes(self) -> list[ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + + def get_shape(v): + return v and v[0] + + return [get_shape(v) for v in self._output_info] + + @property + def output_counts(self) -> list[int]: + """number of frames/samples read""" + return [0] * len(self._output_info) if self._n0 is None else list(self._n0) + + def _init_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 = self.output_rates + + if any(r is None for r in self._rates): + raise FFmpegioError("There is an output stream without known output rate.") + + 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_pipes() + + def _read_stream_bytes( + self, + converter: FromBytesCallable, + counter: CountDataCallable, + dtype: DTypeString, + shape: ShapeTuple, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze + ) + + # update the frame/sample counter + n = counter(obj=data) # actual number read + self._n0[stream_id] += n + + return data + + +class BaseEncodedOutputsMixin: + + default_timeout: float | None + _input_info: list[InputSourceDict] + _output_info: list[OutputDestinationDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread + + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) + + # set the default read block size + self._blocksize = blocksize + + def _init_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_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) + + +class RawInputsMixin(BaseRawInputsMixin): + + _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_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"] + self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) + + 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 RawInputMixin(BaseRawInputsMixin): + + _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() + info = self._input_info[0] + 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( + self, data: RawDataBlob, timeout: float | None = None + ): + """write a raw media data to a specified stream + + :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[0] + except IndexError: + raise FFmpegioError("stream_id=0 is an invalid input stream index.") + + if timeout is None: + timeout = self.default_timeout + + media_type = info["media_type"] + self._write_stream_bytes(self._get_bytes[media_type], 0, data, timeout) + + +class EncodedInputsMixin(BaseEncodedInputsMixin): + + 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 RawOutputsMixin(BaseRawOutputsMixin): + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(blocksize=blocksize, ref_output=ref_output, **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} + + def _read_stream( + self, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + converter = self._converters[info["media_type"]] + dtype, shape, _ = info["raw_info"] + counter = self._get_num[info["media_type"]] + + return self._read_stream_bytes( + converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze + ) + + 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 EncodedOutputsMixin(BaseEncodedOutputsMixin): + + 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 EncodedOutputMixin(BaseEncodedOutputsMixin): + + def read_encoded( + self, n: int, timeout: float | None = None + ) -> bytes: + """read encoded output stream + + :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, 0) + return self._read_encoded_stream(info[stream_id], n, timeout) + + +############################# + + +class EncodedInputMixin(BaseEncodedInputsMixin): + + def write_encoded(self, data: bytes, timeout: float | None = None): + """write a raw media data to a specified stream + + :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[0] + except IndexError: + raise FFmpegioError("stream_id=0 is an invalid input stream index.") + + if timeout is None: + timeout = self.default_timeout + + self._write_encoded_stream(0, info, data, timeout) + + +class RawOutputMixin(BaseRawOutputsMixin): + def __init__(self, blocksize, **kwargs): + super().__init__(blocksize=blocksize, ref_output=0, **kwargs) + hook = plugins.get_hook() + info = self._input_info[0] + self._converter = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio}[ + info["media_type"] + ] + self.converter = self._converter + self._get_num = {"video": hook.video_frames, "audio": hook.audio_samples}[ + info["media_type"] + ] + + def read(self, n: int, timeout: float | None = None) -> RawDataBlob: + """read selected output stream + + :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[0] + converter = self._converter + counter = self._get_num + dtype, shape, _ = info["raw_info"] + + return self._read_stream_bytes( + converter, counter, dtype, shape, info, 0, n, timeout, squeeze=False + ) + + @property + def output_label(self) -> str | None: + """FFmpeg/custom labels of output streams""" + return self._output_info[0].get("user_map", None) + + @property + def output_type(self) -> MediaType | None: + """media type associated with the output streams (key)""" + return self._output_info[0]["media_type"] + + @property + def output_rate(self) -> int | Fraction | None: + """sample or frame rates associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][2] if "raw_info" in info else None + + @property + def output_dtype(self) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][0] if "raw_info" in info else None + + @property + def output_shape(self) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + info = self._output_info[0] + return info["raw_info"][1] if "raw_info" in info else None + + @property + def output_count(self) -> int: + """number of frames/samples read""" + return 0 if self._n0 is None else self._n0[0] + + @property + def output_bytesize(self) -> int | None: + """number of bytes per output sample/pixel""" + return get_bytesize(self.output_shape, self.output_dtype) + + def __iter__(self): + return self + + def __next__(self): + F = self.read(self._blocksize) + if plugins.get_hook().is_empty(obj=F): + raise StopIteration + return F diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index ac3c2103..0876e4b8 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -23,29 +23,31 @@ FFmpegArgs, FFmpegInputUrlComposite, FFmpegUrlType, - MediaType, FFmpegOutputUrlComposite, InitMediaOutputsCallable, ) from ..filtergraph.abc import FilterGraphObject -from contextlib import ExitStack -from time import time from fractions import Fraction -from .. import configure, plugins, utils, probe -from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError +from .. import configure, plugins +from ..errors import FFmpegioError from ..configure import FFmpegArgs, InitMediaOutputsCallable -from .BaseFFmpegRunner import BaseFFmpegRunner +from .BaseFFmpegRunner import ( + BaseFFmpegRunner as _BaseFFmpegRunner, + RawInputsMixin as _RawInputsMixin, + EncodedInputsMixin as _EncodedInputsMixin, + RawOutputsMixin as _RawOutputsMixin, + EncodedOutputsMixin as _EncodedOutputsMixin, +) # fmt:off __all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] # fmt:on -class _PipedFFmpegRunner(BaseFFmpegRunner): +class _PipedFFmpegRunner(_BaseFFmpegRunner): """Base class to run FFmpeg and manage its multiple I/O's""" def __init__( @@ -131,661 +133,7 @@ def _assign_pipes(self): ) -class BaseRawInputsMixin: - """write a raw media data to a specified stream (backend)""" - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - _args: dict - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - # 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"] - for data in src: - writer.write(data, self.default_timeout) - self._deferred_data = [] - self._input_ready = True - - def _write_stream_bytes( - self, - converter: ToBytesCallable, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - b = converter(obj=data) - if not len(b): - return - - if self._input_ready is True: - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (KeyError, BrokenPipeError, OSError): - if self._logger: - 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, 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) - - @property - def input_types(self) -> dict[int, MediaType | None]: - """media type associated with the input streams""" - return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} - - @property - def input_rates(self) -> dict[int, int | Fraction | None]: - """sample or frame rates associated with the input streams""" - return { - i: v["raw_info"][2] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_dtypes(self) -> dict[int, DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - return { - i: v["raw_info"][0] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_shapes(self) -> dict[int, ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - return { - i: v["raw_info"][1] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - -class BaseEncodedInputsMixin: - - # FFmpegRunner's properties accessed - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - - 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 is True: - 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[index] - if len(data0): - 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) - - -class BaseRawOutputsMixin: - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread | None - - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(**kwargs) - - # 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 | None]: - """FFmpeg/custom labels of output streams""" - return [ - v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) - ] - - @property - def output_types(self) -> list[MediaType | None]: - """media type associated with the output streams (key)""" - return [v["media_type"] for v in self._output_info] - - @property - def output_rates(self) -> list[int | Fraction | None]: - """sample or frame rates associated with the output streams (key)""" - - def get_rate(v): - return v and v[2] - - return [get_rate(v) for v in self._output_info] - - @property - def output_dtypes(self) -> list[DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - - def get_dtype(v): - return v and v[1] - - return [get_dtype(v) for v in self._output_info] - - @property - def output_shapes(self) -> list[ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - - def get_shape(v): - return v and v[0] - - return [get_shape(v) for v in self._output_info] - - @property - def output_counts(self) -> list[int]: - """number of frames/samples read""" - return [0] * len(self._output_info) if self._n0 is None else list(self._n0) - - def _init_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 = self.output_rates - - if any(r is None for r in self._rates): - raise FFmpegioError('There is an output stream without known output rate.') - - 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_pipes() - - def _read_stream_bytes( - self, - converter: FromBytesCallable, - counter: CountDataCallable, - dtype: DTypeString, - shape: ShapeTuple, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze - ) - - # update the frame/sample counter - n = counter(obj=data) # actual number read - self._n0[stream_id] += n - - return data - - -class BaseEncodedOutputsMixin: - - default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread - - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - - # set the default read block size - self._blocksize = blocksize - - def _init_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_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) - -class _RawInputMixin(BaseRawInputsMixin): - - _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_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"] - self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) - - 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(BaseEncodedInputsMixin): - - 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(BaseRawOutputsMixin): - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(blocksize=blocksize, ref_output=ref_output, **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} - - def _read_stream( - self, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - converter = self._converters[info["media_type"]] - dtype, shape, _ = info["raw_info"] - counter = self._get_num[info["media_type"]] - - return self._read_stream_bytes( - converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze - ) - - 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(BaseEncodedOutputsMixin): - - 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): +class PipedMediaReader(_EncodedInputsMixin, _RawOutputsMixin, _PipedFFmpegRunner): def __init__( self, @@ -870,7 +218,7 @@ def __next__(self): return F -class PipedMediaWriter(_EncodedOutputMixin, _RawInputMixin, _PipedFFmpegRunner): +class PipedMediaWriter(_EncodedOutputsMixin, _RawInputsMixin, _PipedFFmpegRunner): def __init__( self, @@ -979,7 +327,7 @@ def __init__( ) -class PipedMediaFilter(_RawOutputMixin, _RawInputMixin, _PipedFFmpegRunner): +class PipedMediaFilter(_RawOutputsMixin, _RawInputsMixin, _PipedFFmpegRunner): def __init__( self, @@ -1065,7 +413,7 @@ def __init__( ) -class PipedMediaTranscoder(_EncodedOutputMixin, _EncodedInputMixin, _PipedFFmpegRunner): +class PipedMediaTranscoder(_EncodedOutputsMixin, _EncodedInputsMixin, _PipedFFmpegRunner): """Class to transcode encoded media streams""" def __init__( From 17c01e8f6cdb629b910880eecaece405291ee161 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sat, 25 Oct 2025 20:56:04 -0400 Subject: [PATCH 312/344] wip7 --- src/ffmpegio/_open.py | 123 +++++++-- src/ffmpegio/streams/PipedStreams.py | 345 +++++++++++++++++++++++--- src/ffmpegio/streams/SimpleStreams.py | 10 +- src/ffmpegio/streams/__init__.py | 35 ++- 4 files changed, 442 insertions(+), 71 deletions(-) diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index 8a79e26a..2878881b 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -72,8 +72,8 @@ @overload def open( - urls_fgs: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer | IO, - mode: Literal["rv", "ra", "e->v", "e->a"], + urls_fgs: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, + mode: Literal["rv"], *, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -81,8 +81,44 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleAudioReader | streams.SimpleVideoReader: - """open a single-source reader (`mode = "rv" | "ra" | "e->v" | "e->a"`) +) -> streams.SimpleVideoReader: + """open a single-stream video reader + + :param urls_fgs: URL of the file or format/device object to obtain a video stream from. + It can also be an input filtergraph object or string. The input + could also be fed by a buffered bytes-like data object or a readable file object. + :param mode: `'rv'` to read video data + :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 queue's item size in bytes, defaults to `None` (auto-set) + :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. + :return: reader stream object + """ + + +@overload +def open( + urls_fgs: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, + mode: Literal["ra"], + *, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> streams.SimpleAudioReader: + """open a single-source audio reader :param urls_fgs: URL of the file or format/device object to obtain a media stream from. It can also be an input filtergraph object or string. The input @@ -108,12 +144,12 @@ def open( @overload def open( - urls_fgs: FFmpegUrlType | IO | Buffer, - mode: Literal["wv", "wa", "v->e", "a->e"], + urls_fgs: FFmpegUrlType, + mode: Literal["wv"], input_rate: int | Fraction, *, - input_shape: ShapeTuple = None, - input_dtype: DTypeString = None, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, overwrite: bool = False, show_log: bool | None = None, @@ -122,8 +158,8 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleAudioWriter | streams.SimpleVideoReader: - """open a single-destination writer (`mode = "wv" | "wa" | "v->e" | "a->e"`) +) -> streams.SimpleVideoReader: + """open a single-destination video writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output could also be written to a bytes object or a writable file object. @@ -155,8 +191,55 @@ def open( @overload def open( - urls_fgs: None | Literal["pipe", "-", "pipe:0"], - mode: Literal["e->v", "e->a"], + urls_fgs: FFmpegUrlType, + mode: Literal["wa"], + input_rate: int | Fraction, + *, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool = False, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + default_timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> streams.SimpleAudioWriter: + """open a single-destination audio writer + + :param urls_fgs: URL of the file or format/device object to write media stream to. The output + could also be written to a bytes object or a writable file object. + :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file + :param input_rate: Input frame rate (video) or sampling rate (audio) + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) + :param extra_inputs: extra media source files/urls, defaults to None + :param overwrite: True to overwrite output URL, defaults to False. + :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 queue's item size in bytes, defaults to `None` (auto-set) + :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. + :return: audio writer stream object + + """ + + +@overload +def open( + urls_fgs: None | Literal["pipe", "-"], + mode: LiteralString, *, f_in: str, show_log: bool | None = None, @@ -166,7 +249,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.StdAudioDecoder | streams.StdVideoDecoder: +) -> streams.MediaReader: """open a piped single-source reader (`mode = "rv" | "ra" | "e->v" | "e->a"`) :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. @@ -193,13 +276,13 @@ def open( @overload def open( - urls_fgs: Literal["-", "pipe", "pipe:1"] | None, - mode: Literal["wv", "wa", "v->e", "a->e"], - input_rate: int | Fraction, + urls_fgs: Literal["-", "pipe"] | None, + mode: LiteralString, # ["w(v|a)+", "(v|a)+->e+"], + input_rate: Sequence[int | Fraction], *, f: str, - input_shape: ShapeTuple = None, - input_dtype: DTypeString = None, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, overwrite: bool = False, show_log: bool | None = None, @@ -209,7 +292,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.StdAudioEncoder | streams.StdVideoEncoder: +) -> streams.MediaWriter: """open a piped single-destination writer (`mode = "wv" | "wa" | "v->e" | "a->e"`) :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. @@ -244,7 +327,7 @@ def open( @overload def open( urls_fgs: str | FilterGraphObject, - mode: Literal["fv", "fa", "v->v", "a->a"], + mode: LiteralString #["f(v|a)+", "fa", "v->v", "a->a"], input_rate: int | Fraction, *, input_shape: ShapeTuple = None, diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 0876e4b8..8ae235c6 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -37,13 +37,18 @@ from .BaseFFmpegRunner import ( BaseFFmpegRunner as _BaseFFmpegRunner, RawInputsMixin as _RawInputsMixin, - EncodedInputsMixin as _EncodedInputsMixin, RawOutputsMixin as _RawOutputsMixin, + RawInputMixin as _RawInputMixin, + RawOutputMixin as _RawOutputMixin, + EncodedInputsMixin as _EncodedInputsMixin, EncodedOutputsMixin as _EncodedOutputsMixin, ) -# fmt:off -__all__ = ["PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] +# fmt: off +__all__ = [ + "MediaReader", "MediaWriter", "MediaTranscoder", + "SISOMediaFilter", "MISOMediaFilter", "SIMOMediaFilter", "MIMOMediaFilter", +] # fmt:on @@ -133,7 +138,7 @@ def _assign_pipes(self): ) -class PipedMediaReader(_EncodedInputsMixin, _RawOutputsMixin, _PipedFFmpegRunner): +class MediaReader(_EncodedInputsMixin, _RawOutputsMixin, _PipedFFmpegRunner): def __init__( self, @@ -218,7 +223,7 @@ def __next__(self): return F -class PipedMediaWriter(_EncodedOutputsMixin, _RawInputsMixin, _PipedFFmpegRunner): +class MediaWriter(_EncodedOutputsMixin, _RawInputsMixin, _PipedFFmpegRunner): def __init__( self, @@ -326,8 +331,77 @@ def __init__( sp_kwargs=sp_kwargs, ) +class MediaTranscoder( + _EncodedOutputsMixin, _EncodedInputsMixin, _PipedFFmpegRunner +): + """Class to transcode encoded media streams""" -class PipedMediaFilter(_RawOutputsMixin, _RawInputsMixin, _PipedFFmpegRunner): + 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, + ) + +class SISOMediaFilter(_RawOutputMixin, _RawInputMixin, _PipedFFmpegRunner): def __init__( self, @@ -413,70 +487,261 @@ def __init__( ) -class PipedMediaTranscoder(_EncodedOutputsMixin, _EncodedInputsMixin, _PipedFFmpegRunner): - """Class to transcode encoded media streams""" +class MISOMediaFilter(_RawOutputMixin, _RawInputsMixin, _PipedFFmpegRunner): def __init__( self, - input_options: Sequence[FFmpegOptionDict], - output_options: Sequence[FFmpegOptionDict], - *, + 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, - extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - progress: ProgressCallable | 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, + sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): - """Encoded media stream transcoder + """Filter audio/video data streams with FFmpeg filtergraphs - :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 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 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 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 SIMOMediaFilter(_RawOutputsMixin, _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 blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks + :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: 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. + :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, output_info = configure.init_media_transcoder( - [("pipe", opts) for opts in input_options], - [("pipe", opts) for opts in output_options], + 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, - extra_outputs, - {"y": None, **options}, + 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=None, - init_deferred_outputs=None, - deferred_output_args=None, + 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 MIMOMediaFilter(_RawOutputsMixin, _RawInputsMixin, _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, ) + + diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 0dce2fdc..1b43fc5e 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -209,8 +209,7 @@ class SimpleVideoReader(SimpleReaderBase): def __init__( self, - url: FFmpegUrlType, - *, + *urls: FFmpegUrlType, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int = 1, @@ -223,7 +222,7 @@ def __init__( map = "0:V:0" if stream is None else stream_spec_to_map_option(stream) args, input_info, ready, output_info, _ = configure.init_media_read( - [url], [map], options + [*urls], [map], options ) if len(output_info) != 1 or output_info[0]["media_type"] != "video": @@ -254,8 +253,7 @@ class SimpleAudioReader(SimpleReaderBase): def __init__( self, - url: FFmpegUrlType, - *, + *urls: FFmpegUrlType, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int = 1, @@ -268,7 +266,7 @@ def __init__( map = "0:a:0" if stream is None else stream_spec_to_map_option(stream) args, input_info, ready, output_info, _ = configure.init_media_read( - [url], [map], options + [*urls], [map], options ) if len(output_info) != 1 or output_info[0]["media_type"] != "audio": diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index f28346fc..2ef5dd92 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -1,3 +1,23 @@ +'''media streamer classes + + ==================== ===================== ==================== + Class Name Input(s) Output(s) + ==================== ===================== ==================== + SimpleVideoReader multiple urls single video + SimpleVideoWriter single video single url + SimpleAudioReader multiple urls single audio + SimpleAudioWriter single audio 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 .SimpleStreams import ( SimpleVideoReader, SimpleVideoWriter, @@ -5,17 +25,22 @@ SimpleAudioWriter ) from .PipedStreams import ( - PipedMediaReader, - PipedMediaWriter, - PipedMediaFilter, - PipedMediaTranscoder, + MediaReader, + MediaWriter, + MediaTranscoder, + SISOMediaFilter, + MISOMediaFilter, + SIMOMediaFilter, + MIMOMediaFilter, ) from .AviStreams import AviMediaReader + # TODO multi-stream write # TODO Buffered reverse video read # fmt: off __all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter", - "PipedMediaReader", "PipedMediaWriter", "PipedMediaFilter", "PipedMediaTranscoder"] + "MediaReader", "MediaWriter", "MediaTranscoder", + "SISOMediaFilter", "MISOMediaFilter", "SIMOMediaFilter", "MIMOMediaFilter"] # fmt: on From f11fabfe195064d1db09b8faead5559325b605ba Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 26 Oct 2025 20:03:31 -0400 Subject: [PATCH 313/344] wip8 --- src/ffmpegio/_open.py | 118 ++-- src/ffmpegio/configure.py | 2 - src/ffmpegio/streams/BaseFFmpegRunner.py | 606 +-------------------- src/ffmpegio/streams/PipedStreams.py | 665 +++++++++++++---------- tests/test_pipedstreams.py | 28 +- 5 files changed, 475 insertions(+), 944 deletions(-) diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index 2878881b..0827f36d 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -238,10 +238,9 @@ def open( @overload def open( - urls_fgs: None | Literal["pipe", "-"], - mode: LiteralString, + urls_fgs: FFmpegInputUrlComposite | Literal["pipe", "-"] | Sequence[FFmpegOutputUrlComposite | Literal["pipe", "-"]], + mode: LiteralString, # r(v|a){2,} or '(v|a)+->e+ *, - f_in: str, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, @@ -254,7 +253,6 @@ def open( :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. :param mode: `'rv'` or `'e->v'` to read video data, `'ra'` or `'e->a'` to read audio data - :param f_in: FFmpeg format option for the input stream :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 queue's item size in bytes, defaults to `None` (auto-set) @@ -276,11 +274,10 @@ def open( @overload def open( - urls_fgs: Literal["-", "pipe"] | None, + urls_fgs: FFmpegOutputUrlComposite | Literal["-", "pipe"] | Sequence[FFmpegOutputUrlComposite | Literal["-", "pipe"]], mode: LiteralString, # ["w(v|a)+", "(v|a)+->e+"], input_rate: Sequence[int | Fraction], *, - f: str, input_shape: ShapeTuple | None = None, input_dtype: DTypeString | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, @@ -326,8 +323,50 @@ def open( @overload def open( - urls_fgs: str | FilterGraphObject, - mode: LiteralString #["f(v|a)+", "fa", "v->v", "a->a"], + urls_fgs: Literal[None], + mode: Literal['e->e']|LiteralString, # 'e+->e+' + *, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: 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, + **options: Unpack[FFmpegOptionDict], +) -> streams.MediaTranscoder: + """open a single-input, single-output streamed transcoder + + :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` + and `read()` operations. + :param mode: transcoding mode is activated by setting `mode = 't'` + :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 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 queue's item size in bytes, defaults to `None` (64 kB) + :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. + :return: transcoder stream object + """ + +@overload +def open( + urls_fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], + mode: LiteralString, #["f(v|a)+", "(v|a)+->(v|a)+"], input_rate: int | Fraction, *, input_shape: ShapeTuple = None, @@ -338,8 +377,8 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.StdAudioFilter | streams.StdVideoFilter: - """open a single-input, single-output (SISO) filter +) -> streams.MIMOMediaFilter|streams.MISOMediaFilter|streams.SIMOMediaFilter|streams.SISOMediaFilter: + """open media stream filter :param urls_fgs: a filtergraph expression :param mode: `"fv"` or `"v->v"` to specify video filter, and `"fa"` or `"a->a"` to specify audio filter @@ -366,33 +405,33 @@ def open( """ + @overload def open( - urls_fgs: Literal[None], - mode: LiteralString, + urls_fgs: str | FilterGraphObject, + mode: LiteralString #["f(v|a)+", "fa", "v->v", "a->a"], + input_rate: int | Fraction, *, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + input_shape: ShapeTuple = None, + input_dtype: DTypeString = 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, + sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.StdMediaTranscoder: - """open a single-input, single-output streamed transcoder +) -> streams.MIMOMediaFilter: + """open a single-input, single-output (SISO) filter - :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` - and `read()` operations. - :param mode: transcoding mode is activated by setting `mode = 't'` - :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 urls_fgs: a filtergraph expression + :param mode: `"fv"` or `"v->v"` to specify video filter, and `"fa"` or `"a->a"` to specify audio filter + :param input_rate: input frame rate (video) or sampling rate (audio) + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, 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 queue's item size in bytes, defaults to `None` (64 kB) + :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto) :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 @@ -405,10 +444,9 @@ def open( 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: transcoder stream object + :return: filter stream object """ - @overload def open( urls_fgs: Sequence[ @@ -425,7 +463,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.PipedMediaReader: +) -> streams.MediaReader: """open a multi-stream reader :param urls_fgs: a list of input sources @@ -482,7 +520,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.PipedMediaWriter: +) -> streams.MediaWriter: """open a multi-stream writer :param urls_fgs: a list of output encoded streams. Specific FFmpeg output options could be specified for @@ -537,7 +575,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.PipedMediaFilter: +) -> streams.MediaFilter: """open a multi-stream filter :param urls_fgs: _description_ @@ -585,7 +623,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], -) -> streams.PipedMediaTranscoder: +) -> streams.MediaTranscoder: """open a streamed transcoder :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` @@ -785,7 +823,7 @@ def _create_reader( args: tuple, kwargs: dict, ) -> ( - streams.PipedMediaReader + streams.MediaReader | streams.StdAudioDecoder | streams.StdVideoDecoder | streams.SimpleAudioReader @@ -815,7 +853,7 @@ def _create_reader( reader = StreamClass(**kwargs) else: StreamClass = ( - streams.PipedMediaReader + streams.MediaReader if not is_siso else streams.SimpleAudioReader if is_audio else streams.SimpleVideoReader ) @@ -830,7 +868,7 @@ def _create_writer( args: tuple, kwargs: dict, ) -> ( - streams.PipedMediaWriter + streams.MediaWriter | streams.StdAudioEncoder | streams.StdVideoEncoder | streams.SimpleAudioWriter @@ -855,7 +893,7 @@ def _create_writer( if not is_siso: rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") - writer = streams.PipedMediaWriter(urls, in_types, *rates, **kwargs) + writer = streams.MediaWriter(urls, in_types, *rates, **kwargs) elif utils.is_pipe(urls[0]): StreamClass = streams.StdAudioEncoder if is_audio else streams.StdVideoEncoder writer = StreamClass(*args, **kwargs) @@ -873,7 +911,7 @@ def _create_filter( fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], args: tuple, kwargs: dict, -) -> streams.PipedMediaFilter | streams.StdAudioFilter | streams.StdVideoFilter: +) -> streams.MediaFilter | streams.StdAudioFilter | streams.StdVideoFilter: if len(args) > 1: raise TypeError( @@ -892,14 +930,14 @@ def _create_filter( filter = StreamClass(fgs, *args, **kwargs) else: rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") - filter = streams.PipedMediaFilter(fgs, in_types, *rates, **kwargs) + filter = streams.MediaFilter(fgs, in_types, *rates, **kwargs) return filter def _create_transcoder( urls: None, args: tuple, kwargs: dict -) -> streams.PipedMediaTranscoder | streams.StdMediaTranscoder: +) -> streams.MediaTranscoder | streams.StdMediaTranscoder: if urls is not None: raise TypeError("urls_fgs argument for a filter must be None.") @@ -913,7 +951,7 @@ def _create_transcoder( use_piped = args[0] if nargs else kwargs.get("input_options", None) return ( - streams.PipedMediaTranscoder(*args, **kwargs) + streams.MediaTranscoder(*args, **kwargs) if use_piped else streams.StdMediaTranscoder(*args, **kwargs) ) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 1afb2b79..f5acc9cb 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -4,12 +4,10 @@ IO, Literal, get_args, - LiteralString, Any, TypedDict, Unpack, Callable, - BinaryIO, ) from ._typing import ( diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 2861f70b..f9c8c402 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -4,13 +4,12 @@ import sys from time import time -from collections.abc import Sequence from contextlib import ExitStack from fractions import Fraction from typing_extensions import Callable, Literal -from .. import configure, plugins, utils, probe, ffmpegprocess +from .. import configure, probe, ffmpegprocess from .._typing import ( ProgressCallable, @@ -27,19 +26,15 @@ from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable -from .._utils import get_bytesize logger = logging.getLogger("ffmpegio") __all__ = [ "BaseFFmpegRunner", - "EncodedInputsMixin", - "RawOutputsMixin", - "EncodedOutputsMixin", - "RawInputMixin", - "EncodedInputMixin", - "RawOutputMixin", - "EncodedOutputMixin", + "BaseRawInputsMixin", + "BaseRawOutputsMixin", + "BaseEncodedInputsMixin", + "BaseEncodedOutputsMixin", ] @@ -569,594 +564,3 @@ def _read_encoded_stream( return info["reader"].read(n, timeout) -class RawInputsMixin(BaseRawInputsMixin): - - _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_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"] - self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) - - 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 RawInputMixin(BaseRawInputsMixin): - - _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() - info = self._input_info[0] - 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( - self, data: RawDataBlob, timeout: float | None = None - ): - """write a raw media data to a specified stream - - :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[0] - except IndexError: - raise FFmpegioError("stream_id=0 is an invalid input stream index.") - - if timeout is None: - timeout = self.default_timeout - - media_type = info["media_type"] - self._write_stream_bytes(self._get_bytes[media_type], 0, data, timeout) - - -class EncodedInputsMixin(BaseEncodedInputsMixin): - - 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 RawOutputsMixin(BaseRawOutputsMixin): - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(blocksize=blocksize, ref_output=ref_output, **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} - - def _read_stream( - self, - info: OutputDestinationDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - converter = self._converters[info["media_type"]] - dtype, shape, _ = info["raw_info"] - counter = self._get_num[info["media_type"]] - - return self._read_stream_bytes( - converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze - ) - - 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 EncodedOutputsMixin(BaseEncodedOutputsMixin): - - 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 EncodedOutputMixin(BaseEncodedOutputsMixin): - - def read_encoded( - self, n: int, timeout: float | None = None - ) -> bytes: - """read encoded output stream - - :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, 0) - return self._read_encoded_stream(info[stream_id], n, timeout) - - -############################# - - -class EncodedInputMixin(BaseEncodedInputsMixin): - - def write_encoded(self, data: bytes, timeout: float | None = None): - """write a raw media data to a specified stream - - :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[0] - except IndexError: - raise FFmpegioError("stream_id=0 is an invalid input stream index.") - - if timeout is None: - timeout = self.default_timeout - - self._write_encoded_stream(0, info, data, timeout) - - -class RawOutputMixin(BaseRawOutputsMixin): - def __init__(self, blocksize, **kwargs): - super().__init__(blocksize=blocksize, ref_output=0, **kwargs) - hook = plugins.get_hook() - info = self._input_info[0] - self._converter = {"video": hook.bytes_to_video, "audio": hook.bytes_to_audio}[ - info["media_type"] - ] - self.converter = self._converter - self._get_num = {"video": hook.video_frames, "audio": hook.audio_samples}[ - info["media_type"] - ] - - def read(self, n: int, timeout: float | None = None) -> RawDataBlob: - """read selected output stream - - :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[0] - converter = self._converter - counter = self._get_num - dtype, shape, _ = info["raw_info"] - - return self._read_stream_bytes( - converter, counter, dtype, shape, info, 0, n, timeout, squeeze=False - ) - - @property - def output_label(self) -> str | None: - """FFmpeg/custom labels of output streams""" - return self._output_info[0].get("user_map", None) - - @property - def output_type(self) -> MediaType | None: - """media type associated with the output streams (key)""" - return self._output_info[0]["media_type"] - - @property - def output_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][2] if "raw_info" in info else None - - @property - def output_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][0] if "raw_info" in info else None - - @property - def output_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][1] if "raw_info" in info else None - - @property - def output_count(self) -> int: - """number of frames/samples read""" - return 0 if self._n0 is None else self._n0[0] - - @property - def output_bytesize(self) -> int | None: - """number of bytes per output sample/pixel""" - return get_bytesize(self.output_shape, self.output_dtype) - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self._blocksize) - if plugins.get_hook().is_empty(obj=F): - raise StopIteration - return F diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 8ae235c6..2362bb7d 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -1,10 +1,11 @@ from __future__ import annotations import logging +from collections.abc import Sequence +from fractions import Fraction +from time import time -logger = logging.getLogger("ffmpegio") - -from typing_extensions import Callable, Literal, Unpack +from typing_extensions import Literal, Unpack from .._typing import ( ProgressCallable, InputSourceDict, @@ -13,12 +14,10 @@ RawDataBlob, ShapeTuple, DTypeString, - MediaType, ) -from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable -from collections.abc import Sequence +from .. import configure, plugins, utils from ..configure import ( FFmpegArgs, FFmpegInputUrlComposite, @@ -27,29 +26,19 @@ InitMediaOutputsCallable, ) from ..filtergraph.abc import FilterGraphObject - -from fractions import Fraction - -from .. import configure, plugins from ..errors import FFmpegioError -from ..configure import FFmpegArgs, InitMediaOutputsCallable from .BaseFFmpegRunner import ( BaseFFmpegRunner as _BaseFFmpegRunner, - RawInputsMixin as _RawInputsMixin, - RawOutputsMixin as _RawOutputsMixin, - RawInputMixin as _RawInputMixin, - RawOutputMixin as _RawOutputMixin, - EncodedInputsMixin as _EncodedInputsMixin, - EncodedOutputsMixin as _EncodedOutputsMixin, + BaseRawInputsMixin as _BaseRawInputsMixin, + BaseRawOutputsMixin as _BaseRawOutputsMixin, + BaseEncodedInputsMixin as _BaseEncodedInputsMixin, + BaseEncodedOutputsMixin as _BaseEncodedOutputsMixin, ) -# fmt: off -__all__ = [ - "MediaReader", "MediaWriter", "MediaTranscoder", - "SISOMediaFilter", "MISOMediaFilter", "SIMOMediaFilter", "MIMOMediaFilter", -] -# fmt:on +logger = logging.getLogger("ffmpegio") + +__all__ = ["MediaReader", "MediaWriter", "MediaTranscoder", "MediaFilter"] class _PipedFFmpegRunner(_BaseFFmpegRunner): @@ -138,6 +127,368 @@ def _assign_pipes(self): ) +class _RawInputsMixin(_BaseRawInputsMixin): + + _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_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"] + self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) + + 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 _EncodedInputsMixin(_BaseEncodedInputsMixin): + + 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 _RawOutputsMixin(_BaseRawOutputsMixin): + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(blocksize=blocksize, ref_output=ref_output, **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} + + def _read_stream( + self, + info: OutputDestinationDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + converter = self._converters[info["media_type"]] + dtype, shape, _ = info["raw_info"] + counter = self._get_num[info["media_type"]] + + return self._read_stream_bytes( + converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze + ) + + def read( + self, n: int, stream_id: int | str = 0, timeout: float | None = None + ) -> RawDataBlob: + """read selected output stream + + :param n: number of frames/samples to read, defaults to -1 to read as many as available + :param stream_id: stream index or label, defaults to 0 + :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 readall(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 _EncodedOutputsMixin(_BaseEncodedOutputsMixin): + + def read_encoded( + self, n: int, stream_id: int = 0, 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 MediaReader(_EncodedInputsMixin, _RawOutputsMixin, _PipedFFmpegRunner): def __init__( @@ -331,9 +682,8 @@ def __init__( sp_kwargs=sp_kwargs, ) -class MediaTranscoder( - _EncodedOutputsMixin, _EncodedInputsMixin, _PipedFFmpegRunner -): + +class MediaTranscoder(_EncodedOutputsMixin, _EncodedInputsMixin, _PipedFFmpegRunner): """Class to transcode encoded media streams""" def __init__( @@ -401,93 +751,8 @@ def __init__( sp_kwargs=sp_kwargs, ) -class SISOMediaFilter(_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 MISOMediaFilter(_RawOutputMixin, _RawInputsMixin, _PipedFFmpegRunner): +class MediaFilter(_RawOutputsMixin, _RawInputsMixin, _PipedFFmpegRunner): def __init__( self, @@ -521,7 +786,7 @@ def __init__( :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. + `MediaFilter.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 @@ -571,177 +836,3 @@ def __init__( queuesize=queuesize, sp_kwargs=sp_kwargs, ) - - -class SIMOMediaFilter(_RawOutputsMixin, _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 MIMOMediaFilter(_RawOutputsMixin, _RawInputsMixin, _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, - ) - - diff --git a/tests/test_pipedstreams.py b/tests/test_pipedstreams.py index 7aefbaf8..2d6c9403 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_pipedstreams.py @@ -15,22 +15,22 @@ outext = ".mp4" -def test_PipedMediaReader(): - with streams.PipedMediaReader(mult_url, t=1) as reader: +def test_MediaReader(): + with streams.MediaReader(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(): +def test_MediaWriter_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( + with streams.MediaWriter( "pipe", stream_types, *rates.values(), @@ -48,14 +48,14 @@ def test_PipedMediaWriter_audio(): b = writer.readall_encoded() -def test_PipedMediaWriter(): +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] - with streams.PipedMediaWriter( + with streams.MediaWriter( "pipe", stream_types, *rates.values(), show_log=True, f="matroska", ) as writer: # write full audio streams @@ -77,11 +77,11 @@ def test_PipedMediaWriter(): frame_count[i] = j + 1 writer.wait(10) - b = writer.read_encoded_stream(0, -1, 10) + b = writer.read_encoded(-1, 10) assert isinstance(b, bytes) and len(b) > 0 -def test_PipedMediaFilter(): +def test_MediaFilter(): ff.use("read_bytes") @@ -91,7 +91,7 @@ def test_PipedMediaFilter(): print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") - with streams.PipedMediaFilter( + with streams.MediaFilter( ["[0:V:0][1:V:0]vstack,split", "[2:a:0][3:a:0]amerge"], "vvaa", fps, @@ -114,10 +114,10 @@ def test_PipedMediaFilter(): assert all(v["shape"][0] == n[k] for k, v in data.items()) -def test_PipedMediaTranscoder(): +def test_MediaTranscoder(): url = "tests/assets/testmulti-1m.mp4" - with streams.PipedMediaTranscoder( + with streams.MediaTranscoder( [], [{"f": "matroska", "codec": "copy", "to": 1}], extra_inputs=[url], @@ -125,14 +125,14 @@ def test_PipedMediaTranscoder(): ) as f: if f.wait(timeout=10): raise f.lasterror - data = f.read_encoded_stream(0, -1, timeout=10) + data = f.read_encoded(-1, timeout=10) - with streams.PipedMediaTranscoder( + with streams.MediaTranscoder( [{"f": "matroska"}], [{"f": "flac"}, {"f": "matroska", "codec": "copy"}], show_log=False, ) as f: - f.write_encoded_stream(0, data, timeout=10) + f.write_encoded(data, timeout=10) if f.wait(timeout=10): raise f.lasterror enc_data = f.readall_encoded(timeout=10) From 80f2e8bed2a55291ccd4905f8a287ca27e87f59f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 25 Dec 2025 21:34:47 -0500 Subject: [PATCH 314/344] wip9 --- src/ffmpegio/_open.py | 6 +- src/ffmpegio/_typing.py | 254 +- src/ffmpegio/configure.py | 3550 +++++++++++----------- src/ffmpegio/media.py | 23 +- src/ffmpegio/plugins/hookspecs.py | 24 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 32 +- src/ffmpegio/streams/PipedStreams.py | 46 +- src/ffmpegio/streams/SimpleStreams.py | 62 +- src/ffmpegio/streams/typing.py | 144 + src/ffmpegio/transcode.py | 18 +- src/ffmpegio/utils/__init__.py | 20 +- tests/test_media.py | 5 + 12 files changed, 2326 insertions(+), 1858 deletions(-) create mode 100644 src/ffmpegio/streams/typing.py diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index 0827f36d..0a8adafa 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -369,8 +369,8 @@ def open( mode: LiteralString, #["f(v|a)+", "(v|a)+->(v|a)+"], input_rate: int | Fraction, *, - input_shape: ShapeTuple = None, - input_dtype: DTypeString = None, + input_shape: ShapeTuple|None = None, + input_dtype: DTypeString|None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, queuesize: int | None = None, @@ -409,7 +409,7 @@ def open( @overload def open( urls_fgs: str | FilterGraphObject, - mode: LiteralString #["f(v|a)+", "fa", "v->v", "a->a"], + mode: LiteralString, #["f(v|a)+", "fa", "v->v", "a->a"], input_rate: int | Fraction, *, input_shape: ShapeTuple = None, diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 07688435..b2c28b6d 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -2,7 +2,6 @@ from __future__ import annotations -from typing import * from typing_extensions import * from fractions import Fraction @@ -63,7 +62,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 @@ -106,7 +105,7 @@ """ 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 +118,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,35 +129,256 @@ =============== ============================================================================ """ +################## +# Plugin protocols +################## -class InputSourceDict(TypedDict): - """input source info""" - src_type: FFmpegInputType # True if file path/url +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) + `'data2bytes'` conversion function + `'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)""" + data2bytes: ToBytesCallable + """converts a Python data blob to raw media bytes""" + buffer: NotRequired[object] + """stores data blob (typically for batch operation)""" + + +class UrlEncodedInputInfoDict(TypedDict): + """url/filtergraph encoded input source info""" + + src_type: Literal["url", "filtergraph"] + """input data is from a url/file or from an input filtergraph""" + + +class PipedEncodedInputInfoDict(TypedDict): + """piped encoded input source info""" + + src_type: Literal["buffer"] buffer: NotRequired[bytes] # index of the source index - fileobj: NotRequired[IO] # file object - media_type: NotRequired[MediaType] # media type if input pipe - raw_info: NotRequired[RawStreamInfoTuple] - pipe: NotRequired[NPopen] # named pipe - writer: NotRequired[WriterThread | CopyFileObjThread] # pipe -class OutputDestinationDict(TypedDict): - """output source info""" +class FileObjEncodedInputInfoDict(TypedDict): + """fileobj encoded input info""" + + src_type: Literal["fileobj"] + fileobj: IO # file object - dst_type: FFmpegOutputType # True if file path/url + +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 InputPipeInfoDict(TypedDict): + """ + ========== ========================================== + `'pipe'` named pipe assigned to this data stream + `'writer'` writer thread assigned to this data stream + ========== ========================================== + """ + + pipe: NPopen + """named pipe assigned to this data stream""" + writer: WriterThread + """writer thread assigned to this data stream""" + + +################################################## + + +class RawOutputInfoDict(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) + `'data_info'` function to gather media information from raw data blob + `'bytes2data'` function to convert bytes to raw data blob + `'is_empty'` function to check empty data frame check + `'user_map'` (optional) user specified FFmpeg map option of this stream + `'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 + =============== ================================================================ + """ + + dst_type: Literal["buffer"] # True if file path/url media_type: MediaType | None # + data_info: GetInfoCallable + bytes2data: FromBytesCallable + is_empty: IsEmptyCallable user_map: NotRequired[str] # user specified map option input_file_id: NotRequired[int] input_stream_id: NotRequired[int] linklabel: NotRequired[str] raw_info: NotRequired[RawStreamInfoTuple] - pipe: NotRequired[NPopen] - reader: NotRequired[ReaderThread | CopyFileObjThread] + + +class UrlOrPipedEncodedOutputInfoDict(TypedDict): + """url/filtergraph encoded input source info""" + + dst_type: Literal["url", "buffer"] + """output data goes to either a url/file 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 + reader: ReaderThread | CopyFileObjThread itemsize: NotRequired[int] nmin: NotRequired[int] +################################################## + + class AudioFilterGraphInfoDict(TypedDict): media_type: Literal["audio"] sample_fmt: str diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index f5acc9cb..da1c541c 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1,6 +1,40 @@ +"""`configure` module + +This module is used by all batch and streaming functions of `ffmpegio` to +process their input arguments and to generate FFmpeg arguments (`FFmpegArgs`) +and lists of input and output information (`InputInfoDict` and `OutputInfoDict`). + +There are four primary functions for the four operation types supported by +`ffmpegio`: + +======================== ================================ +`init_media_read()` encoded data to raw media data +`init_media_write()` raw media data to encoded data +`init_media_filter()` raw media data to raw media data +`init_media_transcode()` encoded data to encoded data +======================== ================================ + +These functions call ffprobe to get raw media information best it could. However, +read calls with a non-seekable input requires to defer setting the output shape +and dtype until the necessary information is posted on the ffmpeg stderr log stream. +Likewise, filter calls with unknown input shape and dtype requires the arrival +of the first input raw data blob. In those cases, the following function must +be called after the ffmpeg operation initiates: + +- `init_media_read_outputs()` +- `init_media_filter_outputs()` + +The above functions do not initialize the pipes and IO threads. + +- `assign_input_pipes()` +- `assign_output_pipes()` +- `init_named_pipes()` + +""" + from __future__ import annotations -from typing_extensions import ( +from ._typing import ( IO, Literal, get_args, @@ -8,17 +42,21 @@ TypedDict, Unpack, Callable, -) - -from ._typing import ( DTypeString, ShapeTuple, RawStreamInfoTuple, Buffer, MediaType, FFmpegUrlType, - InputSourceDict, - OutputDestinationDict, + RawInputInfoDict, + RawInputInfoDict, + EncodedInputInfoDict, + InputInfoDict, + InputPipeInfoDict, + OutputInfoDict, + RawOutputInfoDict, + EncodedOutputInfoDict, + OutputPipeInfoDict, RawStreamDef, RawDataBlob, FFmpegOptionDict, @@ -89,11 +127,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,2065 +155,2082 @@ class FFmpegArgs(TypedDict): ################################# ## module functions +####################R### +### I/O initializers ### +######################## -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 - """ +def init_media_read( + urls: list[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ], + map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + options: FFmpegOptionDict, +) -> tuple[ + FFmpegArgs, + list[EncodedInputInfoDict], + list[bool], + list[RawOutputInfoDict], + list[FFmpegOptionDict | None], +]: + """Initialize FFmpeg arguments for media read - if rate is None and "r" not in opts: - raise ValueError("rate argument must be specified if opts['r'] is not given.") + :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 - return ( - pipe_id or "-", - {**utils.array_to_video_options(data)[0], f"r": rate, **opts}, - ) + 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: -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 + map = ['0:v:0','1:a:3'] # pick 1st file's 1st video stream and 2nd file's 4th audio stream - :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 + 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. """ - if rate is None and "ar" not in opts: - raise ValueError("rate argument must be specified if opts['ar'] is not given.") + ninputs = len(urls) + if not ninputs: + raise ValueError("At least one URL must be given.") - return ( - pipe_id or "-", - {**utils.array_to_audio_options(data)[0], f"ar": rate, **opts}, - ) + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") -def empty(global_options: FFmpegOptionDict | None = None) -> FFmpegArgs: - """create empty ffmpeg arg dict + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) + gopts = args["global_options"] # global options dict + gopts["y"] = None - :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 {}} + # 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) -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 + # add the default output options to output_options dict with None as the key + output_options = (map, options) - :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 + if all(ready): + output_info = init_media_read_outputs(args, input_info, output_options) + output_options = None + else: + output_info = None - Custom Pipe Class - ----------------- + return args, input_info, ready, output_info, output_options - `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. +def init_media_read_outputs( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + output_options: tuple[ + Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + FFmpegOptionDict, + ], + deferred_inputs: list[bytes | None] = None, +) -> 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: 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 """ - def hasmethod(o, name): - return hasattr(o, name) and callable(getattr(o, name)) + # 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) + ] - fileobj = None - data = None + # analyze and assign outputs + output_info, _ = process_raw_outputs(args, input_info, *output_options) - 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 + return output_info - if nodata and data is not None: - raise ValueError("Bytes-like object cannot be specified as url.") - return url, fileobj, data +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 = None, + shapes: list[ShapeTuple | None] | None = None, +) -> tuple[ + FFmpegArgs, + list[RawInputInfoDict], + list[bool], + list[EncodedOutputInfoDict] | None, + tuple | None, +]: + """write multiple streams to a url/file + :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 -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 + 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 - :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) + noutputs = len(urls) + if not noutputs: + raise FFmpegioError("At least one URL must be given.") - # 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} - ) - ), - ) - return file_id, filelist[file_id] + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") + # create a new FFmpeg dict + args = empty(utils.pop_global_options(options)) -def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: - """True if FFmpeg arguments specify a filter graph + # analyze and assign inputs + input_info = process_raw_inputs( + args, stream_types, stream_args, inopts_default, dtypes, shapes + ) - :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 + # 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 - # input filter - if any( - ( - opts is not None and opts.get("f", None) == "lavfi" - for _, opts in args["inputs"] - ) - ): - return True + ready = utils.are_input_pipes_ready(args["inputs"], input_info) - # 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 + output_args = ( + urls, + options, + merge_audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad, + ) - return False # no output options defined + 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 finalize_video_read_opts( +def init_media_write_outputs( args: FFmpegArgs, - ofile: int = 0, - input_info: list[InputSourceDict] = [], - fg_info: dict[str, FilterGraphInfoDict] | None = None, -) -> RawStreamInfoTuple: - """finalize raw video read output options + input_info: list[RawInputInfoDict], + output_args: tuple, + deferred_inputs: list[bytes | None] | None = None, +) -> list[EncodedOutputInfoDict]: + """Initialize FFmpeg arguments for media read - :param args: FFmpeg arguments (will be modified) - :param ofile: output index, defaults to 0 - :param input_info: source information of the inputs, defaults to [] - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :return dtype: Numpy-style buffer data type string - :return s: video shape tuple (height, width, nb_components) - :return r: video framerate - """ + :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 - options = ["r", "pix_fmt", "s"] + `args['inputs']` is expected to have all the necessary options of piped input + (see `PipedStreams.PipedRawInputMixin._write_stream`) - 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") + """ - # use the output option by default - opt_vals = [outopts.get(o, None) for o in options] + ( + urls, + options, + merge_audio_streams, + merge_audio_ar, + merge_audio_sample_fmt, + merge_audio_outpad, + ) = output_args - # 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." + # 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 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." ) - 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"] + 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 input option values - inopt_vals = utils.analyze_video_stream( - outmap_fields["stream_specifier"], - *args["inputs"][ifile], - input_info[ifile], + # 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", ) - # 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) - ) - - 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"} + 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) + else: + gopts["filter_complex"] = [afilt] - # 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 - if pix_fmt is None: + # analyze and assign outputs + output_info = process_url_outputs(args, input_info, urls, options) - 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) + return output_info - outopts["f"] = "rawvideo" - # use output option value or else use the input value - r = r or r_in - s = s or s_in +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[RawInputInfoDict], + list[bool], + list[RawOutputInfoDict] | None, + dict[str | None, FFmpegOptionDict] | None, +]: + """Prepare FFmpeg arguments for media read - return dtype, None if s is None else (*s[::-1], ncomp), r - - -def check_alpha_change(args, dir=None, ifile=0, ofile=0): - # check removal of alpha channel - inopts = args["inputs"][ifile][1] - outopts = args["outputs"][ofile][1] - if inopts is None or outopts is None: - return None if dir is None else False # indeterminable - return utils.alpha_change(inopts.get("pix_fmt", None), outopts.get("pix_fmt", None)) + :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 + """ -def build_basic_vf( - args: FFmpegArgs, remove_alpha: bool | None = None, ofile: int = 0 -) -> bool: - """convert basic VF options to vf option + 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.") - :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 - """ + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") - # get output opts, nothing to do if no option set - outopts = args["outputs"][ofile][1] + # 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 - # 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)) + # analyze and assign inputs + input_info = process_raw_inputs( + args, input_types, input_args, inopts_default, input_dtypes, input_shapes ) - 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: + if extra_inputs 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 + 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 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) + # 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 " + ) - outopts["vf"] = vf + # 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 True + return args, input_info, ready, output_info, output_options -def finalize_audio_read_opts( +def init_media_filter_outputs( args: FFmpegArgs, - ofile: int = 0, - input_info: list[InputSourceDict] = [], - fg_info: dict[str, FilterGraphInfoDict] | 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 - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :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 - - + input_info: list[RawInputInfoDict], + output_options: tuple[dict[str, FFmpegOptionDict], FFmpegOptionDict], + deferred_inputs: list[list[RawDataBlob | None] | bytes] | None = None, +) -> list[RawOutputInfoDict]: + """Initialize FFmpeg arguments for media read - * 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 + :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 """ - options = ["ar", "sample_fmt", "ac"] - - 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 + # 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 ) - # 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)): + # separate specific and default output options + (output_options, default_opts) = output_options + + # 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( - f"Complex filtergraph or the specified {linklabel=} do not exist." + "The `map` option of a raw output can specify only one stream." ) - inopt_vals = [info["ar"], info["sample_fmt"], info["ac"]] + elif (st_map := f"[{k}]") in fg_info: + out_maps[st_map] = k else: - ifile = outmap_fields["input_file_id"] + out_maps[k] = k - # get input option values - inopt_vals = utils.analyze_audio_stream( - outmap_fields["stream_specifier"], - *args["inputs"][ifile], - input_info[ifile], - ) + # 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] = {} - # if a simple filter is present, use the stream specs of its output - if "af" in outopts or "filter:a" in outopts: + # analyze and assign outputs + output_info, fg_info = process_raw_outputs( + args, input_info, streams, default_opts, fg_info + ) - # 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"} - ) + return output_info - 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 +def init_media_transcode( + 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, list[EncodedInputInfoDict], list[EncodedOutputInfoDict]]: + """initialize media transcoder - # 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 + :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: list of input stream information + :return output_info: list of output stream information + """ - # set output format and codec - outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) + if "n" in options: + raise ValueError("Cannot have an `n` option set to output to named pipes.") - # sample_fmt must be given - dtype, _ = utils.get_audio_format(sample_fmt, ac) + # separate the options + inopts_default = utils.pop_extra_options(options, "_in") - return dtype, ac and (ac,), ar + # 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 as e: + raise FFmpegioError("extra_inputs cannot be piped in.") + if not len(input_info): + raise ValueError("At least one input must be given.") -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 + output_info = process_url_outputs( + args, input_info, outputs, options, skip_automapping=True + ) - If stream is specified, several option names are looked up till one is defined. For example, - 3 entries are checked for `name`='c', `stream_type`='v', and `stream_id`=0 in this order: - "c:v:0", "c:v", then "c". Function returns the first hit. + if 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 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 + if not len(output_info): + raise ValueError("At least one output must be given.") - v = None - while v is None and len(names): - name = names.pop() - v = opts.get(name, None) + return args, input_info, output_info - 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} - else: - ffmpeg_args[type] = {**opts, **user_options} - 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}, - ) - return ffmpeg_args +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 + """ -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") + if rate is None and "r" not in opts: + raise ValueError("rate argument must be specified if opts['r'] is not given.") - return shape, dtype + return ( + pipe_id or "-", + {**utils.array_to_video_options(data)[0], f"r": rate, **opts}, + ) -def move_global_options(args: FFmpegArgs) -> FFmpegArgs: - """move global options from the output options dicts +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 - :param args: FFmpeg arguments - :returns: FFmpeg arguments (the same object as the input) + :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 """ - from .caps import options - - _global_options = options("global", name_only=True) - - global_options = args.get("global_options", None) or {} - - # global options may be given as output options - for _, inopts in args.get("inputs", ()): - if inopts: - for k in (*(k for k in inopts.keys() if k in _global_options),): - global_options[k] = inopts.pop(k) - for _, outopts in args.get("outputs", ()): - if outopts: - for k in (*(k for k in outopts.keys() if k in _global_options),): - global_options[k] = outopts.pop(k) - if len(global_options): - args["global_options"] = global_options - - return args + if rate is None and "ar" not in opts: + raise ValueError("rate argument must be specified if opts['ar'] is not given.") + return ( + pipe_id or "-", + {**utils.array_to_audio_options(data)[0], f"ar": rate, **opts}, + ) -def clear_loglevel(args: FFmpegArgs): - """clear global loglevel option - :param args: FFmpeg argument dict +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. """ - try: - del args["global_options"]["loglevel"] - logger.warn("loglevel option is cleared by ffmpegio") - except: - pass + return {"inputs": [], "outputs": [], "global_options": global_options or {}} -def finalize_avi_read_opts(args): - """finalize multiple-input media reader setup +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 args: FFmpeg dict - :type args: dict - :return: use_ya flag - True to expect grayscale+alpha pixel format rather than grayscale - :rtype: bool + :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 - - 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 + Custom Pipe Class + ----------------- + + `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. """ - # get output options, create new - options = args["outputs"][0][1] + def hasmethod(o, name): + return hasattr(o, name) and callable(getattr(o, name)) - # 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." - ) + fileobj = None + data = None - # 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" + 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 - # add output formats and codecs - options["f"] = "avi" - options["c:v"] = "rawvideo" + if nodata and data is not None: + raise ValueError("Bytes-like object cannot be specified as url.") - # add audio codec - for k in utils.find_stream_options(options, "sample_fmt"): - options[f"c:a" + k[10:]] = utils.get_audio_codec(options[k])[0] + return url, fileobj, data - return ya8 > 0 +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 -def config_input_fg(expr, args, kwargs): - """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) + :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 """ - fg = fgb.Graph(expr) - dopt = None # duration option - - if len(fg) != 1 or len(fg[0]) != 1: - # multi-filter input filtergraph, cannot take arguments - if len(args): - raise FFmpegioError( - f"filtergraph input expresion cannot take ordered options." - ) - return expr, dopt, kwargs - - # single-filter graph, can apply its options given in the arguments - f = fg[0][0] - info = f.info - if info.inputs is None or len(info.inputs) > 0: - raise FFmpegioError(f"{f.name} filter is not a source filter") - - # get the full list of filter options - opts = set() # - for o in info.options: - if not dopt and o.name == "duration": - dopt = (o.name, o.aliases, o.default) - opts.add(o.name) - opts.update(o.aliases) - - # split filter named option andn other keyword arguments - fargs = {i: v for i, v in enumerate(args)} - oargs = {} - for k, v in kwargs.items(): - (fargs if k in opts else oargs)[k] = v - - if dopt is not None: - name, aliases, default = dopt - val = fargs.get(name, None) - if val is None: - for a in aliases: - val = fargs.get(a, None) - if val is not None: - break - if val is None: - val = default - dopt = utils.parse_time_duration(val) - if dopt <= 0: - dopt = None # infinite + # get current list of in/outputs + filelist = args[f"{type}s"] + n = len(filelist) - return f.apply(fargs), dopt, oargs + # 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} + ) + ), + ) + return file_id, filelist[file_id] -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 +def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: + """True if FFmpeg arguments specify a filter graph - :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 + :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 - 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 - ) + # input filter + if any( + ( + opts is not None and opts.get("f", None) == "lavfi" + for _, opts in args["inputs"] ) + ): + return True - ret = process_one(urls) - return [process_one(url) for url in urls] if ret is None else [ret] + # 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 -def add_filtergraph( + +def finalize_video_read_opts( 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 - - :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 + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + fg_info: dict[str, FilterGraphInfoDict] | 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 [] + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + :return dtype: Numpy-style buffer data type string + :return s: video shape tuple (height, width, nb_components) + :return r: video framerate """ - if len(args["outputs"]) <= ofile: - raise ValueError( - f"The specified output #{ofile} is not defined in the FFmpegArgs dict." - ) + options = ["r", "pix_fmt", "s"] - if automap and map is None: - map = [f"[{l[0]}]" for l in filtergraph.iter_output_labels()] + 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") - # add the merging filter graph to the filter_complex argument - gopts = args.get("global_options", None) + # use the output option by default + opt_vals = [outopts.get(o, None) for o in options] - 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) + # 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." ) - complex_filters.append(filtergraph) + inopt_vals = [info["r"], info["pix_fmt"], info["s"]] else: - complex_filters = filtergraph + # insert basic video filter if specified + build_basic_vf(args, False, ofile) - if gopts is None: - args["global_options"] = {"filter_complex": complex_filters} - else: - gopts["filter_complex"] = complex_filters + ifile = outmap_fields["input_file_id"] - if not len(map): - # nothing to map - return + # get input option values + inopt_vals = utils.analyze_video_stream( + outmap_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) - 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: + # directly from the input url (if not forced via input options) + if has_simple_filter: - existing_map = outopts["map"] + # 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) + ) - # 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] + 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"} ) - outopts["map"] = map - - -def resolve_raw_output_streams( - args: FFmpegArgs, - input_info: list[InputSourceDict], - fg_info: dict[str, FilterGraphInfoDict] | 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 fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :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 - """ + # assign the values to individual variables + r, pix_fmt, s = opt_vals + r_in, pix_fmt_in, s_in = inopt_vals - dst_type = "buffer" + # pixel format must be specified + if pix_fmt is None: - # parse all mapping option values - input_file_id = 0 if len(input_info) == 1 else None + if pix_fmt_in == "unknown": + raise FFmpegioError( + "input pixel format unknown. Please specify output pix_fmt (to be autoset)" + ) - def parse_map(spec): + # deduce output pixel format from the input pixel format try: - return parse_map_option( - spec, parse_stream=True, input_file_id=input_file_id - ) + outopts["pix_fmt"], ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) 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(spec) for spec in streams) - ] + 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) - 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 - 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 - ) + outopts["f"] = "rawvideo" - 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, - "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 + # 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 -def auto_map( - args: FFmpegArgs, - input_info: list[InputSourceDict], - fg_info: dict[str, FilterGraphInfoDict] | None, -) -> dict[str, OutputDestinationDict]: - """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: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :return: a map of input/filtergraph output labels and their stream information. +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)) - 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. +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 (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 """ - 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 - ) - 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() - } + # get output opts, nothing to do if no option set + outopts = args["outputs"][ofile][1] - counter = {"file": None, "audio": 0, "video": 0} + # 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 - 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 `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 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 {}) - } + 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 -def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: - """list all available output labels of the complex filtergraphs + # existing simple filter + vf = outopts.pop("filter:v", outopts.pop("vf", None)) or fgb.Chain() - :param args: FFmpeg argument dict. `filter_complex` argument may be modified if present. - :return: a map of filtergraph output labels to their media types + if basic: + vf = vf + filter_video_basic(**fopts) # Graph is remove alpha else Chain - Possible Complex Filtergraph Modification - ----------------------------------------- + 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) - 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. - """ + outopts["vf"] = vf - gopts = args.get("global_options", None) or {} + return True - if "filter_complex" not in gopts: - # no filtergraph - return {} - # make sure it's a list of filtergraphs - filters_complex = utils.as_multi_option( - gopts["filter_complex"], (str, FilterGraphObject) - ) - - # make sure all are FilterGraphObjects - filters_complex = [fgb.as_filtergraph_object(fg) for fg in filters_complex] - - # check for unlabeled outputs and log existing output labels - out_indices = set() - out_labels = {} - out_unlabeled = False - for fg in filters_complex: - for idx, filter, _ in fg.iter_output_pads(full_pad_index=True): - label = fg.get_label(outpad=idx) - if label is None: - out_unlabeled = True - elif m := re.match(r"out(\d+)$", label): - out_indices.add(int(m[1])) - out_labels[label] = (filter, idx) - - # remove all the output pads connected to an input pad of another filtergraph - if len(filters_complex) > 1: - for fg in filters_complex: - for label, _ in fg.iter_input_labels(): - if label in out_labels: - out_labels.pop(label) - - # if there are unlabeled outputs, label them all - if out_unlabeled: - out_n = next(i for i in range(len(out_labels) + 1) if i not in out_labels) - for i, fg in enumerate(filters_complex): - new_labels = [] - for idx, filter, _ in fg.iter_output_pads( - unlabeled_only=True, full_pad_index=True - ): - label = f"out{out_n}" - out_labels[label] = (filter, idx) - new_labels.append({"label": label, "outpad": idx}) - - # next index - while True: - out_n += 1 - if out_n not in out_labels: - break - - for kwargs in new_labels: - fg = fg.add_label(**kwargs) - filters_complex[i] = fg +def finalize_audio_read_opts( + args: FFmpegArgs, + ofile: int = 0, + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + fg_info: dict[str, FilterGraphInfoDict] | None = None, +) -> RawStreamInfoTuple: + """finalize a raw output audio stream - # create the output map - map = { - f"[{label}]": filter.get_pad_media_type("output", pad_id) - for label, (filter, pad_id) in out_labels.items() - } + :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 + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + :return dtype: input data type (Numpy style) + :return ac: number of channels + :return ar: sampling rate - # update the filtergraphs - args["global_options"]["filter_complex"] = filters_complex + * 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 + - - return map + * 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 + """ -def process_url_inputs( - args: FFmpegArgs, - urls: list[ - FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] - ], - inopts_default: FFmpegOptionDict, - no_pipe: bool = False, -) -> list[InputSourceDict]: - """analyze and process heterogeneous input url argument + options = ["ar", "sample_fmt", "ac"] - :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 - :param no_pipe: True to raise exception if an input is piped without data buffer, defaults to False - :return: list of input information - """ + 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 + ) - input_info_list = [None] * len(urls) - for i, url in enumerate(urls): # add inputs - # get the option dict - if utils.is_non_str_sequence(url, (str, FilterGraphObject, Buffer)): - if len(url) != 2: - raise ValueError( - "url-options pair input must be a tuple of the length 2." + # 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." ) - url, opts = url - opts = inopts_default if opts is None else {**inopts_default, **opts} + inopt_vals = [info["ar"], info["sample_fmt"], info["ac"]] else: - # only URL given - opts = inopts_default - - # check url (must be url and not fileobj) - is_fg = isinstance(url, FilterGraphObject) - if is_fg or ("lavfi" == opts.get("f", None) and isinstance(url, str)): - if is_fg: - if "f" not in opts: - opts["f"] = "lavfi" - elif opts["f"] != "lavfi": - raise ValueError( - "input filtergraph must use the `'lavfi'` input format." - ) + ifile = outmap_fields["input_file_id"] - input_info = {"src_type": "filtergraph"} + # get input option values + inopt_vals = utils.analyze_audio_stream( + outmap_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) - elif utils.is_fileobj(url, readable=True): - if not url.seekable(): - raise FFmpegioNoPipeAllowed("Fileobj input must be seekable.") - input_info = {"src_type": "fileobj", "fileobj": url} - url = None - elif utils.is_pipe(url): - if no_pipe: - raise FFmpegioNoPipeAllowed("No input pipe allowed.") - input_info = {"src_type": "buffer"} - url = None - elif utils.is_url(url): - input_info = {"src_type": "url"} - elif isinstance(url, FFConcat): - # TODO - generalize this to handle an arbitrary Muxer class - opts["f"] = "concat" - url0 = url.url - if url0 in ("-", "unset"): - input_info = { - "src_type": "buffer", - "buffer": url.compose().getvalue().encode(), - } - url = None - else: - input_info = {"src_type": "url"} - url = url0 - else: - try: - buffer = memoryview(url) - except TypeError as e: - raise TypeError("Given input URL argument is not supported.") from e - else: - input_info = {"src_type": "buffer", "buffer": buffer} - url = None + # if a simple filter is present, use the stream specs of its output + if "af" in outopts or "filter:a" in outopts: - url_opts, input_info_list[i] = (url, opts), input_info + # 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"} + ) - # leave the URL None if data needs to be piped in - add_url(args, "input", *url_opts) + opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] - return input_info_list + # assign the values to individual variables + ar, sample_fmt, ac = opt_vals + # 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 -def process_raw_outputs( - args: FFmpegArgs, - input_info: list[InputSourceDict], - streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, - options: FFmpegOptionDict, - fg_info: dict[str, FilterGraphInfoDict] | None = None, -) -> tuple[list[OutputDestinationDict], dict[str, FilterGraphInfoDict] | None]: - """analyze and process piped raw outputs + # set output format and codec + outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) - :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 - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, 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; - None if no filtergraph defined - """ + # sample_fmt must be given + dtype, _ = utils.get_audio_format(sample_fmt, ac) - gopts = args["global_options"] + return dtype, ac and (ac,), ar - # 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 - ) - if "filter_complex" in gopts - else None - ) - # 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} - 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) +def get_option(ffmpeg_args, type, name, file_id=0, stream_type=None, stream_id=None): + """get ffmpeg option value from ffmpeg args dict - # 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 + :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 - # automatically map all the streams - stream_info = resolve_raw_output_streams(args, input_info, fg_info, user_maps) + 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. - # 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 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 - # 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) + v = None + while v is None and len(names): + name = names.pop() + v = opts.get(name, None) - return list(stream_info.values()), fg_info + return v -def process_raw_inputs( - args: FFmpegArgs, - stream_types: Sequence[Literal["a", "v"]], - stream_args: Sequence[RawStreamDef], - inopts_default: FFmpegOptionDict, - dtypes: list[DTypeString | None] | None = None, - shapes: list[ShapeTuple | None] | None = None, -) -> list[InputSourceDict]: +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} + else: + ffmpeg_args[type] = {**opts, **user_options} + 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}, + ) - input_info: list[InputSourceDict] = [] - if dtypes is None: - dtypes = [None] * len(stream_types) - if shapes is None: - shapes = [None] * len(stream_types) - for i, (mtype, arg, dtype, shape) in enumerate( - zip(stream_types, stream_args, dtypes, shapes) - ): - ropt = {"v": "r", "a": "ar"}.get(mtype, None) # rate option - try: - a1, a2 = arg - if isinstance(a1, (int, float, Fraction)): - data = a2 - opts = {ropt: a1} - if ropt is None: - raise FFmpegioError( - "stream_type not specified, cannot resolve the `rate` input." - ) - else: - assert isinstance(a2, dict) - data, opts = a1, a2 - if ropt is None: # unknown - if "ar" in opts: - mtype = "a" - ropt = "ar" - elif "r" in opts: - mtype = "v" - ropt = "r" - else: - raise FFmpegioError("unknown input stream media type") + return ffmpeg_args - except FFmpegioError: - raise - except Exception as e: - 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 - """ - ) from e - opts = {**inopts_default, **opts} - more_opts = None - raw_info = None - if mtype == "a": # audio - media_type = "audio" - opts[ropt] = round(opts[ropt]) # force int sampling rate - if data is not None: - more_opts, raw_info = utils.array_to_audio_options(data) - data = plugins.get_hook().audio_bytes(obj=data) +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") - elif dtypes and shapes and shapes[i] is not None and dtypes[i] is not None: - 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} + return shape, dtype - raw_info = (*raw_info, opts["ar"]) if raw_info else (None, None, opts["ar"]) - else: # video - 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 dtype and shape: - raw_info = shape, dtype - pix_fmt, s = utils.guess_video_format(*raw_info) - more_opts = { - "f": "rawvideo", - f"c:v": "rawvideo", - "pix_fmt": pix_fmt, - "s": s, - } +def move_global_options(args: FFmpegArgs) -> FFmpegArgs: + """move global options from the output options dicts - if more_opts is not None: - opts.update(more_opts) + :param args: FFmpeg arguments + :returns: FFmpeg arguments (the same object as the input) + """ - info = {"src_type": "buffer", "media_type": media_type} + from .caps import options - info["raw_info"] = ( - (None, None, opts[ropt]) if raw_info is None else (*raw_info, opts[ropt]) - ) + _global_options = options("global", name_only=True) - if data is not None: - info["buffer"] = data - add_url(args, "input", None, opts) - input_info.append(info) + global_options = args.get("global_options", None) or {} - return input_info + # global options may be given as output options + for _, inopts in args.get("inputs", ()): + if inopts: + for k in (*(k for k in inopts.keys() if k in _global_options),): + global_options[k] = inopts.pop(k) + for _, outopts in args.get("outputs", ()): + if outopts: + for k in (*(k for k in outopts.keys() if k in _global_options),): + global_options[k] = outopts.pop(k) + if len(global_options): + args["global_options"] = global_options + return args -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 +def clear_loglevel(args: FFmpegArgs): + """clear global loglevel option - * updates `args['inputs'][stream_id][1]` dict - * updates `raw_info` field of ``input_info[stream_id]` dict + :param args: FFmpeg argument dict """ + try: + del args["global_options"]["loglevel"] + logger.warn("loglevel option is cleared by ffmpegio") + except: + pass - 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 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 -def process_url_outputs( - args: FFmpegArgs, - input_info: list[InputSourceDict], - urls: list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ], - options: FFmpegOptionDict, - skip_automapping: bool = False, - no_pipe: bool = False, -) -> tuple[list[OutputDestinationDict], FFmpegOptionDict | None]: - """analyze and process url outputs + - 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 - :param args: FFmpeg argument dict, A new item in`args['outputs']` is - appended for each piped output. Output URLs are left `None`. - :param input_info: list of input information (same length as `args['inputs']) - :param urls: output file names and optionally with file-specific options - :param options: default output options. If `"map"` option is given, it is appended - to the per-file `"map"` option in `streams` argument - :param skip_automapping: True to skip automapping, uses the default mapping, - defaults to False - :param no_pipe: True to raise exception if output is piped without data buffer, - defaults to False - :return output_info: list of output information """ - missing_map = False - output_info_list = [None] * len(urls) - for i, url in enumerate(urls): # add inputs - # get the option dict - if utils.is_non_str_sequence(url, (str, FilterGraphObject, Buffer)): - if len(url) != 2: - raise ValueError( - "url-options pair input must be a tuple of the length 2." - ) - url, opts = url - opts = {**options} if opts is None else {**options, **opts} - else: - # only URL given - opts = {**options} - - # check url (must be url and not fileobj) - if utils.is_fileobj(url, writable=True): - output_info = {"dst_type": "fileobj", "fileobj": url} - url = None - elif utils.is_pipe(url): - if no_pipe: - raise FFmpegioNoPipeAllowed("No output pipe allowed.") - # convert to buffer - output_info = {"dst_type": "buffer"} - url = None - elif utils.is_url(url): - output_info = {"dst_type": "url"} - else: - raise TypeError("Unknown output {url}.") + # get output options, create new + options = args["outputs"][0][1] - url_opts, output_info_list[i] = (url, opts), output_info + # 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." + ) - # leave the URL None if data needs to be piped in - add_url(args, "output", *url_opts) + # 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" - if "map" not in opts: - missing_map = True + # add output formats and codecs + options["f"] = "avi" + options["c:v"] = "rawvideo" - if missing_map and not skip_automapping: + # 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] - # some output file is missing `map` option - # add all input streams or all complex filter outputs - map_opts = [*auto_map(args, input_info, None)] + return ya8 > 0 - # add outputs to FFmpeg arguments - for _, opts in args["outputs"]: - if "map" not in opts: - opts["map"] = map_opts - return output_info_list +def config_input_fg(expr, args, kwargs): + """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) + """ + fg = fgb.Graph(expr) + dopt = None # duration option -def assign_input_url(args: FFmpegArgs, ifile: int, url: str): - """assign a new url to an FFmpeg input + if len(fg) != 1 or len(fg[0]) != 1: + # multi-filter input filtergraph, cannot take arguments + if len(args): + raise FFmpegioError( + f"filtergraph input expresion cannot take ordered options." + ) + return expr, dopt, kwargs - :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]) + # single-filter graph, can apply its options given in the arguments + f = fg[0][0] + info = f.info + if info.inputs is None or len(info.inputs) > 0: + raise FFmpegioError(f"{f.name} filter is not a source filter") + # get the full list of filter options + opts = set() # + for o in info.options: + if not dopt and o.name == "duration": + dopt = (o.name, o.aliases, o.default) + opts.add(o.name) + opts.update(o.aliases) -def assign_output_url(args: FFmpegArgs, ofile: int, url: str): - """assign a new url to an FFmpeg output + # split filter named option andn other keyword arguments + fargs = {i: v for i, v in enumerate(args)} + oargs = {} + for k, v in kwargs.items(): + (fargs if k in opts else oargs)[k] = v - :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]) + if dopt is not None: + name, aliases, default = dopt + val = fargs.get(name, None) + if val is None: + for a in aliases: + val = fargs.get(a, None) + if val is not None: + break + if val is None: + val = default + dopt = utils.parse_time_duration(val) + if dopt <= 0: + dopt = None # infinite -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 + return f.apply(fargs), dopt, oargs - :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) - """ - # check raw formats first - if info["src_type"] == "buffer" and "buffer" not in info: - # raw input real-time stream - return [[0, info["media_type"]]] +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 - # 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 [] + :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 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 - ), + 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 + ) ) - # 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 - + ret = process_one(urls) + return [process_one(url) for url in urls] if ret is None else [ret] -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[bool], - list[OutputDestinationDict], - list[FFmpegOptionDict | None], -]: - """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 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. - - 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 +def add_filtergraph( + 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 - 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. + :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 - 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.") - - if "n" in options: - raise ValueError("Cannot have an `n` option set to output to named pipes.") + if len(args["outputs"]) <= ofile: + raise ValueError( + f"The specified output #{ofile} is not defined in the FFmpegArgs dict." + ) - # separate the options - inopts_default = utils.pop_extra_options(options, "_in") + if automap and map is None: + map = [f"[{l[0]}]" for l in filtergraph.iter_output_labels()] - # create a new FFmpeg dict - args = empty(utils.pop_global_options(options)) - gopts = args["global_options"] # global options dict - gopts["y"] = None + # add the merging filter graph to the filter_complex argument + gopts = args.get("global_options", None) - # analyze and assign inputs - input_info = process_url_inputs(args, urls, inopts_default) + 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 - # make sure all inputs are complete - ready = utils.are_input_pipes_ready(args["inputs"], input_info, must_probe=True) + if gopts is None: + args["global_options"] = {"filter_complex": complex_filters} + else: + gopts["filter_complex"] = complex_filters - # add the default output options to output_options dict with None as the key - output_options = (map, options) + if not len(map): + # nothing to map + return - if all(ready): - output_info = init_media_read_outputs(args, input_info, output_options) - output_options = None + outopts = args["outputs"][ofile][1] + if outopts is None: + args["outputs"][ofile] = (args["outputs"][ofile][0], {"map": map}) else: - output_info = None + if append_map and "map" in outopts: - return args, input_info, ready, output_info, output_options + 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 init_media_read_outputs( + +def resolve_raw_output_streams( 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 + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + fg_info: dict[str, FilterGraphInfoDict] | None, + streams: dict[str, str | None], +) -> dict[str, RawOutputInfoDict]: + """resolve the raw output streams from given sequence of map options - :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 + :param args: FFmpeg argument dict + :param input_info: FFmpeg inputs' additional information, its length must match that of `args['inputs']` + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + :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 """ - # 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) - ] + dst_type = "buffer" - # analyze and assign outputs - output_info, _ = process_raw_outputs(args, input_info, *output_options) + # parse all mapping option values + input_file_id = 0 if len(input_info) == 1 else None - return output_info + 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(spec) for spec in streams) + ] -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 = None, - shapes: list[ShapeTuple | None] | None = None, -) -> tuple[ - FFmpegArgs, - list[InputSourceDict], - list[bool], - list[OutputDestinationDict] | None, - tuple | None, -]: - """write multiple streams to a url/file + 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 + 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 + ) - :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 + 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, + "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 - TIPS - ---- - * All the input streams will be added to the output file by default, unless `map` option is specified - * If the input streams are of different durations, use `shortest=ffmpegio.FLAG` option to trim all streams to the shortest. - * Using merge_audio_streams: - - adds a `filter_complex` global option - - merged input streams are removed from the `map` option and replaced by the merged stream +def auto_map( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + fg_info: dict[str, FilterGraphInfoDict] | 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: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + :return: a map of input/filtergraph output labels and their stream information. + + 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. """ - noutputs = len(urls) - if not noutputs: - raise FFmpegioError("At least one URL must be given.") + 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 + ) + else: + fg_info = None - # separate the options - inopts_default = utils.pop_extra_options(options, "_in") + 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() + } - # create a new FFmpeg dict - args = empty(utils.pop_global_options(options)) + counter = {"file": None, "audio": 0, "video": 0} - # analyze and assign inputs - input_info = process_raw_inputs( - args, stream_types, stream_args, inopts_default, dtypes, shapes - ) + 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}" - # 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 + # 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 {}) + } - 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, - ) +def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: + """list all available output labels of the complex filtergraphs - if all(ready): - output_info = init_media_write_outputs( - args, - input_info, - output_args, - ) - output_args = None - else: - output_info = None + :param args: FFmpeg argument dict. `filter_complex` argument may be modified if present. + :return: a map of filtergraph output labels to their media types - return args, input_info, ready, output_info, output_args + 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. + """ -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 + gopts = args.get("global_options", None) or {} - :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 + if "filter_complex" not in gopts: + # no filtergraph + return {} - `args['inputs']` is expected to have all the necessary options of piped input - (see `PipedStreams.PipedRawInputMixin._write_stream`) + # 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] - ( - urls, - options, - merge_audio_streams, - merge_audio_ar, - merge_audio_sample_fmt, - merge_audio_outpad, - ) = output_args + # 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) - # 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 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 - 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 + # 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) - # 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", - ) + # 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}) - 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) - else: - gopts["filter_complex"] = [afilt] + # next index + while True: + out_n += 1 + if out_n not in out_labels: + break - # analyze and assign outputs - output_info = process_url_outputs(args, input_info, urls, options) + for kwargs in new_labels: + fg = fg.add_label(**kwargs) + filters_complex[i] = fg - # 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' - ) + # create the output map + map = { + f"[{label}]": filter.get_pad_media_type("output", pad_id) + for label, (filter, pad_id) in out_labels.items() + } - return output_info + # update the filtergraphs + args["global_options"]["filter_complex"] = filters_complex + return map -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 +################################################################################ + +def process_url_inputs( + args: FFmpegArgs, + urls: list[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + ], + inopts_default: FFmpegOptionDict, + no_pipe: bool = False, +) -> list[EncodedInputInfoDict]: + """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 + 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 + :param no_pipe: True to raise exception if an input is piped without data buffer, defaults to False + :return: list of input information """ - 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.") + input_info_list = [None] * len(urls) + for i, url in enumerate(urls): # add inputs + # get the option dict + if utils.is_non_str_sequence(url, (str, FilterGraphObject, Buffer)): + if len(url) != 2: + raise ValueError( + "url-options pair input must be a tuple of the length 2." + ) + url, opts = url + opts = inopts_default if opts is None else {**inopts_default, **opts} + else: + # only URL given + opts = inopts_default - # separate the options - inopts_default = utils.pop_extra_options(options, "_in") + # 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." + ) - # 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 + input_info = {"src_type": "filtergraph"} - # analyze and assign inputs - input_info = process_raw_inputs( - args, input_types, input_args, inopts_default, input_dtypes, input_shapes - ) + elif utils.is_fileobj(url, readable=True): + if not url.seekable(): + raise FFmpegioNoPipeAllowed("Fileobj input must be seekable.") + input_info = {"src_type": "fileobj", "fileobj": url} + url = None + elif utils.is_pipe(url): + if no_pipe: + raise FFmpegioNoPipeAllowed("No input pipe allowed.") + input_info = {"src_type": "buffer"} + url = None + elif utils.is_url(url): + input_info = {"src_type": "url"} + elif isinstance(url, FFConcat): + # TODO - generalize this to handle an arbitrary Muxer class + opts["f"] = "concat" + url0 = url.url + if url0 in ("-", "unset"): + input_info = { + "src_type": "buffer", + "buffer": url.compose().getvalue().encode(), + } + url = None + else: + input_info = {"src_type": "url"} + url = url0 + else: + try: + buffer = memoryview(url) + except TypeError as e: + raise TypeError("Given input URL argument is not supported.") from e + else: + input_info = {"src_type": "buffer", "buffer": buffer} + url = None - 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.") + url_opts, input_info_list[i] = (url, opts), input_info - # 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 " + # leave the URL None if data needs to be piped in + add_url(args, "input", *url_opts) + + return input_info_list + + +def process_raw_outputs( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + options: FFmpegOptionDict, + fg_info: dict[str, FilterGraphInfoDict] | None = None, +) -> tuple[list[RawOutputInfoDict], dict[str, FilterGraphInfoDict] | None]: + """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 + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, 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; + None if no filtergraph defined + """ + + 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 + ) + if "filter_complex" in gopts + else None ) - # 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 + # resolve requested output streams + stream_info: dict[str, RawOutputInfoDict] + 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: - output_info = None + # 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 + + # automatically map all the streams + stream_info = resolve_raw_output_streams(args, input_info, fg_info, user_maps) + + # add outputs to FFmpeg arguments + for spec, info in stream_info.items(): + opts = {**stream_maps[spec], "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 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) + + return list(stream_info.values()), fg_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 = None, + shapes: list[ShapeTuple | None] | None = None, +) -> list[RawInputInfoDict]: + """configure input raw media streams + + :param args: _description_ + :param stream_types: _description_ + :param stream_args: _description_ + :param inopts_default: _description_ + :param dtypes: _description_, defaults to None + :param shapes: _description_, defaults to None + :return: a list of dict containing the provided info + """ + input_info: list[RawInputInfoDict] = [] + if dtypes is None: + dtypes = [None] * len(stream_types) + if shapes is None: + shapes = [None] * len(stream_types) + for i, (mtype, arg, dtype, shape) in enumerate( + zip(stream_types, stream_args, dtypes, shapes) + ): + ropt = {"v": "r", "a": "ar"}.get(mtype, None) # rate option + try: + a1, a2 = arg + if isinstance(a1, (int, float, Fraction)): + data = a2 + opts = {ropt: a1} + if ropt is None: + raise FFmpegioError( + "stream_type not specified, cannot resolve the `rate` input." + ) + else: + assert isinstance(a2, dict) + data, opts = a1, a2 + if ropt is None: # unknown + if "ar" in opts: + mtype = "a" + ropt = "ar" + elif "r" in opts: + mtype = "v" + ropt = "r" + else: + raise FFmpegioError("unknown input stream media type") + + except FFmpegioError: + raise + except Exception as e: + 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 + """ + ) from e + + opts = {**inopts_default, **opts} + more_opts = None + raw_info = None + if mtype == "a": # audio + media_type = "audio" + opts[ropt] = round(opts[ropt]) # force int sampling rate + if data is not None: + more_opts, raw_info = utils.array_to_audio_options(data) + data = plugins.get_hook().audio_bytes(obj=data) + + elif dtypes and shapes and shapes[i] is not None and dtypes[i] is not None: + 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} + + raw_info = (*raw_info, opts["ar"]) if raw_info else (None, None, opts["ar"]) + + else: # video + 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 dtype and shape: + raw_info = shape, dtype + pix_fmt, s = utils.guess_video_format(*raw_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} + + info["raw_info"] = ( + (None, None, opts[ropt]) if raw_info is None else (*raw_info, opts[ropt]) + ) - return args, input_info, ready, output_info, output_options + if data is not None: + info["buffer"] = data + add_url(args, "input", None, opts) + input_info.append(info) + + return input_info -def init_media_filter_outputs( +def update_raw_input( 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 + input_info: list[RawInputInfoDict], + stream_id: int, + data: RawDataBlob, +): + """update raw input stream from the data blob - :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 + :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 """ - # 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 + 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) ) - # separate specific and default output options - (output_options, default_opts) = output_options + opts.update(more_opts) + info["raw_info"] = (*raw_info[::-1], rate) # dtype, shape, rate - # 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." + +def process_url_outputs( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict], + urls: list[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ], + options: FFmpegOptionDict, + skip_automapping: bool = False, + no_pipe: bool = False, +) -> list[EncodedOutputInfoDict]: + """analyze and process url outputs + + :param args: FFmpeg argument dict, A new item in`args['outputs']` is + appended for each piped output. Output URLs are left `None`. + :param input_info: list of input information (same length as `args['inputs']) + :param urls: output file names and optionally with file-specific options + :param options: default output options. If `"map"` option is given, it is appended + to the per-file `"map"` option in `streams` argument + :param skip_automapping: True to skip automapping, uses the default mapping, + defaults to False + :param no_pipe: True to raise exception if output is piped without data buffer, + defaults to False + :return output_info: list of output information + """ + + 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." ) - elif (st_map := f"[{k}]") in fg_info: - out_maps[st_map] = k + url, opts = url + opts = {**options} if opts is None else {**options, **opts} else: - out_maps[k] = k + # only URL given + opts = {**options} - # 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] + # check url (must be url and not fileobj) + if utils.is_fileobj(url, writable=True): + output_info = {"dst_type": "fileobj", "fileobj": url} + url = None + elif utils.is_pipe(url): + if no_pipe: + raise FFmpegioNoPipeAllowed("No output pipe allowed.") + # convert to buffer + output_info = {"dst_type": "buffer"} + url = None + elif utils.is_url(url): + output_info = {"dst_type": "url"} else: - label = spec[1:-1] - streams[label] = {} + raise TypeError("Unknown output {url}.") - # analyze and assign outputs - output_info, fg_info = process_raw_outputs( - args, input_info, streams, default_opts, fg_info - ) + url_opts, output_info_list[i] = (url, opts), output_info - return 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 -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 + if missing_map and not skip_automapping: - :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 + # some output file is missing `map` option + # add all input streams or all complex filter outputs + 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 + + +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]) - 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") +def assign_output_url(args: FFmpegArgs, ofile: int, url: str): + """assign a new url to an FFmpeg output - # create a new FFmpeg dict - args = empty(utils.pop_global_options(options)) + :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]) - 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 as e: - raise FFmpegioError("extra_inputs cannot be piped in.") +def retrieve_input_stream_ids( + info: RawInputInfoDict | EncodedInputInfoDict, + 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 - if not len(input_info): - raise ValueError("At least one input must be given.") + :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) + """ - output_info = process_url_outputs( - args, input_info, outputs, options, skip_automapping=True - ) + # check raw formats first + if info["src_type"] == "buffer" and "buffer" not in info: + # raw input real-time stream + return [[0, info["media_type"]]] - 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.") + # 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 [] - if not len(output_info): - raise ValueError("At least one output must be given.") + 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 + ), + ) - return args, input_info, output_info + # 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 ######################################## @@ -2183,22 +2238,19 @@ def init_media_transcoder( def assign_output_pipes( args: FFmpegArgs, - output_info: list[OutputDestinationDict], - sp_kwargs: dict | None = None, + output_info: list[OutputInfoDict], use_std_pipes: bool = False, -) -> dict: +) -> 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 sp_kwargs: Specify the subprocess.Popen keyword arguments. :param use_std_pipes: True to assign the first piped output to stdout - :returns sp_kwargs: Modified Popen keyword arguments + :param sp_kwargs: the subprocess.Popen keyword arguments for stdout pipe """ - if sp_kwargs is None: - sp_kwargs = {} + sp_kwargs = {} if sp_kwargs is None else {**sp_kwargs} if output_info is None: return sp_kwargs @@ -2206,6 +2258,7 @@ def assign_output_pipes( # configure output pipes use_stdout = False has_pipeout = False + pipe_info = {} for i, (info, arg) in enumerate(zip(output_info, args["outputs"])): @@ -2228,7 +2281,7 @@ def assign_output_pipes( # if fileobj or buffer output, use pipe pipe = NPopen("r", bufsize=0) pipe_path = pipe.path - info["pipe"] = pipe + pipe_info[i] = {"pipe": pipe} assign_output_url(args, i, pipe_path) if has_pipeout: @@ -2236,32 +2289,32 @@ def assign_output_pipes( args["global_options"].pop("n", None) args["global_options"]["y"] = None - return sp_kwargs + return pipe_info, sp_kwargs def assign_input_pipes( args: FFmpegArgs, - input_info: list[InputSourceDict], - sp_kwargs: dict | None = None, + input_info: list[InputInfoDict], use_std_pipes: bool = False, set_sp_kwargs_input: bool = False, -) -> dict: +) -> 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 sp_kwargs: Specify the subprocess.Popen keyword arguments. :param set_sp_kwargs_input: True to assign 'input' instead of 'stdin' for sp_kwargs - :returns sp_kwargs: Modified Popen keyword arguments + :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 """ - if sp_kwargs is None: - sp_kwargs = {} + pipe_info = {} + sp_kwargs = {} if input_info is None: - return sp_kwargs + return pipe_info, sp_kwargs # configure input pipes use_stdin = False @@ -2282,24 +2335,25 @@ def assign_input_pipes( assert "fileobj" in info sp_kwargs["input"] = info["fileobj"] elif src_type == "buffer": - if ( - set_sp_kwargs_input and "buffer" in info - ): # given data to send to subprocess + 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 else: pipe = NPopen("w", bufsize=0) pipe_path = pipe.path - info["pipe"] = pipe + pipe_info[i] = {"pipe": pipe} assign_input_url(args, i, pipe_path) - return sp_kwargs + return pipe_info, sp_kwargs def init_named_pipes( - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], + inpipe_info: dict[int, InputPipeInfoDict], + outpipe_info: dict[int, OutputPipeInfoDict], + input_info: list[InputInfoDict], + output_info: list[OutputInfoDict], update_rate: float | None = None, blocksize: int | None = None, queue_size: int | None = None, @@ -2332,11 +2386,10 @@ def init_named_pipes( wr_kws = {"queuesize": queue_size} if queue_size else {} # configure output pipes - for info in output_info: - if "pipe" not in info: - continue + for i, pinfo in outpipe_info.items(): + info = output_info[i] - pipe = info["pipe"] + pipe = pinfo["pipe"] stack.enter_context(pipe) dst_type = info["dst_type"] @@ -2357,15 +2410,14 @@ def init_named_pipes( kws["nmin"] = blocksize or 2**16 reader = ReaderThread(pipe, **kws) - info["reader"] = reader + pinfo["reader"] = reader stack.enter_context(reader) # starts thread & wait for pipe connection # configure input pipes (if needed) - for info in input_info: - if "pipe" not in info: - continue + for i, pinfo in inpipe_info.items(): + info = input_info[i] - pipe = info["pipe"] + pipe = pinfo["pipe"] stack.enter_context(pipe) src_type = info["src_type"] @@ -2383,7 +2435,7 @@ def init_named_pipes( writer.write(None) # close the writer immediately else: # if no data given, provide the access to the writer - info["writer"] = writer + pinfo["writer"] = writer stack.enter_context(writer) return stack diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 85fbd4f8..a473cd5c 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -12,8 +12,8 @@ RawDataBlob, Unpack, FFmpegUrlType, - InputSourceDict, - OutputDestinationDict, + InputInfoDict, + OutputInfoDict, FFmpegOptionDict, ) from .configure import ( @@ -34,8 +34,8 @@ 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, @@ -51,11 +51,18 @@ def _runner( capture_log = True if need_stderr else None if show_log else True # configure named pipes + input_pipes = output_pipes = {} if len(input_info): - sp_kwargs = configure.assign_input_pipes(args, input_info, False, sp_kwargs) + input_pipes, sp_kwargs = configure.assign_input_pipes( + args, input_info, sp_kwargs, False + ) if len(output_info): - sp_kwargs = configure.assign_output_pipes(args, output_info, False, sp_kwargs) - stack = configure.init_named_pipes(input_info, output_info) + output_pipes, sp_kwargs = configure.assign_output_pipes( + args, output_info, sp_kwargs, False + ) + stack = configure.init_named_pipes( + input_pipes, output_pipes, input_info, output_info + ) def on_exit(rc): stack.close() @@ -86,7 +93,7 @@ def on_exit(rc): def _gather_outputs( - output_info: list[OutputDestinationDict], proc: ffmpegprocess.Popen + output_info: list[OutputInfoDict], proc: ffmpegprocess.Popen ) -> tuple[dict[str, int | Fraction], dict[str, RawDataBlob]]: rates = {} data = {} diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index 45ef44fb..50de1b66 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -1,7 +1,7 @@ from __future__ import annotations import pluggy -from typing import Protocol, Callable +from typing import Callable from .._typing import DTypeString, ShapeTuple hookspec = pluggy.HookspecMarker("ffmpegio") @@ -12,9 +12,6 @@ def finder() -> tuple[str, str]: """find ffmpeg and ffprobe executable""" ... -class GetInfoCallable(Protocol): - def __call__(self, *, obj: object) -> tuple[ShapeTuple, DTypeString]: ... - @hookspec(firstresult=True) def video_info(obj: object) -> tuple[ShapeTuple, DTypeString]: @@ -38,10 +35,6 @@ def audio_info(obj: object) -> tuple[ShapeTuple, DTypeString]: ... -class ToBytesCallable(Protocol): - def __call__(self, *, obj: object) -> memoryview: ... - - @hookspec(firstresult=True) def video_bytes(obj: object) -> memoryview: """return bytes-like object of packed video pixels, associated with `video_info()` @@ -62,12 +55,6 @@ def audio_bytes(obj: object) -> memoryview: ... -class CountDataCallable(Protocol): - def __call__( - self, *, b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool - ) -> int: ... - - @hookspec(firstresult=True) def video_frames(obj: object) -> int: """get number of video frames in obj @@ -88,12 +75,6 @@ def audio_samples(obj: object) -> int: ... -class FromBytesCallable(Protocol): - def __call__( - self, *, b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool - ) -> object: ... - - @hookspec(firstresult=True) def bytes_to_video( b: bytes, dtype: DTypeString, shape: ShapeTuple, squeeze: bool @@ -151,9 +132,6 @@ def device_sink_api() -> tuple[str, dict[str, Callable]]: """ ... -class HasDataCallable(Protocol): - def __call__(self, *, obj: object) -> bool: ... - @hookspec(firstresult=True) def is_empty(obj: object) -> bool: diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index f9c8c402..fb606720 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -13,8 +13,8 @@ from .._typing import ( ProgressCallable, - InputSourceDict, - OutputDestinationDict, + InputInfoDict, + OutputInfoDict, FFmpegOptionDict, RawDataBlob, ShapeTuple, @@ -25,7 +25,7 @@ from ..configure import FFmpegArgs, MediaType, InitMediaOutputsCallable from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError -from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable +from .._typing import FromBytesCallable, CountDataCallable, ToBytesCallable logger = logging.getLogger("ffmpegio") @@ -47,8 +47,8 @@ class BaseFFmpegRunner: def __init__( self, ffmpeg_args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], + input_info: list[InputInfoDict], + output_info: list[OutputInfoDict], input_ready: Literal[True] | list[bool], init_deferred_outputs: InitMediaOutputsCallable | None, deferred_output_args: list[FFmpegOptionDict | None], @@ -275,8 +275,8 @@ class BaseRawInputsMixin: """write a raw media data to a specified stream (backend)""" default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] _deferred_data: list[list[bytes]] _input_ready: Literal[True] | list[bool] _logger: LoggerThread | None @@ -370,8 +370,8 @@ class BaseEncodedInputsMixin: # FFmpegRunner's properties accessed default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] _deferred_data: list[list[bytes]] _input_ready: Literal[True] | list[bool] _logger: LoggerThread | None @@ -387,7 +387,7 @@ def _write_deferred_data(self): def _write_encoded_stream( self, index: int, - info: OutputDestinationDict, + info: OutputInfoDict, data: bytes, timeout: float | None, ): @@ -426,8 +426,8 @@ def _write_encoded_stream( class BaseRawOutputsMixin: default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] _deferred_data: list[list[bytes]] _input_ready: bool _logger: LoggerThread | None @@ -511,7 +511,7 @@ def _read_stream_bytes( counter: CountDataCallable, dtype: DTypeString, shape: ShapeTuple, - info: OutputDestinationDict, + info: OutputInfoDict, stream_id: int | str, n: int, timeout: float | None = None, @@ -533,8 +533,8 @@ def _read_stream_bytes( class BaseEncodedOutputsMixin: default_timeout: float | None - _input_info: list[InputSourceDict] - _output_info: list[OutputDestinationDict] + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] _deferred_data: list[list[bytes]] _input_ready: bool _logger: LoggerThread @@ -555,7 +555,7 @@ def _init_pipes(self) -> ExitStack: def _read_encoded_stream( self, - info: OutputDestinationDict, + info: OutputInfoDict, n: int, timeout: float | None = None, ) -> bytes: diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 2362bb7d..9cd50794 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -8,8 +8,8 @@ from typing_extensions import Literal, Unpack from .._typing import ( ProgressCallable, - InputSourceDict, - OutputDestinationDict, + InputInfoDict, + OutputInfoDict, FFmpegOptionDict, RawDataBlob, ShapeTuple, @@ -36,9 +36,17 @@ BaseEncodedOutputsMixin as _BaseEncodedOutputsMixin, ) -logger = logging.getLogger("ffmpegio") +logger = (logging.getLogger("ffmpegio"),) -__all__ = ["MediaReader", "MediaWriter", "MediaTranscoder", "MediaFilter"] +__all__ = [ + "MediaReader", + "MediaWriter", + "MediaTranscoder", + "SISOMediaFilter", + "MISOMediaFilter", + "SIMOMediaFilter", + "MIMOMediaFilter", +] class _PipedFFmpegRunner(_BaseFFmpegRunner): @@ -47,8 +55,8 @@ class _PipedFFmpegRunner(_BaseFFmpegRunner): def __init__( self, ffmpeg_args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], + input_info: list[InputInfoDict], + output_info: list[OutputInfoDict], input_ready: Literal[True] | list[bool], init_deferred_outputs: InitMediaOutputsCallable | None, deferred_output_args: list[FFmpegOptionDict | None], @@ -109,18 +117,18 @@ def _assign_pipes(self): All named pipes must be """ if len(self._input_info): - configure.assign_input_pipes( + inpipe_info = configure.assign_input_pipes( self._args["ffmpeg_args"], self._input_info, self._args["sp_kwargs"], - ) + )[0] if len(self._output_info): - configure.assign_output_pipes( + outpipe_info = configure.assign_output_pipes( self._args["ffmpeg_args"], self._output_info, self._args["sp_kwargs"], - ) + )[0] configure.init_named_pipes( self._input_info, self._output_info, **self._pipe_kws, stack=self._stack @@ -146,7 +154,7 @@ def __init__(self, **kwargs): def _write_stream( self, - info: OutputDestinationDict, + info: OutputInfoDict, stream_id: int, data: RawDataBlob, timeout: float | None, @@ -309,7 +317,7 @@ def __init__(self, blocksize, ref_output, **kwargs): def _read_stream( self, - info: OutputDestinationDict, + info: OutputInfoDict, stream_id: int | str, n: int, timeout: float | None = None, @@ -728,7 +736,7 @@ def __init__( sequence will overwrite those specified here. """ - args, input_info, output_info = configure.init_media_transcoder( + args, input_info, output_info = configure.init_media_transcode( [("pipe", opts) for opts in input_options], [("pipe", opts) for opts in output_options], extra_inputs, @@ -752,6 +760,18 @@ def __init__( ) +class SISOMediaFilter: ... + + +class MISOMediaFilter: ... + + +class SIMOMediaFilter: ... + + +class MIMOMediaFilter: ... + + class MediaFilter(_RawOutputsMixin, _RawInputsMixin, _PipedFFmpegRunner): def __init__( diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 1b43fc5e..29bac2c7 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -6,8 +6,6 @@ logger = logging.getLogger("ffmpegio") -from ..plugins.hookspecs import FromBytesCallable, CountDataCallable, ToBytesCallable - from typing_extensions import Unpack, Literal from collections.abc import Sequence from .._typing import ( @@ -16,8 +14,11 @@ ProgressCallable, RawDataBlob, FFmpegOptionDict, - InputSourceDict, - OutputDestinationDict, + InputInfoDict, + RawOutputInfoDict, + FromBytesCallable, + CountDataCallable, + ToBytesCallable, ) from fractions import Fraction @@ -36,6 +37,10 @@ # fmt:on +# info["reader"].read(n, timeout) +# info["writer"].write(None, None if timeout is None else timeout - time()) + + class SimpleReaderBase(BaseFFmpegRunner): """queue-less SISO media reader class""" @@ -43,8 +48,8 @@ def __init__( self, *, ffmpeg_args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], + input_info: list[InputInfoDict], + output_info: list[RawOutputInfoDict], from_bytes: FromBytesCallable, to_memoryview: ToBytesCallable, show_log: bool | None, @@ -132,11 +137,48 @@ def output_count(self) -> int: """number of frames/samples read""" return self._n0 - @property def output_bytesize(self) -> int | None: """number of bytes per output sample/pixel""" return get_bytesize(self.output_shape, self.output_dtype) + @property + def output_labels(self) -> list[str | None]: + """FFmpeg/custom labels of output streams""" + return [self._output_info[0].get("user_map", None)] + + @property + def output_types(self) -> list[MediaType | None]: + """media type associated with the output streams (key)""" + return [self._output_info[0]["media_type"]] + + @property + def output_rates(self) -> list[int | Fraction | None]: + """sample or frame rates associated with the output streams (key)""" + info = self._output_info[0] + return [info["raw_info"][2] if "raw_info" in info else None] + + @property + def output_dtypes(self) -> list[DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + info = self._output_info[0] + return [info["raw_info"][0] if "raw_info" in info else None] + + @property + def output_shapes(self) -> list[ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + info = self._output_info[0] + return [info["raw_info"][1] if "raw_info" in info else None] + + @property + def output_counts(self) -> list[int]: + """number of frames/samples read""" + return [self._n0] + + @property + def output_bytesizes(self) -> list[int | None]: + """number of bytes per output sample/pixel""" + return [get_bytesize(self.output_shape, self.output_dtype)] + def _assign_pipes(self): configure.assign_output_pipes( @@ -226,7 +268,7 @@ def __init__( ) if len(output_info) != 1 or output_info[0]["media_type"] != "video": - raise FFmpegioError(f'no output video stream found in "{url}" ({map=})') + raise FFmpegioError(f'no output video stream found in "{urls}" ({map=})') if not all(ready): raise RuntimeError( @@ -300,8 +342,8 @@ class SimpleWriterBase(BaseFFmpegRunner): def __init__( self, ffmpeg_args: FFmpegArgs, - input_info: list[InputSourceDict], - output_info: list[OutputDestinationDict], + input_info: list[InputInfoDict], + output_info: list[RawOutputInfoDict], input_ready: Literal[True] | list[bool], init_deferred_outputs: InitMediaOutputsCallable | None, deferred_output_args: list[FFmpegOptionDict | None], diff --git a/src/ffmpegio/streams/typing.py b/src/ffmpegio/streams/typing.py new file mode 100644 index 00000000..d33f9645 --- /dev/null +++ b/src/ffmpegio/streams/typing.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from fractions import Fraction +from .._typing import ( + MediaType, + DTypeString, + ShapeTuple, + Any, + Protocol, + RawInputInfoDict, + RawOutputInfoDict, + EncodedInputInfoDict, + EncodedOutputInfoDict, +) + + +class FrameReaderProtocol(Protocol): + + def output_label(self, stream_index: int = 0) -> str | None: + """FFmpeg/custom label of the output stream in FFmpeg""" + + def output_type(self, stream_index: int = 0) -> MediaType | None: + """media type associated with the output stream (key)""" + + def output_rate(self, stream_index: int = 0) -> int | Fraction | None: + """sample or frame rates associated with the output stream (key)""" + + def output_dtype(self, stream_index: int = 0) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + + def output_shape(self, stream_index: int = 0) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + + def output_count(self, stream_index: int = 0) -> int: + """number of frames/samples read""" + + def output_bytesize(self, stream_index: int = 0) -> int | None: + """number of bytes per output sample/pixel""" + + @property + def output_labels(self) -> list[str | None]: + """FFmpeg/custom labels of output streams if specified""" + ... + + @property + def output_types(self) -> list[MediaType | None]: + """media type associated with the output streams (key)""" + ... + + @property + def output_rates(self) -> list[int | Fraction | None]: + """sample or frame rates associated with the output streams (key)""" + ... + + @property + def output_dtypes(self) -> list[DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + ... + + @property + def output_shapes(self) -> list[ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + ... + + @property + def output_counts(self) -> list[int]: + """number of frames/samples read""" + ... + + @property + def output_bytesizes(self) -> list[int | None]: + """number of bytes per output sample/pixel""" + ... + + def read(self, n: int = -1, timeout: float | None = None) -> Any: + """read n raw frames from FFmpeg + + :param n: number of samples/frames to read, if negative, read all frames, + defaults to -1 + :param timeout: timeout in seconds, defaults to None + :return: n frames of data data type depending on the active plugin. A frame + is one video image or a set of audio samples at one sample time. + """ + + +class FrameWriterProtocol(Protocol): + """to write raw frame data to FFmpeg""" + + def input_type(self, stream_id: int = 0) -> MediaType | None: + """media type associated with the input streams""" + + def input_rate(self, stream_id: int = 0) -> int | Fraction | None: + """sample or frame rates associated with the input streams""" + + def input_dtype(self, stream_id: int = 0) -> DTypeString | None: + """frame/sample data type associated with the output streams (key)""" + + def input_shape(self, stream_id: int = 0) -> ShapeTuple | None: + """frame/sample shape associated with the output streams (key)""" + + def input_count(self, stream_id: int = 0) -> int: + """number of input frames/samples written""" + + def input_bytesize(self, stream_id: int = 0) -> int | None: + """input sample/pixel count per frame""" + + @property + def input_types(self) -> list[MediaType]: + """media type associated with the input streams""" + + @property + def input_rates(self) -> list[int | Fraction]: + """sample or frame rates associated with the input streams""" + + @property + def input_dtypes(self) -> list[DTypeString]: + """frame/sample data type associated with the output streams (key)""" + + @property + def input_shapes(self) -> list[ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + + @property + def input_counts(self) -> list[int]: + """number of input frames/samples written""" + + @property + def input_bytesizes(self) -> list[int | None]: + """input sample/pixel count per frame""" + + def write(self, data: Any, timeout: float | None = None): + """write raw frame data + + :param data: _description_ + :param timeout: _description_, defaults to None + """ + + 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 + """ diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index a4ef4d0b..0a5c92c1 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -86,7 +86,7 @@ def transcode( if utils.is_valid_output_url(outputs): outputs = [outputs] - args, input_info, output_info = configure.init_media_transcoder( + args, input_info, output_info = configure.init_media_transcode( inputs, outputs, None, None, options ) @@ -95,7 +95,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( @@ -106,18 +106,18 @@ def transcode( 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 - ) - 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, } ) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index a5e58e2f..e19fa99a 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -21,9 +21,9 @@ from .._typing import ( Any, MediaType, - InputSourceDict, + InputInfoDict, RawDataBlob, - OutputDestinationDict, + OutputInfoDict, FFmpegUrlType, IO, Buffer, @@ -540,7 +540,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,7 +578,7 @@ 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]: """analyze a file and return requested field values of all returned streams @@ -616,7 +616,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 @@ -655,7 +655,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 +689,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 @@ -728,7 +728,7 @@ def analyze_audio_stream( def analyze_complex_filtergraphs( filtergraphs: list[FilterGraphObject | str], inputs: list[tuple[FFmpegUrlType | None, FFmpegOptionDict]], - inputs_info: list[InputSourceDict], + inputs_info: list[InputInfoDict], ) -> tuple[list[FilterGraphObject], dict[str, FilterGraphInfoDict]]: """analyze filtergraphs and return requested field values @@ -866,7 +866,7 @@ def analyze_complex_filtergraphs( def are_input_pipes_ready( inputs: list[tuple[FFmpegUrlType, FFmpegOptionDict]], - input_info: list[InputSourceDict], + input_info: list[InputInfoDict], must_probe: bool = False, ) -> list[bool]: """Test if all the input information is provided for raw output initialization @@ -908,7 +908,7 @@ def are_input_pipes_ready( def get_output_stream_id( - output_info: list[OutputDestinationDict], stream: str | int + output_info: list[OutputInfoDict], stream: str | int ) -> int: """get output stream id diff --git a/tests/test_media.py b/tests/test_media.py index 3c73e061..d1e66059 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -20,12 +20,14 @@ ], ) def test_media_read(urls, kwargs): + assert False 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(): + assert False 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]') @@ -35,6 +37,7 @@ def test_media_read_filter_complex(): def test_media_write(): + assert False fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) @@ -54,6 +57,7 @@ def test_media_write(): def test_media_write_audio_merge(): + assert False 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") @@ -77,6 +81,7 @@ def test_media_write_audio_merge(): def test_media_filter(): + assert False fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) From c949b17c76c5026c1695973a35bf2a294f2b9d83 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 29 Dec 2025 09:54:44 -0500 Subject: [PATCH 315/344] wip10 --- CHANGELOG.md | 15 + src/ffmpegio/_std_runners.py | 158 +++++ src/ffmpegio/_typing.py | 83 ++- src/ffmpegio/audio.py | 328 +++++----- src/ffmpegio/configure.py | 817 ++++++++++++++++++------- src/ffmpegio/filtergraph/Chain.py | 6 +- src/ffmpegio/filtergraph/Graph.py | 80 ++- src/ffmpegio/filtergraph/GraphLinks.py | 7 +- src/ffmpegio/filtergraph/build.py | 10 +- src/ffmpegio/image.py | 2 +- src/ffmpegio/media.py | 57 +- src/ffmpegio/streams/SimpleStreams.py | 5 +- src/ffmpegio/transcode.py | 4 +- src/ffmpegio/utils/__init__.py | 18 +- src/ffmpegio/utils/log.py | 47 +- src/ffmpegio/video.py | 2 +- tests/test_audio.py | 57 +- tests/test_configure.py | 23 +- tests/test_filtergraph_build.py | 11 + 19 files changed, 1237 insertions(+), 493 deletions(-) create mode 100644 src/ffmpegio/_std_runners.py 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/src/ffmpegio/_std_runners.py b/src/ffmpegio/_std_runners.py new file mode 100644 index 00000000..aed4a5c9 --- /dev/null +++ b/src/ffmpegio/_std_runners.py @@ -0,0 +1,158 @@ +"""FFmpeg runner functions for SISO operations over standard pipes""" + +from __future__ import annotations + +import logging + +from . import ( + ffmpegprocess, + configure, + FFmpegError, + FFmpegioError, + plugins, + analyze, +) +from .utils import log as log_utils +from ._typing import ( + Sequence, + TYPE_CHECKING, + Buffer, + Any, + ProgressCallable, + FFmpegUrlType, + FFmpegOptionDict, + RawDataBlob, + RawInputInfoDict, + EncodedInputInfoDict, + RawOutputInfoDict, + EncodedOutputInfoDict, +) + +if TYPE_CHECKING: + from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegOutputOptionTuple, + FFmpegOutputUrlNoPipe, + FFmpegNoPipeOutputOptionTuple, + FFmpegArgs, + ) + from .filtergraph.abc import FilterGraphObject + from .utils.concat import FFConcat + +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]["media_type"] != "audio": + raise ValueError("Mapped stream is not an audio stream.") + 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 = ffmpegprocess.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 +): + 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} + + out = ffmpegprocess.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/_typing.py b/src/ffmpegio/_typing.py index b2c28b6d..3269c370 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -100,8 +100,8 @@ =============== ================================================================ """ -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"] @@ -158,7 +158,7 @@ class ToBytesCallable(Protocol): :return: a FFmpeg raw media stream compatible bytes """ - def __call__(self, obj: object) -> memoryview: ... + def __call__(self, *, obj: object) -> memoryview: ... class CountDataCallable(Protocol): @@ -293,7 +293,7 @@ class InputPipeInfoDict(TypedDict): ################################################## -class RawOutputInfoDict(TypedDict): +class RawDirectOutputInfoDict(TypedDict): """raw output media stream info =================== ================================================================ @@ -302,27 +302,76 @@ class RawOutputInfoDict(TypedDict): `'dst_type'` `'buffer'` `'media_type'` media stream identifier: `'audio'` or '`video'` `'raw_info'` tuple of (dtype, shape, rate) - `'data_info'` function to gather media information from raw data blob `'bytes2data'` function to convert bytes to raw data blob - `'is_empty'` function to check empty data frame check - `'user_map'` (optional) user specified FFmpeg map option of this stream - `'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 - =============== ================================================================ + `'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 | None # - data_info: GetInfoCallable + raw_info: RawStreamInfoTuple bytes2data: FromBytesCallable - is_empty: IsEmptyCallable - user_map: NotRequired[str] # user specified map option + 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] + + +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) + `'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 | None # + raw_info: RawStreamInfoTuple + 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): diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 9efa1508..10c2cfd6 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -1,91 +1,41 @@ -"""Audio Read/Write Module -""" +"""Audio Read/Write Module""" -import warnings -from . import ffmpegprocess, utils, configure, FFmpegError, plugins, analyze -from .utils import log as log_utils +from __future__ import annotations -__all__ = ["create", "read", "write", "filter", "detect"] +import logging +import warnings +from . import configure, plugins, analyze, FFmpegioError -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 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] - """ - """ +from ._typing import TYPE_CHECKING, Any, ProgressCallable, RawDataBlob - :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 TYPE_CHECKING: + from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegOutputUrlNoPipe, + FFmpegNoPipeOutputOptionTuple, ) + from .filtergraph.abc import FilterGraphObject - 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) +from ._std_runners import run_and_return_raw, run_and_return_encoded - 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) +logger = logging.getLogger("ffmpegio") - return rate, plugins.get_hook().bytes_to_audio( - b=out.stdout, dtype=dtype, shape=ac, squeeze=False - ) +__all__ = ["create", "read", "write", "filter", "detect"] -def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options): +def create( + expr: str, + *args, + squeeze=True, + 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 @@ -94,6 +44,9 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) 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, @@ -131,53 +84,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 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) + :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 .. 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. @@ -188,25 +155,30 @@ 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) - ) + # use user-specified map or default 'a:0' map + output_map = options.pop("map", "a:0") - ffmpeg_args = configure.empty() - configure.add_url(ffmpeg_args, "input", url, input_options)[1][1] - configure.add_url(ffmpeg_args, "output", "-", options)[1][1] + # initialize FFmpeg argument dict and get input & output information + args, input_info, _, output_info, __ = configure.init_media_read( + url if isinstance(url, list) else [url], + [output_map], + options, + extra_outputs, + squeeze, + ) - # 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." + ) - 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, ) @@ -214,13 +186,16 @@ def write( url, rate_in, data, + *, + extra_inputs: ( + list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None + ) = None, progress=None, overwrite=None, show_log=None, - extra_inputs=None, sp_kwargs=None, **options, -): +) -> bytes | None: """Write a NumPy array to an audio file. :param url: URL of the audio file to write. @@ -236,7 +211,7 @@ def write( :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 + :type show_slog: 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)) @@ -248,89 +223,86 @@ def write( :type \\**options: dict, optional """ - url, stdout, _ = configure.check_url(url, True) - input_options = utils.pop_extra_options(options, "_in") + ac, dtype = plugins.get_hook().audio_info(obj=data) - 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], ["a"], [(rate_in, data)], extra_inputs, options, [dtype], [(ac,)] ) - # 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) """ - 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 and extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:a"] = expr + expr = None + + ac, dtype = plugins.get_hook().audio_info(obj=input) + + # initialize FFmpeg argument dict and get input & output information + args, input_info, _, output_info, __ = configure.init_media_filter( + expr, + ["a"], + [(input_rate, input)], + extra_inputs, + extra_outputs, + [dtype], + [(ac,)], + options, + {}, + squeeze, ) - 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 da1c541c..0b564830 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -34,10 +34,14 @@ from __future__ import annotations +from functools import cache + from ._typing import ( IO, Literal, + LiteralString, get_args, + cast, Any, TypedDict, Unpack, @@ -48,6 +52,10 @@ Buffer, MediaType, FFmpegUrlType, + FromBytesCallable, + ToBytesCallable, + IsEmptyCallable, + CountDataCallable, RawInputInfoDict, RawInputInfoDict, EncodedInputInfoDict, @@ -89,6 +97,13 @@ ) from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence +from .utils import ( + FFmpegInputUrlComposite, + FFmpegOutputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegOutputUrlNoPipe, +) + from .stream_spec import ( stream_spec as compose_stream_spec, StreamSpecDict, @@ -105,11 +120,44 @@ 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())) @@ -165,30 +213,46 @@ def init_media_read( FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] ], - map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, options: FFmpegOptionDict, + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ), + squeeze: bool, + full_input_scan: bool = False, ) -> tuple[ FFmpegArgs, list[EncodedInputInfoDict], list[bool], - list[RawOutputInfoDict], - list[FFmpegOptionDict | None], + list[RawOutputInfoDict] | None, + tuple[ + Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + FFmpegOptionDict, + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None, + bool, + ] + | None, ]: """Initialize FFmpeg arguments for media read - :param *urls: URLs of the media files to read. - :param map: output stream mappings: + :param urls: URLs of the media files to read. + :param streams: 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) + :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 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 + :return output_options_args: output options arguments, to be expanded when calling + `init_media_read_outputs()`, 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. @@ -219,16 +283,43 @@ def init_media_read( # analyze and assign inputs input_info = process_url_inputs(args, urls, inopts_default) + # scan the output + if full_input_scan: + # wait to process outputs with full input information + output_ready = False + else: + # if input scan is not required, see if output options provides all + # the necessary information for raw media data + output_info = process_raw_outputs_from_options(args, streams, options, squeeze) + output_ready = not any(x is None for x in output_info) + # 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) + output_options = (map, options, extra_outputs, squeeze) if all(ready): - output_info = init_media_read_outputs(args, input_info, output_options) - output_options = None + # inputs are ready + if full_input_scan or not output_ready: + output_info = init_media_read_outputs(args, input_info, output_options) + output_options = None + elif output_ready: + # if additional (encoded) outputs are specified, append them to ffmpeg args + # and output info + if extra_outputs is not None: + output_info.extend( + process_url_outputs( + args, + input_info, + extra_outputs, + {}, + skip_automapping=True, + no_pipe=True, + ) + ) else: + # incomplete data, must wait for deferred input raw data output_info = None return args, input_info, ready, output_info, output_options @@ -240,14 +331,20 @@ def init_media_read_outputs( output_options: tuple[ Sequence[str] | dict[str, FFmpegOptionDict | None] | None, FFmpegOptionDict, + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None, + bool, ], - deferred_inputs: list[bytes | None] = None, + deferred_inputs: list[bytes | None] | None = None, ) -> 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: tuple of mapping assignments and common output 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 squeeze output data blob shape :param deferred_inputs: deferred (partial) input data, probable to retrieve necessary stream information :return output_info: output file information @@ -259,8 +356,27 @@ def init_media_read_outputs( {**info, "buffer": data} for info, data in zip(input_info, deferred_inputs) ] + streams, options, extra_outputs, squeeze = output_options + # analyze and assign outputs - output_info, _ = process_raw_outputs(args, input_info, *output_options) + output_info, _ = process_raw_outputs(args, input_info, streams, options, squeeze) + + # 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 output_info @@ -271,10 +387,6 @@ def init_media_write( ], 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] @@ -296,10 +408,6 @@ def init_media_write( :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 @@ -349,14 +457,7 @@ def init_media_write( 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, - ) + output_args = (urls, options) if all(ready): output_info = init_media_write_outputs( @@ -390,61 +491,7 @@ def init_media_write_outputs( """ - ( - 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 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 - 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", - ) - - 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) - else: - gopts["filter_complex"] = [afilt] + (urls, options) = output_args # analyze and assign outputs output_info = process_url_outputs(args, input_info, urls, options) @@ -460,19 +507,18 @@ def init_media_write_outputs( def init_media_filter( - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject], + expr: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, input_types: Sequence[Literal["a", "v"]], input_args: Sequence[RawStreamDef], - extra_inputs: ( - Sequence[ - FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] - ] - | None + extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), input_dtypes: list[DTypeString] | None, input_shapes: list[ShapeTuple] | None, options: FFmpegOptionDict, output_options: dict[str, FFmpegOptionDict], + squeeze: bool, ) -> tuple[ FFmpegArgs, list[RawInputInfoDict], @@ -487,6 +533,9 @@ def init_media_filter( :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 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 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, @@ -495,6 +544,7 @@ def init_media_filter( 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 + :param squeeze: True to squeeze output data blob shape :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) @@ -506,7 +556,9 @@ def init_media_filter( 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.") + raise ValueError( + "Cannot have a `filter_complex` or `lavfi` option already set." + ) # separate the options inopts_default = utils.pop_extra_options(options, "_in") @@ -515,7 +567,12 @@ def init_media_filter( args = empty(utils.pop_global_options(options)) gopts = args["global_options"] # global options dict gopts["y"] = None - gopts["filter_complex"] = expr + + # complex filtergraph may not be used + # (siso filtergraph or implicit filter like -s or -r) + nofg = expr is None + if not nofg: + gopts["filter_complex"] = expr # analyze and assign inputs input_info = process_raw_inputs( @@ -538,7 +595,14 @@ def init_media_filter( # 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) + if nofg: + output_info = init_media_read_outputs( + args, input_info, output_options, extra_outputs, squeeze + ) + else: + output_info = init_media_filter_outputs( + args, input_info, output_options, extra_outputs, squeeze + ) output_options = None else: output_info = None @@ -550,6 +614,10 @@ def init_media_filter_outputs( args: FFmpegArgs, input_info: list[RawInputInfoDict], output_options: tuple[dict[str, FFmpegOptionDict], FFmpegOptionDict], + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ), + squeeze: bool, deferred_inputs: list[list[RawDataBlob | None] | bytes] | None = None, ) -> list[RawOutputInfoDict]: """Initialize FFmpeg arguments for media read @@ -557,6 +625,10 @@ def init_media_filter_outputs( :param args: partial FFmpeg arguments (to be modified) :param input_info: list of input information :param output_options: default and specific output 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 squeeze output data blob shape :param deferred_inputs: deferred_inputs- list of input data :return output_info: output file information @@ -564,9 +636,10 @@ def init_media_filter_outputs( # 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 - ) + if "filter_complex" in gopts: + gopts["filter_complex"], fg_info = utils.analyze_complex_filtergraphs( + gopts["filter_complex"], args["inputs"], input_info + ) # separate specific and default output options (output_options, default_opts) = output_options @@ -598,9 +671,26 @@ def init_media_filter_outputs( # analyze and assign outputs output_info, fg_info = process_raw_outputs( - args, input_info, streams, default_opts, fg_info + args, input_info, streams, default_opts, squeeze, fg_info ) + # 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 output_info @@ -865,11 +955,111 @@ def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: return False # no output options defined +def gather_video_read_opts( + options: FFmpegOptionDict, + skip_rate: bool = False, + args: FFmpegArgs | None = None, + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + fg_info: dict[str, FilterGraphInfoDict] | None = None, +) -> tuple[RawStreamInfoTuple, FFmpegOptionDict | None]: + """Gathering raw video read output options + + :param options: option dict for this output + :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 + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + :return dtype: Numpy-style buffer data type string + :return raw_info: video shape tuple (height, width, nb_components) + :return additional_options: additional output options or None if `raw_info` + is not complete + """ + + # required options + req_opts = ("s", "pix_fmt", "r") + + # use the output option by default + opt_vals = [options.get(o, None) for o in req_opts] + map_spec = options["map"] + outmap_fields = parse_map_option(map_spec, input_file_id=0) + + if args is not None and not all(opt_vals[:-1] if skip_rate else opt_vals): + # run input analysis + + # 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"] + + # get input option values + inopt_vals = utils.analyze_video_stream( + outmap_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) + + opt_vals = tuple(i if o is None else o for o, i in zip(opt_vals, inopt_vals)) + + # if not all required options are given, + if not all(opt_vals[:-1] if skip_rate else opt_vals): + return opt_vals, None + + # assign the values to individual variables + s, pix_fmt, r = opt_vals + + # 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) + 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 dtype, None if s is None else (*s[::-1], ncomp), r + + def finalize_video_read_opts( args: FFmpegArgs, ofile: int = 0, input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], fg_info: dict[str, FilterGraphInfoDict] | None = None, + skip_analysis: bool = False, ) -> RawStreamInfoTuple: """finalize raw video read output options @@ -899,6 +1089,9 @@ def finalize_video_read_opts( # use the output option by default opt_vals = [outopts.get(o, None) for o in options] + if all(opt_vals) and skip_analysis: + return tuple(opt_vals) + # 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)): @@ -908,7 +1101,7 @@ def finalize_video_read_opts( inopt_vals = [info["r"], info["pix_fmt"], info["s"]] else: # insert basic video filter if specified - build_basic_vf(args, False, ofile) + # build_basic_vf(args, False, ofile) ifile = outmap_fields["input_file_id"] @@ -960,9 +1153,9 @@ def finalize_video_read_opts( 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) + # if remove_alpha: + # # append the remove-video-alpha filter chain + # build_basic_vf(args, True, ofile) outopts["f"] = "rawvideo" @@ -982,69 +1175,107 @@ 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: FFmpegArgs, remove_alpha: bool | None = None, ofile: int = 0 -) -> bool: - """convert basic VF options to vf option +def get_audio_key_opts(opts) -> RawStreamInfoTuple: + return [opts.get(o, None) for o in ("sample_fmt", "ac", "ar")] + + +def gather_audio_read_opts( + args: FFmpegArgs, + input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], + fg_info: dict[str, FilterGraphInfoDict] | None = None, + skip_analysis: bool = False, +) -> 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 [] + :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, + keyed by their linklabels, defaults to None to perform the + filtergraph analysis internally + :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 - :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] + options = ["ar", "sample_fmt", "ac"] - # 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)) + 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 ) - 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 + # use the output options by default + opt_vals = [outopts.get(o, None) for o in options] + if not all(opt_vals): + if skip_analysis: + return tuple(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." + ) + inopt_vals = [info["ar"], info["sample_fmt"], info["ac"]] + else: + ifile = outmap_fields["input_file_id"] - 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 + # get input option values + inopt_vals = utils.analyze_audio_stream( + outmap_fields["stream_specifier"], + *args["inputs"][ifile], + input_info[ifile], + ) - basic = any(fopts.values()) - if not (basic or remove_alpha): - return False # no filter needed + # if a simple filter is present, use the stream specs of its output + if "af" in outopts or "filter:a" in outopts: - # existing simple filter - vf = outopts.pop("filter:v", outopts.pop("vf", None)) or fgb.Chain() + # create a source chain with matching specs and attach it to the af graph + af1 = temp_audio_src(*inopt_vals) + af2 = outopts.get("filter:a", outopts.get("af", None)) + inopt_vals = utils.analyze_audio_stream( + "0", af1 + af2, {"f": "lavfi"}, {"src_type": "filtergraph"} + ) - if basic: - vf = vf + filter_video_basic(**fopts) # Graph is remove alpha else Chain + opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] - 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) + # assign the values to individual variables + ar, sample_fmt, ac = opt_vals + + # 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 + + # set output format and codec + outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) - outopts["vf"] = vf + # sample_fmt must be given + dtype, _ = utils.get_audio_format(sample_fmt, ac) - return True + return dtype, ac and (ac,), ar def finalize_audio_read_opts( @@ -1052,12 +1283,13 @@ def finalize_audio_read_opts( ofile: int = 0, input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], fg_info: dict[str, FilterGraphInfoDict] | None = None, + skip_analysis: bool = False, ) -> 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 + :param input_info: list of input information, defaults to [] :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, keyed by their linklabels, defaults to None to perform the filtergraph analysis internally @@ -1086,6 +1318,8 @@ 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 skip_analysis: + return tuple(opt_vals) if linklabel := outmap_fields.get("linklabel", None): if fg_info is None or not (info := fg_info.get(linklabel, None)): raise FFmpegioError( @@ -1106,10 +1340,10 @@ 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 - af = temp_audio_src(*inopt_vals) - af = af + outopts.get("filter:a", outopts.get("af", None)) + af1 = temp_audio_src(*inopt_vals) + af2 = outopts.get("filter:a", outopts.get("af", None)) inopt_vals = utils.analyze_audio_stream( - "0", af, {"f": "lavfi"}, {"src_type": "filtergraph"} + "0", af1 + af2, {"f": "lavfi"}, {"src_type": "filtergraph"} ) opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] @@ -1329,21 +1563,20 @@ def finalize_avi_read_opts(args): return ya8 > 0 -def config_input_fg(expr, args, kwargs): +def config_input_fg( + expr: str, 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) dopt = None # duration option @@ -1506,11 +1739,43 @@ def add_filtergraph( outopts["map"] = map +class RawInputCallablesDict(TypedDict): + data2bytes: ToBytesCallable + + +class RawOutputCallablesDict(TypedDict): + bytes2data: FromBytesCallable + data_count: CountDataCallable + data_is_empty: IsEmptyCallable + + +def get_raw_output_plugin_callables( + media_type: MediaType, +) -> RawOutputCallablesDict: + """get three raw output plugin callbacks""" + hook = plugins.pm.hook + is_empty = cast(IsEmptyCallable, hook.is_empty) + if media_type == "audio": + return { + "bytes2data": cast(FromBytesCallable, hook.bytes_to_audio), + "data_count": cast(CountDataCallable, hook.audio_samples), + "data_is_empty": is_empty, + } + + else: + return { + "bytes2data": cast(FromBytesCallable, hook.bytes_to_video), + "data_count": cast(CountDataCallable, hook.video_frames), + "data_is_empty": is_empty, + } + + def resolve_raw_output_streams( args: FFmpegArgs, input_info: list[RawInputInfoDict | EncodedInputInfoDict], fg_info: dict[str, FilterGraphInfoDict] | None, streams: dict[str, str | None], + squeeze: bool = False, ) -> dict[str, RawOutputInfoDict]: """resolve the raw output streams from given sequence of map options @@ -1544,35 +1809,45 @@ def parse_map(spec): {"stream_specifier": {}, **opt} for opt in (parse_map(spec) for spec in streams) ] + @cache + def get_callables(media_type): + return get_raw_output_plugin_callables(media_type) + 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 + # case 1: complex filtergraph requires only its outputs to be used 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, + "squeeze": squeeze, "linklabel": spec, + **get_callables(info["media_type"]), } - 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] + else: 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) + media_type = stream_type_to_media_type( + opt["stream_specifier"].get("stream_type", None) + ) + if media_type in (None, "subtitle", "data", "attachments"): + raise FFmpegioError( + f"Map option ({spec}) does not reveal the media type or specifies unsupported stream type." + ) + + file_index = opt["input_file_id"] + + # retrieve input stream data + if "index" in opt["stream_specifier"]: + # case 2: specific input stream with known media type stream_data = [(None, media_type)] else: + # case 3: generic stream spec, possibly resultsing in multiple output streams stream_data = retrieve_input_stream_ids( - info, *inputs[file_index], stream_spec=stream_spec + input_info[file_index], *inputs[file_index], stream_spec=stream_spec ) unique_stream = len(stream_data) == 1 @@ -1583,23 +1858,12 @@ def parse_map(spec): "dst_type": dst_type, "user_map": user_map or spec, "media_type": media_type, + "squeeze": squeeze, "input_file_id": file_index, "input_stream_id": stream_index, + **get_callables(media_type), } - 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 @@ -1607,6 +1871,7 @@ def auto_map( args: FFmpegArgs, input_info: list[RawInputInfoDict | EncodedInputInfoDict], fg_info: dict[str, FilterGraphInfoDict] | None, + squeeze: bool, ) -> dict[str, RawOutputInfoDict]: """list all available streams from all FFmpeg input sources @@ -1626,6 +1891,10 @@ def auto_map( """ + @cache + def get_callables(media_type): + return get_raw_output_plugin_callables(media_type) + 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"] @@ -1642,7 +1911,9 @@ def auto_map( "dst_type": "buffer", "user_map": linklabel[1:-1], "media_type": info["media_type"], + "squeeze": squeeze, "linklabel": linklabel, + **get_callables(info["media_type"]), } for linklabel, info in fg_info.items() } @@ -1663,8 +1934,10 @@ def next_map_option(i, media_type): "dst_type": "buffer", "user_map": spec, "media_type": media_type, + "squeeze": squeeze, "input_file_id": i, "input_stream_id": j, + **get_callables(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 {}) @@ -1805,8 +2078,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): @@ -1851,6 +2124,7 @@ def process_raw_outputs( input_info: list[RawInputInfoDict | EncodedInputInfoDict], streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, options: FFmpegOptionDict, + squeeze: bool, fg_info: dict[str, FilterGraphInfoDict] | None = None, ) -> tuple[list[RawOutputInfoDict], dict[str, FilterGraphInfoDict] | None]: """analyze and process piped raw outputs @@ -1858,7 +2132,10 @@ def process_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: user's list of map options to be included, Use a dict, keyed + by the map option to specify stream-dependent ffmpeg output + options. None to map all inputs or filtergraph outputs each to + a raw stream :param options: default output options :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, keyed by their linklabels, defaults to None to perform the @@ -1883,7 +2160,7 @@ def process_raw_outputs( # resolve requested output streams stream_info: dict[str, RawOutputInfoDict] if streams is None or len(streams) == 0: - stream_info = auto_map(args, input_info, fg_info) + stream_info = auto_map(args, input_info, fg_info, squeeze) stream_maps = {st: options for st in stream_info} else: # add outputs to FFmpeg arguments @@ -1916,7 +2193,9 @@ def process_raw_outputs( stream_maps[st_map] = v # automatically map all the streams - stream_info = resolve_raw_output_streams(args, input_info, fg_info, user_maps) + stream_info = resolve_raw_output_streams( + args, input_info, fg_info, user_maps, squeeze + ) # add outputs to FFmpeg arguments for spec, info in stream_info.items(): @@ -1926,15 +2205,105 @@ def process_raw_outputs( # 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) + if info["media_type"] == "audio": + info["raw_info"] = finalize_audio_read_opts(args, i, input_info, fg_info) + else: + info["raw_info"] = finalize_video_read_opts(args, i, input_info, fg_info) return list(stream_info.values()), fg_info +def process_raw_outputs_from_options( + args: FFmpegArgs, + streams: Sequence[str] | dict[str, FFmpegOptionDict | None], + options: FFmpegOptionDict, + squeeze: bool, +) -> list[RawOutputInfoDict | None]: + """process piped raw outputs purely based on ffmpeg output options + + minimum required raw output information is: + + * media type (-map; element of `streams` sequence or key of `streams` dict) + * data shape (-s for video -ac for audio) + * data format (-pix_fmt for video, -sample_fmt for audio) + * data rate (-r for video -ar for audio) + + If all 4 are resolved from `streams` and `options`, this function appends a + new element to `args['outputs']` list and returns RawOutputInfoDict. Otherwise, + `args` is unchanged and returns `None` + + :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, Use a dict, keyed + by the map option to specify stream-dependent ffmpeg output + options. The map option must reveal the stream's media type. + :param options: default output options + :param squeeze: True to squeeze output data blob shape + :return output_info: list of output information, if `raw_info` of an output + option dict is missing, output information of those + elements are omitted and filled with `None` + """ + + # add output streams to ffmpeg args + get_opts = (lambda s: streams[s]) if isinstance(streams, dict) else (lambda s: {}) + + has_fg = ( + "filter_complex", + "lavfi", + "filter_complex_script", + "/filter_complex", + "/lavfi", + ) in args["global_options"] + out_info = [] + + for spec in streams: + + map_dict = parse_map_option(spec, input_file_id=0, parse_stream=True) + try: + media_type: MediaType = stream_type_to_media_type( + map_dict["stream_specifier"]["stream_type"] + ) + assert media_type in ("audio", "video") + except KeyError as e: + # output stream's media type is missing + out_info.append(None) + continue + + raw_info = ( + finalize_audio_read_opts(args, i, [], None, True) + if media_type == "audio" + else finalize_video_read_opts(args, i, [], None, True) + ) + + if any(v is None for v in raw_info): + # at least one raw_info (shape, dtype, and rate) is missing + out_info.append(None) + continue + + opts = get_opts(spec) + i, (_, opts) = add_url(args, "output", None, {**options, **opts, "map": spec}) + + info = { + "dst_type": "buffer", + "media_type": media_type, + "raw_info": raw_info, + "user_map": spec, + "squeeze": squeeze, + **get_raw_output_plugin_callables(media_type), + } + + if has_fg: + info["linklabel"] = "unknown" + else: + info["input_file_id"] = -1 + info["input_stream_id"] = -1 + + out_info.append(info) + + return out_info + + def process_raw_inputs( args: FFmpegArgs, stream_types: Sequence[Literal["a", "v"]], @@ -1958,6 +2327,16 @@ def process_raw_inputs( dtypes = [None] * len(stream_types) if shapes is None: shapes = [None] * len(stream_types) + + @cache + def get_callables(media_type: MediaType) -> RawInputCallablesDict: + hook = plugins.pm.hook + return ( + {"data2bytes": cast(ToBytesCallable, hook.audio_bytes)} + if media_type == "audio" + else {"data2bytes": cast(ToBytesCallable, hook.video_bytes)} + ) + for i, (mtype, arg, dtype, shape) in enumerate( zip(stream_types, stream_args, dtypes, shapes) ): @@ -2030,11 +2409,16 @@ def process_raw_inputs( if more_opts is not None: opts.update(more_opts) - info = {"src_type": "buffer", "media_type": media_type} - - info["raw_info"] = ( - (None, None, opts[ropt]) if raw_info is None else (*raw_info, opts[ropt]) - ) + info = { + "src_type": "buffer", + "media_type": media_type, + "raw_info": ( + (None, None, opts[ropt]) + if raw_info is None + else (*raw_info, opts[ropt]) + ), + **get_callables(media_type), + } if data is not None: info["buffer"] = data @@ -2143,7 +2527,7 @@ def process_url_outputs( # some output file is missing `map` option # add all input streams or all complex filter outputs - map_opts = [*auto_map(args, input_info, None)] + map_opts = [*auto_map(args, input_info, None, False)] # add outputs to FFmpeg arguments for _, opts in args["outputs"]: @@ -2247,13 +2631,16 @@ def assign_output_pipes( :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 """ - sp_kwargs = {} if sp_kwargs is None else {**sp_kwargs} + pipe_info = {} + sp_kwargs = {} if output_info is None: - return sp_kwargs + return sp_kwargs, sp_kwargs # configure output pipes use_stdout = False @@ -2333,7 +2720,7 @@ def assign_input_pipes( src_type = info["src_type"] if src_type == "fileobj": assert "fileobj" in info - sp_kwargs["input"] = info["fileobj"] + sp_kwargs["stdin"] = info["fileobj"] elif src_type == "buffer": if set_sp_kwargs_input and "buffer" in info: # given data to send to subprocess diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index f0befc04..96d6b498 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -86,7 +86,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: @@ -104,7 +106,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/Graph.py b/src/ffmpegio/filtergraph/Graph.py index d53ade64..5889ea02 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -587,7 +587,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 +807,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. """ @@ -841,7 +847,6 @@ def is_chain_siso( :param chain_id: chain id :param check_input: False to check only for single-output, defaults to True :param check_output: False to check only for single-input, defaults to True - :param check_link: True to return True if and only if the chain has no active connection, defaults to True """ try: @@ -849,14 +854,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, @@ -959,13 +1013,9 @@ def _connect( # label check if ( - fg.is_chain_siso( - ochain, check_input=False, check_output=True, check_link=False - ) + fg.is_chain_appendable(ochain) and not fg._links.are_linked(None, outpad) - and right.is_chain_siso( - ichain, check_input=True, check_output=False, check_link=True - ) + and right.is_chain_prependable(ichain) ): # add the right chain to the matching left chain fg[ochain].extend(right[ichain]) @@ -1060,13 +1110,9 @@ def _rconnect( # label check if ( - fg.is_chain_siso( - ichain, check_input=True, check_output=False, check_link=False - ) + fg.is_chain_prependable(ichain) and not fg._links.are_linked(inpad, None) - and left.is_chain_siso( - ochain, check_input=False, check_output=True, check_link=True - ) + and left.is_chain_appendable(ochain) ): # add the right chain to the matching left chain left_chain = left[ochain] diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 2ee0a5d3..379ab149 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -620,7 +620,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/build.py b/src/ffmpegio/filtergraph/build.py index 468403b1..70bf7495 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -206,8 +206,12 @@ def join( 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)) + left_pad, *_ = next( + left.iter_output_pads(chain=c, chainable_only=True, **iter_kws) + ) + right_pad, *_ = next( + right.iter_input_pads(chain=c, chainable_only=True, **iter_kws) + ) links[c] = (left_pad, right_pad) except: if how == "auto": @@ -415,7 +419,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/image.py b/src/ffmpegio/image.py index 534bf94c..56df8ab0 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -208,7 +208,7 @@ def write( 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)) + # configure.build_basic_vf(ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1)) kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index a473cd5c..2401ef89 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -13,7 +13,10 @@ Unpack, FFmpegUrlType, InputInfoDict, + RawInputInfoDict, OutputInfoDict, + RawOutputInfoDict, + OutputPipeInfoDict, FFmpegOptionDict, ) from .configure import ( @@ -25,7 +28,7 @@ from fractions import Fraction from . import ffmpegprocess, utils, configure, FFmpegError, plugins -from .utils.log import extract_output_stream +from .utils import log from .errors import FFmpegioError from .filtergraph.abc import FilterGraphObject @@ -93,50 +96,34 @@ def on_exit(rc): def _gather_outputs( - output_info: list[OutputInfoDict], proc: ffmpegprocess.Popen + pipe_info: dict[int, OutputPipeInfoDict], + output_info: list[RawOutputInfoDict], + proc: ffmpegprocess.Popen, ) -> 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] spec = info["user_map"] - b = info["reader"].read_all() + b = pinfo["reader"].read_all() # get datablob info from stderr if needed + dtype, shape, rate = info["raw_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["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 - ) + if proc.stderr is None: + raise FFmpegioError( + "stderr was not captured to compose the output data" + ) + dtype, shape, rate = ( + log.extract_output_video_raw_info + if info["media_type"] == "video" + else log.extract_output_audio_raw_info + )(proc.stderr.readlines(), i) + + data[spec] = info["bytes2data"](b=b, dtype=dtype, shape=shape, squeeze=False) rates[spec] = rate return rates, data diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 29bac2c7..f3bcb48d 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -263,10 +263,11 @@ def __init__( # assign the input stream map = "0:V:0" if stream is None else stream_spec_to_map_option(stream) - args, input_info, ready, output_info, _ = configure.init_media_read( + args, input_info, output_info, _ = configure.init_media_read( [*urls], [map], options ) - + ready = output_info is not None + if len(output_info) != 1 or output_info[0]["media_type"] != "video": raise FFmpegioError(f'no output video stream found in "{urls}" ({map=})') diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index 0a5c92c1..eb91603e 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -103,8 +103,8 @@ def transcode( ) # convert basic VF options to vf option - for i in range(len(output_info)): - configure.build_basic_vf(args, None, i) + # for i in range(len(output_info)): + # configure.build_basic_vf(args, None, i) kwargs = {**sp_kwargs} if sp_kwargs else {} diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index e19fa99a..8ad75a31 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -38,7 +38,15 @@ from .concat import FFConcat 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 @@ -130,7 +138,7 @@ def alpha_change( 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 @@ -251,9 +259,7 @@ 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[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 @@ -907,9 +913,7 @@ def are_input_pipes_ready( ] -def get_output_stream_id( - output_info: list[OutputInfoDict], stream: str | int -) -> int: +def get_output_stream_id(output_info: list[OutputInfoDict], stream: str | int) -> int: """get output stream id :param output_info: list of output stream information diff --git a/src/ffmpegio/utils/log.py b/src/ffmpegio/utils/log.py index b254e3cb..0da429a1 100644 --- a/src/ffmpegio/utils/log.py +++ b/src/ffmpegio/utils/log.py @@ -3,6 +3,8 @@ from . import layout_to_channels from ..caps import sample_fmts +from .._typing import RawStreamInfoTuple, Sequence +from .. import utils _re_audio = re.compile(r"(?:(\d+) Hz, )?(.+)") @@ -111,19 +113,19 @@ def parse_log_video_stream(info): ) -def extract_output_stream(logs, file_id=0, stream_id=0, hint=None): +def extract_output_stream( + logs: str | Sequence[str], + file_id: int = 0, + stream_id: int = 0, + hint: int | None = None, +) -> dict: """extract output stream info from the log lines :param logs: lines of FFmpeg log messages - :type logs: seq(str) :param file_id: output file id, defaults to 0 - :type file_id: int, optional :param stream_id: output stream id, defaults to 0 - :type stream_id: int, optional :param hint: starting log line index to search, defaults to None - :type hint: int, optional :return: stream information - :rtype: dict """ if isinstance(logs, str): logs = re.split(r"[\n\r]+", logs) @@ -155,3 +157,36 @@ def extract_output_stream(logs, file_id=0, stream_id=0, hint=None): raise RuntimeError(f"parser for {type.lower()} codec is not defined.") return sinfo + + +def extract_output_audio_raw_info( + logs: str | Sequence[str], file_id=0, stream_id=0, hint=None +) -> RawStreamInfoTuple: + """extract output stream info from the log lines + + :param logs: lines of FFmpeg log messages + :param file_id: output file id, defaults to 0 + :param stream_id: output stream id, defaults to 0 + :param hint: starting log line index to search, defaults to None + :return: stream information + """ + + info = extract_output_stream(logs, file_id, stream_id, hint) + return utils.get_audio_format(info["sample_fmt"])[0], info["ac"], info["ar"] + + +def extract_output_video_raw_info( + logs: str | Sequence[str], file_id=0, stream_id=0, hint=None +) -> RawStreamInfoTuple: + """extract output stream info from the log lines + + :param logs: lines of FFmpeg log messages + :param file_id: output file id, defaults to 0 + :param stream_id: output stream id, defaults to 0 + :param hint: starting log line index to search, defaults to None + :return: stream information + """ + + info = extract_output_stream(logs, file_id, stream_id, hint) + dtype, nb_comp = utils.get_pixel_format(info["pix_fmt"]) + return dtype, (*info["s"][::-1], nb_comp), info["r"] diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 80484903..b8bc34cc 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -230,7 +230,7 @@ def write( configure.add_url(ffmpeg_args, "output", url, options) - configure.build_basic_vf(ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1)) + # configure.build_basic_vf(ffmpeg_args, configure.check_alpha_change(ffmpeg_args, -1)) kwargs = {**sp_kwargs} if sp_kwargs else {} kwargs.update( diff --git a/tests/test_audio.py b/tests/test_audio.py index c3f4c47c..b002db53 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -2,6 +2,10 @@ import tempfile, re, logging from os import path import pytest +import numpy as np +from io import BytesIO + +from namedpipe import NPopen logging.basicConfig(level=logging.DEBUG) @@ -27,11 +31,11 @@ def test_create(): fs, x = audio.create("anoisesrc", d=60, c="pink", r=44100, a=0.5) print(x["shape"], 60 * 44100) - assert x["shape"] == (60 * 44100, 1) + assert x["shape"] == (60 * 44100,) fs, x = audio.create("sine", f=220, b=4, d=5) print(x["shape"], 5 * 44100) - assert x["shape"] == (5 * 44100, 1) + assert x["shape"] == (5 * 44100,) @pytest.mark.skip(reason="takes too long to test") @@ -104,7 +108,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,11 +131,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_configure.py b/tests/test_configure.py index b6004bd1..53f6831d 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -178,6 +178,14 @@ def test_process_url_inputs(url, opts, defopts, ret): fileobj.close() +from functools import cache + + +@cache +def get_output_callables(media_type): + return configure.get_raw_output_plugin_callables(media_type) + + @pytest.mark.parametrize( ("inputs", "input_info", "filters_complex", "ret"), [ @@ -190,6 +198,7 @@ def test_process_url_inputs(url, opts, defopts, ret): "media_type": mtype, "input_file_id": 0, "input_stream_id": i, + **get_output_callables(mtype), } for (i, mtype), j in zip(mul_streams, [0, 0, 1, 1]) }, @@ -203,11 +212,13 @@ def test_process_url_inputs(url, opts, defopts, ret): "media_type": "video", "input_file_id": 0, "input_stream_id": 0, + **get_output_callables("video"), }, "1:a:0": { "media_type": "audio", "input_file_id": 1, "input_stream_id": 0, + **get_output_callables("audio"), }, }, ), @@ -216,8 +227,16 @@ def test_process_url_inputs(url, opts, defopts, ret): [{"src_type": "url"}], ["split=outputs=2"], { - "[out0]": {"media_type": "video", "linklabel": "[out0]"}, - "[out1]": {"media_type": "video", "linklabel": "[out1]"}, + "[out0]": { + "media_type": "video", + "linklabel": "[out0]", + **get_output_callables("video"), + }, + "[out1]": { + "media_type": "video", + "linklabel": "[out1]", + **get_output_callables("video"), + }, }, ), ], diff --git a/tests/test_filtergraph_build.py b/tests/test_filtergraph_build.py index c9c3dfb0..ef4efb74 100644 --- a/tests/test_filtergraph_build.py +++ b/tests/test_filtergraph_build.py @@ -76,3 +76,14 @@ 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=0,aformat=sample_fmts=s16:r=44100") + af2 = fgb.Graph( + "channelmap=channel_layout=stereo:map=FC|FC,bandpass=channels=FL,aresample=22050" + ) + af3 = fgb.Chain("channelmap=channel_layout=stereo:map=FC|FC,bandpass=channels=FL,aresample=22050" + ) + af = af1 + af2 + assert af==af1+af3 \ No newline at end of file From 2406b423e60c29b49fb413b28063e4b03364e99e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 31 Dec 2025 09:04:46 -0500 Subject: [PATCH 316/344] wip11 --- src/ffmpegio/audio.py | 6 +- src/ffmpegio/configure.py | 1049 ++++++++++++++---------- src/ffmpegio/filtergraph/presets.py | 12 + src/ffmpegio/media.py | 13 +- src/ffmpegio/plugins/finder_ffdl.py | 2 +- src/ffmpegio/plugins/finder_syspath.py | 1 + src/ffmpegio/utils/__init__.py | 117 ++- tests/test_utils.py | 28 +- 8 files changed, 759 insertions(+), 469 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 10c2cfd6..aaa6eabb 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -155,11 +155,11 @@ def read( """ - # use user-specified map or default 'a:0' map - output_map = options.pop("map", "a:0") + # use user-specified map or default '0:a:0' map + output_map = options.pop("map", "0:a:0") # initialize FFmpeg argument dict and get input & output information - args, input_info, _, output_info, __ = configure.init_media_read( + args, input_info, output_info = configure.init_media_read( url if isinstance(url, list) else [url], [output_map], options, diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 0b564830..1f13947d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -35,6 +35,8 @@ from __future__ import annotations from functools import cache +from itertools import count +from collections import Counter from ._typing import ( IO, @@ -213,33 +215,29 @@ def init_media_read( FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] ], - streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + streams: ( + Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None + ), options: FFmpegOptionDict, extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), squeeze: bool, - full_input_scan: bool = False, -) -> tuple[ - FFmpegArgs, - list[EncodedInputInfoDict], - list[bool], - list[RawOutputInfoDict] | None, - tuple[ - Sequence[str] | dict[str, FFmpegOptionDict | None] | None, - FFmpegOptionDict, - Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None, - bool, - ] - | None, -]: +) -> tuple[FFmpegArgs, list[EncodedInputInfoDict], list[RawOutputInfoDict]]: """Initialize FFmpeg arguments for media read :param urls: URLs of the media files to read. :param streams: 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 + - 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. + - None to select all available streams :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) @@ -249,10 +247,7 @@ def init_media_read( :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 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_args: output options arguments, to be expanded when calling - `init_media_read_outputs()`, 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. @@ -280,105 +275,27 @@ def init_media_read( gopts = args["global_options"] # global options dict gopts["y"] = None - # analyze and assign inputs + # assign inputs input_info = process_url_inputs(args, urls, inopts_default) - # scan the output - if full_input_scan: - # wait to process outputs with full input information - output_ready = False - else: - # if input scan is not required, see if output options provides all - # the necessary information for raw media data - output_info = process_raw_outputs_from_options(args, streams, options, squeeze) - output_ready = not any(x is None for x in output_info) - - # 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, extra_outputs, squeeze) - - if all(ready): - # inputs are ready - if full_input_scan or not output_ready: - output_info = init_media_read_outputs(args, input_info, output_options) - output_options = None - elif output_ready: - # if additional (encoded) outputs are specified, append them to ffmpeg args - # and output info - if extra_outputs is not None: - output_info.extend( - process_url_outputs( - args, - input_info, - extra_outputs, - {}, - skip_automapping=True, - no_pipe=True, - ) - ) - else: - # incomplete data, must wait for deferred input raw data - output_info = None - - return args, input_info, ready, output_info, output_options - + # assign outputs + output_info = process_raw_outputs(args, input_info, streams, options, squeeze) -def init_media_read_outputs( - args: FFmpegArgs, - input_info: list[RawInputInfoDict | EncodedInputInfoDict], - output_options: tuple[ - Sequence[str] | dict[str, FFmpegOptionDict | None] | None, - FFmpegOptionDict, - Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None, - bool, - ], - deferred_inputs: list[bytes | None] | None = None, -) -> 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: tuple of mapping assignments and common output 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 squeeze output data blob shape - :param deferred_inputs: deferred (partial) input data, probable to retrieve - necessary stream information - :return output_info: output file information - """ + # standardize output stream options - # 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) - ] - - streams, options, extra_outputs, squeeze = output_options - - # analyze and assign outputs - output_info, _ = process_raw_outputs(args, input_info, streams, options, squeeze) - - # 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, - ) + 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 output_info + return args, input_info, output_info def init_media_write( @@ -960,98 +877,155 @@ def gather_video_read_opts( skip_rate: bool = False, args: FFmpegArgs | None = None, input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], - fg_info: dict[str, FilterGraphInfoDict] | None = None, + get_fg_info: Callable[[], dict[str, FilterGraphInfoDict] | None] | None = None, + default_pix_fmt: str = "rgb24", ) -> tuple[RawStreamInfoTuple, FFmpegOptionDict | None]: """Gathering raw video read output options - :param options: option dict for this output + :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 - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :return dtype: Numpy-style buffer data type string + 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_pix_fmt: if the analysis could not determine the output pixel + format, force this format, defaults to 'rgb24' :return raw_info: 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 = ("s", "pix_fmt", "r") + req_opts = ("pix_fmt", "s", "r") # use the output option by default opt_vals = [options.get(o, None) for o in req_opts] - map_spec = options["map"] - outmap_fields = parse_map_option(map_spec, input_file_id=0) - if args is not None and not all(opt_vals[:-1] if skip_rate else opt_vals): + 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 := 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"]] + if linklabel := map_fields.get("linklabel", None): + try: + info = get_fg_info()[linklabel] + except (AttributeError, KeyError) as e: + raise KeyError(f"`fg_info[{linklabel}]` is missing.") from e + try: + pix_fmt_in = info["pix_fmt"] + s_in = info["s"] + r_in = info["r"] + except KeyError as e: + raise KeyError( + f'`fg_info[{linklabel}]` is missing at least one of the required video attributes ("s", "pix_fmt", "r")' + ) from e else: # insert basic video filter if specified # build_basic_vf(args, False, ofile) - ifile = outmap_fields["input_file_id"] + ifile = map_fields["input_file_id"] # get input option values - inopt_vals = utils.analyze_video_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], ) - opt_vals = tuple(i if o is None else o for o, i in zip(opt_vals, inopt_vals)) + 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, s if scaled_s else None, r_in, pix_fmt_in, s_in + ) - # if not all required options are given, - if not all(opt_vals[:-1] if skip_rate else opt_vals): - return opt_vals, None + # pixel format must be specified + remove_alpha = False + 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 - s, pix_fmt, r = opt_vals + # deduce output pixel format from the input pixel format + pix_fmt, ncomp, dtype, remove_alpha = utils.get_pixel_config( + pix_fmt_in, default_pix_fmt + ) + outopts["pix_fmt"] = pix_fmt - # pixel format must be specified - if pix_fmt is None: + else: + # make sure assigned pix_fmt is valid + if pix_fmt_in is None: + # 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 + else: + remove_alpha = utils.alpha_change(pix_fmt_in, pix_fmt, -1) - if pix_fmt_in == "unknown": + if remove_alpha: raise FFmpegioError( - "input pixel format unknown. Please specify output pix_fmt (to be autoset)" + "The output pix_fmt does not have a transparency while its input does. " + "Additional filtering is necessary to remove the alpha channel properly. See ffmpegio.filtergraph.presets.remove_video_alpha()." ) - # 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) + if s is None: + s = s_in - outopts["f"] = "rawvideo" + if r is None: + r = r_in - # use output option value or else use the input value - r = r or r_in - s = s or s_in + # get shape tuple if resolved + shape = (*s[::-1], ncomp) if s is not None and ncomp != 0 else None + raw_info = (dtype, shape, r) - return dtype, None if s is None else (*s[::-1], ncomp), 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 finalize_video_read_opts( @@ -1078,7 +1052,7 @@ def finalize_video_read_opts( outopts = args["outputs"][ofile][1] outmap = outopts["map"] - outmap_fields = parse_map_option( + map_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 @@ -1093,7 +1067,7 @@ def finalize_video_read_opts( return tuple(opt_vals) # get the options of the input/filtergraph output - if linklabel := outmap_fields.get("linklabel", None): + if linklabel := map_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." @@ -1103,11 +1077,11 @@ def finalize_video_read_opts( # insert basic video filter if specified # build_basic_vf(args, False, ofile) - ifile = outmap_fields["input_file_id"] + ifile = map_fields["input_file_id"] # get input option values inopt_vals = utils.analyze_video_stream( - outmap_fields["stream_specifier"], + map_fields["stream_specifier"], *args["inputs"][ifile], input_info[ifile], ) @@ -1180,102 +1154,134 @@ def get_audio_key_opts(opts) -> RawStreamInfoTuple: def gather_audio_read_opts( - args: FFmpegArgs, + options: FFmpegOptionDict, + skip_rate: bool = False, + args: FFmpegArgs | None = None, input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], - fg_info: dict[str, FilterGraphInfoDict] | None = None, - skip_analysis: bool = False, -) -> RawStreamInfoTuple: - """finalize a raw output audio stream + 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 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 [] - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :return dtype: input data type (Numpy style) - :return ac: number of channels - :return ar: sampling rate + :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: video shape tuple (height, width, nb_components) + :return additional_options: additional output options or None if `raw_info` + is not complete - * 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 - - + The output `sample_fmt` must be a raw-data compatible format (i.e., grayscales + and RGBs, and byte-aligned alternate formats). - * 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 + 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. """ - options = ["ar", "sample_fmt", "ac"] + # required options + req_opts = ("sample_fmt", "ac", "ar") - 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 - ) + # TODO - support channel_layout/ch_layout options as stronger alternative to ac - # use the output options by default - opt_vals = [outopts.get(o, None) for o in options] - if not all(opt_vals): - if skip_analysis: - return tuple(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." - ) - inopt_vals = [info["ar"], info["sample_fmt"], info["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: - 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"], + ar_in, sample_fmt_in, ac_in = utils.analyze_audio_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 - af1 = temp_audio_src(*inopt_vals) - af2 = outopts.get("filter:a", outopts.get("af", None)) - inopt_vals = utils.analyze_audio_stream( - "0", af1 + af2, {"f": "lavfi"}, {"src_type": "filtergraph"} + if af := (options.get("af") or options.get("filter:a")): + # analyze output simple filter + ar_in, sample_fmt_in, ac_in = utils.analyze_output_audio_filter( + af, ar_in, sample_fmt_in, ac_in ) - opt_vals = [v or s for v, s in zip(opt_vals, inopt_vals)] + # sample format must be specified + if sample_fmt is None: + sample_fmt = sample_fmt_in or default_sample_fmt - # assign the values to individual variables - ar, sample_fmt, ac = opt_vals + if ac is None: + ac = ac_in - # 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], - ) + 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: + dtype, shape = utils.get_audio_format(sample_fmt, ac) + + # get shape tuple if resolved + raw_info = (dtype, shape, ar) + + # if any raw info is missing, return + if any(v is None for v in raw_info): + return raw_info, None + # set output format and codec outopts["c:a"], outopts["f"] = utils.get_audio_codec(sample_fmt) - # sample_fmt must be given - dtype, _ = utils.get_audio_format(sample_fmt, ac) - - return dtype, ac and (ac,), ar + return raw_info, outopts def finalize_audio_read_opts( @@ -1311,7 +1317,7 @@ def finalize_audio_read_opts( outopts = args["outputs"][ofile][1] outmap = outopts["map"] - outmap_fields = parse_map_option( + map_fields = parse_map_option( outmap, input_file_id=0 if len(args["inputs"]) == 1 else None ) @@ -1320,18 +1326,18 @@ def finalize_audio_read_opts( if not all(opt_vals): if skip_analysis: return tuple(opt_vals) - if linklabel := outmap_fields.get("linklabel", None): + if linklabel := map_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["ar"], info["sample_fmt"], info["ac"]] else: - ifile = outmap_fields["input_file_id"] + ifile = map_fields["input_file_id"] # get input option values inopt_vals = utils.analyze_audio_stream( - outmap_fields["stream_specifier"], + map_fields["stream_specifier"], *args["inputs"][ifile], input_info[ifile], ) @@ -1771,116 +1777,220 @@ def get_raw_output_plugin_callables( def resolve_raw_output_streams( + stream_opts: list[FFmpegOptionDict], + stream_names: dict[int, str], args: FFmpegArgs, input_info: list[RawInputInfoDict | EncodedInputInfoDict], - fg_info: dict[str, FilterGraphInfoDict] | None, - streams: dict[str, str | None], - squeeze: bool = False, -) -> dict[str, RawOutputInfoDict]: +) -> tuple[list[FFmpegOptionDict], list[dict]]: """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']` - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :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 - """ + :return: list of individual output streams. Each item is a tuple of + (stream_index, output_opts, partial_RawOutputInfoDict) - dst_type = "buffer" + -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 + + """ # 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 + inputs = args["inputs"] - map_options = [ - {"stream_specifier": {}, **opt} for opt in (parse_map(spec) for spec in streams) - ] + output_opts = [] + output_info = [] + for i, opts in enumerate(stream_opts): - @cache - def get_callables(media_type): - return get_raw_output_plugin_callables(media_type) - - 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): + spec = opts["map"] + opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) # get output stream information - if (fg_info and (info := fg_info.get(spec, None))) is not None: + if "linklabel" in opt: # case 1: complex filtergraph requires only its outputs to be used - stream_info[spec] = { - "dst_type": dst_type, - "user_map": user_map or spec, - "media_type": info["media_type"], - "squeeze": squeeze, - "linklabel": spec, - **get_callables(info["media_type"]), - } - else: - stream_spec = opt["stream_specifier"] - media_type = stream_type_to_media_type( - opt["stream_specifier"].get("stream_type", None) + # 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": stream_names.get(i, spec[1:-1]), + "linklabel": opt["linklabel"], + } ) - if media_type in (None, "subtitle", "data", "attachments"): - raise FFmpegioError( - f"Map option ({spec}) does not reveal the media type or specifies unsupported stream type." - ) + else: + + if "negative" in opt: + raise ValueError("negative map is not supported.") file_index = opt["input_file_id"] + stream_spec = opt["stream_specifier"] + user_map = stream_names.get(i, spec) # retrieve input stream data - if "index" in opt["stream_specifier"]: + if "index" in stream_spec and "stream_type" in stream_spec: # case 2: specific input stream with known media type - stream_data = [(None, media_type)] + output_opts.append(opts) + output_info.append( + { + "user_map": user_map, + "media_type": stream_type_to_media_type( + stream_spec["stream_type"] + ), + "input_file_id": file_index, + "input_stream_id": -1, # unknown and don't care + } + ) else: # case 3: generic stream spec, possibly resultsing in multiple output streams stream_data = retrieve_input_stream_ids( input_info[file_index], *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, - "squeeze": squeeze, - "input_file_id": file_index, - "input_stream_id": stream_index, - **get_callables(media_type), - } + # append all streams + for stream_index, media_type in stream_data: + output_opts.append({**opts, "map": f"{file_index}:{stream_index}"}) + output_info.append( + { + "user_map": user_map, + "media_type": media_type, + "input_file_id": file_index, + "input_stream_id": stream_index, + }, + ) - return stream_info + # resolve duplicate user_map names + 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 format_raw_output_stream_defs( + streams: Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, + options: FFmpegOptionDict | None, +) -> tuple[list[FFmpegOptionDict], dict[int, str]]: + """convert user-supplied streams arguments to the standard form + + :param streams: 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 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. + - None to select all available streams + :param options: default output options + :return stream_options: list of stream options + :return stream_alias: list of pairs of stream map options and user-supplied stream labels + """ + + # depending on user's streams input, label output streams differently + # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists + streams_: list[FFmpegOptionDict] + stream_names: dict[int, str] = ( + {} + ) # dict of user-specified stream name (only via dict streams input) + + if isinstance(streams, dict): # dict[str,FFmpegOptionDict] + # dict key is used as both stream names (labels) and map option. + # * If FFmpegOptionDict in the dict value contains 'map' option, the key + # would only be used as the stream name + # * Note that if the map option is not unique the stream name will + # be renamed with an appended index. + streams_ = [] + for i, (k, v) in enumerate(streams.items()): + if "map" in v: # user provided non-map stream name + stream_names[i] = k + streams.append({**options, "map": k, **v}) + + else: # isinstance(stream,list[str|FFmpegOptionDict]) + # if an item is a str, it is the map option value + # if FFmpegOptionDict, it must contain a 'map' option + + streams_ = [ + {**options, **({"map": v} if isinstance(v, str) else v)} for v in streams + ] + + return streams_, stream_names def auto_map( args: FFmpegArgs, + options: FFmpegOptionDict, input_info: list[RawInputInfoDict | EncodedInputInfoDict], fg_info: dict[str, FilterGraphInfoDict] | None, - squeeze: bool, -) -> dict[str, RawOutputInfoDict]: +) -> tuple[list[FFmpegOptionDict], list[dict[str, Any]]]: """list all available streams from all FFmpeg input sources + This function complements `format_raw_output_stream_defs()` + :param args: FFmpeg argument dict. `filter_complex` argument may be modified. + :param options: FFmpeg output options to be applied to every output :param input_info: a list of input data source information :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :return: a map of input/filtergraph output labels and their stream information. + 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 ----------------------------------------------------- @@ -1891,57 +2001,46 @@ def auto_map( """ - @cache - def get_callables(media_type): - return get_raw_output_plugin_callables(media_type) - - 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: + 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 + 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 {}): + spec = next_map_option(i, media_type) + stream_opts.append({**options, "map": spec}) + stream_info.append( + { + "user_map": spec, + "media_type": media_type, + "input_file_id": i, + "input_stream_id": j, + } + ) + else: + # return all filtergraph outputs + for linklabel, info in fg_info: + 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"], - "squeeze": squeeze, - "linklabel": linklabel, - **get_callables(info["media_type"]), - } - 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, - "squeeze": squeeze, - "input_file_id": i, - "input_stream_id": j, - **get_callables(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 stream_opts, stream_info def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: @@ -2125,100 +2224,121 @@ def process_raw_outputs( streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, options: FFmpegOptionDict, squeeze: bool, - fg_info: dict[str, FilterGraphInfoDict] | None = None, -) -> tuple[list[RawOutputInfoDict], dict[str, FilterGraphInfoDict] | None]: +) -> tuple[list[RawOutputInfoDict], list[str, FilterGraphInfoDict] | None]: """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, Use a dict, keyed - by the map option to specify stream-dependent ffmpeg output - options. None to map all inputs or filtergraph outputs each to - a raw stream + :param streams: 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 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. + - None to select all available streams :param options: default output options - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, 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; - None if no filtergraph defined + None if no complex filtergraph defined + """ 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]: + """: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 + """ + + if any( + o in gopts for o 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 + + optname = next(o for o in ("filter_complex", "lavfi") if o in gopts) + + 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, RawOutputInfoDict] + stream_opts: list[FFmpegOptionDict] + stream_info: list[dict[str, Any]] # partial RawOutputInfoDict if streams is None or len(streams) == 0: - stream_info = auto_map(args, input_info, fg_info, squeeze) - stream_maps = {st: options for st in stream_info} + # gather all available streams keyed by their map specifier + stream_opts, stream_info = auto_map(args, input_info, get_fg_info()) else: - # add outputs to FFmpeg arguments - get_opts = isinstance(streams, dict) + stream_opts, stream_names = format_raw_output_stream_defs(streams, options) - # analyze for custom labels - user_maps = {} - stream_maps = {} - for k, v in streams.items() if get_opts else ((s, None) for s in streams): + # expand all streams (targetting ) + stream_opts, stream_info = resolve_raw_output_streams( + stream_opts, stream_names, args, input_info + ) - if isinstance(k, tuple): - k = ":".join(str(s) for s in k) + # finalize the output configuration - # add default options (if given) - v = {**options} if v is None else {**options, **v} + @cache + def get_callables(media_type): + return get_raw_output_plugin_callables(media_type) - 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 + for opts, info in zip(stream_opts, stream_info): - # automatically map all the streams - stream_info = resolve_raw_output_streams( - args, input_info, fg_info, user_maps, squeeze - ) + media_type = info.get("media_type", None) + + # if media_type is unknown (must be a linklabel not yet analyzed) + if media_type is None: + + fg_info = get_fg_info() + pad_info = fg_info[info["linklabel"]] + info["media_type"] = media_type = pad_info["media_type"] - # add outputs to FFmpeg arguments - for spec, info in stream_info.items(): - opts = {**stream_maps[spec], "map": spec} - add_url(args, "output", None, opts) + # 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 - if info["media_type"] == "audio": - info["raw_info"] = finalize_audio_read_opts(args, i, input_info, fg_info) - else: - info["raw_info"] = 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["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_outputs_from_options( args: FFmpegArgs, - streams: Sequence[str] | dict[str, FFmpegOptionDict | None], + streams: Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, options: FFmpegOptionDict, squeeze: bool, -) -> list[RawOutputInfoDict | None]: + skip_rate: bool = False, +) -> list[RawOutputInfoDict] | None: """process piped raw outputs purely based on ffmpeg output options minimum required raw output information is: @@ -2240,26 +2360,53 @@ def process_raw_outputs_from_options( options. The map option must reveal the stream's media type. :param options: default output options :param squeeze: True to squeeze output data blob shape - :return output_info: list of output information, if `raw_info` of an output - option dict is missing, output information of those - elements are omitted and filled with `None` + :return output_info: list of output information only if configuratoins of + all the outputs are resolved. Otherwise, None to indicate + the need for ffprobe based analysis + """ - # add output streams to ffmpeg args - get_opts = (lambda s: streams[s]) if isinstance(streams, dict) else (lambda s: {}) + # resolve requested output streams + stream_opts: list[FFmpegOptionDict] + stream_info: list[dict[str, Any]] # partial RawOutputInfoDict + if streams is None or len(streams) == 0: + return None - has_fg = ( - "filter_complex", - "lavfi", - "filter_complex_script", - "/filter_complex", - "/lavfi", - ) in args["global_options"] - out_info = [] + # depending on user's streams input, label output streams differently + # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists + streams_: list[FFmpegOptionDict] + stream_aliases: list[tuple[str, str]] # list of (map_option, stream_name) + if isinstance(streams, dict): # dict[str,FFmpegOptionDict] + # dict key is used as both stream names (labels) and map option. + # * If FFmpegOptionDict in the dict value contains 'map' option, the key + # would only be used as the stream name + # * Note that if the map option is not unique the stream name will + # be renamed with an appended index. + stream_aliases = [] + streams_ = [] + for k, v in streams.items(): + st_opts = {**options, "map": k, **v} + streams_.append(st_opts) + stream_aliases.append((st_opts["map"], k)) + + else: # isinstance(stream,list[str|FFmpegOptionDict]) + # if an item is a str, it is the map option value + # if FFmpegOptionDict, it must contain a 'map' option + + streams_ = [ + {**options, **({"map": v} if isinstance(v, str) else v)} for v in streams + ] + stream_aliases = [(v["map"], v["map"]) for v in streams_] - for spec in streams: + # analyze each stream + out_info = [] + for opts, (spec, user_map) in zip(streams_, stream_aliases): map_dict = parse_map_option(spec, input_file_id=0, parse_stream=True) + + if not is_unique_stream(map_dict["stream_specifier"]): + return None + try: media_type: MediaType = stream_type_to_media_type( map_dict["stream_specifier"]["stream_type"] @@ -2267,21 +2414,19 @@ def process_raw_outputs_from_options( assert media_type in ("audio", "video") except KeyError as e: # output stream's media type is missing - out_info.append(None) - continue + return None - raw_info = ( - finalize_audio_read_opts(args, i, [], None, True) - if media_type == "audio" - else finalize_video_read_opts(args, i, [], None, True) + # append raw_info key to the output info dict + gather_media_read_opts = ( + gather_audio_read_opts if media_type == "audio" else gather_video_read_opts ) - if any(v is None for v in raw_info): - # at least one raw_info (shape, dtype, and rate) is missing - out_info.append(None) - continue + # get config without analyzing inputs / filtergraphs + raw_info, more_opts = gather_media_read_opts(opts, skip_rate) + + if more_opts is None: + return None - opts = get_opts(spec) i, (_, opts) = add_url(args, "output", None, {**options, **opts, "map": spec}) info = { @@ -2565,19 +2710,23 @@ def retrieve_input_stream_ids( ) -> list[tuple[int, MediaType]]: """Retrieve ids and media types of streams in an input source + Note: The stream ids are unique ids among all streams in a container. + :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) + :return: A list of stream indices and media types of the input streams. If + the stream_spec is uniquely specified and media type is known, the + index is not resolved. Maybe empty if failed to probe the media + (e.g., data inaccessible 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 + # raw input format, single-stream return [[0, info["media_type"]]] # file/network input - process only if seekable diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index ae9d0a7c..dfe0d47f 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -19,6 +19,18 @@ def remove_video_alpha( fill_color: str, input_label: str | None = None, output_label: str | None = None ) -> Graph: + """generate a filter graph to remove alpha channel from a video + + :param fill_color: _description_ + :param input_label: _description_, defaults to None + :param output_label: _description_, defaults to None + :return: Resulting filter graph in the form: + + ``` + color,[in]scale2ref[main],[main]overlay[out] + ``` + + """ fg = fgb.Graph("scale2ref[l2],[l2]overlay=shortest=1").rconnect( f"color=c={fill_color}", (0, 0, 0), (0, 0, 0) diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 2401ef89..274dbab9 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -131,7 +131,12 @@ def _gather_outputs( def read( *urls: *tuple[FFmpegInputUrlComposite | tuple[FFmpegUrlType, FFmpegOptionDict]], - map: Sequence[str] | dict[str, FFmpegOptionDict | None] | None = None, + map: ( + Sequence[str] + | Sequence[FFmpegOptionDict] + | dict[str, FFmpegOptionDict | None] + | None + ) = None, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, @@ -140,7 +145,11 @@ 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 map: 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 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) diff --git a/src/ffmpegio/plugins/finder_ffdl.py b/src/ffmpegio/plugins/finder_ffdl.py index e754cf38..3c48c347 100644 --- a/src/ffmpegio/plugins/finder_ffdl.py +++ b/src/ffmpegio/plugins/finder_ffdl.py @@ -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_syspath.py b/src/ffmpegio/plugins/finder_syspath.py index df47bc4a..568c76d6 100644 --- a/src/ffmpegio/plugins/finder_syspath.py +++ b/src/ffmpegio/plugins/finder_syspath.py @@ -16,6 +16,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/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 8ad75a31..fd79bf89 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -60,8 +60,10 @@ def get_pixel_config( :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 @@ -96,8 +98,13 @@ def get_pixel_config( pix_fmt = "ya8" if bpp <= 16 else "ya16le" elif n_in == 3: pix_fmt = "rgb24" if bpp <= 24 else "rgb48le" - elif n_in == 4: + else: # if n_in == 4: pix_fmt = "rgba" if bpp <= 32 else "rgba64le" + else: + fmt_info = caps.pix_fmts()[pix_fmt] + bits_per_comp = fmt_info["bits_per_pixel"] / fmt_info["nb_components"] + if bits_per_comp != round(bits_per_comp): + raise ValueError(f"{pix_fmt=} is not supported as a raw data pixel format.") if pix_fmt == input_pix_fmt: n_out = n_in @@ -106,7 +113,6 @@ def get_pixel_config( pix_fmt = input_pix_fmt n_out = n_in else: - fmt_info = caps.pix_fmts()[pix_fmt] n_out = fmt_info["nb_components"] bpp = fmt_info["bits_per_pixel"] @@ -142,7 +148,14 @@ 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( @@ -159,8 +172,16 @@ def get_pixel_format(fmt: str) -> tuple[DTypeString, int]: rgba=("|u1", 4), rgba64le=(" 1 else "|u1" + return dtype, fmt_info["nb_components"] def get_video_format( @@ -184,7 +205,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') @@ -264,7 +286,8 @@ def get_audio_format(fmt: str, ac: int | None = None) -> tuple[DTypeString, Shap :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: @@ -586,7 +609,7 @@ def analyze_input_file( input_opts: dict, 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 @@ -653,12 +676,15 @@ 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 def analyze_video_stream( stream_specifier: str, + s: tuple[int, int] | None, inurl: FFmpegUrlType, inopts: FFmpegOptionDict, input_info: InputInfoDict, @@ -703,7 +729,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 @@ -870,6 +896,73 @@ def analyze_complex_filtergraphs( return filtergraphs, fg_info +def analyze_output_video_filter( + filtergraph: FilterGraphObject, + s: tuple[int, int] | None, + r_in: Fraction | int, + pix_fmt_in: str, + s_in: tuple[int, int], +) -> tuple[int | Fraction, str, tuple[int, int]]: + """analyze an output video filter + + :param filtergraph: simple filter graph. + :param s: -s output option + :param r_in: input frame rate + :param pix_fmt_in: input pixel format + :param s_in: input frame shape (width, height) + :return r: output frame rate + :return pix_fmt: output pixel format + :return s: output frame shape (width, height) + + """ + + # append a color source filter to the filtergraph + fg = temp_video_src(r_in, pix_fmt_in, s_in) + fgb.as_filtergraph_object(filtergraph) + + if s is not None: + fg += fgb.scale(*s) + + # query the filtergraph + fields = ["pix_fmt", "width", "height", "r_frame_rate", "avg_frame_rate"] + stream = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + )[0] + + return video_fields_to_options(*(stream[f] for f in fields)) + + +def analyze_output_audio_filter( + filtergraph: FilterGraphObject, + ar_in: int, + sample_fmt_in: str, + ac_in: int, +) -> tuple[int, str, tuple[int, int]]: + """analyze an output audio filter + + :param filtergraph: simple filter graph. + :param ar: input sampling rate + :param sample_fmt: input sample format + :param ac: input number of channels + :return ar: output sampling rate + :return sample_fmt: output sample format + :return ac: output number of channels + + """ + + # append a color source filter to the filtergraph + fg = temp_audio_src(ar_in, sample_fmt_in, ac_in) + fgb.as_filtergraph_object( + filtergraph + ) + + # query the filtergraph + fields = ["sample_rate", "sample_fmt", "channels"] + stream = analyze_input_file( + fields, fg, {"f": "lavfi"}, {"src_type": "filtergraph"} + )[0] + + return (*stream.values(),) + + def are_input_pipes_ready( inputs: list[tuple[FFmpegUrlType, FFmpegOptionDict]], input_info: list[InputInfoDict], diff --git a/tests/test_utils.py b/tests/test_utils.py index c893127c..da6b1097 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -42,6 +42,19 @@ def test_get_pixel_config(): assert cfg[0] == "rgb24" and cfg[1] == 3 and cfg[2] == "|u1" +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" + + 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_alpha_change(): cases = (("rgb24", "rgba", 1), ("rgb24", "rgb24", 0), ("ya8", "gray", -1)) @@ -116,7 +129,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], @@ -126,3 +139,16 @@ def test_get_output_stream_id(): def test_are_input_pipes_ready(inputs, input_info, must_probe, ret): assert utils.are_input_pipes_ready(inputs, input_info, must_probe) == ret + + +def test_analyze_output_video_filter(): + res = utils.analyze_output_video_filter( + "format=yuv420p,scale=320:240,framerate=100", + 30, + "rgb24", + ( + 1920, + 1080, + ), + ) + assert res==(100,'yuv420p',(320,240)) \ No newline at end of file From 88ee259871b00b69299eba1f30b4983273fdfd62 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 31 Dec 2025 22:25:47 -0500 Subject: [PATCH 317/344] wip12 --- src/ffmpegio/audio.py | 52 ++-- src/ffmpegio/configure.py | 378 ++++++------------------------ src/ffmpegio/filtergraph/Graph.py | 18 +- src/ffmpegio/utils/__init__.py | 73 ++++++ tests/test_audio.py | 22 ++ 5 files changed, 213 insertions(+), 330 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index aaa6eabb..899d4451 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -183,51 +183,62 @@ def read( def write( - url, - rate_in, - data, + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + rate_in: int, + data: RawDataBlob, *, extra_inputs: ( list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None ) = None, - progress=None, - overwrite=None, - show_log=None, - sp_kwargs=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 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_slog: 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 """ + # 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", + ) + ): + if not isinstance(url, list): + url = [url] + if "map" not in options: + options["map"] = "0:a:0" + ac, dtype = plugins.get_hook().audio_info(obj=data) # initialize FFmpeg argument dict and get input & output information - args, input_info, _, output_info, __ = configure.init_media_write( - [url], ["a"], [(rate_in, data)], extra_inputs, options, [dtype], [(ac,)] + args, input_info, output_info = configure.init_media_write( + url, ["a"], [(rate_in, data)], extra_inputs, options, [dtype], [(ac,)] ) return run_and_return_encoded( @@ -280,21 +291,22 @@ def filter( if expr and extra_inputs is None and extra_outputs is None: # guaranteed SISO filtering options["filter:a"] = expr + options["map"] = "0:a:0" expr = None ac, dtype = plugins.get_hook().audio_info(obj=input) # initialize FFmpeg argument dict and get input & output information - args, input_info, _, output_info, __ = configure.init_media_filter( + args, input_info, output_info = configure.init_media_filter( expr, ["a"], [(input_rate, input)], extra_inputs, + None, extra_outputs, [dtype], [(ac,)], options, - {}, squeeze, ) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 1f13947d..c0f67fc5 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -315,10 +315,8 @@ def init_media_write( shapes: list[ShapeTuple | None] | None = None, ) -> tuple[ FFmpegArgs, - list[RawInputInfoDict], - list[bool], - list[EncodedOutputInfoDict] | None, - tuple | None, + list[RawInputInfoDict | EncodedInputInfoDict], + list[EncodedOutputInfoDict], ]: """write multiple streams to a url/file @@ -372,44 +370,6 @@ def init_media_write( 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) - - 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[RawInputInfoDict], - output_args: tuple, - deferred_inputs: list[bytes | None] | None = None, -) -> list[EncodedOutputInfoDict]: - """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) = output_args - # analyze and assign outputs output_info = process_url_outputs(args, input_info, urls, options) @@ -420,7 +380,7 @@ def init_media_write_outputs( 'all piped encoded output stream must have its format (`"f"`) defined in its option dict' ) - return output_info + return args, input_info, output_info def init_media_filter( @@ -428,21 +388,15 @@ def init_media_filter( input_types: Sequence[Literal["a", "v"]], input_args: Sequence[RawStreamDef], extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, + output_args: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), input_dtypes: list[DTypeString] | None, input_shapes: list[ShapeTuple] | None, options: FFmpegOptionDict, - output_options: dict[str, FFmpegOptionDict], squeeze: bool, -) -> tuple[ - FFmpegArgs, - list[RawInputInfoDict], - list[bool], - list[RawOutputInfoDict] | None, - dict[str | None, FFmpegOptionDict] | None, -]: +) -> tuple[FFmpegArgs, list[RawInputInfoDict], list[RawOutputInfoDict]]: """Prepare FFmpeg arguments for media read :param expr: complex filtergraph definition(s). @@ -450,23 +404,29 @@ def init_media_filter( :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 output_args: output stream mappings and optional per-stream options: + - `None` to map all filtergraph outputs + - 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. + - None to select all available streams :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 input_dtypes: list of numpy-style data type strings of input samples or frames - of input media streams, use `None` to auto-detect. + 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. + 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 + 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 :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 """ @@ -487,8 +447,7 @@ def init_media_filter( # complex filtergraph may not be used # (siso filtergraph or implicit filter like -s or -r) - nofg = expr is None - if not nofg: + if expr is not None: gopts["filter_complex"] = expr # analyze and assign inputs @@ -502,94 +461,9 @@ def init_media_filter( except FFmpegioNoPipeAllowed as e: 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): - if nofg: - output_info = init_media_read_outputs( - args, input_info, output_options, extra_outputs, squeeze - ) - else: - output_info = init_media_filter_outputs( - args, input_info, output_options, extra_outputs, squeeze - ) - 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[RawInputInfoDict], - output_options: tuple[dict[str, FFmpegOptionDict], FFmpegOptionDict], - extra_outputs: ( - Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None - ), - squeeze: bool, - deferred_inputs: list[list[RawDataBlob | None] | bytes] | None = None, -) -> 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 - :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 squeeze output data blob shape - :param deferred_inputs: deferred_inputs- list of input data - :return output_info: output file information - - """ - - # analyze filtergraph and create an output stream for each filtergraph output - 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 - ) - - # separate specific and default output options - (output_options, default_opts) = output_options - - # 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, squeeze, fg_info - ) + + output_info = process_raw_outputs(args, input_info, output_args, options, squeeze) # if additional (encoded) outputs are specified, append them to ffmpeg args # and output info @@ -608,7 +482,7 @@ def init_media_filter_outputs( except FFmpegioNoPipeAllowed: raise FFmpegioError("extra_outputs cannot be piped out.") - return output_info + return args, input_info, output_info def init_media_transcode( @@ -835,41 +709,41 @@ def add_url( return file_id, filelist[file_id] -def has_filtergraph(args: FFmpegArgs, type: MediaType) -> bool: +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 type: filter type - :return: True if filter graph is specified + :param stream: output stream index, by default -1 to check global complex filtergraphs + :param media_type: for output stream filter, specify to check a particular + media type, defaults to checking both types of filters + :return: FFmpeg option name if filter graph is specified else None """ - try: - if ( - "filter_complex" in args["global_options"] - or "lavfi" in args["global_options"] - ): - return True - except: - pass # no global_options defined - - # input filter - if any( - ( - opts is not None and opts.get("f", None) == "lavfi" - for _, opts in args["inputs"] - ) - ): - return True - - # output filter - short_opt = {"video": "vf", "audio": "af"}[type] - other_st = {"video": "a", "audio": "v"}[type] - re_opt = re.compile(rf"{short_opt}$|filter(?::(?=[^{other_st}]).*?)?$") - if any( - (any((re_opt.match(key) for key in opts.keys())) for _, opts in args["outputs"]) - ): - return True - return False # no output options defined + if stream < 0: # global filtergraph + return utils.find_filter_complex_option(args["global_options"]) + else: + return utils.find_filter_simple_option(args["outputs"][stream], media_type) def gather_video_read_opts( @@ -1245,7 +1119,7 @@ def gather_audio_read_opts( if af := (options.get("af") or options.get("filter:a")): # analyze output simple filter - ar_in, sample_fmt_in, ac_in = utils.analyze_output_audio_filter( + sample_fmt_in, ar_in, ac_in = utils.analyze_output_audio_filter( af, ar_in, sample_fmt_in, ac_in ) @@ -1961,7 +1835,8 @@ def format_raw_output_stream_defs( if "map" in v: # user provided non-map stream name stream_names[i] = k streams.append({**options, "map": k, **v}) - + elif "map" in options: + streams_ = [options] else: # isinstance(stream,list[str|FFmpegOptionDict]) # if an item is a str, it is the map option value # if FFmpegOptionDict, it must contain a 'map' option @@ -2224,7 +2099,7 @@ def process_raw_outputs( streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, options: FFmpegOptionDict, squeeze: bool, -) -> tuple[list[RawOutputInfoDict], list[str, FilterGraphInfoDict] | None]: +) -> list[OutputInfoDict]: """analyze and process piped raw outputs :param args: FFmpeg argument dict, A new item in`args['outputs']` is @@ -2244,8 +2119,6 @@ def process_raw_outputs( :param options: default output options :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; - None if no complex filtergraph defined """ @@ -2259,16 +2132,14 @@ def get_fg_info() -> dict[str, FilterGraphInfoDict]: filtergraph analysis internally """ - if any( - o in gopts for o in ("/filter_complex", "/lavfi", "filter_complex_script") - ): + optname = utils.find_filter_complex_option(gopts) + + if any(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." ) - optname = next(o for o in ("filter_complex", "lavfi") if o in gopts) - gopts[optname], fg_info = utils.analyze_complex_filtergraphs( gopts[optname], args["inputs"], input_info ) @@ -2277,9 +2148,9 @@ def get_fg_info() -> dict[str, FilterGraphInfoDict]: # resolve requested output streams stream_opts: list[FFmpegOptionDict] stream_info: list[dict[str, Any]] # partial RawOutputInfoDict - if streams is None or len(streams) == 0: + 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, input_info, get_fg_info()) + stream_opts, stream_info = auto_map(args, options, input_info, get_fg_info()) else: stream_opts, stream_names = format_raw_output_stream_defs(streams, options) @@ -2332,123 +2203,6 @@ def get_callables(media_type): return stream_info -def process_raw_outputs_from_options( - args: FFmpegArgs, - streams: Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, - options: FFmpegOptionDict, - squeeze: bool, - skip_rate: bool = False, -) -> list[RawOutputInfoDict] | None: - """process piped raw outputs purely based on ffmpeg output options - - minimum required raw output information is: - - * media type (-map; element of `streams` sequence or key of `streams` dict) - * data shape (-s for video -ac for audio) - * data format (-pix_fmt for video, -sample_fmt for audio) - * data rate (-r for video -ar for audio) - - If all 4 are resolved from `streams` and `options`, this function appends a - new element to `args['outputs']` list and returns RawOutputInfoDict. Otherwise, - `args` is unchanged and returns `None` - - :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, Use a dict, keyed - by the map option to specify stream-dependent ffmpeg output - options. The map option must reveal the stream's media type. - :param options: default output options - :param squeeze: True to squeeze output data blob shape - :return output_info: list of output information only if configuratoins of - all the outputs are resolved. Otherwise, None to indicate - the need for ffprobe based analysis - - """ - - # resolve requested output streams - stream_opts: list[FFmpegOptionDict] - stream_info: list[dict[str, Any]] # partial RawOutputInfoDict - if streams is None or len(streams) == 0: - return None - - # depending on user's streams input, label output streams differently - # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists - streams_: list[FFmpegOptionDict] - stream_aliases: list[tuple[str, str]] # list of (map_option, stream_name) - if isinstance(streams, dict): # dict[str,FFmpegOptionDict] - # dict key is used as both stream names (labels) and map option. - # * If FFmpegOptionDict in the dict value contains 'map' option, the key - # would only be used as the stream name - # * Note that if the map option is not unique the stream name will - # be renamed with an appended index. - stream_aliases = [] - streams_ = [] - for k, v in streams.items(): - st_opts = {**options, "map": k, **v} - streams_.append(st_opts) - stream_aliases.append((st_opts["map"], k)) - - else: # isinstance(stream,list[str|FFmpegOptionDict]) - # if an item is a str, it is the map option value - # if FFmpegOptionDict, it must contain a 'map' option - - streams_ = [ - {**options, **({"map": v} if isinstance(v, str) else v)} for v in streams - ] - stream_aliases = [(v["map"], v["map"]) for v in streams_] - - # analyze each stream - out_info = [] - for opts, (spec, user_map) in zip(streams_, stream_aliases): - - map_dict = parse_map_option(spec, input_file_id=0, parse_stream=True) - - if not is_unique_stream(map_dict["stream_specifier"]): - return None - - try: - media_type: MediaType = stream_type_to_media_type( - map_dict["stream_specifier"]["stream_type"] - ) - assert media_type in ("audio", "video") - except KeyError as e: - # output stream's media type is missing - return None - - # append raw_info key to the output info dict - gather_media_read_opts = ( - gather_audio_read_opts if media_type == "audio" else gather_video_read_opts - ) - - # get config without analyzing inputs / filtergraphs - raw_info, more_opts = gather_media_read_opts(opts, skip_rate) - - if more_opts is None: - return None - - i, (_, opts) = add_url(args, "output", None, {**options, **opts, "map": spec}) - - info = { - "dst_type": "buffer", - "media_type": media_type, - "raw_info": raw_info, - "user_map": spec, - "squeeze": squeeze, - **get_raw_output_plugin_callables(media_type), - } - - if has_fg: - info["linklabel"] = "unknown" - else: - info["input_file_id"] = -1 - info["input_stream_id"] = -1 - - out_info.append(info) - - return out_info - - def process_raw_inputs( args: FFmpegArgs, stream_types: Sequence[Literal["a", "v"]], @@ -2672,7 +2426,15 @@ def process_url_outputs( # some output file is missing `map` option # add all input streams or all complex filter outputs - map_opts = [*auto_map(args, input_info, None, False)] + + fgname = find_filtergraph_option(args) + if fgname is None: + # get filtergraph + fg = fgb.as_filtergraph(args["global_options"][fgname]) + map_opts = [label for label in fg.iter_output_labels()] + else: + out_opts, _ = auto_map(args, options, input_info, None) + map_opts = [o["map"] for o in out_opts] # add outputs to FFmpeg arguments for _, opts in args["outputs"]: diff --git a/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index 5889ea02..2e65f801 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -1001,7 +1001,14 @@ def _connect( must_link_fwd = [True] * len(fwd_links) right_chained = [] - if chain_siso and not len(bwd_links): + if ( + chain_siso + and not len(bwd_links) + and ( + len(set(l[0][0] for l in fwd_links)) + == len(set(l[1][0] for l in fwd_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 @@ -1098,7 +1105,14 @@ def _rconnect( must_link_fwd = [True] * len(fwd_links) left_chained = [] - if chain_siso and not len(bwd_links): + if ( + chain_siso + and not len(bwd_links) + and ( + len(set(l[0][0] for l in fwd_links)) + == len(set(l[1][0] for l in fwd_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 diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index fd79bf89..c283ee87 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -1057,3 +1057,76 @@ 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 +): + """True if FFmpeg arguments specify a complex filter graph + + :param options: FFmpeg 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) diff --git a/tests/test_audio.py b/tests/test_audio.py index b002db53..b12a4974 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -70,6 +70,28 @@ def test_read(): # assert np.array_equal(x1, x2) +def test_read_af(): + + url = "tests/assets/testaudio-1m.mp3" + + T = 0.51111 + T = 0.49805 + fs, x = audio.read( + url, t=T, show_log=True, af="aresample=8000,channelmap=map=FL-FC" + ) + assert fs == 8000 + assert len(x["shape"]) == 1 + +def test_read_filter(): + + url = "tests/assets/testaudio-1m.mp3" + + T = 0.51111 + T = 0.49805 + fs, x = audio.read( + [url,url], t=T, show_log=True, filter_complex="[0][1]amix[mixed]",map='[mixed]' + ) + def test_read_write(): url = "tests/assets/testaudio-1m.mp3" outext = ".flac" From a7c01689b141ac96367afdd9898a637b5230549a Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 2 Jan 2026 22:08:22 -0500 Subject: [PATCH 318/344] wip13 --- src/ffmpegio/audio.py | 98 ++--- src/ffmpegio/configure.py | 86 ++-- src/ffmpegio/filtergraph/Filter.py | 21 +- src/ffmpegio/filtergraph/Graph.py | 42 +- src/ffmpegio/filtergraph/GraphLinks.py | 86 ++-- src/ffmpegio/filtergraph/__init__.py | 1 + src/ffmpegio/filtergraph/presets.py | 83 ++-- src/ffmpegio/image.py | 348 ++++++++-------- src/ffmpegio/media.py | 142 +++---- .../{_std_runners.py => std_runners.py} | 25 +- src/ffmpegio/transcode.py | 4 +- src/ffmpegio/utils/__init__.py | 29 +- src/ffmpegio/video.py | 372 +++++++++--------- tests/test_filtergraph_presets.py | 4 +- tests/test_image.py | 85 ++-- tests/test_media.py | 32 +- tests/test_transcode.py | 20 +- tests/test_video.py | 13 - 18 files changed, 747 insertions(+), 744 deletions(-) rename src/ffmpegio/{_std_runners.py => std_runners.py} (90%) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 899d4451..1b5bd053 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -5,22 +5,22 @@ import logging import warnings -from . import configure, plugins, analyze, FFmpegioError +from . import configure, plugins, analyze, FFmpegioError, utils from ._typing import TYPE_CHECKING, Any, ProgressCallable, RawDataBlob -if TYPE_CHECKING: - from .configure import ( - FFmpegInputOptionTuple, - FFmpegInputUrlComposite, - FFmpegInputUrlNoPipe, - FFmpegNoPipeInputOptionTuple, - FFmpegOutputUrlNoPipe, - FFmpegNoPipeOutputOptionTuple, - ) - from .filtergraph.abc import FilterGraphObject +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegOutputUrlNoPipe, + FFmpegNoPipeOutputOptionTuple, +) +from .filtergraph.abc import FilterGraphObject +from . import filtergraph as fgb -from ._std_runners import run_and_return_raw, run_and_return_encoded +from .std_runners import run_and_return_raw, run_and_return_encoded logger = logging.getLogger("ffmpegio") @@ -28,35 +28,30 @@ def create( - expr: str, + expr: str | fgb.abc.FilterGraphObject, *args, - squeeze=True, - progress=None, - show_log=None, - sp_kwargs=None, + squeeze: bool = True, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + sp_kwargs: dict[str, Any] | None = 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 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 only considered as the filter options if expr is a single-filter graph, and take the precedents over @@ -64,10 +59,11 @@ def create( 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] + :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 .. seealso:: https://ffmpeg.org/ffmpeg-filters.html#Audio-Sources for available @@ -160,7 +156,7 @@ def read( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_read( - url if isinstance(url, list) else [url], + [url] if utils.is_valid_input_url(url) else url, [output_map], options, extra_outputs, @@ -171,6 +167,8 @@ def read( 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, @@ -199,8 +197,8 @@ def write( show_log: bool | None = None, sp_kwargs: dict[str, Any] | None = None, **options, -) -> bytes | None: - """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. :param rate_in: The sample rate in samples/second. @@ -218,27 +216,29 @@ def write( :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ + # single input, put it in a list + if utils.is_valid_output_url(url): + url = [url] + # 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", + if ( + not any( + (o in options) + for o in ( + "filter_complex", + "lavfi", + "/filter_complex", + "/lavfi", + "filter_complex_script", + ) ) + or "map" not in options ): - if not isinstance(url, list): - url = [url] - if "map" not in options: - options["map"] = "0:a:0" - - ac, dtype = plugins.get_hook().audio_info(obj=data) + options["map"] = "0:a:0" # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_write( - url, ["a"], [(rate_in, data)], extra_inputs, options, [dtype], [(ac,)] + url, ["a"], [(rate_in, data)], extra_inputs, options ) return run_and_return_encoded( @@ -284,7 +284,11 @@ def filter( `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: output sampling rate and audio data object, created by `bytes_to_audio` plugin hook + :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 """ @@ -294,8 +298,6 @@ def filter( options["map"] = "0:a:0" expr = None - ac, dtype = plugins.get_hook().audio_info(obj=input) - # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_filter( expr, @@ -304,8 +306,6 @@ def filter( extra_inputs, None, extra_outputs, - [dtype], - [(ac,)], options, squeeze, ) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index c0f67fc5..12fe4311 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -93,7 +93,7 @@ from .filtergraph.presets import ( merge_audio, filter_video_basic, - remove_video_alpha, + remove_alpha, temp_video_src, temp_audio_src, ) @@ -211,7 +211,7 @@ class FFmpegArgs(TypedDict): def init_media_read( - urls: list[ + urls: Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] ], @@ -392,10 +392,10 @@ def init_media_filter( extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), - input_dtypes: list[DTypeString] | None, - input_shapes: list[ShapeTuple] | None, options: FFmpegOptionDict, squeeze: bool, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, ) -> tuple[FFmpegArgs, list[RawInputInfoDict], list[RawOutputInfoDict]]: """Prepare FFmpeg arguments for media read @@ -486,18 +486,16 @@ def init_media_filter( def init_media_transcode( - inputs: Sequence[FFmpegInputOptionTuple], - outputs: Sequence[FFmpegOutputOptionTuple], - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, - extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None, + inputs: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], + outputs: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], options: FFmpegOptionDict, ) -> tuple[FFmpegArgs, list[EncodedInputInfoDict], list[EncodedOutputInfoDict]]: """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 + :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 @@ -514,12 +512,6 @@ def init_media_transcode( 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 as e: - raise FFmpegioError("extra_inputs cannot be piped in.") - if not len(input_info): raise ValueError("At least one input must be given.") @@ -527,21 +519,6 @@ def init_media_transcode( 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.") @@ -752,7 +729,6 @@ def gather_video_read_opts( args: FFmpegArgs | None = None, input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], get_fg_info: Callable[[], dict[str, FilterGraphInfoDict] | None] | None = None, - default_pix_fmt: str = "rgb24", ) -> tuple[RawStreamInfoTuple, FFmpegOptionDict | None]: """Gathering raw video read output options @@ -765,8 +741,6 @@ def gather_video_read_opts( 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_pix_fmt: if the analysis could not determine the output pixel - format, force this format, defaults to 'rgb24' :return raw_info: video shape tuple (height, width, nb_components) :return additional_options: additional output options or None if `raw_info` is not complete @@ -858,9 +832,7 @@ def gather_video_read_opts( ) # deduce output pixel format from the input pixel format - pix_fmt, ncomp, dtype, remove_alpha = utils.get_pixel_config( - pix_fmt_in, default_pix_fmt - ) + pix_fmt, ncomp, dtype, remove_alpha = utils.get_pixel_config(pix_fmt_in) outopts["pix_fmt"] = pix_fmt else: @@ -879,7 +851,7 @@ def gather_video_read_opts( if remove_alpha: raise FFmpegioError( "The output pix_fmt does not have a transparency while its input does. " - "Additional filtering is necessary to remove the alpha channel properly. See ffmpegio.filtergraph.presets.remove_video_alpha()." + "Additional filtering is necessary to remove the alpha channel properly. See ffmpegio.filtergraph.presets.remove_alpha()." ) if s is None: @@ -1444,7 +1416,7 @@ def finalize_avi_read_opts(args): def config_input_fg( - expr: str, args: tuple, kwargs: dict + expr: str | FilterGraphObject, args: tuple, kwargs: dict ) -> tuple[str | fgb.Filter, float | None, dict]: """configure input filtergraph @@ -1458,19 +1430,19 @@ def config_input_fg( :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( f"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") @@ -1483,7 +1455,7 @@ def config_input_fg( 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(): @@ -1834,7 +1806,7 @@ def format_raw_output_stream_defs( for i, (k, v) in enumerate(streams.items()): if "map" in v: # user provided non-map stream name stream_names[i] = k - streams.append({**options, "map": k, **v}) + streams_.append({**options, "map": k, **v}) elif "map" in options: streams_ = [options] else: # isinstance(stream,list[str|FFmpegOptionDict]) @@ -1905,7 +1877,7 @@ def next_map_option(i, media_type): ) else: # return all filtergraph outputs - for linklabel, info in fg_info: + for linklabel, info in fg_info.items(): stream_opts.append({**options, "map": linklabel}) stream_info.append( { @@ -2006,7 +1978,7 @@ def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: def process_url_inputs( args: FFmpegArgs, - urls: list[ + urls: Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] ], inopts_default: FFmpegOptionDict, @@ -2126,7 +2098,7 @@ def process_raw_outputs( # on-demand complex filtergraph analysis @cache - def get_fg_info() -> dict[str, FilterGraphInfoDict]: + 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 @@ -2134,7 +2106,10 @@ def get_fg_info() -> dict[str, FilterGraphInfoDict]: optname = utils.find_filter_complex_option(gopts) - if any(optname in ("/filter_complex", "/lavfi", "filter_complex_script")): + 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." @@ -2300,7 +2275,7 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: 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, } @@ -2429,13 +2404,12 @@ def process_url_outputs( fgname = find_filtergraph_option(args) if fgname is None: + out_opts, _ = auto_map(args, options, input_info, None) + map_opts = [o["map"] for o in out_opts] + else: # get filtergraph fg = fgb.as_filtergraph(args["global_options"][fgname]) map_opts = [label for label in fg.iter_output_labels()] - else: - out_opts, _ = auto_map(args, options, input_info, None) - map_opts = [o["map"] for o in out_opts] - # add outputs to FFmpeg arguments for _, opts in args["outputs"]: if "map" not in opts: diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index 42dc5945..20bbd9a4 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) ) @@ -371,6 +369,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), @@ -383,6 +395,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), @@ -494,7 +507,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 2e65f801..3b6b4907 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -956,7 +956,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: @@ -1000,6 +1000,8 @@ def _connect( must_link_fwd = [True] * len(fwd_links) right_chained = [] + lut_shift = {} + lut_map = {} if ( chain_siso @@ -1018,6 +1020,8 @@ def _connect( for i, (outpad, inpad) in enumerate(fwd_links): ochain, ichain = outpad[0], inpad[0] + lut_shift[inpad[0]] = len(fg[ochain]) + # label check if ( fg.is_chain_appendable(ochain) @@ -1042,31 +1046,31 @@ def _connect( 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 + lut_map[i] = n0 n0 += 1 fg = fg._stack(c) - right_links = right._links.drop_labels(tuple(fg._links.keys())).map_chains( - lut, False - ) + # map the remainig right links to the new fg + right_links = right._links.drop_labels(tuple(fg._links.keys())).map_chains( + lut_map, lut_shift + ) - # transfer the right links to fg (remap chains) - fg._links.update(right_links) + # 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, - ) + # create iterators to organize the links in (input, output) of the combined graph + it_fwd = ( + ((lut[r[0]], *r[1:]), l) + for (l, r), do_link in zip(fwd_links, must_link_fwd) + if do_link + ) + it_bwd = ((l, (lut[r[0]], *r[1:])) for (r, l) in bwd_links) + fg._links.update( + {i: link for i, link in enumerate(chain(it_fwd, it_bwd))}, + validate=False, + ) if replace_sws_flags and right.sws_flags: fg.sws_flags = right.sws_flags diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 379ab149..cb6faa5e 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -625,7 +625,7 @@ def chain_has_link( :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 @@ -903,14 +903,41 @@ 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): @@ -918,6 +945,9 @@ class OffsetMapper: def __init__(self, offset): self._off = offset + def __len__(self): + return len(data) + def __contains__(self, _): # applies to all return True @@ -929,41 +959,27 @@ 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 shifter: + 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 + inpad = map_padidx(inpad) + 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: diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index f293cffb..ea89d6f9 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/presets.py b/src/ffmpegio/filtergraph/presets.py index dfe0d47f..ee0ee732 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -5,6 +5,7 @@ from .._typing import TYPE_CHECKING, Any, Sequence, Literal from ..stream_spec import StreamSpecDict from .abc import FilterGraphObject +from ..path import check_version from functools import reduce from fractions import Fraction @@ -16,8 +17,12 @@ from .Chain import Chain -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 @@ -32,14 +37,33 @@ def remove_video_alpha( """ - 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 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 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)) + if pix_fmt is not None: + fg += fgb.format(pix_fmts=pix_fmt) + outpad = (outpad[0], outpad[1] + 1, 0) + + fg.add_label(input_label, inpad) + fg.add_label(output_label, outpad=outpad) return fg @@ -49,26 +73,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: @@ -108,6 +115,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/image.py b/src/ffmpegio/image.py index 56df8ab0..07e1d3df 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -1,85 +1,48 @@ -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 warnings +import logging +from fractions import Fraction + +from . import configure, plugins, analyze, FFmpegioError, utils +from .std_runners import run_and_return_raw, run_and_return_encoded + +from ._typing import Any, ProgressCallable, RawDataBlob, FFmpegOptionDict + +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegOutputUrlNoPipe, + FFmpegNoPipeOutputOptionTuple, +) +from . import filtergraph as fgb + +__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 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 only considered as the filter options if expr is a single-filter graph, and take the precedents over @@ -87,9 +50,9 @@ def create(expr, *args, show_log=None, sp_kwargs=None, **options): 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 +60,202 @@ 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, t_, options = configure.config_input_fg(expr, args, options) - url, _, 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 + :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, - **options -): + url: ( + FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] + ), + 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, +) -> 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 """ - url, stdout, _ = configure.check_url(url, True) + if utils.is_valid_output_url(url): + url = [url] + + # 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, ["v"], [(1.0, data)], extra_inputs, options ) - # 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) + :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") + if expr and extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:v"] = expr + options["map"] = "0:V:0" + expr = None - 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, + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + expr, ["v"], [(1.0, input)], extra_inputs, None, extra_outputs, options, True ) + + 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 274dbab9..04ce22d8 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -18,11 +18,18 @@ RawOutputInfoDict, OutputPipeInfoDict, FFmpegOptionDict, + DTypeString, + ShapeTuple, + InputPipeInfoDict, ) from .configure import ( FFmpegArgs, FFmpegOutputUrlComposite, FFmpegInputUrlComposite, + FFmpegOutputUrlNoPipe, + FFmpegNoPipeOutputOptionTuple, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, ) from fractions import Fraction @@ -43,7 +50,9 @@ def _runner( progress: ProgressCallable | None, sp_kwargs: dict | None, overwrite: bool | None = None, -) -> ffmpegprocess.Popen: +) -> tuple[ + ffmpegprocess.Popen, dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict] +]: # True if there is unknown datablob info need_stderr = any( @@ -56,12 +65,10 @@ def _runner( # configure named pipes input_pipes = output_pipes = {} if len(input_info): - input_pipes, sp_kwargs = configure.assign_input_pipes( - args, input_info, sp_kwargs, False - ) + 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, sp_kwargs, False + args, output_info, False ) stack = configure.init_named_pipes( input_pipes, output_pipes, input_info, output_info @@ -92,51 +99,46 @@ def on_exit(rc): if proc.returncode: raise FFmpegError(proc.stderr, capture_log) - return proc + return proc, input_pipes, output_pipes def _gather_outputs( - pipe_info: dict[int, OutputPipeInfoDict], output_info: list[RawOutputInfoDict], - proc: ffmpegprocess.Popen, + pipe_info: dict[int, OutputPipeInfoDict], ) -> tuple[dict[str, int | Fraction], dict[str, RawDataBlob]]: + rates = {} data = {} for i, pinfo in pipe_info.items(): info = output_info[i] + spec = info["user_map"] b = pinfo["reader"].read_all() - - # get datablob info from stderr if needed dtype, shape, rate = info["raw_info"] - missing = any(v is None for v in info["raw_info"]) - - if missing: - logger.warning('Retrieving stream "%s" information from FFmpeg log.', spec) - if proc.stderr is None: - raise FFmpegioError( - "stderr was not captured to compose the output data" - ) - dtype, shape, rate = ( - log.extract_output_video_raw_info - if info["media_type"] == "video" - else log.extract_output_audio_raw_info - )(proc.stderr.readlines(), i) - - data[spec] = info["bytes2data"](b=b, dtype=dtype, shape=shape, squeeze=False) + + 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: ( + *urls: *tuple[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ], + streams: ( Sequence[str] | Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None ) = None, + extra_outputs: ( + Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, @@ -145,16 +147,20 @@ 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: 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 + :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 progress: progress callback function, defaults to None + :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 input url or '_in' to be applied to all inputs. The url-specific option gets the preference (see :doc:`options` for custom options) @@ -167,26 +173,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( + 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( @@ -198,10 +197,6 @@ 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, overwrite: bool | None = None, show_log: bool | None = None, @@ -243,22 +238,10 @@ def write( if not isinstance(urls, list): urls = [urls] - args, input_info, input_ready, output_info, _ = 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, + args, input_info, output_info = configure.init_media_write( + urls, stream_types, stream_args, extra_inputs, options ) - # 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) @@ -272,11 +255,17 @@ 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_args: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + extra_outputs: ( + list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + ) = None, + squeeze: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, @@ -308,22 +297,21 @@ def filter( for some outputs as needed. """ - args, input_info, input_ready, output_info, _ = configure.init_media_filter( + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( expr, input_types, input_args, extra_inputs, - None, - None, + output_args, + extra_outputs, options, - output_options or {}, + squeeze, ) - # 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) diff --git a/src/ffmpegio/_std_runners.py b/src/ffmpegio/std_runners.py similarity index 90% rename from src/ffmpegio/_std_runners.py rename to src/ffmpegio/std_runners.py index aed4a5c9..4d3da6b1 100644 --- a/src/ffmpegio/_std_runners.py +++ b/src/ffmpegio/std_runners.py @@ -5,7 +5,7 @@ import logging from . import ( - ffmpegprocess, + ffmpegprocess as fp, configure, FFmpegError, FFmpegioError, @@ -71,8 +71,6 @@ def run_and_return_raw( raise FFmpegioError("No audio stream found.") if len(output_info) > 1: raise ValueError("Too many audio stream found.") - if output_info[0]["media_type"] != "audio": - raise ValueError("Mapped stream is not an audio stream.") if output_info[0]["dst_type"] != "buffer": raise ValueError("Not outputting to pipe") @@ -94,7 +92,7 @@ def run_and_return_raw( # ignore user's stdin, stdout, stdout if specified kwargs = {**sp_kwargs, **kwargs} - out = ffmpegprocess.run( + out = fp.run( args, progress=progress, capture_log=None if show_log else True, @@ -112,7 +110,16 @@ def run_and_return_raw( def run_and_return_encoded( - progress, overwrite, show_log, sp_kwargs, args, input_info, output_info + 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.") @@ -144,7 +151,13 @@ def run_and_return_encoded( # ignore user's stdin, stdout, stdout if specified kwargs = {**sp_kwargs, **kwargs} - out = ffmpegprocess.run( + 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, diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index eb91603e..e8d4966b 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -87,7 +87,7 @@ def transcode( outputs = [outputs] args, input_info, output_info = configure.init_media_transcode( - inputs, outputs, None, None, options + inputs, outputs, options ) # check number of pipes @@ -122,6 +122,8 @@ def transcode( } ) 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 c283ee87..7ac442a7 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -54,12 +54,11 @@ def get_pixel_config( - input_pix_fmt: str, pix_fmt: str | None = None + 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 pix_fmt_out: output pix_fmt :return ncomp: number of components :return dtype: data type string @@ -82,6 +81,7 @@ def get_pixel_config( 4 tuple[Fraction | int, RawDataBlob]: """Create a video using a source video filter - :param name: name of the source filter - :type name: str + :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 only considered as the filter options if expr is a single-filter graph, and take the precedents over @@ -87,9 +54,11 @@ def create(expr, *args, progress=None, show_log=None, sp_kwargs=None, **options) 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 +66,231 @@ 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 :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, + 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 """ - url, stdout, _ = configure.check_url(url, True) + if utils.is_valid_output_url(url): + url = [url] - 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, ["v"], [(rate_in, data)], extra_inputs, options ) - # 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 :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 and extra_inputs is None and extra_outputs is None: + # guaranteed SISO filtering + options["filter:v"] = expr + options["map"] = "0:V:0" + expr = None + + # initialize FFmpeg argument dict and get input & output information + args, input_info, output_info = configure.init_media_filter( + expr, + ["v"], + [(input_rate, input)], + extra_inputs, + None, + extra_outputs, + options, + squeeze, ) - 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_filtergraph_presets.py b/tests/test_filtergraph_presets.py index c9233bea..5de4760b 100644 --- a/tests/test_filtergraph_presets.py +++ b/tests/test_filtergraph_presets.py @@ -22,5 +22,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)) diff --git a/tests/test_image.py b/tests/test_image.py index a2eb7740..52e947c2 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,9 +1,9 @@ import pytest -from ffmpegio import image, probe, transcode, FFmpegError +from ffmpegio import image, probe, transcode, FFmpegError, FFmpegioError import tempfile, re from os import path -from ffmpegio.filtergraph import utils as filter_utils +from ffmpegio import filtergraph as fgb outext = ".png" @@ -49,15 +49,14 @@ def test_read_write(): A = image.read(url) print(A["dtype"] == "|u1") B = image.read(url, pix_fmt="ya8") - print(B["shape"]) - C = image.read(url, pix_fmt="rgb24") - D = image.read(url, pix_fmt="gray") + with pytest.raises(FFmpegioError): + image.read(url, pix_fmt="rgb24") + with pytest.raises(FFmpegioError): + image.read(url, pix_fmt="gray") with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - print(out_url, C["shape"]) - image.write(out_url, C) - print(probe.video_streams_basic(out_url)) - C = image.read(out_url, pix_fmt="rgba", show_log=True) + image.write(out_url, B) + image.read(out_url, pix_fmt="rgba", show_log=True) # with open(path.join(tmpdirname, "progress.txt")) as f: # print(f.read()) @@ -75,44 +74,52 @@ def test_read_write(): # plt.show() +def test_read_basic_filter(): + + url = "tests/assets/ffmpeg-logo.png" + vf = fgb.presets.filter_video_basic( + crop=(300, 50), + flip="horizontal", + transpose="clock", + ) + image.read(url, show_log=True, vf=vf) + +def test_filter(): + + url = "tests/assets/ffmpeg-logo.png" + I = image.read(url,vf=fgb.presets.remove_alpha('red','rgb24')) + vf = fgb.presets.filter_video_basic( + crop=(10, 50), + flip="horizontal", + transpose="clock", + ) + J = image.filter(vf, I, show_log=True) + @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"), - ], + "fill_color,pix_fmt,ncomp", + [("red", "rgb24", 3), ("red", None, 4), ("red", "gray", 0)], ) -def test_read_basic_filter(kwargs): +def test_remove_alpha_filter(fill_color, pix_fmt, ncomp): url = "tests/assets/ffmpeg-logo.png" - image.read(url, show_log=True, **kwargs) - + vf = fgb.presets.remove_alpha(fill_color=fill_color, pix_fmt=pix_fmt) + print(str(vf)) + I = image.read(url, show_log=True, vf=vf) + assert I["shape"] == ((100, 396, ncomp) if ncomp else (100, 396)) -def test_square_pixels(): +@pytest.fixture(scope='module') +def nonsquarepix_url(): url = "tests/assets/testvideo-1m.mp4" with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, path.basename(url)) - transcode(url, out_url, show_log=True, vf="setsar=11/13", t=0.5) - - B = image.read(out_url) - Bu = image.read(out_url, square_pixels="upscale") - Bd = image.read(out_url, square_pixels="downscale") - Bue = image.read(out_url, square_pixels="upscale_even") - Bde = image.read(out_url, square_pixels="downscale_even") - - print(B["shape"]) - print(Bu["shape"]) - print(Bd["shape"]) - print(Bue["shape"]) - print(Bde["shape"]) + transcode(url, out_url, show_log=True, vf="setsar=11/13", t=0.5, pix_fmt='gray') + yield out_url + +@pytest.mark.parametrize('mode',['upscale','downscale','upscale_even','downscale_even']) +def test_square_pixels(nonsquarepix_url, mode): + + vf = fgb.presets.square_pixels(mode) + image.read(nonsquarepix_url, vf=vf,show_log=None) if __name__ == "__main__": diff --git a/tests/test_media.py b/tests/test_media.py index d1e66059..1b16f3bd 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -4,6 +4,7 @@ import pytest import ffmpegio as ff +import ffmpegio.filtergraph as fgb url = "tests/assets/testmulti-1m.mp4" url1 = "tests/assets/testvideo-1m.mp4" @@ -11,25 +12,28 @@ @pytest.mark.parametrize( - "urls,kwargs", + "urls,kwargs,nout", [ - ((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)), + ((url,), dict(t=1, show_log=True), 4), + ((url,), dict(streams=("v:0", "v:1", "a:1", "a:0"), t=1, show_log=True), 4), + ((url1, url2), dict(t=1, show_log=True), 2), + ((url1,), dict(t=1, filter_complex="[0:0]split[out1][out2]", show_log=True), 2), ], ) -def test_media_read(urls, kwargs): - assert False +def test_media_read(urls, kwargs, nout): rates, data = ff.media.read(*urls, **kwargs) + assert len(rates) == nout print(rates) print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) def test_media_read_filter_complex(): - assert False - 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') + 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) @@ -37,7 +41,6 @@ def test_media_read_filter_complex(): def test_media_write(): - assert False fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) @@ -57,13 +60,13 @@ def test_media_write(): def test_media_write_audio_merge(): - assert False 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" + fg = fgb.presets.merge_audio(["0:a", "1:a", "2:a"]) with TemporaryDirectory() as tmpdirname: outfile = path.join(tmpdirname, f"out{outext}") ff.media.write( @@ -72,7 +75,7 @@ def test_media_write_audio_merge(): stream1, stream2, stream3, - merge_audio_streams=True, + filter_complex=fg, show_log=True, shortest=ff.FLAG, ) @@ -81,7 +84,6 @@ def test_media_write_audio_merge(): def test_media_filter(): - assert False fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3") fps, F = ff.video.read("tests/assets/testvideo-1m.mp4", vframes=120) @@ -96,7 +98,7 @@ def test_media_filter(): (fps, F), (fs, x), (fs, x), - output_options={"[out0]": {}, "audio": {"map": "[out2]"}}, + output_args={"[out0]": {}, "audio": {"map": "[out2]"}}, show_log=True, shortest=ff.FLAG, ) diff --git a/tests/test_transcode.py b/tests/test_transcode.py index 1a5622bb..d69d1161 100644 --- a/tests/test_transcode.py +++ b/tests/test_transcode.py @@ -66,7 +66,7 @@ def test_transcode_2pass(): show_log=True, two_pass=True, t=1, - **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"} + **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}, ) transcode( @@ -78,7 +78,7 @@ def test_transcode_2pass(): pass1_extras={"an": None}, overwrite=True, t=1, - **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"} + **{"c:v": "libx264", "b:v": "1000k", "c:a": "aac", "b:a": "128k"}, ) @@ -91,21 +91,5 @@ def test_transcode_vf(): assert path.isfile(out_url) -def test_transcode_image(): - url = "tests/assets/ffmpeg-logo.png" - with tempfile.TemporaryDirectory() as tmpdirname: - # print(probe.audio_streams_basic(url)) - out_url = path.join(tmpdirname, path.basename(url) + ".jpg") - transcode( - url, - out_url, - show_log=True, - remove_alpha=True, - s=[300, -1], - transpose=0, - vframes=1, - ) - - if __name__ == "__main__": test_transcode_from_filter() diff --git a/tests/test_video.py b/tests/test_video.py index 11b12b65..4774a582 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -105,19 +105,6 @@ 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:]) - - 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 From c414b45eca291a64b308b8265aa32530eae83e80 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 4 Jan 2026 14:59:55 -0500 Subject: [PATCH 319/344] wip14 --- src/ffmpegio/configure.py | 231 ++---------------------- src/ffmpegio/filtergraph/Chain.py | 10 +- src/ffmpegio/filtergraph/Filter.py | 20 ++- src/ffmpegio/filtergraph/Graph.py | 236 ++++++++++++------------- src/ffmpegio/filtergraph/GraphLinks.py | 37 ++-- src/ffmpegio/filtergraph/abc.py | 26 ++- src/ffmpegio/filtergraph/build.py | 4 +- src/ffmpegio/media.py | 6 +- src/ffmpegio/utils/__init__.py | 4 +- tests/test_filtergraph.py | 20 +-- tests/test_filtergraph_abc.py | 4 +- tests/test_filtergraph_build.py | 21 ++- tests/test_filtergraph_chain.py | 2 +- tests/test_filtergraph_fglinks.py | 4 +- tests/test_filtergraph_presets.py | 2 +- tests/test_media.py | 29 ++- 16 files changed, 254 insertions(+), 402 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 12fe4311..22238ede 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -819,7 +819,7 @@ def gather_video_read_opts( 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, s if scaled_s else None, r_in, pix_fmt_in, s_in + vf, r_in, pix_fmt_in, s_in, s if scaled_s else None ) # pixel format must be specified @@ -874,118 +874,6 @@ def gather_video_read_opts( return raw_info, outopts -def finalize_video_read_opts( - args: FFmpegArgs, - ofile: int = 0, - input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], - fg_info: dict[str, FilterGraphInfoDict] | None = None, - skip_analysis: bool = False, -) -> 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 [] - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :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"] - - outopts = args["outputs"][ofile][1] - outmap = outopts["map"] - map_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") - - # use the output option by default - opt_vals = [outopts.get(o, None) for o in options] - - if all(opt_vals) and skip_analysis: - return tuple(opt_vals) - - # get the options of the input/filtergraph output - if linklabel := map_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 = map_fields["input_file_id"] - - # get input option values - inopt_vals = utils.analyze_video_stream( - map_fields["stream_specifier"], - *args["inputs"][ifile], - 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 - vf = temp_video_src(*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]" - inopt_vals = utils.analyze_video_stream( - "0", vf, {"f": "lavfi"}, {"src_type": "filtergraph"} - ) - - # 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 - 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) - 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 dtype, None if s is None else (*s[::-1], ncomp), r - - def check_alpha_change(args, dir=None, ifile=0, ofile=0): # check removal of alpha channel inopts = args["inputs"][ifile][1] @@ -1130,106 +1018,6 @@ def gather_audio_read_opts( return raw_info, outopts -def finalize_audio_read_opts( - args: FFmpegArgs, - ofile: int = 0, - input_info: list[RawInputInfoDict | EncodedInputInfoDict] = [], - fg_info: dict[str, FilterGraphInfoDict] | None = None, - skip_analysis: bool = False, -) -> 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 [] - :param fg_info: filtergraph output info if filtergraph has been pre-analyzed, - keyed by their linklabels, defaults to None to perform the - filtergraph analysis internally - :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 - - """ - - options = ["ar", "sample_fmt", "ac"] - - outopts = args["outputs"][ofile][1] - outmap = outopts["map"] - map_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] - if not all(opt_vals): - if skip_analysis: - return tuple(opt_vals) - if linklabel := map_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["ar"], info["sample_fmt"], info["ac"]] - else: - ifile = map_fields["input_file_id"] - - # get input option values - inopt_vals = utils.analyze_audio_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 - af1 = temp_audio_src(*inopt_vals) - af2 = outopts.get("filter:a", outopts.get("af", None)) - inopt_vals = utils.analyze_audio_stream( - "0", af1 + af2, {"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: - 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) - - # sample_fmt must be given - dtype, _ = utils.get_audio_format(sample_fmt, ac) - - return dtype, ac and (ac,), ar - - ################################################################################ @@ -1658,7 +1446,19 @@ def resolve_raw_output_streams( for i, opts in enumerate(stream_opts): spec = opts["map"] - opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + user_map = stream_names.get(i, spec) + + try: + opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + except ValueError: + # incorrect spec if there is no complex filter in place + if not utils.find_filter_complex_option(args['global_options']): + raise + + # test spec with possibly omitted brackets + spec = f"[{spec}]" + opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) + opts["map"] = spec # get output stream information if "linklabel" in opt: @@ -1669,7 +1469,7 @@ def resolve_raw_output_streams( output_opts.append(opts) output_info.append( { - "user_map": stream_names.get(i, spec[1:-1]), + "user_map": user_map, "linklabel": opt["linklabel"], } ) @@ -1680,7 +1480,6 @@ def resolve_raw_output_streams( file_index = opt["input_file_id"] stream_spec = opt["stream_specifier"] - user_map = stream_names.get(i, spec) # retrieve input stream data if "index" in stream_spec and "stream_type" in stream_spec: diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index 96d6b498..d6356396 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -35,6 +35,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: @@ -124,7 +130,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 20bbd9a4..e8d29a2b 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -65,7 +65,23 @@ def _get_info(name: str) -> FilterInfo: def __new__(self, filter_spec, *args, filter_id=None, **kwargs): """_summary_""" + + if isinstance(filter_spec, fgb.Graph): + if len(filter_spec) != 1: + raise TypeError( + "Cannot convert a `Graph` object with more than one filter to a `Filter` object" + ) + filter_spec = filter_spec[0] + + if isinstance(filter_spec, fgb.Chain): + if len(filter_spec) != 1: + raise TypeError( + "Cannot convert a `Chain` or `Graph` object to a `Filter` object if it does not have exactly one filter." + ) + filter_spec = filter_spec[0] + proto = [] + if isinstance(filter_spec, Filter): if filter_spec.id and filter_id is not None: # new id proto.append((filter_spec.name, filter_id)) @@ -481,7 +497,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 3b6b4907..56796fcc 100644 --- a/src/ffmpegio/filtergraph/Graph.py +++ b/src/ffmpegio/filtergraph/Graph.py @@ -220,7 +220,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. @@ -984,8 +986,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, @@ -996,83 +998,126 @@ def _connect( """ + # 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 + fg = Graph(self) - must_link_fwd = [True] * len(fwd_links) - right_chained = [] + right_links = ( + GraphLinks(right._links) if isinstance(right, Graph) else GraphLinks(None) + ) + lut_shift = {} lut_map = {} - if ( - chain_siso - and not len(bwd_links) - and ( - len(set(l[0][0] for l in fwd_links)) - == len(set(l[1][0] for l in fwd_links)) - ) - ): - # if linking chains are both siso and free of any other linkages and both pads are not labeled - # the chain of the right fg is joined to the chain of the left - - right = fgb.as_filtergraph(right, copy=True) - - # chain links if there is no ambiguity - for i, (outpad, inpad) in enumerate(fwd_links): - ochain, ichain = outpad[0], inpad[0] - - lut_shift[inpad[0]] = len(fg[ochain]) - - # label check - if ( - fg.is_chain_appendable(ochain) - and not fg._links.are_linked(None, outpad) - and right.is_chain_prependable(ichain) - ): - # 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) + # 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: - # 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 + link = ( + self.normalize_pad_index(False, outpad), + right.normalize_pad_index(True, inpad), + ) - # stack 2 filtergraphs and build right chain id conversion lookup table - for i, c in enumerate(right): - if i not in right_chained: - lut_map[i] = n0 - n0 += 1 - fg = fg._stack(c) + 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.drop_labels(tuple(fg._links.keys())).map_chains( - lut_map, lut_shift - ) + 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) - # 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) + # 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 @@ -1100,63 +1145,9 @@ def _rconnect( """ - # return fgb.as_filtergraph(left)._connect( - # self, fwd_links, bwd_links, chain_siso, replace_sws_flags - # ) - - fg = Graph(self) - - must_link_fwd = [True] * len(fwd_links) - left_chained = [] - - if ( - chain_siso - and not len(bwd_links) - and ( - len(set(l[0][0] for l in fwd_links)) - == len(set(l[1][0] for l in fwd_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_prependable(ichain) - and not fg._links.are_linked(inpad, None) - and left.is_chain_appendable(ochain) - ): - # 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 @@ -1271,10 +1262,7 @@ def _input_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified input pad index is available""" # check linked indices - if any( - link[1] == index - for link in self._links.iter_links(include_input_stream=True) - ): + if self._links.are_linked(inpad=index, outpad=None, check_input_stream=True): # already connected return False @@ -1285,7 +1273,7 @@ def _output_pad_is_available(self, index: tuple[int, int, int]) -> bool: """returns True if specified output pad index is available""" # check linked indices - if any(link[2] == index for link in self._links.iter_links()): + if self._links.are_linked(outpad=index, inpad=None): # already connected return False diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index cb6faa5e..d7823c44 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -261,7 +261,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) @@ -310,11 +310,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 @@ -322,6 +324,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 """ @@ -335,7 +341,13 @@ 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) @@ -667,7 +679,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 @@ -771,7 +783,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 @@ -903,7 +915,9 @@ def adj(pid): self._modify_pad_ids(select, adj) def map_chains( - self, mapper: int | Mapping[int, int]|None, shifter: Mapping[int, int] | None = None + self, + mapper: int | Mapping[int, int] | None, + shifter: Mapping[int, int] | None = None, ) -> GraphLinks: """Generate a new GraphLink object with a chain id mapper @@ -942,11 +956,13 @@ def shift_pair(inpads, outpad): if isinstance(mapper, int): class OffsetMapper: + nmap = len(self) + def __init__(self, offset): self._off = offset def __len__(self): - return len(data) + return self.nmap def __contains__(self, _): # applies to all @@ -961,8 +977,9 @@ def get(self, k, defaults=None): mapper = OffsetMapper(mapper) if mapper is not None and len(mapper): + def map_padidx(pad): - if pad[0] in shifter: + if pad[0] in mapper: pad = (mapper[pad[0]], *pad[1:]) return pad @@ -971,7 +988,7 @@ def adjust_pair(inpads, outpad): outpad = map_padidx(outpad) if inpads is not None: if isinstance(inpads[0], int): # single-input - inpad = map_padidx(inpad) + inpads = map_padidx(inpads) else: # multiple-inputs (an input stream) inpads = tuple(map_padidx(d) for d in inpads) return (inpads, outpad) @@ -993,7 +1010,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/abc.py b/src/ffmpegio/filtergraph/abc.py index e49e52bd..5a5095f5 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -269,7 +269,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. @@ -351,7 +353,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. """ @@ -663,8 +665,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 70bf7495..45d9253f 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -207,10 +207,10 @@ def join( for c in range(nleft): # get the first available pad to join left_pad, *_ = next( - left.iter_output_pads(chain=c, chainable_only=True, **iter_kws) + left.iter_output_pads(chain=c, **iter_kws) ) right_pad, *_ = next( - right.iter_input_pads(chain=c, chainable_only=True, **iter_kws) + right.iter_input_pads(chain=c, **iter_kws) ) links[c] = (left_pad, right_pad) except: diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 04ce22d8..e7de071a 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -314,7 +314,9 @@ def filter( 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/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 7ac442a7..ef58f74c 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -891,18 +891,18 @@ def analyze_complex_filtergraphs( def analyze_output_video_filter( filtergraph: FilterGraphObject, - s: tuple[int, int] | None, 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 s: -s output option :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) diff --git a/tests/test_filtergraph.py b/tests/test_filtergraph.py index 0ba58283..e04e2b81 100644 --- a/tests/test_filtergraph.py +++ b/tests/test_filtergraph.py @@ -215,14 +215,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]", ), ], ) @@ -242,11 +242,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 ], ) @@ -373,8 +373,8 @@ def test_get_output_pad(fg, id, 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]"), + ("[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 ], ) @@ -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[ou1];[in2]trim[UNC1];[L0]scale[out2]"), + ("[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_abc.py b/tests/test_filtergraph_abc.py index cf488936..4603e1d6 100644 --- a/tests/test_filtergraph_abc.py +++ b/tests/test_filtergraph_abc.py @@ -224,8 +224,8 @@ def test_resolve_pad_index( # 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 is_input_linkable(self, inpad: PAD_INDEX)->bool: +# def is_output_linkable(self, outpad: PAD_INDEX)->bool: # def _check_partial_pad_index( # self, index: tuple[int | None, int | None, int | None], is_input: bool # ) -> bool: diff --git a/tests/test_filtergraph_build.py b/tests/test_filtergraph_build.py index ef4efb74..38ee491f 100644 --- a/tests/test_filtergraph_build.py +++ b/tests/test_filtergraph_build.py @@ -16,7 +16,7 @@ ("split", "vstack",[(0,0,0),(0,0,1)],[(0,0,1),(0,0,0)],True,'[UNC0]split[L0][L1];[L1][L0]vstack[UNC1]'), ("scale", "fps,eq",(0,0,0),(0,0,0),True,'scale,fps,eq'), ("scale,fps", "eq",(0,1,0),(0,0,0),True,'scale,fps,eq'), - ("scale", "[0:v]vstack[out]",(0,0,0),(0,0,1),True,'[UNC0]scale[L0];[0:v][L0]vstack[out]'), + ("scale", "[0:v]vstack[out]",(0,0,0),(0,0,1),True,'[UNC0]scale,[0:v]vstack[out]'), ("scale", "[in1][0:v]vstack[out]",(0,0,0),(0,0,0),True,'[UNC0]scale[L0];[L0][0:v]vstack[out]'), # fmt: on ], @@ -37,11 +37,11 @@ def test_connect(left, right, from_left, to_right, chain_siso, ret): ("scale,fps","eq",'all',0,False,False,'scale,fps,eq'), ("split","vstack",'all',0,False,False,'[UNC0]split[L0][L1];[L0][L1]vstack[UNC1]'), ("split","vstack",'all',1,False,False,'[UNC0]split[L0][UNC2];[L0][UNC1]vstack[UNC3]'), - ("[vin]scale;[ain]asplit","vstack[vout];atrim[aout]",'all',0,False,False,'[vin]scale[L0];[ain]asplit[L1][L2];[L0][L1]vstack[vout];[L2]atrim[aout]'), + ("[vin1]scale;[vin2]split","vstack[vout1];trim[vout2]",'all',0,False,False,'[vin1]scale[L0];[vin2]split[L1],trim[vout2];[L0][L1]vstack[vout1]'), ("[vin]scale;[ain]asplit","vstack[vout];atrim[aout]",'per_chain',0,False,False,'[vin]scale[L0];[ain]asplit[L1][UNC1];[L0][UNC0]vstack[vout];[L1]atrim[aout]'), ("[vin]scale;[ain]asplit","vstack[vout]",'all',0,False,False,'[vin]scale[L0];[ain]asplit[L1][UNC0];[L0][L1]vstack[vout]'), ("[vin]scale;[ain]asplit","vstack[vout]",'all',0,True,False,None), - ("split[out]","[in]vstack",'all',0,False,True,'[UNC0]split[out][L0];[in][L0]vstack[UNC1]'), + ("split[out]","[in]vstack",'all',0,False,True,'[UNC0]split[out],[in]vstack[UNC1]'), # fmt: on ], ) @@ -79,11 +79,10 @@ def test_attach(left, right, left_on, right_on, ret): def test_join_bug(): - af1 = fgb.Chain("aevalsrc=0,aformat=sample_fmts=s16:r=44100") - af2 = fgb.Graph( - "channelmap=channel_layout=stereo:map=FC|FC,bandpass=channels=FL,aresample=22050" - ) - af3 = fgb.Chain("channelmap=channel_layout=stereo:map=FC|FC,bandpass=channels=FL,aresample=22050" - ) - af = af1 + af2 - assert af==af1+af3 \ No newline at end of file + af1 = fgb.Chain("aevalsrc,aformat") + af2 = fgb.Graph("channelmap,bandpass,aresample") + af3 = fgb.Chain("channelmap,bandpass,aresample") + af_a = af1 + af2 + af_b = af1 + af3 + + assert af_a==af_b \ No newline at end of file diff --git a/tests/test_filtergraph_chain.py b/tests/test_filtergraph_chain.py index ecfb18a3..fe61c44d 100644 --- a/tests/test_filtergraph_chain.py +++ b/tests/test_filtergraph_chain.py @@ -139,7 +139,7 @@ def test_iter_chains(expr, skip_if_no_input, skip_if_no_output, chainable_only, (operator.__rshift__, ("split",(0,1)), fgb.Chain("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), (operator.__rshift__, ("split[out]",1), fgb.Chain("overlay"), "[UNC0]split[L0][UNC2];[UNC1][L0]overlay[UNC3]"), (operator.__rshift__, ("split[out]", '[out]',None), fgb.Chain("overlay"), "[UNC0]split[L0][UNC2];[L0][UNC1]overlay[UNC3]"), - (operator.__rshift__, ["scale","fps"], fgb.Chain("hstack"), "[UNC0]scale[L0];[UNC1]fps[L1];[L0][L1]hstack[UNC2]"), + (operator.__rshift__, ["scale","fps"], fgb.Chain("hstack"), "[UNC0]scale[L0];[UNC1]fps,[L0]hstack[UNC2]"), (operator.__rshift__, fgb.Chain("split"), ["[v1]","[v2]"], "[UNC0]split[v1][v2]"), # (operator.__rshift__, fgb.Graph("split[out1][out2]"), ('[out1]', '[over]', "[base][over]overlay"), "split[out1][out2];[base][out1]overlay"), # fmt:on diff --git a/tests/test_filtergraph_fglinks.py b/tests/test_filtergraph_fglinks.py index fd20e3df..3b422ce2 100644 --- a/tests/test_filtergraph_fglinks.py +++ b/tests/test_filtergraph_fglinks.py @@ -152,11 +152,11 @@ def test_init(base_links): (["a", "b"], ["a", "b"]), ], ) -def test_resolve_label(labels, expects): +def testresolve_label(labels, expects): links = GraphLinks() def update(label): - links.data[links._resolve_label(label)] = None + links.data[links.resolve_label(label)] = None for label in labels: update(label) diff --git a/tests/test_filtergraph_presets.py b/tests/test_filtergraph_presets.py index 5de4760b..8f58d528 100644 --- a/tests/test_filtergraph_presets.py +++ b/tests/test_filtergraph_presets.py @@ -8,7 +8,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): diff --git a/tests/test_media.py b/tests/test_media.py index 1b16f3bd..9da403bd 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -58,7 +58,7 @@ def test_media_write(): pprint(ff.probe.format_basic(outfile)) pprint(ff.probe.streams_basic(outfile)) - +@pytest.mark.skip(reason='To be implemented - merge_audio preset filtergraph needs more work.') 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") @@ -90,19 +90,18 @@ def test_media_filter(): 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_args={"[out0]": {}, "audio": {"map": "[out2]"}}, - show_log=True, - shortest=ff.FLAG, - ) + outrates, outdata = ff.media.filter( + ["[0:V:0][1:V:0]vstack,split[out0]", "[2:a:0][3:a:0]amerge[out2]"], + "vvaa", + (fps, F), + (fps, F), + (fs, x), + (fs, x), + output_args={"[out0]": {},"out1":{}, "audio": {"map": "[out2]"}}, + show_log=True, + shortest=ff.FLAG, + ) - assert all(k in ("[out0]", "out1", "audio") for k in outrates) + assert all(k in ("[out0]", "out1", "audio") for k in outrates) - print(outrates) + print(outrates) From 43646a3e4add83848117a193bf8d26bf9d9a6cea Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 4 Jan 2026 21:58:21 -0500 Subject: [PATCH 320/344] wip15 --- src/ffmpegio/_typing.py | 2 +- src/ffmpegio/configure.py | 63 ++- src/ffmpegio/media.py | 9 +- src/ffmpegio/streams/AviStreams.py | 238 --------- src/ffmpegio/streams/BaseFFmpegRunner.py | 626 +++++++++-------------- src/ffmpegio/streams/SimpleStreams.py | 297 +---------- src/ffmpegio/streams/mixins.py | 327 ++++++++++++ 7 files changed, 611 insertions(+), 951 deletions(-) delete mode 100644 src/ffmpegio/streams/AviStreams.py create mode 100644 src/ffmpegio/streams/mixins.py diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 3269c370..196bdccb 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -49,7 +49,7 @@ RawStreamDef = ( - tuple[int | Fraction, RawDataBlob] | tuple[RawDataBlob | None, FFmpegOptionDict] + tuple[int | Fraction, RawDataBlob] | tuple[RawDataBlob, FFmpegOptionDict] ) """2-element tuple to define a raw stream data diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 22238ede..5829203a 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -211,11 +211,11 @@ class FFmpegArgs(TypedDict): def init_media_read( - urls: Sequence[ + input_urls: Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] ], - streams: ( + output_streams: ( Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None ), options: FFmpegOptionDict, @@ -227,7 +227,7 @@ def init_media_read( """Initialize FFmpeg arguments for media read :param urls: URLs of the media files to read. - :param streams: output stream mappings: + :param output_streams: 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 sequence of output option dict with `'map'` item to output-specific @@ -260,7 +260,7 @@ def init_media_read( 'pix_fmt' option is not explicitly set, 'rgb24' is used. """ - ninputs = len(urls) + ninputs = len(input_urls) if not ninputs: raise ValueError("At least one URL must be given.") @@ -276,10 +276,12 @@ def init_media_read( gopts["y"] = None # assign inputs - input_info = process_url_inputs(args, urls, inopts_default) + input_info = process_url_inputs(args, input_urls, inopts_default) # assign outputs - output_info = process_raw_outputs(args, input_info, streams, options, squeeze) + output_info = process_raw_outputs( + args, input_info, output_streams, options, squeeze + ) # standardize output stream options @@ -299,11 +301,11 @@ def init_media_read( def init_media_write( - urls: list[ + output_urls: list[ FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] ], - stream_types: Sequence[Literal["a", "v"]], - stream_args: Sequence[RawStreamDef], + input_stream_types: Sequence[Literal["a", "v"]], + input_stream_args: Sequence[RawStreamDef], extra_inputs: ( Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] @@ -311,8 +313,8 @@ def init_media_write( | None ), options: dict[str, Any], - dtypes: list[DTypeString | None] | None = None, - shapes: list[ShapeTuple | None] | None = None, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, ) -> tuple[ FFmpegArgs, list[RawInputInfoDict | EncodedInputInfoDict], @@ -320,16 +322,16 @@ def init_media_write( ]: """write multiple streams to a url/file - :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 output_url: output url + :param input_stream_types: list/string of 'a' or 'v', specifying the input raw streams' media types + :param input_stream_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 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 + :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: list of shapes of input samples or frames of input media streams, + :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 @@ -348,7 +350,7 @@ def init_media_write( """ - noutputs = len(urls) + noutputs = len(output_urls) if not noutputs: raise FFmpegioError("At least one URL must be given.") @@ -360,7 +362,12 @@ def init_media_write( # analyze and assign inputs input_info = process_raw_inputs( - args, stream_types, stream_args, inopts_default, dtypes, shapes + args, + input_stream_types, + input_stream_args, + inopts_default, + input_dtypes, + input_shapes, ) # append extra (not-piped) inputs @@ -371,7 +378,7 @@ def init_media_write( raise FFmpegioError("extra_inputs cannot be piped in.") from e # analyze and assign outputs - output_info = process_url_outputs(args, input_info, urls, options) + output_info = process_url_outputs(args, input_info, output_urls, options) # if output is piped, it must have the -f option specified for url, opts in args["outputs"]: @@ -388,7 +395,9 @@ def init_media_filter( input_types: Sequence[Literal["a", "v"]], input_args: Sequence[RawStreamDef], extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, - output_args: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + output_streams: ( + Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None + ), extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), @@ -463,7 +472,9 @@ def init_media_filter( # analyze and assign outputs - output_info = process_raw_outputs(args, input_info, output_args, options, squeeze) + output_info = process_raw_outputs( + args, input_info, output_streams, options, squeeze + ) # if additional (encoded) outputs are specified, append them to ffmpeg args # and output info @@ -486,8 +497,8 @@ def init_media_filter( def init_media_transcode( - inputs: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], - outputs: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], + input_urls: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], + output_urls: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], options: FFmpegOptionDict, ) -> tuple[FFmpegArgs, list[EncodedInputInfoDict], list[EncodedOutputInfoDict]]: """initialize media transcoder @@ -510,13 +521,13 @@ def init_media_transcode( # create a new FFmpeg dict args = empty(utils.pop_global_options(options)) - input_info = process_url_inputs(args, inputs, inopts_default) + input_info = process_url_inputs(args, input_urls, inopts_default) if not len(input_info): raise ValueError("At least one input must be given.") output_info = process_url_outputs( - args, input_info, outputs, options, skip_automapping=True + args, input_info, output_urls, options, skip_automapping=True ) if not len(output_info): @@ -1452,7 +1463,7 @@ def resolve_raw_output_streams( 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']): + if not utils.find_filter_complex_option(args["global_options"]): raise # test spec with possibly omitted brackets diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index e7de071a..cee81694 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -54,13 +54,8 @@ def _runner( ffmpegprocess.Popen, dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict] ]: - # 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 + # convert show_log to capture_log + capture_log = None if show_log else True # configure named pipes input_pipes = output_pipes = {} diff --git a/src/ffmpegio/streams/AviStreams.py b/src/ffmpegio/streams/AviStreams.py deleted file mode 100644 index f10b5aed..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/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index fb606720..a93f9b63 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -1,104 +1,97 @@ from __future__ import annotations import logging - import sys from time import time from contextlib import ExitStack -from fractions import Fraction - -from typing_extensions import Callable, Literal +from enum import IntEnum -from .. import configure, probe, ffmpegprocess +from .. import ffmpegprocess, configure, utils from .._typing import ( + Any, + Iterable, + Callable, + RawDataBlob, ProgressCallable, InputInfoDict, OutputInfoDict, - FFmpegOptionDict, - RawDataBlob, - ShapeTuple, - DTypeString, - MediaType, + InputPipeInfoDict, + OutputPipeInfoDict, ) -from ..configure import FFmpegArgs, MediaType, InitMediaOutputsCallable +from ..configure import FFmpegArgs from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError -from .._typing import FromBytesCallable, CountDataCallable, ToBytesCallable logger = logging.getLogger("ffmpegio") -__all__ = [ - "BaseFFmpegRunner", - "BaseRawInputsMixin", - "BaseRawOutputsMixin", - "BaseEncodedInputsMixin", - "BaseEncodedOutputsMixin", -] +__all__ = ["BaseFFmpegRunner"] class BaseFFmpegRunner: """Base class to run FFmpeg and manage its multiple I/O's""" + class Status(IntEnum): + NOTHING_SET = 0 + ARGUMENTS_SET = 1 + PIPES_SET = 2 + RUNNING = 3 + STOPPED = 4 + + probesize: int = 64 * 1024 default_timeout: float | None = None + _args: dict[str, Any] + + _init_func: Callable + _init_kws: dict + + _nb_inputs: tuple[int, int] = (0, 0) # (raw, raw+encoded) + _init_pipe: dict + _buffer: dict[int, bytes | list[RawDataBlob]] + + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] + _input_pipes: dict[int, InputPipeInfoDict] | None = None + _output_pipes: dict[int, OutputPipeInfoDict] | None = None + + _status: Status = Status.NOTHING_SET + _proc: ffmpegprocess.Popen + _stack: ExitStack + _logger: LoggerThread def __init__( self, - ffmpeg_args: FFmpegArgs, - input_info: list[InputInfoDict], - output_info: list[OutputInfoDict], - input_ready: Literal[True] | list[bool], - init_deferred_outputs: InitMediaOutputsCallable | None, - deferred_output_args: list[FFmpegOptionDict | None], + init_func: Callable, + init_kws: dict, + probesize: int | None = None, default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **_, ): """Base FFmpeg runner - :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: True to start FFmpeg, if not provide a list of per-stream readiness - :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 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._init_func = staticmethod(init_func) + self._init_kws = init_kws + self._stack: ExitStack = ExitStack() - 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 = [] # create logger without assigning the source stream self._logger = LoggerThread(None, bool(show_log)) # prepare FFmpeg keyword arguments self._args = { - "ffmpeg_args": ffmpeg_args, "progress": progress, "capture_log": True, "sp_kwargs": sp_kwargs, @@ -110,78 +103,204 @@ def __init__( if default_timeout is not None: self.default_timeout = default_timeout - def __enter__(self): + if probesize is not None: + self.probesize = int(probesize) + + self._input_pipes = {} + self._buffer = {} + + def _analyze_inputs(self): + """identify which input init_fun keyword arguments require user input""" + kws = self._init_kws + pipes = {} + if ( + "input_urls" in kws + ): # list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + pipes["input_urls"] = [ + i + for i, (url, opts) in enumerate(kws["input_urls"]) + if utils.is_pipe(url) + ] + self._nb_inputs = (0, len(kws["input_urls"])) + if "input_stream_args" in kws: # list[tuple[RawDataBlob, FFmpegOptionDict]] + n_in = len(kws["input_stream_args"]) + pipes["input_stream_args"] = range(n_in) + if ( + "extra_input" in kws + ): # list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + pipes["extra_input"] = [ + i + n_in + for i, (url, opts) in enumerate(kws["extra_input"]) + if utils.is_pipe(url) + ] + self._nb_inputs = (n_in, n_in + len(kws["extra_input"])) + self._init_pipe = pipes + + def _config_ffmpeg(self) -> bool: + """Configure FFmpeg options""" + + if self._status != self._status.NOTHING_SET: + raise FFmpegioError("FFmpeg options have already been configured.") + + kws = self._init_kws + kws["options"] = {"probesize_in": self.probesize, **kws["options"]} - self.open() - return self + try: + ffmpeg_args, input_info, output_info = self._init_func(**kws) + except: + return False - def open(self): - """start FFmpeg processing + self._args["ffmpeg_args"] = ffmpeg_args + self._input_info = input_info + self._output_info = output_info - Note - ---- + self._status = self._status.ARGUMENTS_SET - 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. + return True + + def _pre_write(self, stream: int, data: RawDataBlob | bytes): + """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 + + if it contains data for a new stream, attempts to configure ffmpeg args """ - if self._input_ready is True or all(self._input_ready): - self._open(False) + assert stream < self._nb_inputs[1] + assert self._status == self._status.NOTHING_SET + + # kws = self._init_func + # if "input_urls" in kws: + + # kws["input_urls"] # encoded input + # kws["extra_input"] # encoded input + # kws["input_stream_args"] # raw input - def _assign_pipes(self): - """assign pipes (pre-popen)""" - pass + if stream in self._buffer: + buf = self._buffer[stream] + if isinstance(data, bytes): + assert isinstance(buf, bytes) + self._buffer[stream] = buf + data - def _init_pipes(self): - """initialize pipes (post-popen)""" - pass + # update the kws + self._pipes - def _write_deferred_data(self): - pass + else: + assert not isinstance(buf, bytes) + self._buffer[stream].append(data) - def _open(self, deferred: bool): + else: # first write -> update the kws + if isinstance(data, bytes): + self._buffer[stream] = data + else: + assert not isinstance(buf, bytes) + self._buffer[stream] = [data] - logger.info("starting FFmpeg subprocess") + def _buffer_full(self, streams: Iterable[int]) -> bool: + """True if all piped input streams - if deferred: + :param streams: iterator of piped input stream indices + """ - assert self._init_deferred_outputs is not None + bufs = self._buffer + for s in streams: + if s not in bufs: + return False + buf = bufs[s] + if isinstance(buf, bytes) and len(buf) < self.probesize: + return False - # 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, + return True + + def _init_pipes(self, use_std_pipes: bool): + # set up and activate standard pipes and read/write threads + # configure named pipes + + if self._status != self._status.ARGUMENTS_SET: + if self._status < self._status.ARGUMENTS_SET: + raise FFmpegioError( + "FFmpeg configuration not set. Run `config_ffmpeg()` first." + ) + raise FFmpegioError("FFmpeg pipes have already configured.") + + args = self._args["ffmpeg_args"] + more_args = {} + input_pipes = {} + output_pipes = {} + + if len(self._input_info): + input_pipes, more_args = configure.assign_input_pipes( + args, self._input_info, use_std_pipes ) - # set up and activate named pipes and read/write threads - self._assign_pipes() + if len(self._output_info): + output_pipes, sp_kwargs = configure.assign_output_pipes( + args, self._output_info, use_std_pipes + ) + more_args.update(sp_kwargs) + + self._stack = configure.init_named_pipes( + input_pipes, output_pipes, self._input_info, self._output_info + ) + + self._input_pipes = input_pipes + self._output_pipes = output_pipes + self._args.update(more_args) + self._status = self._status.PIPES_SET + + def _on_exit(self, rc): + if self._status.RUNNING: + self._stack.close() + self._status = self._status.STOPPED + + def _run_ffmpeg(self): + + if self._status != self._status.PIPES_SET: + if self._status < self._status.PIPES_SET: + raise FFmpegioError( + "FFmpeg configuration not set. Run `config_ffmpeg()` first." + ) + raise FFmpegioError("FFmpeg pipes have already configured.") # run the FFmpeg try: - self._proc = ffmpegprocess.Popen( - **self._args, on_exit=(lambda _: self._stack.close()) - ) + self._status = self._status.RUNNING + self._proc = ffmpegprocess.Popen(**self._args, on_exit=self._on_exit) except: if self._stack is not None: self._stack.close() raise - # set up and activate standard pipes and read/write threads - self._init_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() + def _terminate(self): + """Kill FFmpeg process and close the streams""" - return self + 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._logger.join() + + def start(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 or all(self._input_ready): + self._open(False) def close(self): """Kill FFmpeg process and close the streams""" @@ -194,6 +313,11 @@ def close(self): self._logger.join() + def __enter__(self): + + self.open() + return self + def __exit__(self, *exc_details) -> bool: try: self.close() @@ -250,15 +374,16 @@ def wait(self, timeout: float | None = None) -> int | None: if timeout is not None: timeout += time() + # std pipe, no threading, flush and close the stdin + if self._proc.stdin is not None: + self._proc.stdin.flush() + self._proc.stdin.close() + # write the sentinel to each input queue - for info in self._input_info: - if "writer" in info: # has writer thread - info["writer"].write( - None, None if timeout is None else timeout - time() - ) - else: # std pipe, no threading - # close the stdout - self._proc.stdin.close() + for pinfo in self._input_pipes.values(): + pinfo["writer"].write( + None, None if timeout is None else timeout - time() + ) # wait until the FFmpeg finishes the job self._proc.wait(None if timeout is None else timeout - time()) @@ -270,297 +395,18 @@ def wait(self, timeout: float | None = None) -> int | None: rc = None return rc + # def _write_raw(self, stream: int, data: RawDataBlob): + # info = self._input_pipes[stream] + # info["writer"].write(data) -class BaseRawInputsMixin: - """write a raw media data to a specified stream (backend)""" - - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - _args: dict - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - # 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"] - for data in src: - writer.write(data, self.default_timeout) - self._deferred_data = [] - self._input_ready = True - - def _write_stream_bytes( - self, - converter: ToBytesCallable, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - b = converter(obj=data) - if not len(b): - return - - if self._input_ready is True: - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (KeyError, BrokenPipeError, OSError): - if self._logger: - 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, 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) - - @property - def input_types(self) -> dict[int, MediaType | None]: - """media type associated with the input streams""" - return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} - - @property - def input_rates(self) -> dict[int, int | Fraction | None]: - """sample or frame rates associated with the input streams""" - return { - i: v["raw_info"][2] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_dtypes(self) -> dict[int, DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - return { - i: v["raw_info"][0] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - @property - def input_shapes(self) -> dict[int, ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - return { - i: v["raw_info"][1] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - -class BaseEncodedInputsMixin: - - # FFmpegRunner's properties accessed - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - - 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: OutputInfoDict, - data: bytes, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - if self._input_ready is True: - 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[index] - if len(data0): - 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) - - -class BaseRawOutputsMixin: - - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread | None - - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(**kwargs) - - # 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 | None]: - """FFmpeg/custom labels of output streams""" - return [ - v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) - ] - - @property - def output_types(self) -> list[MediaType | None]: - """media type associated with the output streams (key)""" - return [v["media_type"] for v in self._output_info] - - @property - def output_rates(self) -> list[int | Fraction | None]: - """sample or frame rates associated with the output streams (key)""" - - def get_rate(v): - return v and v[2] - - return [get_rate(v) for v in self._output_info] - - @property - def output_dtypes(self) -> list[DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - - def get_dtype(v): - return v and v[1] - - return [get_dtype(v) for v in self._output_info] - - @property - def output_shapes(self) -> list[ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - - def get_shape(v): - return v and v[0] - - return [get_shape(v) for v in self._output_info] - - @property - def output_counts(self) -> list[int]: - """number of frames/samples read""" - return [0] * len(self._output_info) if self._n0 is None else list(self._n0) - - def _init_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 = self.output_rates - - if any(r is None for r in self._rates): - raise FFmpegioError("There is an output stream without known output rate.") - - 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_pipes() - - def _read_stream_bytes( - self, - converter: FromBytesCallable, - counter: CountDataCallable, - dtype: DTypeString, - shape: ShapeTuple, - info: OutputInfoDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze - ) - - # update the frame/sample counter - n = counter(obj=data) # actual number read - self._n0[stream_id] += n - - return data - - -class BaseEncodedOutputsMixin: - - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread - - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - - # set the default read block size - self._blocksize = blocksize - - def _init_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_pipes() - - def _read_encoded_stream( - self, - info: OutputInfoDict, - n: int, - timeout: float | None = None, - ) -> bytes: - """read selected output stream (shared backend)""" - - return info["reader"].read(n, timeout) + # def _write_enc(self, stream: int, data: bytes): + # info = self._input_pipes[stream] + # info["writer"].write(data) + # def _read_raw(self, stream: int, n: int, timeout: float | None) -> RawDataBlob: + # info = self._output_pipes[stream] + # return info["reader"].read(n, timeout or self.default_timeout) + # def _read_enc(self, stream: int, n: int, timeout: float | None) -> bytes: + # info = self._output_pipes[stream] + # return info["reader"].read(n, timeout or self.default_timeout) diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index f3bcb48d..97e93877 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -41,22 +41,15 @@ # info["writer"].write(None, None if timeout is None else timeout - time()) -class SimpleReaderBase(BaseFFmpegRunner): +class SimpleReader(BaseFFmpegRunner): """queue-less SISO media reader class""" def __init__( self, - *, - ffmpeg_args: FFmpegArgs, - input_info: list[InputInfoDict], - output_info: list[RawOutputInfoDict], - from_bytes: FromBytesCallable, - to_memoryview: ToBytesCallable, - show_log: bool | None, - progress: ProgressCallable | None, - blocksize: int, - default_timeout: float | None, - sp_kwargs: dict | None, + **init_kws, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + sp_kwargs: dict | None = None, ): """Queue-less simple media io runner @@ -247,113 +240,17 @@ def readinto(self, array: RawDataBlob) -> int: ) -class SimpleVideoReader(SimpleReaderBase): - - def __init__( - self, - *urls: FFmpegUrlType, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int = 1, - sp_kwargs: dict | None = None, - stream: str | StreamSpecDict | None = None, - default_timeout: float | None = None, - **options, - ): - # assign the input stream - map = "0:V:0" if stream is None else stream_spec_to_map_option(stream) - - args, input_info, output_info, _ = configure.init_media_read( - [*urls], [map], options - ) - ready = output_info is not None - - if len(output_info) != 1 or output_info[0]["media_type"] != "video": - raise FFmpegioError(f'no output video stream found in "{urls}" ({map=})') - - if not all(ready): - raise RuntimeError( - "Given file/url does not pre-provide the media information. Use media.read instead." - ) - - hook = plugins.get_hook() - - super().__init__( - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - show_log=show_log, - progress=progress, - blocksize=blocksize, - sp_kwargs=sp_kwargs, - from_bytes=hook.bytes_to_video, - to_memoryview=hook.video_bytes, - default_timeout=default_timeout, - ) +########################################################################### -class SimpleAudioReader(SimpleReaderBase): +class SimpleWriter(BaseFFmpegRunner): def __init__( self, - *urls: FFmpegUrlType, + **init_kws, show_log: bool | None = None, progress: ProgressCallable | None = None, - blocksize: int = 1, sp_kwargs: dict | None = None, - stream: str | StreamSpecDict | None = None, - default_timeout: float | None = None, - **options, - ): - # assign the input stream - map = "0:a:0" if stream is None else stream_spec_to_map_option(stream) - - args, input_info, ready, output_info, _ = configure.init_media_read( - [*urls], [map], options - ) - - if len(output_info) != 1 or output_info[0]["media_type"] != "audio": - raise FFmpegioError(f'no output audio stream found in "{url}" ({map=})') - - if not all(ready): - raise RuntimeError( - "Given file/url does not pre-provide the media information. Use media.read instead." - ) - - hook = plugins.get_hook() - - super().__init__( - ffmpeg_args=args, - input_info=input_info, - output_info=output_info, - show_log=show_log, - progress=progress, - blocksize=blocksize, - sp_kwargs=sp_kwargs, - from_bytes=hook.bytes_to_audio, - to_memoryview=hook.audio_bytes, - default_timeout=default_timeout, - ) - - -########################################################################### - - -class SimpleWriterBase(BaseFFmpegRunner): - def __init__( - self, - ffmpeg_args: FFmpegArgs, - input_info: list[InputInfoDict], - output_info: list[RawOutputInfoDict], - input_ready: Literal[True] | list[bool], - init_deferred_outputs: InitMediaOutputsCallable | None, - deferred_output_args: list[FFmpegOptionDict | None], - from_bytes: FromBytesCallable, - to_memoryview: ToBytesCallable, - show_log: bool | None, - progress: ProgressCallable | None, - default_timeout: float | None, - sp_kwargs: dict | None, ): """Queue-less simple media writer @@ -405,8 +302,6 @@ def __init__( ############ - self._get_bytes = to_memoryview - # input data must be initially buffered self._deferred_data = [] @@ -502,179 +397,3 @@ def write(self, data): def flush(self): self._proc.stdin.flush() - -class SimpleVideoWriter(SimpleWriterBase): - - def __init__( - self, - url: FFmpegUrlType, - input_rate: int | Fraction, - *, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - overwrite: bool | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - sp_kwargs: dict | None = None, - stream: str | StreamSpecDict | None = None, - default_timeout: float | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Write video data to a video file - - :param url: output url - :param input_rate: video frame rate - :param input_dtype: numpy-style data type string of input frames, defaults - to `None` (auto-detect). - :param input_shape: shapes of each video frame, 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 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 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) - """ - - # assign the input stream - st_map = options.pop("map", None) - if st_map is None: - st_map = "0:v:0" if stream is None else stream_spec_to_map_option(stream) - - hook = plugins.get_hook() - - options = {"probesize_in": 32, **options, "map": st_map} - if overwrite: - if "n" in options: - raise FFmpegioError( - "cannot specify both `overwrite=True` and `n=ff.FLAG`." - ) - options["y"] = None - - args, input_info, input_ready, output_info, output_args = ( - configure.init_media_write( - [url], - ["v"], - [(None, {"r": input_rate})], - False, - None, - None, - None, - extra_inputs, - options, - [input_dtype], - [input_shape], - ) - ) - - super().__init__( - ffmpeg_args=args, - input_info=input_info, - output_info=[{}] if output_info is None else output_info, - input_ready=input_ready, - init_deferred_outputs=configure.init_media_write_outputs, - deferred_output_args=output_args, - from_bytes=hook.bytes_to_video, - to_memoryview=hook.video_bytes, - show_log=show_log, - progress=progress, - default_timeout=default_timeout, - sp_kwargs=sp_kwargs, - ) - - -class SimpleAudioWriter(SimpleWriterBase): - - def __init__( - self, - url: FFmpegUrlType, - input_rate: int | Fraction, - *, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - overwrite: bool | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - sp_kwargs: dict | None = None, - stream: str | StreamSpecDict | None = None, - default_timeout: float | None = None, - **options: Unpack[FFmpegOptionDict], - ): - """Write video data to a video file - - :param url: output url - :param input_rate: video frame rate - :param input_dtype: numpy-style data type string of input frames, defaults - to `None` (auto-detect). - :param input_shape: shapes of each video frame, 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 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 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) - """ - - # assign the input stream - st_map = options.pop("map", None) - if st_map is None: - st_map = "0:a:0" if stream is None else stream_spec_to_map_option(stream) - hook = plugins.get_hook() - - options = {"probesize_in": 32, **options, "map": st_map} - if overwrite: - if "n" in options: - raise FFmpegioError( - "cannot specify both `overwrite=True` and `n=ff.FLAG`." - ) - options["y"] = None - - args, input_info, input_ready, output_info, output_args = ( - configure.init_media_write( - [url], - ["a"], - [(None, {"ar": input_rate})], - False, - None, - None, - None, - extra_inputs, - options, - [input_dtype], - [input_shape], - ) - ) - - 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, - from_bytes=hook.bytes_to_audio, - to_memoryview=hook.audio_bytes, - show_log=show_log, - progress=progress, - default_timeout=default_timeout, - sp_kwargs=sp_kwargs, - ) diff --git a/src/ffmpegio/streams/mixins.py b/src/ffmpegio/streams/mixins.py new file mode 100644 index 00000000..3035b39c --- /dev/null +++ b/src/ffmpegio/streams/mixins.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import logging + +from contextlib import ExitStack +from fractions import Fraction + +from typing_extensions import Callable, Literal + +from .. import configure, probe + +from .._typing import ( + InputInfoDict, + OutputInfoDict, + FFmpegOptionDict, + RawDataBlob, + ShapeTuple, + DTypeString, + MediaType, +) + +from ..configure import MediaType +from ..threading import LoggerThread +from ..errors import FFmpegError, FFmpegioError +from .._typing import FromBytesCallable, CountDataCallable, ToBytesCallable + +logger = logging.getLogger("ffmpegio") + +__all__ = [ + "BaseRawInputsMixin", + "BaseRawOutputsMixin", + "BaseEncodedInputsMixin", + "BaseEncodedOutputsMixin", +] + + +class BaseRawInputsMixin: + """write a raw media data to a specified stream (backend)""" + + default_timeout: float | None + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + _args: dict + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + # 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"] + for data in src: + writer.write(data, self.default_timeout) + self._deferred_data = [] + self._input_ready = True + + def _write_stream_bytes( + self, + converter: ToBytesCallable, + stream_id: int, + data: RawDataBlob, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + b = converter(obj=data) + if not len(b): + return + + if self._input_ready is True: + logger.debug("[writer main] writing...") + + try: + self._input_info[stream_id]["writer"].write(b, timeout) + except (KeyError, BrokenPipeError, OSError): + if self._logger: + 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, 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) + + @property + def input_types(self) -> dict[int, MediaType | None]: + """media type associated with the input streams""" + return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} + + @property + def input_rates(self) -> dict[int, int | Fraction | None]: + """sample or frame rates associated with the input streams""" + return { + i: v["raw_info"][2] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_dtypes(self) -> dict[int, DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + return { + i: v["raw_info"][0] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + @property + def input_shapes(self) -> dict[int, ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + return { + i: v["raw_info"][1] if "raw_info" in v else None + for i, v in enumerate(self._input_info) + } + + +class BaseEncodedInputsMixin: + + # FFmpegRunner's properties accessed + default_timeout: float | None + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] + _deferred_data: list[list[bytes]] + _input_ready: Literal[True] | list[bool] + _logger: LoggerThread | None + _open: Callable[[bool], None] + + 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: OutputInfoDict, + data: bytes, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + if self._input_ready is True: + 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[index] + if len(data0): + 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) + + +class BaseRawOutputsMixin: + + default_timeout: float | None + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread | None + + def __init__(self, blocksize, ref_output, **kwargs): + super().__init__(**kwargs) + + # 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 | None]: + """FFmpeg/custom labels of output streams""" + return [ + v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) + ] + + @property + def output_types(self) -> list[MediaType | None]: + """media type associated with the output streams (key)""" + return [v["media_type"] for v in self._output_info] + + @property + def output_rates(self) -> list[int | Fraction | None]: + """sample or frame rates associated with the output streams (key)""" + + def get_rate(v): + return v and v[2] + + return [get_rate(v) for v in self._output_info] + + @property + def output_dtypes(self) -> list[DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" + + def get_dtype(v): + return v and v[1] + + return [get_dtype(v) for v in self._output_info] + + @property + def output_shapes(self) -> list[ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + + def get_shape(v): + return v and v[0] + + return [get_shape(v) for v in self._output_info] + + @property + def output_counts(self) -> list[int]: + """number of frames/samples read""" + return [0] * len(self._output_info) if self._n0 is None else list(self._n0) + + def _init_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 = self.output_rates + + if any(r is None for r in self._rates): + raise FFmpegioError("There is an output stream without known output rate.") + + 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_pipes() + + def _read_stream_bytes( + self, + converter: FromBytesCallable, + counter: CountDataCallable, + dtype: DTypeString, + shape: ShapeTuple, + info: OutputInfoDict, + stream_id: int | str, + n: int, + timeout: float | None = None, + squeeze: bool = False, + ) -> RawDataBlob: + """read selected output stream (shared backend)""" + + data = converter( + b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze + ) + + # update the frame/sample counter + n = counter(obj=data) # actual number read + self._n0[stream_id] += n + + return data + + +class BaseEncodedOutputsMixin: + + default_timeout: float | None + _input_info: list[InputInfoDict] + _output_info: list[OutputInfoDict] + _deferred_data: list[list[bytes]] + _input_ready: bool + _logger: LoggerThread + + def __init__(self, blocksize, **kwargs): + super().__init__(**kwargs) + + # set the default read block size + self._blocksize = blocksize + + def _init_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_pipes() + + def _read_encoded_stream( + self, + info: OutputInfoDict, + n: int, + timeout: float | None = None, + ) -> bytes: + """read selected output stream (shared backend)""" + + return info["reader"].read(n, timeout) From c785ad7ba0318bb3586928368dbc084497bac816 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 5 Jan 2026 09:01:00 -0500 Subject: [PATCH 321/344] wip16 --- src/ffmpegio/streams/BaseFFmpegRunner.py | 165 ++++++++++++----------- 1 file changed, 86 insertions(+), 79 deletions(-) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index a93f9b63..59bbf81b 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -10,6 +10,7 @@ from .._typing import ( Any, + Literal, Iterable, Callable, RawDataBlob, @@ -47,8 +48,9 @@ class Status(IntEnum): _init_kws: dict _nb_inputs: tuple[int, int] = (0, 0) # (raw, raw+encoded) - _init_pipe: dict - _buffer: dict[int, bytes | list[RawDataBlob]] + _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_input"]] + _piped_inputs_buffer: dict[int, bytes | list[RawDataBlob]] + _piped_inputs_avail: int = 0 _input_info: list[InputInfoDict] _output_info: list[OutputInfoDict] @@ -65,12 +67,12 @@ def __init__( self, init_func: Callable, init_kws: dict, - probesize: int | None = None, default_timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, + probesize: int | None = None, ): """Base FFmpeg runner @@ -82,6 +84,8 @@ def __init__( to None """ + init_kws["options"] = {"probesize_in": probesize, **init_kws["options"]} + self._init_func = staticmethod(init_func) self._init_kws = init_kws @@ -106,63 +110,45 @@ def __init__( if probesize is not None: self.probesize = int(probesize) - self._input_pipes = {} - self._buffer = {} + self._piped_inputs = {} + self._piped_inputs_buffer = {} def _analyze_inputs(self): - """identify which input init_fun keyword arguments require user input""" + """identify which input init_fun keyword arguments require data from pipe""" kws = self._init_kws - pipes = {} - if ( - "input_urls" in kws - ): # list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - pipes["input_urls"] = [ - i - for i, (url, opts) in enumerate(kws["input_urls"]) - if utils.is_pipe(url) - ] + pipes = self._piped_inputs + if "input_urls" in kws: + # encoded: list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + for i, (url, _) in enumerate(kws["input_urls"]): + if utils.is_pipe(url): + pipes[i] = "input_urls" self._nb_inputs = (0, len(kws["input_urls"])) - if "input_stream_args" in kws: # list[tuple[RawDataBlob, FFmpegOptionDict]] + if "input_stream_args" in kws: + # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] n_in = len(kws["input_stream_args"]) - pipes["input_stream_args"] = range(n_in) - if ( - "extra_input" in kws - ): # list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - pipes["extra_input"] = [ - i + n_in - for i, (url, opts) in enumerate(kws["extra_input"]) - if utils.is_pipe(url) - ] + for i in range(n_in): + pipes[i] = "input_stream_args" + if "extra_input" in kws: + # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + for i, (url, _) in enumerate(kws["extra_input"]): + if utils.is_pipe(url): + pipes[i + n_in] = "extra_input" self._nb_inputs = (n_in, n_in + len(kws["extra_input"])) - self._init_pipe = pipes - - def _config_ffmpeg(self) -> bool: - """Configure FFmpeg options""" - - if self._status != self._status.NOTHING_SET: - raise FFmpegioError("FFmpeg options have already been configured.") - - kws = self._init_kws - kws["options"] = {"probesize_in": self.probesize, **kws["options"]} - try: - ffmpeg_args, input_info, output_info = self._init_func(**kws) - except: - return False - - self._args["ffmpeg_args"] = ffmpeg_args - self._input_info = input_info - self._output_info = output_info - - self._status = self._status.ARGUMENTS_SET - - return True - - def _pre_write(self, stream: int, data: RawDataBlob | bytes): + def _put_aside_input( + self, stream: int, data: RawDataBlob | bytes + ) -> bytes | RawDataBlob | None: """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 + :returns: the first data blob of the raw stream or all received bytes of + encoded stream (repeats every time) or None if no new data + + 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 """ @@ -170,46 +156,52 @@ def _pre_write(self, stream: int, data: RawDataBlob | bytes): assert stream < self._nb_inputs[1] assert self._status == self._status.NOTHING_SET - # kws = self._init_func - # if "input_urls" in kws: - - # kws["input_urls"] # encoded input - # kws["extra_input"] # encoded input - # kws["input_stream_args"] # raw input - - if stream in self._buffer: - buf = self._buffer[stream] + if stream in self._piped_inputs_buffer: + buf = self._piped_inputs_buffer[stream] if isinstance(data, bytes): assert isinstance(buf, bytes) - self._buffer[stream] = buf + data - - # update the kws - self._pipes + self._piped_inputs_buffer[stream] = buf = buf + data + return buf else: assert not isinstance(buf, bytes) - self._buffer[stream].append(data) + self._piped_inputs_buffer[stream].append(data) else: # first write -> update the kws - if isinstance(data, bytes): - self._buffer[stream] = data - else: - assert not isinstance(buf, bytes) - self._buffer[stream] = [data] + self._piped_inputs_buffer[stream] = ( + data if isinstance(data, bytes) else [data] + ) + self._piped_inputs_avail = self._piped_inputs_avail + 1 + return data - def _buffer_full(self, streams: Iterable[int]) -> bool: - """True if all piped input streams + return None - :param streams: iterator of piped input stream indices - """ + def _try_config_ffmpeg( + self, stream: int = -1, data: bytes | RawDataBlob | None = None + ) -> bool: + """Configure FFmpeg options""" + + if self._status != self._status.NOTHING_SET: + raise FFmpegioError("FFmpeg options have already been configured.") + + if stream >= 0 and data is not None: + # load the new data blob/bytes to the respective keyword argument + kw_name = self._piped_inputs[stream] + i = stream - self._nb_inputs[0] if kw_name == "extra_input" else stream + self._init_kws[kw_name][i] = (data, self._init_kws[kw_name][i][1]) + + kws = self._init_kws + + try: + ffmpeg_args, input_info, output_info = self._init_func(**kws) + except: + return False - bufs = self._buffer - for s in streams: - if s not in bufs: - return False - buf = bufs[s] - if isinstance(buf, bytes) and len(buf) < self.probesize: - return False + self._args["ffmpeg_args"] = ffmpeg_args + self._input_info = input_info + self._output_info = output_info + + self._status = self._status.ARGUMENTS_SET return True @@ -276,6 +268,21 @@ def _run_ffmpeg(self): self._logger.stderr = self._proc.stderr self._logger.start() + def _write_from_buffer(self): + # remove the data from the init keyword args + for st, kw in self._piped_inputs.items(): + i = st - self._nb_inputs[0] if kw == "extra_input" else st + self._init_kws[kw][i] = ("-", self._init_kws[kw][i][1]) + + buf = self._piped_inputs_buffer[buf] + if isinstance(buf, bytes): + self._input_pipes[i]["writer"].write(buf) + else: + for frame in buf: + self._input_pipes[i]["writer"].write(frame) + + del self._piped_inputs_buffer[buf] + def _terminate(self): """Kill FFmpeg process and close the streams""" From 00bd5855904c3a39d3d1a3c29e2d9fa2b7f67c17 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 6 Jan 2026 19:16:37 -0500 Subject: [PATCH 322/344] wip17 --- src/ffmpegio/_open.py | 20 +-- src/ffmpegio/_typing.py | 4 +- src/ffmpegio/configure.py | 110 +++++++++--- src/ffmpegio/errors.py | 3 + src/ffmpegio/streams/BaseFFmpegRunner.py | 118 ++++++++---- src/ffmpegio/streams/PipedStreams.py | 4 +- src/ffmpegio/streams/SimpleStreams.py | 10 +- src/ffmpegio/streams/__init__.py | 42 ++--- src/ffmpegio/streams/mixins.py | 219 +++++++++++++++-------- src/ffmpegio/utils/__init__.py | 19 +- tests/test_open.py | 8 +- tests/test_simplestreams.py | 16 +- 12 files changed, 361 insertions(+), 212 deletions(-) diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index 0a8adafa..b36cb7ec 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -81,7 +81,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleVideoReader: +) -> streams.SimpleReader: """open a single-stream video reader :param urls_fgs: URL of the file or format/device object to obtain a video stream from. @@ -117,7 +117,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleAudioReader: +) -> streams.SimpleReader: """open a single-source audio reader :param urls_fgs: URL of the file or format/device object to obtain a media stream from. @@ -158,7 +158,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleVideoReader: +) -> streams.SimpleReader: """open a single-destination video writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output @@ -205,7 +205,7 @@ def open( default_timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleAudioWriter: +) -> streams.SimpleWriter: """open a single-destination audio writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output @@ -826,8 +826,8 @@ def _create_reader( streams.MediaReader | streams.StdAudioDecoder | streams.StdVideoDecoder - | streams.SimpleAudioReader - | streams.SimpleVideoReader + | streams.SimpleReader + | streams.SimpleReader ): if len(args): @@ -855,7 +855,7 @@ def _create_reader( StreamClass = ( streams.MediaReader if not is_siso - else streams.SimpleAudioReader if is_audio else streams.SimpleVideoReader + else streams.SimpleReader if is_audio else streams.SimpleReader ) reader = StreamClass(*urls, **kwargs) @@ -871,8 +871,8 @@ def _create_writer( streams.MediaWriter | streams.StdAudioEncoder | streams.StdVideoEncoder - | streams.SimpleAudioWriter - | streams.SimpleVideoWriter + | streams.SimpleWriter + | streams.SimpleWriter ): if len(args) > 1: @@ -899,7 +899,7 @@ def _create_writer( writer = StreamClass(*args, **kwargs) else: StreamClass = ( - streams.SimpleAudioWriter if is_audio else streams.SimpleVideoWriter + streams.SimpleWriter if is_audio else streams.SimpleWriter ) writer = StreamClass(*urls, *args, **kwargs) return writer diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 196bdccb..cd8e5409 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -313,7 +313,7 @@ class RawDirectOutputInfoDict(TypedDict): """ dst_type: Literal["buffer"] # True if file path/url - media_type: MediaType | None # + media_type: MediaType # raw_info: RawStreamInfoTuple bytes2data: FromBytesCallable data_is_empty: IsEmptyCallable @@ -343,7 +343,7 @@ class RawFilteredOutputInfoDict(TypedDict): """ dst_type: Literal["buffer"] # True if file path/url - media_type: MediaType | None # + media_type: MediaType # raw_info: RawStreamInfoTuple bytes2data: FromBytesCallable data_is_empty: IsEmptyCallable diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 5829203a..5b27056d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -46,6 +46,7 @@ cast, Any, TypedDict, + NotRequired, Unpack, Callable, DTypeString, @@ -90,13 +91,6 @@ from . import utils, probe, plugins from . import filtergraph as fgb from .filtergraph.abc import FilterGraphObject -from .filtergraph.presets import ( - merge_audio, - filter_video_basic, - remove_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 .utils import ( @@ -114,7 +108,12 @@ parse_map_option, map_option as compose_map_option, ) -from .errors import FFmpegioError, FFmpegioNoPipeAllowed +from .errors import ( + FFmpegioError, + FFmpegioNoPipeAllowed, + FFmpegioInsufficientInputData, + FFmpegError, +) from .threading import ReaderThread, WriterThread, CopyFileObjThread ################################# @@ -205,6 +204,48 @@ class FFmpegArgs(TypedDict): ################################# ## module functions +############################################################################### +### compatible typed dicts for media initializer function keyword arguments ### +############################################################################### + + +class MediaReadKwsDict(TypedDict): + input_urls: list[FFmpegInputOptionTuple] + output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + options: FFmpegOptionDict + extra_outputs: list[FFmpegOutputOptionTuple] + squeeze: bool + + +class MediaWriteKwsDict(TypedDict): + output_urls: list[FFmpegOutputOptionTuple] + input_stream_types: list[Literal["a", "v"]] + input_stream_args: list[tuple[RawDataBlob|None, FFmpegOptionDict]] + extra_inputs: list[FFmpegInputOptionTuple] + options: dict[str, Any] + input_dtypes: NotRequired[list[DTypeString | None] | None] + input_shapes: NotRequired[list[ShapeTuple | None] | None] + + +class MediaFilterKwsDict(TypedDict): + expr: str | FilterGraphObject | list[str | FilterGraphObject] | None + input_stream_types: list[Literal["a", "v"]] + input_stream_args: list[tuple[RawDataBlob|None, FFmpegOptionDict]] + extra_inputs: list[FFmpegInputOptionTuple] + output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + extra_outputs: list[FFmpegOutputOptionTuple] + options: FFmpegOptionDict + squeeze: bool + input_dtypes: NotRequired[list[DTypeString] | None] + input_shapes: NotRequired[list[ShapeTuple] | None] + + +class MediaTranscoderKwsDict(TypedDict): + input_urls: list[FFmpegInputOptionTuple] + output_urls: list[FFmpegOutputOptionTuple] + options: FFmpegOptionDict + + ####################R### ### I/O initializers ### ######################## @@ -279,9 +320,14 @@ def init_media_read( input_info = process_url_inputs(args, input_urls, inopts_default) # assign outputs - output_info = process_raw_outputs( - args, input_info, output_streams, options, squeeze - ) + try: + output_info = process_raw_outputs( + args, input_info, output_streams, options, squeeze + ) + except FFmpegError as e: + raise FFmpegioInsufficientInputData( + "Failed to retrieve input stream information." + ) from e # standardize output stream options @@ -392,8 +438,8 @@ def init_media_write( def init_media_filter( expr: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, - input_types: Sequence[Literal["a", "v"]], - input_args: Sequence[RawStreamDef], + input_stream_types: Sequence[Literal["a", "v"]], + input_stream_args: Sequence[RawStreamDef], extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, output_streams: ( Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None @@ -408,12 +454,13 @@ def init_media_filter( ) -> tuple[FFmpegArgs, list[RawInputInfoDict], list[RawOutputInfoDict]]: """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 expr: filtergraph definition(s), may be None to perform implicit filtering + via output options (e.g., rate or format changes) + :param input_stream_types: list/string of 'a' or 'v', specifying the input raw streams' media types + :param input_stream_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 output_args: output stream mappings and optional per-stream options: + :param output_streams: output stream mappings and optional per-stream options: - `None` to map all filtergraph outputs - a sequence of output option dict with `'map'` item to output-specific options @@ -461,7 +508,12 @@ def init_media_filter( # analyze and assign inputs input_info = process_raw_inputs( - args, input_types, input_args, inopts_default, input_dtypes, input_shapes + args, + input_stream_types, + input_stream_args, + inopts_default, + input_dtypes, + input_shapes, ) if extra_inputs is not None: @@ -472,9 +524,14 @@ def init_media_filter( # analyze and assign outputs - output_info = process_raw_outputs( - args, input_info, output_streams, options, squeeze - ) + 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 @@ -2090,17 +2147,18 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: "s": s, } + if raw_info is None: + raise FFmpegioInsufficientInputData( + "Failed to resolve raw input data format." + ) + if more_opts is not None: opts.update(more_opts) info = { "src_type": "buffer", "media_type": media_type, - "raw_info": ( - (None, None, opts[ropt]) - if raw_info is None - else (*raw_info, opts[ropt]) - ), + "raw_info": (*raw_info, opts[ropt]), **get_callables(media_type), } diff --git a/src/ffmpegio/errors.py b/src/ffmpegio/errors.py index c1469985..ac4f0e0d 100644 --- a/src/ffmpegio/errors.py +++ b/src/ffmpegio/errors.py @@ -5,6 +5,9 @@ class FFmpegioError(Exception): pass +class FFmpegioInsufficientInputData(FFmpegioError): + pass + class FFmpegioNoPipeAllowed(FFmpegioError): pass diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 59bbf81b..8f78875b 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -23,7 +23,7 @@ from ..configure import FFmpegArgs from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError +from ..errors import FFmpegError, FFmpegioError, FFmpegioInsufficientInputData logger = logging.getLogger("ffmpegio") @@ -35,31 +35,37 @@ class BaseFFmpegRunner: class Status(IntEnum): NOTHING_SET = 0 - ARGUMENTS_SET = 1 - PIPES_SET = 2 - RUNNING = 3 - STOPPED = 4 + BUFFERING = 1 + ARGUMENTS_SET = 2 + PIPES_SET = 3 + RUNNING = 4 + STOPPED = 5 probesize: int = 64 * 1024 default_timeout: float | None = None - _args: dict[str, Any] + # configure.init_media_xxx function & its keyword arguments _init_func: Callable _init_kws: dict + # object status enum + _status: Status = Status.NOTHING_SET + + # pre-analysis/buffering variables _nb_inputs: tuple[int, int] = (0, 0) # (raw, raw+encoded) - _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_input"]] + _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_inputs"]] _piped_inputs_buffer: dict[int, bytes | list[RawDataBlob]] _piped_inputs_avail: int = 0 + # ffmpeg arguments and associated input/output information + _args: dict[str, Any] _input_info: list[InputInfoDict] _output_info: list[OutputInfoDict] - _input_pipes: dict[int, InputPipeInfoDict] | None = None - _output_pipes: dict[int, OutputPipeInfoDict] | None = None - - _status: Status = Status.NOTHING_SET + # ffmpeg subprocess and associated objects _proc: ffmpegprocess.Popen + _input_pipes: dict[int, InputPipeInfoDict] | None = None + _output_pipes: dict[int, OutputPipeInfoDict] | None = None _stack: ExitStack _logger: LoggerThread @@ -113,6 +119,10 @@ def __init__( self._piped_inputs = {} self._piped_inputs_buffer = {} + # identify the piped inputs, which may require data pre-buffering to + # configure the FFmpeg arguments + self._analyze_inputs() + def _analyze_inputs(self): """identify which input init_fun keyword arguments require data from pipe""" kws = self._init_kws @@ -128,12 +138,12 @@ def _analyze_inputs(self): n_in = len(kws["input_stream_args"]) for i in range(n_in): pipes[i] = "input_stream_args" - if "extra_input" in kws: + if "extra_inputs" in kws: # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - for i, (url, _) in enumerate(kws["extra_input"]): + for i, (url, _) in enumerate(kws["extra_inputs"]): if utils.is_pipe(url): - pipes[i + n_in] = "extra_input" - self._nb_inputs = (n_in, n_in + len(kws["extra_input"])) + pipes[i + n_in] = "extra_inputs" + self._nb_inputs = (n_in, n_in + len(kws["extra_inputs"])) def _put_aside_input( self, stream: int, data: RawDataBlob | bytes @@ -187,16 +197,36 @@ def _try_config_ffmpeg( if stream >= 0 and data is not None: # load the new data blob/bytes to the respective keyword argument kw_name = self._piped_inputs[stream] - i = stream - self._nb_inputs[0] if kw_name == "extra_input" else stream + i = stream - self._nb_inputs[0] if kw_name == "extra_inputs" else stream self._init_kws[kw_name][i] = (data, self._init_kws[kw_name][i][1]) kws = self._init_kws try: ffmpeg_args, input_info, output_info = self._init_func(**kws) - except: + except FFmpegioInsufficientInputData: + # fail only if the error was caused by insufficient input data return False + # Set Input Pipes + # those input streams which required pre-buffered data are + # misconfigured because the data were presented to the configurator + # as the buffered data (as opposed to partial data) + + for st, kw in self._piped_inputs.items(): + i = st - self._nb_inputs[0] if kw == "extra_inputs" else st + data, opts = self._init_kws[kw][i] + + # look for the matching data in info['buffer'] + for info in input_info: + data_in_info = info.get("buffer", None) + if data_in_info == data: + del info["buffer"] + break + + # remove the data from the init keyword args + self._init_kws[kw][i] = ("-", opts) + self._args["ffmpeg_args"] = ffmpeg_args self._input_info = input_info self._output_info = output_info @@ -205,7 +235,13 @@ def _try_config_ffmpeg( return True - def _init_pipes(self, use_std_pipes: bool): + def _on_exit(self, rc): + if self._status.RUNNING: + self._stack.close() + self._status = self._status.STOPPED + + def _run_ffmpeg(self, use_std_pipes: bool): + # set up and activate standard pipes and read/write threads # configure named pipes @@ -241,13 +277,6 @@ def _init_pipes(self, use_std_pipes: bool): self._args.update(more_args) self._status = self._status.PIPES_SET - def _on_exit(self, rc): - if self._status.RUNNING: - self._stack.close() - self._status = self._status.STOPPED - - def _run_ffmpeg(self): - if self._status != self._status.PIPES_SET: if self._status < self._status.PIPES_SET: raise FFmpegioError( @@ -269,15 +298,18 @@ def _run_ffmpeg(self): self._logger.start() def _write_from_buffer(self): - # remove the data from the init keyword args + for st, kw in self._piped_inputs.items(): - i = st - self._nb_inputs[0] if kw == "extra_input" else st + i = st - self._nb_inputs[0] if kw == "extra_inputs" else st + + # remove the data from the init keyword args self._init_kws[kw][i] = ("-", self._init_kws[kw][i][1]) - buf = self._piped_inputs_buffer[buf] - if isinstance(buf, bytes): + # write all the buffered data to the stream + buf = self._piped_inputs_buffer[st] + if isinstance(buf, bytes): # bytes -> encoded stream self._input_pipes[i]["writer"].write(buf) - else: + else: # raw data blob -> raw data stream for frame in buf: self._input_pipes[i]["writer"].write(frame) @@ -294,7 +326,7 @@ def _terminate(self): self._logger.join() - def start(self): + def open(self): """start FFmpeg processing Note @@ -306,19 +338,27 @@ def start(self): """ - if self._input_ready is True or all(self._input_ready): - self._open(False) + if self._status != self._status.NOTHING_SET: + 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 not ok: + self._status = self._status.BUFFERING + return + + # otherwise, ready to roll + self._run_ffmpeg(False) 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() + if self._status != self._status.RUNNING: + raise FFmpegioError("FFmpeg is not running.") - self._logger.join() + self._terminate() def __enter__(self): diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 9cd50794..abda103d 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -28,8 +28,8 @@ from ..filtergraph.abc import FilterGraphObject from ..errors import FFmpegioError -from .BaseFFmpegRunner import ( - BaseFFmpegRunner as _BaseFFmpegRunner, +from .BaseFFmpegRunner import BaseFFmpegRunner as _BaseFFmpegRunner +from .mixins import ( BaseRawInputsMixin as _BaseRawInputsMixin, BaseRawOutputsMixin as _BaseRawOutputsMixin, BaseEncodedInputsMixin as _BaseEncodedInputsMixin, diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 97e93877..8885ff6d 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -32,8 +32,7 @@ from .._utils import get_bytesize # fmt:off -__all__ = [ "SimpleVideoReader", "SimpleAudioReader", "SimpleVideoWriter", - "SimpleAudioWriter"] +__all__ = [ "SimpleReader", "SimpleWriter"] # fmt:on @@ -46,7 +45,8 @@ class SimpleReader(BaseFFmpegRunner): def __init__( self, - **init_kws, + *, + init_kws, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, @@ -240,14 +240,13 @@ def readinto(self, array: RawDataBlob) -> int: ) - ########################################################################### class SimpleWriter(BaseFFmpegRunner): def __init__( self, - **init_kws, + # **init_kws, show_log: bool | None = None, progress: ProgressCallable | None = None, sp_kwargs: dict | None = None, @@ -396,4 +395,3 @@ def write(self, data): def flush(self): self._proc.stdin.flush() - diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index 2ef5dd92..954ef235 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -1,29 +1,22 @@ -'''media streamer classes +"""media streamer classes - ==================== ===================== ==================== - Class Name Input(s) Output(s) - ==================== ===================== ==================== - SimpleVideoReader multiple urls single video - SimpleVideoWriter single video single url - SimpleAudioReader multiple urls single audio - SimpleAudioWriter single audio single url +=============== ===================== ==================== +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 - ==================== ==================== ==================== -''' +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 .SimpleStreams import ( - SimpleVideoReader, - SimpleVideoWriter, - SimpleAudioReader, - SimpleAudioWriter -) +from .SimpleStreams import SimpleReader, SimpleWriter from .PipedStreams import ( MediaReader, MediaWriter, @@ -33,14 +26,13 @@ SIMOMediaFilter, MIMOMediaFilter, ) -from .AviStreams import AviMediaReader # TODO multi-stream write # TODO Buffered reverse video read # fmt: off -__all__ = ["SimpleVideoReader", "SimpleVideoWriter", "SimpleAudioReader", "SimpleAudioWriter", +__all__ = ["SimpleReader", "SimpleWriter", "MediaReader", "MediaWriter", "MediaTranscoder", "SISOMediaFilter", "MISOMediaFilter", "SIMOMediaFilter", "MIMOMediaFilter"] # fmt: on diff --git a/src/ffmpegio/streams/mixins.py b/src/ffmpegio/streams/mixins.py index 3035b39c..686309df 100644 --- a/src/ffmpegio/streams/mixins.py +++ b/src/ffmpegio/streams/mixins.py @@ -12,6 +12,8 @@ from .._typing import ( InputInfoDict, OutputInfoDict, + InputPipeInfoDict, + OutputPipeInfoDict, FFmpegOptionDict, RawDataBlob, ShapeTuple, @@ -34,7 +36,104 @@ ] -class BaseRawInputsMixin: +class BaseInputsMixin: + """write a raw media data to a specified stream (backend)""" + + _init_kws: dict + _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_input"]] + _input_info: list[InputInfoDict] | None + _input_pipes: list[InputPipeInfoDict] | None + _logger: LoggerThread | None + _open: Callable[[bool], None] + _args: dict + + # def __init__(self, **kwargs): + # super().__init__(**kwargs) + + # # input data must be initially buffered + # self._deferred_data = [[] for _ in range(len(self._input_info))] + + @property + def input_types(self) -> dict[int, MediaType | Literal["encoded"]]: + """input piped types (lists both encoded and raw media pipes) + + - only piped inputs are returned + - integer keys is the unique input index (this index is not contiguous + if non-piped inputs are also used.) + - values are either 'video' or 'audio' if raw media stream or 'encoded' + if encoded byte stream + + """ + + kws = self._init_kws + return { + i: ( + "encoded" + if kw in ("input_urls", "extra_inputs") + else {"a": "audio", "v": "video"}[kws["input_stream_types"][i]] + ) + for i, kw in self._piped_inputs.items() + } + + @property + def input_rates(self) -> dict[int, int | Fraction]: + """audio sample or video frame rates associated with the input media streams""" + kws = self._init_kws + return { + i: kws["input_stream_args"][i][1][ + {"a": "ar", "v": "r"}[kws["input_stream_types"][i]] + ] + for i, kw in self._piped_inputs.items() + if kw == "input_stream_args" + } + + 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: OutputInfoDict, + data: bytes, + timeout: float | None, + ): + """write a raw media data to a specified stream (backend)""" + + if self._input_ready is True: + 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[index] + if len(data0): + 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) + + +class BaseRawInputsMixin(BaseInputsMixin): """write a raw media data to a specified stream (backend)""" default_timeout: float | None @@ -99,91 +198,55 @@ def _write_stream_bytes( # analyze them and start the FFmpeg self._open(True) - @property - def input_types(self) -> dict[int, MediaType | None]: - """media type associated with the input streams""" - return {i: v.get("media_type", None) for i, v in enumerate(self._input_info)} - - @property - def input_rates(self) -> dict[int, int | Fraction | None]: - """sample or frame rates associated with the input streams""" - return { - i: v["raw_info"][2] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - @property def input_dtypes(self) -> dict[int, DTypeString | None]: """frame/sample data type associated with the output streams (key)""" - return { - i: v["raw_info"][0] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } + kws = self._init_kws + if self._input_info is not None: + return { + i: v["raw_info"][0] + for i, v in enumerate(self._input_info) + if "raw_info" in v + } + elif "input_dtypes" in kws: # dtypes maybe given + dtypes = kws["input_dtypes"] + return { + i: dtypes[i] + for i, kw in self._piped_inputs.items() + if kw == "input_stream_args" + } + else: + # not known yet + return { + i: None + for i, kw in self._piped_inputs.items() + if kw == "input_stream_args" + } @property def input_shapes(self) -> dict[int, ShapeTuple | None]: """frame/sample shape associated with the output streams (key)""" - return { - i: v["raw_info"][1] if "raw_info" in v else None - for i, v in enumerate(self._input_info) - } - - -class BaseEncodedInputsMixin: - - # FFmpegRunner's properties accessed - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - - 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: OutputInfoDict, - data: bytes, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - if self._input_ready is True: - try: - info["writer"].write(data, timeout) - except: - raise FFmpegioError("Cannot write to a non-piped input.") - + kws = self._init_kws + if self._input_info is not None: + return { + i: v["raw_info"][1] + for i, v in enumerate(self._input_info) + if "raw_info" in v + } + elif "input_dtypes" in kws: # dtypes maybe given + dtypes = kws["input_dtypes"] + return { + i: dtypes[i] + for i, kw in self._piped_inputs.items() + if kw == "input_stream_args" + } else: - - # buffer must be contiguous - data0 = self._deferred_data[index] - if len(data0): - 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) + # not known yet + return { + i: None + for i, kw in self._piped_inputs.items() + if kw == "input_stream_args" + } class BaseRawOutputsMixin: diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index ef58f74c..14eaaa5d 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -53,9 +53,7 @@ # sys.byteorder -def get_pixel_config( - input_pix_fmt: str -) -> 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 @@ -648,17 +646,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: @@ -894,7 +889,7 @@ def analyze_output_video_filter( r_in: Fraction | int, pix_fmt_in: str, s_in: tuple[int, int], - s: tuple[int, int] | None=None, + s: tuple[int, int] | None = None, ) -> tuple[int | Fraction, str, tuple[int, int]]: """analyze an output video filter diff --git a/tests/test_open.py b/tests/test_open.py index 59b4b640..7091ddbc 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -15,10 +15,10 @@ def test_fg(): @pytest.mark.parametrize( "src,mode,Cls", [ - (url, "rv", ff_streams.SimpleVideoReader), - (url, "ra", ff_streams.SimpleAudioReader), - (url, "e->v", ff_streams.SimpleVideoReader), - (url, "e->a", ff_streams.SimpleAudioReader), + (url, "rv", ff_streams.SimpleReader), + (url, "ra", ff_streams.SimpleReader), + (url, "e->v", ff_streams.SimpleReader), + (url, "e->a", ff_streams.SimpleReader), ], ) def test_readers(src, mode, Cls): diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py index e2cea9cd..6ca9d894 100644 --- a/tests/test_simplestreams.py +++ b/tests/test_simplestreams.py @@ -14,7 +14,7 @@ def test_read_video(): w = 420 h = 360 - with streams.SimpleVideoReader( + with streams.SimpleReader( url, vf="transpose", pix_fmt="gray", s=(w, h), show_log=True, r=30 ) as f: F = f.read(10) @@ -40,7 +40,7 @@ def test_read_write_video(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleVideoWriter(out_url, fs) as f: + with streams.SimpleWriter(out_url, fs) as f: f.write(F0) f.write(F1) f.wait() @@ -54,7 +54,7 @@ def test_read_audio(caplog): 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: + with streams.SimpleReader(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] @@ -66,7 +66,7 @@ def test_read_audio(caplog): t0 = n0 / fs t1 = n1 / fs - with streams.SimpleAudioReader( + with streams.SimpleReader( 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]) @@ -87,7 +87,7 @@ def test_read_audio(caplog): def test_read_write_audio(): outext = ".flac" - with streams.SimpleAudioReader(url) as f: + with streams.SimpleReader(url) as f: F = b"".join((f.read(100)["buffer"], f.read(-1)["buffer"])) fs = f.output_rate shape = f.output_shape @@ -100,7 +100,7 @@ def test_read_write_audio(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleAudioWriter(out_url, fs, show_log=True) as f: + with streams.SimpleWriter(out_url, fs, show_log=True) as f: f.write({**out, "buffer": F[: 100 * bps]}) f.write({**out, "buffer": F[100 * bps :]}) f.wait() @@ -120,7 +120,7 @@ def test_write_extra_inputs(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleVideoWriter( + with streams.SimpleWriter( out_url, fs, extra_inputs=[url_aud], map=["0:v", "1:a"], show_log=True,loglevel='debug' ) as f: f.write(F) @@ -130,7 +130,7 @@ def test_write_extra_inputs(): info = ffmpegio.probe.streams_basic(out_url) assert len(info) == 2 - with streams.SimpleVideoWriter( + with streams.SimpleWriter( out_url, fs, extra_inputs=[("anoisesrc", {"f": "lavfi"})], From dba87178da2cf703fd49929cb74df7ae47d2226a Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 7 Jan 2026 21:55:20 -0500 Subject: [PATCH 323/344] wip18 --- src/ffmpegio/_open.py | 52 ++-- src/ffmpegio/_typing.py | 22 +- src/ffmpegio/configure.py | 100 ++++++- src/ffmpegio/streams/BaseFFmpegRunner.py | 109 +++++--- src/ffmpegio/streams/PipedStreams.py | 64 ++--- src/ffmpegio/streams/SimpleStreams.py | 8 +- src/ffmpegio/streams/mixins.py | 330 +++++++++-------------- src/ffmpegio/threading.py | 326 ++-------------------- 8 files changed, 398 insertions(+), 613 deletions(-) diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/_open.py index b36cb7ec..4cb5da36 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/_open.py @@ -78,7 +78,7 @@ def open( show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.SimpleReader: @@ -91,7 +91,7 @@ def open( :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 queue's item size in bytes, defaults to `None` (auto-set) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -114,7 +114,7 @@ def open( show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.SimpleReader: @@ -127,7 +127,7 @@ def open( :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 queue's item size in bytes, defaults to `None` (auto-set) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -155,7 +155,7 @@ def open( show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.SimpleReader: @@ -173,7 +173,7 @@ def open( :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 queue's item size in bytes, defaults to `None` (auto-set) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -202,7 +202,7 @@ def open( show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.SimpleWriter: @@ -220,7 +220,7 @@ def open( :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 queue's item size in bytes, defaults to `None` (auto-set) - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -245,7 +245,7 @@ def open( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MediaReader: @@ -257,7 +257,7 @@ def open( :param progress: progress callback function, defaults to None :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) :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 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 @@ -286,7 +286,7 @@ def open( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MediaWriter: @@ -305,7 +305,7 @@ def open( :param progress: progress callback function, defaults to None :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) :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 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 @@ -332,7 +332,7 @@ def open( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MediaTranscoder: @@ -349,7 +349,7 @@ def open( :param progress: progress callback function, defaults to None :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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -374,7 +374,7 @@ def open( show_log: bool | None = None, progress: ProgressCallable | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MIMOMediaFilter|streams.MISOMediaFilter|streams.SIMOMediaFilter|streams.SISOMediaFilter: @@ -390,7 +390,7 @@ def open( :param progress: progress callback function, defaults to None :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto) :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 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 @@ -417,7 +417,7 @@ def open( show_log: bool | None = None, progress: ProgressCallable | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MIMOMediaFilter: @@ -433,7 +433,7 @@ def open( :param progress: progress callback function, defaults to None :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto) :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 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 @@ -460,7 +460,7 @@ def open( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MediaReader: @@ -479,7 +479,7 @@ def open( :param progress: progress callback function, defaults to None :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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -517,7 +517,7 @@ def open( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MediaWriter: @@ -543,7 +543,7 @@ def open( :param progress: progress callback function, defaults to None :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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -572,7 +572,7 @@ def open( show_log: bool | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MediaFilter: @@ -592,7 +592,7 @@ def open( :param progress: progress callback function, defaults to None :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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -620,7 +620,7 @@ def open( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], ) -> streams.MediaTranscoder: @@ -642,7 +642,7 @@ def open( :param progress: progress callback function, defaults to None :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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index cd8e5409..8c51e173 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -48,9 +48,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, 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 @@ -276,6 +274,14 @@ class FileObjEncodedInputInfoDict(TypedDict): InputInfoDict = RawInputInfoDict | EncodedInputInfoDict +class PipeWriter(Protocol): + def write(self, data: bytes | None): ... + + +class PipeReader(Protocol): + def read(self, n: int = -1) -> bytes: ... + + class InputPipeInfoDict(TypedDict): """ ========== ========================================== @@ -284,9 +290,9 @@ class InputPipeInfoDict(TypedDict): ========== ========================================== """ - pipe: NPopen + pipe: NPopen | Literal["stdin"] """named pipe assigned to this data stream""" - writer: WriterThread + writer: PipeWriter """writer thread assigned to this data stream""" @@ -338,7 +344,7 @@ class RawFilteredOutputInfoDict(TypedDict): `'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 + `'linklabel'` mapped filtergraph output label =============== ================================================================ """ @@ -419,8 +425,8 @@ class OutputPipeInfoDict(TypedDict): =============== ================================================================ """ - pipe: NPopen - reader: ReaderThread | CopyFileObjThread + pipe: NPopen | Literal["stdout"] + reader: PipeReader | CopyFileObjThread itemsize: NotRequired[int] nmin: NotRequired[int] diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 5b27056d..bf3454d6 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -220,7 +220,7 @@ class MediaReadKwsDict(TypedDict): class MediaWriteKwsDict(TypedDict): output_urls: list[FFmpegOutputOptionTuple] input_stream_types: list[Literal["a", "v"]] - input_stream_args: list[tuple[RawDataBlob|None, FFmpegOptionDict]] + input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]] extra_inputs: list[FFmpegInputOptionTuple] options: dict[str, Any] input_dtypes: NotRequired[list[DTypeString | None] | None] @@ -230,7 +230,7 @@ class MediaWriteKwsDict(TypedDict): class MediaFilterKwsDict(TypedDict): expr: str | FilterGraphObject | list[str | FilterGraphObject] | None input_stream_types: list[Literal["a", "v"]] - input_stream_args: list[tuple[RawDataBlob|None, FFmpegOptionDict]] + input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]] extra_inputs: list[FFmpegInputOptionTuple] output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None extra_outputs: list[FFmpegOutputOptionTuple] @@ -246,6 +246,10 @@ class MediaTranscoderKwsDict(TypedDict): options: FFmpegOptionDict +FFmpegMediaKwsDict = ( + MediaReadKwsDict | MediaWriteKwsDict | MediaFilterKwsDict | MediaTranscoderKwsDict +) + ####################R### ### I/O initializers ### ######################## @@ -2417,6 +2421,7 @@ def assign_output_pipes( 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) @@ -2480,6 +2485,7 @@ def assign_input_pipes( 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 @@ -2494,9 +2500,10 @@ def init_named_pipes( outpipe_info: dict[int, OutputPipeInfoDict], input_info: list[InputInfoDict], output_info: list[OutputInfoDict], - update_rate: float | None = None, + update_rate: int | Fraction | None = None, 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 @@ -2523,13 +2530,17 @@ def init_named_pipes( if stack is None: stack = ExitStack() - wr_kws = {"queuesize": queue_size} if queue_size else {} + wr_kws = {"queuesize": queue_size, "timeout": timeout} if queue_size else {} # configure output pipes for i, pinfo in outpipe_info.items(): info = output_info[i] pipe = pinfo["pipe"] + + if pipe == "stdout": + continue + stack.enter_context(pipe) dst_type = info["dst_type"] @@ -2543,6 +2554,7 @@ def init_named_pipes( dtype, shape, rate = info["raw_info"] kws["itemsize"] = utils.get_samplesize(shape, dtype) if update_rate is not None: + # set the number of frames/samples to enqueue at a time kws["nmin"] = round(rate / update_rate) or 1 else: # assume encoded output @@ -2553,11 +2565,14 @@ def init_named_pipes( pinfo["reader"] = reader stack.enter_context(reader) # starts thread & wait for pipe connection - # configure input pipes (if needed) + # configure input pipes for i, pinfo in inpipe_info.items(): info = input_info[i] pipe = pinfo["pipe"] + if pipe == "stdin": + continue + stack.enter_context(pipe) src_type = info["src_type"] @@ -2579,3 +2594,78 @@ def init_named_pipes( stack.enter_context(writer) return stack + + +class StdWriter: + def __init__(self, proc: fp.Popen) -> None: + self._proc = proc + + def write(self, data: bytes | None): + if data is None: + self._proc.stdin.flush() + self._proc.stdin.close() + else: + self._proc.stdin.write(data) + + +class StdReader: + def __init__(self, proc: fp.Popen) -> None: + self._proc = proc + + def read(self, n: int = -1) -> bytes: + return self._proc.stdout.read(n) + + +def init_std_pipes( + input_pipes: dict[int, InputPipeInfoDict], + output_pipes: dict[int, OutputPipeInfoDict], + proc: fp.Popen, +): + + 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) + + +def find_primary_output_index( + output_pipes: dict[int, OutputPipeInfoDict], + output_info: list[OutputInfoDict], + primary_output: int | str | None = None, +) -> int | None: + """find index of the primary raw media output stream + + :param output_pipes: output pipe information dicts, keyed by output stream index + :param output_info: output stream information list + :param primary_output: primary output index or label, defaults to the first + output media stream + :return: primary output index or None if not found + """ + + if primary_output is None: + # use first raw stream + try: + st = next(i for i in output_pipes if "media_type" in output_info[i]) + except StopIteration: + return None # no output raw stream present + else: + # validate the specified stream (convert to int idx if str label given) + st_ = primary_output + if isinstance(st_, str): + try: + st = next( + i for i in output_pipes if output_info[i].get("user_map") == st_ + ) + except StopIteration: + return None + else: + st = st_ + + # if invalid output stream index, return None + if st < 0 or st >= len(output_info): + return None + + return st diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 8f78875b..d8d69aac 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -5,6 +5,7 @@ from time import time from contextlib import ExitStack from enum import IntEnum +from fractions import Fraction from .. import ffmpegprocess, configure, utils @@ -30,19 +31,21 @@ __all__ = ["BaseFFmpegRunner"] +class FFmpegStatus(IntEnum): + NOTHING_SET = 0 + BUFFERING = 1 + ARGUMENTS_SET = 2 + PIPES_SET = 3 + RUNNING = 4 + STOPPED = 5 + + class BaseFFmpegRunner: """Base class to run FFmpeg and manage its multiple I/O's""" - class Status(IntEnum): - NOTHING_SET = 0 - BUFFERING = 1 - ARGUMENTS_SET = 2 - PIPES_SET = 3 - RUNNING = 4 - STOPPED = 5 + Status = FFmpegStatus - probesize: int = 64 * 1024 - default_timeout: float | None = None + _pipe_kws: dict # configure.init_media_xxx function & its keyword arguments _init_func: Callable @@ -61,9 +64,10 @@ class Status(IntEnum): _args: dict[str, Any] _input_info: list[InputInfoDict] _output_info: list[OutputInfoDict] + _primary_output: int | str | None = None # ffmpeg subprocess and associated objects - _proc: ffmpegprocess.Popen + _proc: ffmpegprocess.Popen | None = None _input_pipes: dict[int, InputPipeInfoDict] | None = None _output_pipes: dict[int, OutputPipeInfoDict] | None = None _stack: ExitStack @@ -73,16 +77,19 @@ def __init__( self, init_func: Callable, init_kws: dict, - default_timeout: float | None = None, + primary_output: int | str | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - probesize: int | None = None, + probesize: int = 1024**2, + blocksize: int | None = None, + queue_size: int | None = None, + timeout: float | None = None, ): """Base FFmpeg runner - :param default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 sp_kwargs: dictionary with keywords passed to `subprocess.run()` or @@ -94,6 +101,7 @@ def __init__( self._init_func = staticmethod(init_func) self._init_kws = init_kws + self._primary_output = primary_output self._stack: ExitStack = ExitStack() @@ -110,8 +118,13 @@ def __init__( self._args["overwrite"] = overwrite # set the default read block size for the reference stream - if default_timeout is not None: - self.default_timeout = default_timeout + self._pipe_kws = {"stack": self._stack} + if timeout is not None: + self._pipe_kws["timeout"] = timeout + if blocksize is not None: + self._pipe_kws["blocksize"] = blocksize + if queue_size is not None: + self._pipe_kws["queue_size"] = queue_size if probesize is not None: self.probesize = int(probesize) @@ -268,8 +281,14 @@ def _run_ffmpeg(self, use_std_pipes: bool): ) more_args.update(sp_kwargs) - self._stack = configure.init_named_pipes( - input_pipes, output_pipes, self._input_info, self._output_info + # find the primary output stream's rate + configure.init_named_pipes( + input_pipes, + output_pipes, + self._input_info, + self._output_info, + update_rate=self.primary_output_rate, + **self._pipe_kws, ) self._input_pipes = input_pipes @@ -297,6 +316,30 @@ def _run_ffmpeg(self, use_std_pipes: bool): self._logger.stderr = self._proc.stderr self._logger.start() + # if stdin/stdout is used, attach StdWriter/StdReader object to each + configure.init_std_pipes(self._input_pipes, self._output_pipes, self._proc) + + @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_index + return st and self._output_info and self._output_info[st].get("user_map") + + @property + def primary_output_index(self) -> int | None: + """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" + + return self._output_pipes or configure.find_primary_output_index( + self._output_pipes, self._output_info, self._primary_output + ) + + @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_index + return st and self._output_info and self._output_info[st]["raw_info"][-1] + def _write_from_buffer(self): for st, kw in self._piped_inputs.items(): @@ -414,26 +457,20 @@ def wait(self, timeout: float | None = None) -> int | None: """ if timeout is None: - timeout = self.default_timeout + timeout = self.timeout if self._proc: if timeout is not None: timeout += time() - # std pipe, no threading, flush and close the stdin - if self._proc.stdin is not None: - self._proc.stdin.flush() - self._proc.stdin.close() - # write the sentinel to each input queue - for pinfo in self._input_pipes.values(): - pinfo["writer"].write( - None, None if timeout is None else timeout - time() - ) + if self._input_pipes is not None: + for pinfo in self._input_pipes.values(): + pinfo["writer"].write(None) # wait until the FFmpeg finishes the job - self._proc.wait(None if timeout is None else timeout - time()) + self._proc.wait(timeout and timeout - time()) rc = self._proc.returncode if rc is not None: @@ -441,19 +478,3 @@ def wait(self, timeout: float | None = None) -> int | None: else: rc = None return rc - - # def _write_raw(self, stream: int, data: RawDataBlob): - # info = self._input_pipes[stream] - # info["writer"].write(data) - - # def _write_enc(self, stream: int, data: bytes): - # info = self._input_pipes[stream] - # info["writer"].write(data) - - # def _read_raw(self, stream: int, n: int, timeout: float | None) -> RawDataBlob: - # info = self._output_pipes[stream] - # return info["reader"].read(n, timeout or self.default_timeout) - - # def _read_enc(self, stream: int, n: int, timeout: float | None) -> bytes: - # info = self._output_pipes[stream] - # return info["reader"].read(n, timeout or self.default_timeout) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index abda103d..7d83c1ab 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -61,7 +61,7 @@ def __init__( init_deferred_outputs: InitMediaOutputsCallable | None, deferred_output_args: list[FFmpegOptionDict | None], *, - default_timeout: float | None = None, + timeout: float | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, queuesize: int | None = None, @@ -79,7 +79,7 @@ def __init__( :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 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) @@ -102,7 +102,7 @@ def __init__( input_ready, init_deferred_outputs, deferred_output_args, - default_timeout, + timeout, progress, show_log, sp_kwargs, @@ -172,7 +172,7 @@ def write_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` + `timeout` property. If `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 @@ -198,7 +198,7 @@ def write_stream( raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") if timeout is None: - timeout = self.default_timeout + timeout = self.timeout self._write_stream(info, stream_id, data, timeout) @@ -211,7 +211,7 @@ def write( :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` + `timeout` property. If `timeout` is `None` then the operation will block until all the data is written to the buffer queue @@ -220,7 +220,7 @@ def write( it_data = data.items() if isinstance(data, dict) else enumerate(data) if timeout is None: - timeout = self.default_timeout + timeout = self.timeout if timeout is not None: timeout += time() @@ -245,7 +245,7 @@ def write_encoded_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` + `timeout` property. If `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 @@ -271,7 +271,7 @@ def write_encoded_stream( raise FFmpegioError(f"{stream_id=} is an invalid input stream index.") if timeout is None: - timeout = self.default_timeout + timeout = self.timeout self._write_encoded_stream(stream_id, info, data, timeout) @@ -284,7 +284,7 @@ def write_encoded( :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` + `timeout` property. If `timeout` is `None` then the operation will block until all the data is written to the buffer queue @@ -293,7 +293,7 @@ def write_encoded( it_data = data.items() if isinstance(data, dict) else enumerate(data) if timeout is None: - timeout = self.default_timeout + timeout = self.timeout if timeout is not None: timeout += time() @@ -341,7 +341,7 @@ def read( :param n: number of frames/samples to read, defaults to -1 to read as many as available :param stream_id: stream index or label, defaults to 0 :param timeout: timeout in seconds or defaults to `None` to use the - `default_timeout` property. If `default_timeout` is `None` + `timeout` property. If `timeout` is `None` then the operation will block until all the data is read from the buffer queue :return: retrieved data @@ -362,7 +362,7 @@ def read( """ if timeout is None: - timeout = self.default_timeout + timeout = self.timeout info = self._output_info stream_id = utils.get_output_stream_id(info, stream_id) @@ -373,7 +373,7 @@ def readall(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob :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` + `timeout` property. If `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 @@ -402,7 +402,7 @@ def readall(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob data = {} # output if timeout is None: - timeout = self.default_timeout + timeout = self.timeout if timeout is not None: timeout += time() @@ -443,7 +443,7 @@ def read_encoded( :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` + `timeout` property. If `timeout` is `None` then the operation will block until all the data is read from the buffer queue :return: retrieved data @@ -464,7 +464,7 @@ def read_encoded( """ if timeout is None: - timeout = self.default_timeout + timeout = self.timeout info = self._output_info stream_id = utils.get_output_stream_id(info, stream_id) @@ -474,7 +474,7 @@ 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` + `timeout` property. If `timeout` is `None` then the operation will block until FFmpeg stops :return: retrieved data keyed by output streams @@ -483,7 +483,7 @@ def readall_encoded(self, timeout: float | None = None) -> dict[str, bytes]: data = {} # output if timeout is None: - timeout = self.default_timeout + timeout = self.timeout if timeout is not None: timeout += time() @@ -508,7 +508,7 @@ def __init__( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): @@ -524,7 +524,7 @@ def __init__( :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 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 @@ -558,7 +558,7 @@ def __init__( deferred_output_args=output_args, ref_output=ref_stream, blocksize=blocksize, - default_timeout=default_timeout, + timeout=timeout, progress=progress, show_log=show_log, queuesize=queuesize, @@ -572,7 +572,7 @@ def __iter__(self): return self def __next__(self): - F = self.read(self._blocksize, self.default_timeout) + F = self.read(self._blocksize, self.timeout) # if not any( # len(self._get_bytes[info["media_type"]](obj=f)) # for f, info in zip(F.values(), self._output_info) @@ -607,7 +607,7 @@ def __init__( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): @@ -637,7 +637,7 @@ def __init__( :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 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 @@ -682,7 +682,7 @@ def __init__( input_ready=input_ready, init_deferred_outputs=configure.init_media_write_outputs, deferred_output_args=output_args, - default_timeout=default_timeout, + timeout=timeout, progress=progress, show_log=show_log, blocksize=blocksize, @@ -705,7 +705,7 @@ def __init__( show_log: bool | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], ): @@ -723,7 +723,7 @@ def __init__( :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 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 @@ -751,7 +751,7 @@ def __init__( input_ready=None, init_deferred_outputs=None, deferred_output_args=None, - default_timeout=default_timeout, + timeout=timeout, progress=progress, show_log=show_log, blocksize=blocksize, @@ -788,7 +788,7 @@ def __init__( progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, - default_timeout: float | None = None, + timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ): @@ -813,7 +813,7 @@ def __init__( :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 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 @@ -850,7 +850,7 @@ def __init__( deferred_output_args=deferred_output_args, ref_output=ref_output, blocksize=blocksize, - default_timeout=default_timeout, + timeout=timeout, progress=progress, show_log=show_log, queuesize=queuesize, diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index 8885ff6d..a8451b21 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -61,7 +61,7 @@ def __init__( 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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -76,7 +76,7 @@ def __init__( input_ready=True, init_deferred_outputs=None, deferred_output_args=[], - default_timeout=default_timeout, + timeout=timeout, progress=progress, show_log=show_log, sp_kwargs={**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, @@ -265,7 +265,7 @@ def __init__( 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 default_timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param 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 @@ -282,7 +282,7 @@ def __init__( input_ready=input_ready, init_deferred_outputs=init_deferred_outputs, deferred_output_args=deferred_output_args, - default_timeout=default_timeout, + timeout=timeout, progress=progress, show_log=show_log, sp_kwargs={**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, diff --git a/src/ffmpegio/streams/mixins.py b/src/ffmpegio/streams/mixins.py index 686309df..d538d29c 100644 --- a/src/ffmpegio/streams/mixins.py +++ b/src/ffmpegio/streams/mixins.py @@ -7,7 +7,7 @@ from typing_extensions import Callable, Literal -from .. import configure, probe +from .. import configure, probe, stream_spec, utils from .._typing import ( InputInfoDict, @@ -21,10 +21,10 @@ MediaType, ) -from ..configure import MediaType from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError from .._typing import FromBytesCallable, CountDataCallable, ToBytesCallable +from .BaseFFmpegRunner import FFmpegStatus logger = logging.getLogger("ffmpegio") @@ -37,15 +37,13 @@ class BaseInputsMixin: - """write a raw media data to a specified stream (backend)""" + """backend mixin for encoded media writer and transcoder""" + _status: FFmpegStatus _init_kws: dict _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_input"]] _input_info: list[InputInfoDict] | None _input_pipes: list[InputPipeInfoDict] | None - _logger: LoggerThread | None - _open: Callable[[bool], None] - _args: dict # def __init__(self, **kwargs): # super().__init__(**kwargs) @@ -75,128 +73,39 @@ def input_types(self) -> dict[int, MediaType | Literal["encoded"]]: for i, kw in self._piped_inputs.items() } - @property - def input_rates(self) -> dict[int, int | Fraction]: - """audio sample or video frame rates associated with the input media streams""" - kws = self._init_kws - return { - i: kws["input_stream_args"][i][1][ - {"a": "ar", "v": "r"}[kws["input_stream_types"][i]] - ] - for i, kw in self._piped_inputs.items() - if kw == "input_stream_args" - } + def _write_encoded(self, index: int, data: bytes): + """backend mixin for raw media writer and filter""" - 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: OutputInfoDict, - data: bytes, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - if self._input_ready is True: - 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[index] - if len(data0): - 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) + try: + info = self._input_pipes[index] + assert "media_type" not in self._input_info[index] + info["writer"].write(data) + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{index} is not an encoded stream.") from e class BaseRawInputsMixin(BaseInputsMixin): """write a raw media data to a specified stream (backend)""" - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: Literal[True] | list[bool] - _logger: LoggerThread | None - _open: Callable[[bool], None] - _args: dict - def __init__(self, **kwargs): super().__init__(**kwargs) # 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"] - for data in src: - writer.write(data, self.default_timeout) - self._deferred_data = [] - self._input_ready = True - - def _write_stream_bytes( - self, - converter: ToBytesCallable, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - b = converter(obj=data) - if not len(b): - return - - if self._input_ready is True: - logger.debug("[writer main] writing...") - - try: - self._input_info[stream_id]["writer"].write(b, timeout) - except (KeyError, BrokenPipeError, OSError): - if self._logger: - 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, 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) + @property + def input_rates(self) -> dict[int, int | Fraction | None]: + """audio sample or video frame rates associated with the input media streams""" + kws = self._init_kws + return { + i: kws["input_stream_args"][i][1][ + {"a": "ar", "v": "r"}[kws["input_stream_types"][i]] + ] + for i, kw in self._piped_inputs.items() + if kw == "input_stream_args" + } @property def input_dtypes(self) -> dict[int, DTypeString | None]: @@ -228,35 +137,132 @@ def input_shapes(self) -> dict[int, ShapeTuple | None]: """frame/sample shape associated with the output streams (key)""" kws = self._init_kws if self._input_info is not None: + # ffmpeg configured return { i: v["raw_info"][1] for i, v in enumerate(self._input_info) if "raw_info" in v } - elif "input_dtypes" in kws: # dtypes maybe given - dtypes = kws["input_dtypes"] + elif "input_shapes" in kws: # dtypes maybe given + # pre-configure, given by user + dtypes = kws["input_shapes"] return { i: dtypes[i] for i, kw in self._piped_inputs.items() if kw == "input_stream_args" } else: - # not known yet + # pre-configure, not given by user return { i: None for i, kw in self._piped_inputs.items() if kw == "input_stream_args" } + def _write_raw(self, index: int, data: RawDataBlob): + """write a raw media data to a specified stream (backend)""" + + try: + info = self._input_info[index] + assert "media_type" in self._input_info[index] + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{index} is not a raw stream.") from e -class BaseRawOutputsMixin: + b = info["data2bytes"](obj=data) + if not len(b): + return - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread | None + self._input_pipes[index]["writer"].write(data) + + +################################################################################ + + +class BaseOutputsMixin: + + _status: FFmpegStatus + _init_kws: configure.FFmpegMediaKwsDict + _output_info: list[OutputInfoDict] | None + _output_pipes: list[OutputPipeInfoDict] | None + + _nb_outputs: tuple[int, int] = (0, 0) # (raw, raw+encoded) + + # def __init__(self, blocksize, **kwargs): + # super().__init__(**kwargs) + + # # set the default read block size + # self._blocksize = blocksize + + @property + def output_types(self) -> dict[int, MediaType | Literal["encoded"]] | None: + """output piped types (lists both encoded and raw media pipes) + + - only piped inputs are returned + - integer keys is the unique input index (this index is not contiguous + if non-piped inputs are also used.) + - values are either 'video' or 'audio' if raw media stream or 'encoded' + if encoded byte stream + + """ + + if self._output_pipes is None: + # not yet running, deducible only if only encoded outputs or well-defined input arguments + kws = self._init_kws + + if "output_streams" in kws: # raw output streams (+extra encoded) + kw = kws["output_streams"] + if kw is None: + return None + + outtypes = {} + for i, (_, opts) in enumerate( + kw if isinstance(kw, list) else iter(v[1] for v in kw.values()) + ): + mapopts = stream_spec.parse_map_option( + opts["map"], input_file_id=0, parse_stream=True + ) + if "stream_specifier" not in mapopts: + return None + media_type = stream_spec.is_unique_stream( + mapopts["stream_specifier"] + ) + if media_type is False: + return None + outtypes[i] = media_type + + if "extra_outputs" in kws: # encoded output also specified + nout = len(kw) + for i, (url, _) in enumerate(kws["extra_outputs"]): + if utils.is_pipe(url): + outtypes[i + nout] = "encoded" + + return outtypes + else: + info = self._output_info + return {i: info[i].get("media_type", "encoded") for i in self._output_pipes} + + def _read_encoded(self, index: int, n: int) -> bytes: + """read selected output stream (shared backend)""" + + try: + info = self._output_pipes[index] + assert "media_type" not in self._output_info[index] + return info["reader"].read(n) + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Output Stream #{index} is not an encoded stream.") from e + + +class BaseRawOutputsMixin(BaseOutputsMixin): + + _init_kws: configure.MediaReadKwsDict | configure.MediaFilterKwsDict + + _status: FFmpegStatus + _output_info: list[OutputInfoDict] | None + _output_pipes: list[OutputPipeInfoDict] | None def __init__(self, blocksize, ref_output, **kwargs): super().__init__(**kwargs) @@ -274,11 +280,6 @@ def output_labels(self) -> list[str | None]: v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) ] - @property - def output_types(self) -> list[MediaType | None]: - """media type associated with the output streams (key)""" - return [v["media_type"] for v in self._output_info] - @property def output_rates(self) -> list[int | Fraction | None]: """sample or frame rates associated with the output streams (key)""" @@ -311,38 +312,7 @@ def output_counts(self) -> list[int]: """number of frames/samples read""" return [0] * len(self._output_info) if self._n0 is None else list(self._n0) - def _init_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 = self.output_rates - - if any(r is None for r in self._rates): - raise FFmpegioError("There is an output stream without known output rate.") - - 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_pipes() - - def _read_stream_bytes( - self, - converter: FromBytesCallable, - counter: CountDataCallable, - dtype: DTypeString, - shape: ShapeTuple, - info: OutputInfoDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: + def _read_raw(self, index: int, n: int) -> RawDataBlob: """read selected output stream (shared backend)""" data = converter( @@ -354,37 +324,3 @@ def _read_stream_bytes( self._n0[stream_id] += n return data - - -class BaseEncodedOutputsMixin: - - default_timeout: float | None - _input_info: list[InputInfoDict] - _output_info: list[OutputInfoDict] - _deferred_data: list[list[bytes]] - _input_ready: bool - _logger: LoggerThread - - def __init__(self, blocksize, **kwargs): - super().__init__(**kwargs) - - # set the default read block size - self._blocksize = blocksize - - def _init_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_pipes() - - def _read_encoded_stream( - self, - info: OutputInfoDict, - n: int, - timeout: float | None = None, - ) -> bytes: - """read selected output stream (shared backend)""" - - return info["reader"].read(n, timeout) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 87e7986c..dc2de207 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -308,6 +308,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) @@ -320,6 +321,7 @@ def __init__( self._halt = Event() self._running = Event() self._retry_delay = 0.001 if retry_delay is None else retry_delay + self._timeout = float(timeout) def start(self): if self.itemsize is None: @@ -335,6 +337,9 @@ def cool_down(self): def join(self, timeout=None): + if timeout is None: + timeout = self._timeout + if self.pipe is None: self.stdout.close() else: @@ -361,7 +366,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() @@ -433,6 +438,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 @@ -515,10 +522,15 @@ 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 @@ -527,6 +539,7 @@ def __init__(self, stdin_or_pipe: BinaryIO | NPopen, queuesize: int | None = Non self._empty_cond = Condition() self._empty = True self._no_more = False # true if sentinel has been written to the queue + self._timeout = float(timeout) def join(self, timeout: float | None = None): @@ -540,7 +553,7 @@ 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 __enter__(self): self.start() @@ -571,21 +584,21 @@ 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") + logger.info("writer thread: received %d bytes to write", len(data)) try: nwritten = 0 nwritten = stream.write(data) - logger.info(f"writer thread: written {nwritten} written") + logger.info("writer thread: 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.info("writer thread exception: %s", 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 @@ -616,7 +629,7 @@ 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: @@ -630,7 +643,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): @@ -642,7 +655,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: @@ -669,291 +686,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 From 3b80da1c789b530cb50e6a23decfb49ba5b4d44d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 11 Jan 2026 22:05:23 -0500 Subject: [PATCH 324/344] wip19 - SimpleRead - reading bytes not frames --- src/ffmpegio/_typing.py | 2 +- src/ffmpegio/configure.py | 47 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 830 ++++++++++++++++++----- src/ffmpegio/streams/PipedStreams.py | 6 +- src/ffmpegio/streams/SimpleStreams.py | 513 +++++++------- src/ffmpegio/streams/mixins.py | 384 ++++++----- tests/test_avistreams.py | 86 --- tests/test_simplestreams.py | 19 +- 8 files changed, 1167 insertions(+), 720 deletions(-) delete mode 100644 tests/test_avistreams.py diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 8c51e173..552b3b09 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -384,7 +384,7 @@ class UrlOrPipedEncodedOutputInfoDict(TypedDict): """url/filtergraph encoded input source info""" dst_type: Literal["url", "buffer"] - """output data goes to either a url/file or a pipe""" + """output data goes to either a url/filepath or a pipe""" class FileObjEncodedOutputInfoDict(TypedDict): diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index bf3454d6..f1bd9cd8 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -213,31 +213,31 @@ class MediaReadKwsDict(TypedDict): input_urls: list[FFmpegInputOptionTuple] output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None options: FFmpegOptionDict - extra_outputs: list[FFmpegOutputOptionTuple] squeeze: bool + extra_outputs: list[FFmpegOutputOptionTuple] | None class MediaWriteKwsDict(TypedDict): output_urls: list[FFmpegOutputOptionTuple] input_stream_types: list[Literal["a", "v"]] input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]] - extra_inputs: list[FFmpegInputOptionTuple] options: dict[str, Any] input_dtypes: NotRequired[list[DTypeString | None] | None] input_shapes: NotRequired[list[ShapeTuple | None] | None] + extra_inputs: list[FFmpegInputOptionTuple] class MediaFilterKwsDict(TypedDict): expr: str | FilterGraphObject | list[str | FilterGraphObject] | None input_stream_types: list[Literal["a", "v"]] input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]] - extra_inputs: list[FFmpegInputOptionTuple] output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None - extra_outputs: list[FFmpegOutputOptionTuple] options: FFmpegOptionDict - squeeze: bool input_dtypes: NotRequired[list[DTypeString] | None] input_shapes: NotRequired[list[ShapeTuple] | None] + squeeze: bool + extra_inputs: list[FFmpegInputOptionTuple] + extra_outputs: list[FFmpegOutputOptionTuple] class MediaTranscoderKwsDict(TypedDict): @@ -813,7 +813,8 @@ def gather_video_read_opts( 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: video shape tuple (height, width, nb_components) + :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 @@ -980,7 +981,7 @@ def gather_audio_read_opts( :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: video shape tuple (height, width, nb_components) + :return raw_info: audio shape tuple (nb_channels,) :return additional_options: additional output options or None if `raw_info` is not complete @@ -1855,7 +1856,7 @@ def process_url_inputs( inopts_default: FFmpegOptionDict, no_pipe: bool = False, ) -> list[EncodedInputInfoDict]: - """analyze and process heterogeneous input url argument + """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 @@ -2632,7 +2633,7 @@ def init_std_pipes( def find_primary_output_index( - output_pipes: dict[int, OutputPipeInfoDict], + # output_pipes: dict[int, OutputPipeInfoDict], output_info: list[OutputInfoDict], primary_output: int | str | None = None, ) -> int | None: @@ -2647,25 +2648,33 @@ def find_primary_output_index( if primary_output is None: # use first raw stream - try: - st = next(i for i in output_pipes if "media_type" in output_info[i]) - except StopIteration: - return None # no output raw stream present + return next( + (i for i, info in enumerate(output_info) if "buffer" in info["dst_type"]), + None, + ) else: # validate the specified stream (convert to int idx if str label given) st_ = primary_output if isinstance(st_, str): try: st = next( - i for i in output_pipes if output_info[i].get("user_map") == st_ + i + for i, info in enumerate(output_info) + if "buffer" in info["dst_type"] and info["user_map"] == st_ ) - except StopIteration: - return None + except StopIteration as e: + raise ValueError( + f'Primary media output stream "{st_}" is not found.' + ) from e else: st = st_ - # if invalid output stream index, return None - if st < 0 or st >= len(output_info): - return None + # if invalid output stream index, return None + try: + assert "media_type" not in output_info[st] + except AssertionError as e: + raise ValueError( + f"Primary media output stream {st} is not found." + ) from e return st diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index d8d69aac..f50b9202 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -2,18 +2,20 @@ import logging import sys -from time import time from contextlib import ExitStack from enum import IntEnum from fractions import Fraction +from functools import cached_property +from abc import ABCMeta, abstractmethod -from .. import ffmpegprocess, configure, utils +from .. import ffmpegprocess, configure, utils, stream_spec from .._typing import ( Any, Literal, - Iterable, Callable, + Iterator, + MediaType, RawDataBlob, ProgressCallable, InputInfoDict, @@ -22,7 +24,6 @@ OutputPipeInfoDict, ) -from ..configure import FFmpegArgs from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError, FFmpegioInsufficientInputData @@ -40,7 +41,162 @@ class FFmpegStatus(IntEnum): STOPPED = 5 -class BaseFFmpegRunner: +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_stream_args' + _enc_pipe_buffer: dict[int, bytes | None] # for 'input_urls' or 'extra_inputs' + + 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_stream_args" in self + self._enc_pipe_buffer = {} + self._raw_pipe_buffer = None + + # analyze the keywords and replace items to be tweaked + if self._raw_input: + # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] + self["input_stream_args"] = [*self["input_stream_args"]] + + self._nraw = len(self["input_stream_args"]) + self._raw_pipe_buffer = [None] * self._nraw + + if "extra_inputs" in self: + # 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"" + + else: + # encoded: list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + self["input_urls"] = [*self["input_urls"]] + + for i, (url, _) in enumerate(self["input_urls"]): + if utils.is_pipe(url): + self._enc_pipe_buffer[i] = None + + def put_data(self, stream: int, data: RawDataBlob | bytes) -> 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 + :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 + + 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]) + + else: # raw or encoded input + if isinstance(data, bytes): + 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 + + urls = self["extra_inputs"] + urls[stream] = (buf, urls[stream][1]) + else: + if self._raw_pipe_buffer[stream] is None: # first write + self._raw_pipe_buffer[stream] = [data] + kw = self["input_stream_args"] + kw[stream] = (data, kw[stream][1]) + else: + self._raw_pipe_buffer[stream].append(data) + return False + return True + + def clear_keywords(self): + # remove all the buffered data from the keywords + + if self._raw_pipe_buffer is not None: + kw = self["input_stream_args"] + for i, buf in enumerate(self._raw_pipe_buffer): + if buf is not None: + kw[i] = (None, kw[i][1]) + + 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]]: + + if self._raw_pipe_buffer is None: + return + + for i, buf in enumerate(self._raw_pipe_buffer): + if buf is not None: + for blob in buf: + yield i, blob + + def iter_enc_data(self) -> Iterator[tuple[int, bytes]]: + + n0 = self._nraw + for i, buf in self._enc_pipe_buffer.items(): + if buf is not None: + yield i + n0, buf + + def clear_data(self): + + 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: + return self._raw_pipe_buffer is None + + @property + def num_encoded_inputs(self) -> int: + return len(self._enc_pipe_buffer) + + @property + def num_raw_inputs(self) -> int: + return self._nraw + + @cached_property + def input_pipes(self) -> list[int]: + + raw_streams = range(self._nraw) + n0 = self._nraw + enc_streams = [i + n0 for i in self._enc_pipe_buffer] + return [*raw_streams, *enc_streams] + + +class BaseFFmpegRunner(metaclass=ABCMeta): """Base class to run FFmpeg and manage its multiple I/O's""" Status = FFmpegStatus @@ -49,27 +205,25 @@ class BaseFFmpegRunner: # configure.init_media_xxx function & its keyword arguments _init_func: Callable - _init_kws: dict + _init_kws: InitMediaKeywordsWithInputBuffer # object status enum _status: Status = Status.NOTHING_SET # pre-analysis/buffering variables _nb_inputs: tuple[int, int] = (0, 0) # (raw, raw+encoded) - _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_inputs"]] - _piped_inputs_buffer: dict[int, bytes | list[RawDataBlob]] - _piped_inputs_avail: int = 0 # ffmpeg arguments and associated input/output information _args: dict[str, Any] _input_info: list[InputInfoDict] _output_info: list[OutputInfoDict] _primary_output: int | str | None = None + _use_std_pipes: bool = False # ffmpeg subprocess and associated objects _proc: ffmpegprocess.Popen | None = None - _input_pipes: dict[int, InputPipeInfoDict] | None = None - _output_pipes: dict[int, OutputPipeInfoDict] | None = None + _input_pipes: dict[int, InputPipeInfoDict] + _output_pipes: dict[int, OutputPipeInfoDict] _stack: ExitStack _logger: LoggerThread @@ -82,10 +236,6 @@ def __init__( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - probesize: int = 1024**2, - blocksize: int | None = None, - queue_size: int | None = None, - timeout: float | None = None, ): """Base FFmpeg runner @@ -97,10 +247,8 @@ def __init__( to None """ - init_kws["options"] = {"probesize_in": probesize, **init_kws["options"]} - self._init_func = staticmethod(init_func) - self._init_kws = init_kws + self._init_kws = InitMediaKeywordsWithInputBuffer(init_kws) self._primary_output = primary_output self._stack: ExitStack = ExitStack() @@ -117,103 +265,37 @@ def __init__( if overwrite is not None: self._args["overwrite"] = overwrite - # set the default read block size for the reference stream - self._pipe_kws = {"stack": self._stack} - if timeout is not None: - self._pipe_kws["timeout"] = timeout - if blocksize is not None: - self._pipe_kws["blocksize"] = blocksize - if queue_size is not None: - self._pipe_kws["queue_size"] = queue_size - - if probesize is not None: - self.probesize = int(probesize) + self._pipe_kws = {} - self._piped_inputs = {} - self._piped_inputs_buffer = {} - - # identify the piped inputs, which may require data pre-buffering to - # configure the FFmpeg arguments - self._analyze_inputs() - - def _analyze_inputs(self): - """identify which input init_fun keyword arguments require data from pipe""" - kws = self._init_kws - pipes = self._piped_inputs - if "input_urls" in kws: - # encoded: list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - for i, (url, _) in enumerate(kws["input_urls"]): - if utils.is_pipe(url): - pipes[i] = "input_urls" - self._nb_inputs = (0, len(kws["input_urls"])) - if "input_stream_args" in kws: - # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] - n_in = len(kws["input_stream_args"]) - for i in range(n_in): - pipes[i] = "input_stream_args" - if "extra_inputs" in kws: - # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - for i, (url, _) in enumerate(kws["extra_inputs"]): - if utils.is_pipe(url): - pipes[i + n_in] = "extra_inputs" - self._nb_inputs = (n_in, n_in + len(kws["extra_inputs"])) + def _try_config_ffmpeg( + self, stream: int = -1, data: bytes | RawDataBlob | None = None + ) -> bool: + """Configure FFmpeg options and populate stream information - def _put_aside_input( - self, stream: int, data: RawDataBlob | bytes - ) -> bytes | RawDataBlob | None: - """write data to a buffer prior to running ffmpeg + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :return: ``True`` if FFmpeg arguments are successfully configured + and `_input_info` and `_output_info` lists are fully + populated. Excludes the pipe information. - :param stream: input stream id, index to self._input_info - :param data: data blob if raw media data or bytes if encoded data - :returns: the first data blob of the raw stream or all received bytes of - encoded stream (repeats every time) or None if no new data - 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 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 it contains data for a new stream, attempts to configure ffmpeg args """ - assert stream < self._nb_inputs[1] - assert self._status == self._status.NOTHING_SET - - if stream in self._piped_inputs_buffer: - buf = self._piped_inputs_buffer[stream] - if isinstance(data, bytes): - assert isinstance(buf, bytes) - self._piped_inputs_buffer[stream] = buf = buf + data - return buf - - else: - assert not isinstance(buf, bytes) - self._piped_inputs_buffer[stream].append(data) - - else: # first write -> update the kws - self._piped_inputs_buffer[stream] = ( - data if isinstance(data, bytes) else [data] - ) - self._piped_inputs_avail = self._piped_inputs_avail + 1 - return data - - return None - - def _try_config_ffmpeg( - self, stream: int = -1, data: bytes | RawDataBlob | None = None - ) -> bool: - """Configure FFmpeg options""" - - if self._status != self._status.NOTHING_SET: + if self._status > self._status.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 - kw_name = self._piped_inputs[stream] - i = stream - self._nb_inputs[0] if kw_name == "extra_inputs" else stream - self._init_kws[kw_name][i] = (data, self._init_kws[kw_name][i][1]) - - kws = self._init_kws + if not kws.put_data(stream, data): + 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) @@ -221,29 +303,20 @@ def _try_config_ffmpeg( # fail only if the error was caused by insufficient input data return False - # Set Input Pipes - # those input streams which required pre-buffered data are - # misconfigured because the data were presented to the configurator - # as the buffered data (as opposed to partial data) - - for st, kw in self._piped_inputs.items(): - i = st - self._nb_inputs[0] if kw == "extra_inputs" else st - data, opts = self._init_kws[kw][i] - - # look for the matching data in info['buffer'] - for info in input_info: - data_in_info = info.get("buffer", None) - if data_in_info == data: - del info["buffer"] - break + # Clear buffered data from the keywords dict + kws.clear_keywords() - # remove the data from the init keyword args - self._init_kws[kw][i] = ("-", opts) + # 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 + # ready to run self._status = self._status.ARGUMENTS_SET return True @@ -253,7 +326,7 @@ def _on_exit(self, rc): self._stack.close() self._status = self._status.STOPPED - def _run_ffmpeg(self, use_std_pipes: bool): + def _run_ffmpeg(self): # set up and activate standard pipes and read/write threads # configure named pipes @@ -272,12 +345,12 @@ def _run_ffmpeg(self, use_std_pipes: bool): if len(self._input_info): input_pipes, more_args = configure.assign_input_pipes( - args, self._input_info, use_std_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, use_std_pipes + args, self._output_info, self._use_std_pipes ) more_args.update(sp_kwargs) @@ -319,44 +392,79 @@ def _run_ffmpeg(self, use_std_pipes: bool): # if stdin/stdout is used, attach StdWriter/StdReader object to each configure.init_std_pipes(self._input_pipes, self._output_pipes, self._proc) - @property - def primary_output_label(self) -> str | None: - """primary raw media stream label (None if FFmpeg not started or no output raw stream)""" + # write pre-buffered data + for st, data in self._init_kws.iter_raw_data(): + self._write_raw(st, data) + for st, data in self._init_kws.iter_enc_data(): + self._write_encoded(st, data) - st = self.primary_output_index - return st and self._output_info and self._output_info[st].get("user_map") + # clear pre-buffered data + self._init_kws.clear_data() - @property - def primary_output_index(self) -> int | None: - """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" + def _write_encoded(self, index: int, data: bytes): + """backend mixin for raw media writer and filter""" - return self._output_pipes or configure.find_primary_output_index( - self._output_pipes, self._output_info, self._primary_output - ) + try: + info = self._input_pipes[index] + assert "media_type" not in self._input_info[index] + info["writer"].write(data) + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{index} is not an encoded stream.") from e - @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_index - return st and self._output_info and self._output_info[st]["raw_info"][-1] + def _write_raw(self, index: int, data: RawDataBlob): + """write a raw media data to a specified stream (backend)""" - def _write_from_buffer(self): + try: + info = self._input_info[index] + assert "media_type" in self._input_info[index] + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{index} is not a raw stream.") from e + + b = info["data2bytes"](obj=data) + if not len(b): + return - for st, kw in self._piped_inputs.items(): - i = st - self._nb_inputs[0] if kw == "extra_inputs" else st + self._input_pipes[index]["writer"].write(data) - # remove the data from the init keyword args - self._init_kws[kw][i] = ("-", self._init_kws[kw][i][1]) + def _read_encoded(self, index: int, n: int) -> bytes: + """read selected output stream (shared backend)""" - # write all the buffered data to the stream - buf = self._piped_inputs_buffer[st] - if isinstance(buf, bytes): # bytes -> encoded stream - self._input_pipes[i]["writer"].write(buf) - else: # raw data blob -> raw data stream - for frame in buf: - self._input_pipes[i]["writer"].write(frame) + try: + info = self._output_pipes[index] + assert "media_type" not in self._output_info[index] + return info["reader"].read(n) + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Output Stream #{index} is not an encoded stream.") from e - del self._piped_inputs_buffer[buf] + def _read_raw(self, index: int, n: int) -> RawDataBlob: + """read selected output stream (shared backend)""" + + try: + info = self._output_info[index] + assert "media_type" in self._output_info[index] + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{index} is not a raw stream.") from e + + (dtype, shape, _) = info["raw_info"] + b = self._output_pipes[index]["reader"].read(n) + + data = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) + + # update the frame/sample counter + # n = counter(obj=data) # actual number read + # self._n0[stream_id] += n + + return data def _terminate(self): """Kill FFmpeg process and close the streams""" @@ -388,12 +496,12 @@ def open(self): ok = self._try_config_ffmpeg() # if failed to configure, need to buffer input data first - if not ok: + if ok: + # ready to roll + self._run_ffmpeg() + else: + # need input data to start ffmpeg self._status = self._status.BUFFERING - return - - # otherwise, ready to roll - self._run_ffmpeg(False) def close(self): """Kill FFmpeg process and close the streams""" @@ -446,7 +554,7 @@ def readlog(self, n: int | None = None) -> str: return "\n".join(self._logger.logs if n is None else self._logger.logs[:n]) def wait(self, timeout: float | None = None) -> int | None: - """close all input pipes and wait for FFmpeg to exit + """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 @@ -456,21 +564,14 @@ def wait(self, timeout: float | None = None) -> int | None: :return returncode: return subprocess Popen returncode attribute """ - if timeout is None: - timeout = self.timeout - if self._proc: - if timeout is not None: - timeout += time() - # write the sentinel to each input queue - if self._input_pipes is not None: - for pinfo in self._input_pipes.values(): - pinfo["writer"].write(None) + for pinfo in self._input_pipes.values(): + pinfo["writer"].write(None) # wait until the FFmpeg finishes the job - self._proc.wait(timeout and timeout - time()) + self._proc.wait(timeout) rc = self._proc.returncode if rc is not None: @@ -478,3 +579,394 @@ def wait(self, timeout: float | None = None) -> int | None: else: rc = None return rc + + @property + def _args_ready(self): + return self._status < self._status.ARGUMENTS_SET + + ########################################################## + ### INPUT PROPERTIES + ########################################################## + + @property + def input_types(self) -> dict[int, MediaType | Literal["encoded"]]: + """input pipe types (lists both encoded and raw media pipes) + + - only piped inputs are returned + - integer keys is the unique input index (this index is not contiguous + if non-piped inputs are also used.) + - values are either 'video' or 'audio' if raw media stream or 'encoded' + if encoded byte stream + + """ + + kws = self._init_kws + + intypes = { + i: "encoded" + for i in range( + kws.num_raw_inputs, kws.num_raw_inputs + kws.num_encoded_inputs + ) + } + + if not kws.encoded_inputs_only: + + intypes = { + i: {"a": "audio", "v": "video"}[av] + for i, av in enumerate(kws["input_stream_types"]) + } | intypes + + return intypes + + ########################################################## + ### OUTPUT PROPERTIES + ########################################################## + + @cached_property + def _all_output_streams_defined(self) -> bool: + """check and remember if user provided concrete raw output streams + + :return: True if `output_streams` is not defined in init_kws + OR every elements of `output_streams` defines `map` option + AND the option values all points to a unique stream + + The outcome informs whether output properties can be evaluated solely + from init_kws + """ + + # not yet configured, deducible only if only encoded outputs or well-defined input arguments + kws = self._init_kws + + if "output_streams" in kws: # raw output streams (+extra encoded) + kw = kws["output_streams"] + if kw is None: + # output streams are entirely to be autodetected + return False + + for _, opts in ( + kw if isinstance(kw, list) else iter(v[1] for v in kw.values()) + ): + mapopts = stream_spec.parse_map_option( + opts["map"], input_file_id=0, parse_stream=True + ) + if "linklabel" not in mapopts: + # output media type + media_type = stream_spec.is_unique_stream( + mapopts["stream_specifier"] + ) + if media_type is False: + return False + + return True + + @cached_property + def _piped_output_stream_indices(self) -> list[int]: + + if self._status < self._status.ARGUMENTS_SET: + if not self._all_output_streams_defined: + raise FFmpegioError( + "Should not call this function before FFmpeg arguments are ready." + ) + + # not yet configured but all piped streams are concretely identifiable + + kws = self._init_kws + + if "output_streams" in kws: # raw output streams (+extra encoded) + kw = kws["output_streams"] + assert kw is not None + streams = list(range(len(kws["output_streams"]))) + + if "extra_outputs" in kws: + nout = len(streams) + streams.extend( + [ + nout + i + for i, (url, _) in enumerate(kws["extra_outputs"]) + if utils.is_pipe(url) + ] + ) + else: # encoded output streams + streams = [ + i + for i, (url, _) in enumerate(kws["output_urls"]) + if utils.is_pipe(url) + ] + + else: + # FFmpeg already configured + streams = [ + i + for i, info in enumerate(self._output_info) + if info["dst_type"] == "buffer" and "buffer" not in info + ] + + return streams + + def _iter_piped_output_info(self) -> Iterator[tuple[int, OutputInfoDict]]: + assert self._status >= self._status.ARGUMENTS_SET + for i in self._piped_output_stream_indices: + yield i, self._output_info[i] + + @property + def output_types(self) -> dict[int, MediaType | Literal["encoded"] | None] | None: + """output piped types (lists both encoded and raw media pipes) + + - None if output streams are not yet concretely known + - only piped streams are returned + - integer keys are unique input indices (their continuity is not guaranteed + if non-piped inputs are also used.) + - values are either 'video' or 'audio' if raw media stream or 'encoded' + if encoded byte stream + + """ + + if self._status < self._status.ARGUMENTS_SET: + + if not self._all_output_streams_defined: + return None + + # not yet configured, deducible only if only encoded outputs or well-defined input arguments + kws = self._init_kws + + if "output_streams" in kws: # raw output streams (+extra encoded) + kw = kws["output_streams"] + + outtypes = {} + for i, (_, opts) in enumerate( + kw if isinstance(kw, list) else iter(v[1] for v in kw.values()) + ): + mapopts = stream_spec.parse_map_option( + opts["map"], input_file_id=0, parse_stream=True + ) + if "linklabel" in mapopts: + outtypes[i] = None # linklabel requires filtergraph analysis + + else: # if "stream_specifier" not in mapopts: + media_type = stream_spec.is_unique_stream( + mapopts["stream_specifier"] + ) + outtypes[i] = None if media_type is False else media_type + + if "extra_outputs" in kws: # encoded output also specified + nout = len(kw) + for i, (url, _) in enumerate(kws["extra_outputs"]): + if utils.is_pipe(url): + outtypes[i + nout] = "encoded" + + return outtypes + else: + return { + i: "encoded" + for i, (url, _) in enumerate(kws["output_urls"]) + if utils.is_pipe(url) + } + + else: + return { + i: info.get("media_type", "encoded") + for i, info in enumerate(self._output_info) + if info["dst_type"] == "buffer" + } + + @property + def output_labels(self) -> dict[int, str | None] | None: + """FFmpeg/custom labels of output streams + + before loading: look for map + + """ + + if self._status < self._status.ARGUMENTS_SET: + if not self._all_output_streams_defined: + return None + + # not yet configured, deducible only if only encoded outputs or well-defined input arguments + kws = self._init_kws + + if "output_streams" in kws: # raw output streams (+extra encoded) + kw = kws["output_streams"] + + outlabels = {} + + for i, (user_map, opts) in enumerate( + ((None, v) for v in kw) + if isinstance(kw, list) + else iter(v[1] for k, v in kw.items()) + ): + if user_map is not None: + outlabels[i] in user_map + else: + outlabels[i] = opts["map"] + + if "extra_outputs" in kws: # encoded output also specified + nout = len(kw) + for i, (url, _) in enumerate(kws["extra_outputs"]): + if utils.is_pipe(url): + outlabels[i + nout] = f"e:{i}" + + return outlabels + else: + + return { + i: f"e:{i}" + for i in self._piped_output_stream_indices + if utils.is_pipe(url) + } + + else: + return { + i: v.get("user_map", None) if "user_map" in v else f"e:{i}" + for i, v in self._iter_piped_output_info() + } + + +class PipedFFmpegRunner(BaseFFmpegRunner): + """Base class to run FFmpeg with pipes and manage its multiple I/O's""" + + # pre-analysis/buffering variables + _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_inputs"]] + _piped_inputs_buffer: dict[int, bytes | list[RawDataBlob]] + # _piped_outputs_type: dict[int, MediaType | Literal["encoded"]] | None = None + + def __init__( + self, + init_func: Callable, + init_kws: dict, + primary_output: int | str | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + blocksize: int | None = None, + queue_size: int | None = None, + timeout: float | None = None, + ): + """Base FFmpeg runner + + :param 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 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, + primary_output, + progress, + show_log, + overwrite, + sp_kwargs, + blocksize, + queue_size, + timeout, + ) + + # set the default read block size for the reference stream + self._pipe_kws = {"stack": self._stack} + if timeout is not None: + self._pipe_kws["timeout"] = timeout + if blocksize is not None: + self._pipe_kws["blocksize"] = blocksize + if queue_size is not None: + self._pipe_kws["queue_size"] = queue_size + + self._piped_inputs = {} + self._piped_inputs_buffer = {} + + def _analyze_inputs(self): + """identify which input init_fun keyword arguments require data from pipe""" + kws = self._init_kws + pipes = self._piped_inputs + if "input_urls" in kws: + # encoded: list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + for i, (url, _) in enumerate(kws["input_urls"]): + if utils.is_pipe(url): + pipes[i] = "input_urls" + self._nb_inputs = (0, len(kws["input_urls"])) + if "input_stream_args" in kws: + # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] + n_in = len(kws["input_stream_args"]) + for i in range(n_in): + pipes[i] = "input_stream_args" + if "extra_inputs" in kws: + # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] + for i, (url, _) in enumerate(kws["extra_inputs"]): + if utils.is_pipe(url): + pipes[i + n_in] = "extra_inputs" + self._nb_inputs = (n_in, n_in + len(kws["extra_inputs"])) + + def _put_aside_input(self, stream: int, data: RawDataBlob | bytes) -> ( + tuple[ + Literal["input_urls", "input_stream_args", "extra_inputs"], + bytes | RawDataBlob, + ] + | None + ): + """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 + :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 + """ + + assert stream < self._nb_inputs[1] + assert self._status == self._status.NOTHING_SET + + if stream in self._piped_inputs_buffer: + buf = self._piped_inputs_buffer[stream] + if isinstance(data, bytes): + assert isinstance(buf, bytes) + self._piped_inputs_buffer[stream] = buf = buf + data + return self._piped_inputs[stream], buf + + else: + assert not isinstance(buf, bytes) + self._piped_inputs_buffer[stream].append(data) + + else: # first write -> update the kws + self._piped_inputs_buffer[stream] = ( + data if isinstance(data, bytes) else [data] + ) + return self._piped_inputs[stream], data + + return None + + def _iter_input_buffer( + self, + ) -> Iterator[ + tuple[int, Literal["input_urls", "input_stream_args", "extra_inputs"]] + ]: + + for itm in self._piped_inputs.items(): + yield itm + + def _write_from_buffer(self): + + for st, kw in self._piped_inputs.items(): + i = st - self._nb_inputs[0] if kw == "extra_inputs" else st + + # remove the data from the init keyword args + self._init_kws[kw][i] = ("-", self._init_kws[kw][i][1]) + + # write all the buffered data to the stream + buf = self._piped_inputs_buffer[st] + if isinstance(buf, bytes): # bytes -> encoded stream + self._input_pipes[i]["writer"].write(buf) + else: # raw data blob -> raw data stream + for frame in buf: + self._input_pipes[i]["writer"].write(frame) + + del self._piped_inputs_buffer[buf] diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py index 7d83c1ab..e0a08841 100644 --- a/src/ffmpegio/streams/PipedStreams.py +++ b/src/ffmpegio/streams/PipedStreams.py @@ -32,8 +32,6 @@ from .mixins import ( BaseRawInputsMixin as _BaseRawInputsMixin, BaseRawOutputsMixin as _BaseRawOutputsMixin, - BaseEncodedInputsMixin as _BaseEncodedInputsMixin, - BaseEncodedOutputsMixin as _BaseEncodedOutputsMixin, ) logger = (logging.getLogger("ffmpegio"),) @@ -235,7 +233,7 @@ def write( ) -class _EncodedInputsMixin(_BaseEncodedInputsMixin): +class _EncodedInputsMixin: def write_encoded_stream( self, stream_id: int, data: bytes, timeout: float | None = None @@ -433,7 +431,7 @@ def readall(self, n: int, timeout: float | None = None) -> dict[str, RawDataBlob return data -class _EncodedOutputsMixin(_BaseEncodedOutputsMixin): +class _EncodedOutputsMixin: def read_encoded( self, n: int, stream_id: int = 0, timeout: float | None = None diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index a8451b21..d2501e55 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -9,6 +9,9 @@ from typing_extensions import Unpack, Literal from collections.abc import Sequence from .._typing import ( + overload, + Callable, + Iterator, DTypeString, ShapeTuple, ProgressCallable, @@ -24,12 +27,26 @@ from fractions import Fraction from math import prod +from .._utils import get_bytesize from .. import configure, plugins from ..stream_spec import stream_spec_to_map_option, StreamSpecDict from ..errors import FFmpegioError -from ..configure import FFmpegArgs, MediaType, FFmpegUrlType, InitMediaOutputsCallable +from ..configure import ( + FFmpegArgs, + MediaType, + FFmpegUrlType, + InitMediaOutputsCallable, + init_media_read, + init_media_write, + MediaReadKwsDict, + MediaWriteKwsDict, + FFmpegInputOptionTuple, +) from .BaseFFmpegRunner import BaseFFmpegRunner -from .._utils import get_bytesize +from .mixins import ( + BaseRawInputsMixin, + BaseRawOutputsMixin, +) # fmt:off __all__ = [ "SimpleReader", "SimpleWriter"] @@ -40,158 +57,179 @@ # info["writer"].write(None, None if timeout is None else timeout - time()) -class SimpleReader(BaseFFmpegRunner): +class StdFFmpegRunner(BaseFFmpegRunner): + """Base class to run FFmpeg only with one std pipe""" + + _use_std_pipes: bool = True + + def _try_config_ffmpeg( + self, stream: int = -1, data: bytes | RawDataBlob | None = None + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :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. + + For ``StdFFmpegRunner``, the number of pipes is validated (1 raw + output and no encoded input or output) + + """ + + ready = super()._try_config_ffmpeg(stream, data) + if ready: # validate + ninputs = sum( + info["src_type"] in ("buffer", "fileobj") for info in self._input_info + ) + noutputs = sum( + info["dst_type"] in ("buffer", "fileobj") for info in self._output_info + ) + + if ninputs + noutputs > 1: + raise FFmpegioError( + "Only 1 pipe (stdin OR stdout) can be used in StdFFmpegRunner." + ) + + return ready + +class SimpleReader(BaseRawOutputsMixin, StdFFmpegRunner): """queue-less SISO media reader class""" + @overload def __init__( self, - *, - init_kws, - show_log: bool | None = None, + input_urls: list[FFmpegInputOptionTuple], + output_options: FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, + extra_outputs: list[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, ): - """Queue-less simple media io runner - - :param ffmpeg_args: (Mostly) populated FFmpeg argument dict - :param input_info: FFmpeg input option dicts with zero or one streaming pipe. (only one in input or output) - :param output_info: FFmpeg output option dicts with zero or one any streaming pipe. (only one in input or output) - :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. + """create a single-pipe media reader + + :param input_urls: list of input urls + :param output_stream: dict of FFmpeg output options. One of it items must + be the ``'map'`` option to uniquely specify a 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 blocksize: Background reader thread blocksize, defaults to `None` to use 64-kB blocks - :param 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) + :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 """ + def __init__( + self, + input_urls: list[FFmpegInputOptionTuple], + output_options: FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | None = None, + squeeze: bool = True, + **kwargs, + ): + super().__init__( - ffmpeg_args=ffmpeg_args, - input_info=input_info, - output_info=output_info, - input_ready=True, - init_deferred_outputs=None, - deferred_output_args=[], - timeout=timeout, - progress=progress, - show_log=show_log, - sp_kwargs={**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, - blocksize=blocksize, - ref_output=0, + init_func=init_media_read, + init_kws={ + "input_urls": input_urls, + "output_streams": [output_options], + "options": options or {}, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + }, + **kwargs, ) - self._converter = from_bytes - self._memoryviewer = to_memoryview + def _try_config_ffmpeg( + self, stream: int = -1, data: bytes | RawDataBlob | None = None + ) -> bool: + """Configure FFmpeg options and populate stream information - # set the default read block size for the reference stream - self._blocksize = blocksize + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :return: ``True`` if FFmpeg arguments are successfully configured + and `_input_info` and `_output_info` lists are fully + populated. Excludes the pipe information. - # set the default read block size for the referenc stream - info = self._output_info[0] - assert "raw_info" in info - self._rate = info["raw_info"][2] - self._n0 = 0 # timestamps of the last read sample + 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. - @property - def output_label(self) -> str | None: - """FFmpeg/custom labels of output streams""" - return self._output_info[0].get("user_map", None) - - @property - def output_type(self) -> MediaType | None: - """media type associated with the output streams (key)""" - return self._output_info[0]["media_type"] - - @property - def output_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][2] if "raw_info" in info else None + For ``SimpleReader``, the number of pipes is validated (1 raw + output and no encoded input or output) - @property - def output_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][0] if "raw_info" in info else None + """ - @property - def output_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - info = self._output_info[0] - return info["raw_info"][1] if "raw_info" in info else None + ready = super()._try_config_ffmpeg(stream, data) + if ready: # validate - @property - def output_count(self) -> int: - """number of frames/samples read""" - return self._n0 + is_raw = next( + "media_type" in info + for info in self._output_info + if info["dst_type"] == "buffer" + ) + if not is_raw: + raise FFmpegioError("The output stream must a raw media stream.") - def output_bytesize(self) -> int | None: - """number of bytes per output sample/pixel""" - return get_bytesize(self.output_shape, self.output_dtype) + return ready @property - def output_labels(self) -> list[str | None]: + def output_label(self) -> str | None: """FFmpeg/custom labels of output streams""" - return [self._output_info[0].get("user_map", None)] + olabels = self.output_labels + return None if olabels is None else olabels[0] @property - def output_types(self) -> list[MediaType | None]: + def output_type(self) -> MediaType | None: """media type associated with the output streams (key)""" - return [self._output_info[0]["media_type"]] + otypes = self.output_types + return None if otypes is None else otypes[0] @property - def output_rates(self) -> list[int | Fraction | None]: + def output_rate(self) -> int | Fraction | None: """sample or frame rates associated with the output streams (key)""" - info = self._output_info[0] - return [info["raw_info"][2] if "raw_info" in info else None] + orates = self.output_rates + return None if orates is None else orates[0] @property - def output_dtypes(self) -> list[DTypeString | None]: + def output_dtype(self) -> DTypeString | None: """frame/sample data type associated with the output streams (key)""" - info = self._output_info[0] - return [info["raw_info"][0] if "raw_info" in info else None] + odtypes = self.output_dtypes + return None if odtypes is None else odtypes[0] @property - def output_shapes(self) -> list[ShapeTuple | None]: + def output_shape(self) -> ShapeTuple | None: """frame/sample shape associated with the output streams (key)""" - info = self._output_info[0] - return [info["raw_info"][1] if "raw_info" in info else None] - - @property - def output_counts(self) -> list[int]: - """number of frames/samples read""" - return [self._n0] - - @property - def output_bytesizes(self) -> list[int | None]: - """number of bytes per output sample/pixel""" - return [get_bytesize(self.output_shape, self.output_dtype)] + oshapes = self.output_shapes + return None if oshapes is None else oshapes[0] - def _assign_pipes(self): - - configure.assign_output_pipes( - self._args["ffmpeg_args"], - self._output_info, - self._args["sp_kwargs"], - use_std_pipes=True, - ) - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self._blocksize) - if plugins.get_hook().is_empty(obj=F): - raise StopIteration - return F - - def read(self, n: int, squeeze: bool = False) -> RawDataBlob: - """Read and return numpy.ndarray with up to n frames/samples. If + def read(self, n: int) -> RawDataBlob: + """Read and return a raw data blob (e.g., a numpy.ndarray if + ``ffmpegio.use('numpy')``) containing 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. @@ -205,154 +243,143 @@ def read(self, n: int, squeeze: bool = False) -> RawDataBlob: A BlockingIOError is raised if the underlying raw stream is in non blocking-mode, and has no data available at the moment.""" - info = self._output_info[0] - converter = self._converter - nbytes = self.output_bytesize - assert nbytes is not None - - dtype, shape, _ = info["raw_info"] # type: ignore - - b = self._proc.stdout.read(n * nbytes if n >= 0 else -1) # type: ignore - data = converter(b=b, dtype=dtype, shape=shape, squeeze=squeeze) - - # update the frame/sample counter - n = len(b) // nbytes # actual number read - self._n0 += n - - return data - - def readinto(self, array: RawDataBlob) -> int: - """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.""" - - info = self._output_info[0] - assert "raw_info" in info - shape = info["raw_info"][1] - - return self._proc.stdout.readinto(self._memoryviewer(obj=array)) // prod( # type: ignore - shape[1:] - ) + return self._read_raw(0, n) ########################################################################### -class SimpleWriter(BaseFFmpegRunner): +class SimpleWriter(BaseRawInputsMixin, StdFFmpegRunner): + """single-pipe media writer""" + + @overload def __init__( self, - # **init_kws, - show_log: bool | None = None, + output_urls: list[FFmpegOutputOptionTuple], + input_stream_type: Literal["a", "v"], + input_stream_options: FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, sp_kwargs: dict | None = None, ): - """Queue-less simple media writer - - :param ffmpeg_args: (Mostly) populated FFmpeg argument dict - :param input_info: FFmpeg input option dicts with zero or one streaming pipe. (only one in input or output) - :param output_info: FFmpeg output option dicts with zero or one any streaming pipe. (only one in input or output) - :param input_ready: True to start FFmpeg, if not provide a list of per-stream readiness - :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 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 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) + """single-pipe media writer + + :param output_urls: pairs of output url and options + :param input_stream_type: specify raw media input type + :param input_stream_options: ffmpeg input options for the raw media input + :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 input_dtype: input media data type as a numpy dtype string, + defaults to ``None`` to autodetect + :param input_shape: input media shape (height x width x components) for + video or (channels,) for audio, defaults to ``None`` + to autodetect + :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 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 output_urls if they exist, defaults to ``False`` + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None """ - # add std writer - + def __init__( + self, + output_urls: list[FFmpegOutputOptionTuple], + input_stream_type: Literal["a", "v"], + input_stream_options: FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + **kwargs, + ): super().__init__( - ffmpeg_args=ffmpeg_args, - input_info=input_info, - output_info=output_info, - input_ready=input_ready, - init_deferred_outputs=init_deferred_outputs, - deferred_output_args=deferred_output_args, - timeout=timeout, - progress=progress, - show_log=show_log, - sp_kwargs={**sp_kwargs, "bufsize": 0} if sp_kwargs else {"bufsize": 0}, - ref_output=0, + init_func=init_media_write, + init_kws={ + "output_urls": output_urls, + "input_stream_types": [input_stream_type], + "input_stream_args": [(None, input_stream_options)], + "extra_inputs": extra_inputs, + "options": options or {}, + "input_dtypes": input_dtype and [input_dtype], + "input_shapes": input_shape and [input_shape], + }, + **kwargs, ) - self._converter = from_bytes - self._memoryviewer = to_memoryview + def _try_config_ffmpeg( + self, stream: int = -1, data: bytes | RawDataBlob | None = None + ) -> bool: + """Configure FFmpeg options and populate stream information - # set the default read block size for the reference stream - info = self._input_info[0] - assert "raw_info" in info + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :return: ``True`` if FFmpeg arguments are successfully configured + and `_input_info` and `_output_info` lists are fully + populated. Excludes the pipe information. - self._rate = info["raw_info"][2] - self._n0 = 0 # timestamps of the last read sample - ############ + 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. - # input data must be initially buffered - self._deferred_data = [] + For ``SimpleReader``, the number of pipes is validated (1 raw + output and no encoded input or output) - def _write_deferred_data(self): - self._proc.stdin.write(self._deferred_data[0]) - self._deferred_data = [] - self._input_ready = True + """ - def _assign_pipes(self): + ready = super()._try_config_ffmpeg(stream, data) + if ready: # validate - configure.assign_input_pipes( - self._args["ffmpeg_args"], - self._input_info, - self._args["sp_kwargs"], - use_std_pipes=True, - ) + is_raw = next( + "media_type" in info + for info in self._input_info + if info["src_type"] == "buffer" + ) + if not is_raw: + raise FFmpegioError("The input stream must a raw media stream.") @property def input_type(self) -> MediaType | None: """media type associated with the input streams""" - info = self._input_info[0] - return info.get("media_type", None) + vals = self.input_types + return None if vals is None else vals[0] @property def input_rate(self) -> int | Fraction | None: """sample or frame rates associated with the input streams""" - info = self._input_info[0] - return info["raw_info"][2] if "raw_info" in info else None + vals = self.input_rates + return None if vals is None else vals[0] @property def input_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - info = self._input_info[0] - return info["raw_info"][0] if "raw_info" in info else None + """frame/sample data type of the input stream""" + vals = self.input_dtypes + return None if vals is None else vals[0] @property def input_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - info = self._input_info[0] - return info["raw_info"][1] if "raw_info" in info else None - - @property - def input_count(self) -> int: - """number of input frames/samples written""" - return self._n0 + """frame/sample shape of the input stream""" + vals = self.input_shapes + return None if vals is None else vals[0] - @property - def input_bytesize(self) -> int | None: - """input sample/pixel count per frame""" - return get_bytesize(self.input_shape, self.input_dtype) + # @property + # def input_count(self) -> int: + # """number of input frames/samples written""" + # return self._n0 - def write(self, data): + def write(self, data: RawDataBlob): """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). @@ -366,32 +393,8 @@ def write(self, data): """ - b = self._get_bytes(obj=data) - if not len(b): - return - - if self._input_ready is True: - logger.debug("[writer main] writing...") - try: - self._proc.stdin.write(b) - except (BrokenPipeError, OSError): - self._logger.join_and_raise() - + if self._status == self._status.BUFFERING: + if self._try_config_ffmpeg(0, data): + self._run_ffmpeg(True) 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 - - if self._input_ready is True: - # once data is written for all the necessary inputs, - # analyze them and start the FFmpeg - self._open(True) - - def flush(self): - self._proc.stdin.flush() + self._write_raw(0, data) diff --git a/src/ffmpegio/streams/mixins.py b/src/ffmpegio/streams/mixins.py index d538d29c..2a994e5c 100644 --- a/src/ffmpegio/streams/mixins.py +++ b/src/ffmpegio/streams/mixins.py @@ -4,17 +4,20 @@ from contextlib import ExitStack from fractions import Fraction +from abc import ABCMeta, abstractmethod from typing_extensions import Callable, Literal from .. import configure, probe, stream_spec, utils from .._typing import ( - InputInfoDict, OutputInfoDict, InputPipeInfoDict, + PipedEncodedInputInfoDict, + RawInputInfoDict, OutputPipeInfoDict, - FFmpegOptionDict, + RawOutputInfoDict, + EncodedOutputInfoDict, RawDataBlob, ShapeTuple, DTypeString, @@ -24,76 +27,24 @@ from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError from .._typing import FromBytesCallable, CountDataCallable, ToBytesCallable -from .BaseFFmpegRunner import FFmpegStatus +from .BaseFFmpegRunner import FFmpegStatus, BaseFFmpegRunner logger = logging.getLogger("ffmpegio") __all__ = [ "BaseRawInputsMixin", "BaseRawOutputsMixin", - "BaseEncodedInputsMixin", - "BaseEncodedOutputsMixin", ] -class BaseInputsMixin: - """backend mixin for encoded media writer and transcoder""" +class BaseRawInputsMixin: + """write a raw media data to a specified stream (backend)""" _status: FFmpegStatus _init_kws: dict _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_input"]] - _input_info: list[InputInfoDict] | None - _input_pipes: list[InputPipeInfoDict] | None - - # def __init__(self, **kwargs): - # super().__init__(**kwargs) - - # # input data must be initially buffered - # self._deferred_data = [[] for _ in range(len(self._input_info))] - - @property - def input_types(self) -> dict[int, MediaType | Literal["encoded"]]: - """input piped types (lists both encoded and raw media pipes) - - - only piped inputs are returned - - integer keys is the unique input index (this index is not contiguous - if non-piped inputs are also used.) - - values are either 'video' or 'audio' if raw media stream or 'encoded' - if encoded byte stream - - """ - - kws = self._init_kws - return { - i: ( - "encoded" - if kw in ("input_urls", "extra_inputs") - else {"a": "audio", "v": "video"}[kws["input_stream_types"][i]] - ) - for i, kw in self._piped_inputs.items() - } - - def _write_encoded(self, index: int, data: bytes): - """backend mixin for raw media writer and filter""" - - try: - info = self._input_pipes[index] - assert "media_type" not in self._input_info[index] - info["writer"].write(data) - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Input Stream #{index} is not an encoded stream.") from e - - -class BaseRawInputsMixin(BaseInputsMixin): - """write a raw media data to a specified stream (backend)""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - - # input data must be initially buffered - self._deferred_data = [[] for _ in range(len(self._input_info))] + _input_info: list[RawInputInfoDict] + _input_pipes: list[InputPipeInfoDict] @property def input_rates(self) -> dict[int, int | Fraction | None]: @@ -159,168 +110,241 @@ def input_shapes(self) -> dict[int, ShapeTuple | None]: if kw == "input_stream_args" } - def _write_raw(self, index: int, data: RawDataBlob): - """write a raw media data to a specified stream (backend)""" - try: - info = self._input_info[index] - assert "media_type" in self._input_info[index] - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Input Stream #{index} is not a raw stream.") from e +################################################################################ - b = info["data2bytes"](obj=data) - if not len(b): - return - self._input_pipes[index]["writer"].write(data) +class BaseRawOutputsMixin(metaclass=ABCMeta): + _init_kws: configure.MediaReadKwsDict | configure.MediaFilterKwsDict -################################################################################ + _status: FFmpegStatus + _output_info: list[RawOutputInfoDict] + _output_pipes: list[OutputPipeInfoDict] + _read_size_in: int | None = None + _read_size: int = 1 -class BaseOutputsMixin: + def __init__(self, blocksize: int | None = None, **kwargs): + super().__init__(**kwargs) - _status: FFmpegStatus - _init_kws: configure.FFmpegMediaKwsDict - _output_info: list[OutputInfoDict] | None - _output_pipes: list[OutputPipeInfoDict] | None + # set the default read block size for the reference stream + self._read_size_in = blocksize + if blocksize is not None: + self._read_size = blocksize - _nb_outputs: tuple[int, int] = (0, 0) # (raw, raw+encoded) + def _try_config_ffmpeg( + self, stream: int = -1, data: bytes | RawDataBlob | None = None + ) -> bool: + """Configure FFmpeg options and populate stream information - # def __init__(self, blocksize, **kwargs): - # super().__init__(**kwargs) + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :return: ``True`` if FFmpeg arguments are successfully configured + and `_input_info` and `_output_info` lists are fully + populated. Excludes the pipe information. - # # set the default read block size - # self._blocksize = blocksize - @property - def output_types(self) -> dict[int, MediaType | Literal["encoded"]] | None: - """output piped types (lists both encoded and raw media pipes) + 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. - - only piped inputs are returned - - integer keys is the unique input index (this index is not contiguous - if non-piped inputs are also used.) - - values are either 'video' or 'audio' if raw media stream or 'encoded' - if encoded byte stream + For ``SimpleReader``, the number of piped outputs are validated (1 raw + output and no encoded input or output) """ - if self._output_pipes is None: - # not yet running, deducible only if only encoded outputs or well-defined input arguments - kws = self._init_kws - - if "output_streams" in kws: # raw output streams (+extra encoded) - kw = kws["output_streams"] - if kw is None: - return None - - outtypes = {} - for i, (_, opts) in enumerate( - kw if isinstance(kw, list) else iter(v[1] for v in kw.values()) - ): - mapopts = stream_spec.parse_map_option( - opts["map"], input_file_id=0, parse_stream=True - ) - if "stream_specifier" not in mapopts: - return None - media_type = stream_spec.is_unique_stream( - mapopts["stream_specifier"] - ) - if media_type is False: - return None - outtypes[i] = media_type - - if "extra_outputs" in kws: # encoded output also specified - nout = len(kw) - for i, (url, _) in enumerate(kws["extra_outputs"]): - if utils.is_pipe(url): - outtypes[i + nout] = "encoded" - - return outtypes - else: - info = self._output_info - return {i: info[i].get("media_type", "encoded") for i in self._output_pipes} + ready = super()._try_config_ffmpeg(stream, data) - def _read_encoded(self, index: int, n: int) -> bytes: - """read selected output stream (shared backend)""" + if ready and self._read_size_in is None: # set read size + index = self.primary_output_index + media_type = self._output_info[index]["media_type"] + self._read_size = 1 if media_type == "video" else 1024 - try: - info = self._output_pipes[index] - assert "media_type" not in self._output_info[index] - return info["reader"].read(n) - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Output Stream #{index} is not an encoded stream.") from e + return ready + @property + def output_rates(self) -> dict[int | Fraction | None] | None: + """sample or frame rates associated with the output streams (key)""" -class BaseRawOutputsMixin(BaseOutputsMixin): + if self._output_info is None: + if not self._all_output_streams_defined: + return None - _init_kws: configure.MediaReadKwsDict | configure.MediaFilterKwsDict + if "output_streams" not in kws: # raw output streams (+extra encoded) + return {} - _status: FFmpegStatus - _output_info: list[OutputInfoDict] | None - _output_pipes: list[OutputPipeInfoDict] | None + kw = self._init_kws["output_streams"] + rates = {} + for i, mtype in self.output_types.items(): + if mtype == "encoded": + # skip encoded output stream + continue - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(**kwargs) + rates[i] = kw[i][1].pop("r" if mtype == "video" else "ar", None) - # 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 + return rates - @property - def output_labels(self) -> list[str | None]: - """FFmpeg/custom labels of output streams""" - return [ - v.get("user_map", None) or f"{i}" for i, v in enumerate(self._output_info) - ] + else: + return { + i: v["raw_info"][2] + for i, v in self._iter_piped_output_info() + if "raw_info" in v + } @property - def output_rates(self) -> list[int | Fraction | None]: - """sample or frame rates associated with the output streams (key)""" + def output_dtypes(self) -> dict[int, DTypeString | None]: + """frame/sample data type associated with the output streams (key)""" - def get_rate(v): - return v and v[2] + if self._output_info is None: + if not self._all_output_streams_defined: + return None + + if "output_streams" not in kws: # raw output streams (+extra encoded) + return {} + + kw = self._init_kws["output_streams"] + dtypes = {} + for i, mtype in self.output_types.items(): + if mtype == "encoded": + # skip encoded output stream + continue + + opts = kw[i][1] + + if mtype == "video": + if "pix_fmt" in opts: + pix_fmt = opts["pix_fmt"] + dtypes[i] = utils.get_pixel_format(pix_fmt)[0] + else: + dtypes[i] = None + else: # if mtype=='audio' + if "sample_fmt" in opts: + sample_fmt = opts["sample_fmt"] + dtypes[i] = utils.get_audio_format(sample_fmt)[0] + else: + dtypes[i] = None + + return dtypes - return [get_rate(v) for v in self._output_info] + else: + return { + i: v["raw_info"][0] + for i, v in self._iter_piped_output_info() + if "raw_info" in v + } @property - def output_dtypes(self) -> list[DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" + def output_shapes(self) -> list[ShapeTuple | None]: + """frame/sample shape associated with the output streams (key)""" + + if self._output_info is None: + if not self._all_output_streams_defined: + return None + + if "output_streams" not in kws: # raw output streams (+extra encoded) + return {} + + kw = self._init_kws["output_streams"] + shapes = {} + for i, mtype in self.output_types.items(): + if mtype == "encoded": + # skip encoded output stream + continue + + opts = kw[i][1] + + if mtype == "video": + if "pix_fmt" in opts: + pix_fmt = opts["pix_fmt"] + s = opts["s"] + shapes[i] = utils.get_video_format(pix_fmt, s)[1] + else: + shapes[i] = None + else: # if mtype=='audio' + has_opt = [k in opts for k in ("ac", "channel_layout", "ch_layout")] + if has_opt[0] or has_opt[1]: + layout = ( + opts["channel_layout"] if has_opt[0] else opts["ch_layout"] + ) + shapes[i] = (utils.layout_to_channels(layout),) + elif has_opt[2]: + shapes[i] = (int(opts["ac"]),) + else: + shapes[i] = None + + return shapes - def get_dtype(v): - return v and v[1] + else: + return { + i: v["raw_info"][1] + for i, v in self._iter_piped_output_info() + if "raw_info" in v + } - return [get_dtype(v) for v in self._output_info] + def __iter__(self): + return self + + def __next__(self): + F = self.read(self._read_size) + if self._output_info[self.primary_output_index]["data_is_empty"](obj=F): + raise StopIteration + return F @property - def output_shapes(self) -> list[ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" + def primary_output_label(self) -> str | None: + """primary raw media stream label (None if FFmpeg not started or no output raw stream)""" - def get_shape(v): - return v and v[0] + st = self.primary_output_index + return st and self._output_info and self._output_info[st].get("user_map") - return [get_shape(v) for v in self._output_info] + @property + def primary_output_index(self) -> int | None: + """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" + + + return configure.find_primary_output_index( + self._output_info, self._primary_output + ) + + @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_index + return st and self._output_info and self._output_info[st]["raw_info"][-1] + + @abstractmethod + def read(self, n: int) -> RawDataBlob | dict[int | str, RawDataBlob]: + """Read and return a raw data blob (e.g., a numpy.ndarray if + ``ffmpegio.use('numpy')``) containing up to n frames/samples. If a + reader outputs multiple raw streams, its output is a dict keyed by + stream identifiers of raw data blobs. + + 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.""" @property def output_counts(self) -> list[int]: """number of frames/samples read""" return [0] * len(self._output_info) if self._n0 is None else list(self._n0) - def _read_raw(self, index: int, n: int) -> RawDataBlob: - """read selected output stream (shared backend)""" - - data = converter( - b=info["reader"].read(n, timeout), dtype=dtype, shape=shape, squeeze=squeeze - ) - - # update the frame/sample counter - n = counter(obj=data) # actual number read - self._n0[stream_id] += n + # @property + # def output_counts(self) -> list[int]: + # """number of frames/samples read""" + # return [self._n0] - return data + # @property + # def output_bytesizes(self) -> list[int | None]: + # """number of bytes per output sample/pixel""" + # return [get_bytesize(self.output_shape, self.output_dtype)] diff --git a/tests/test_avistreams.py b/tests/test_avistreams.py deleted file mode 100644 index 71c43dee..00000000 --- a/tests/test_avistreams.py +++ /dev/null @@ -1,86 +0,0 @@ -from ffmpegio.streams import AviStreams -from ffmpegio import open - - -def test_open(): - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - with open((url1, url2), "rav", t=1, blocksize=0) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - - print('testing "rvv"') - with open( - url1, - "rvv", - t=1, - blocksize=0, - filter_complex="[0:v]split=2[out1][out2]", - map=["[out1]", "[out2]"], - ) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - - print('testing "raa"') - with open( - url2, - "raa", - t=1, - blocksize=0, - filter_complex="[0:a]asplit=2[out1][out2]", - map=["[out1]", "[out2]"], - ) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - # print(reader.readlog()) - -def test_avireadstream(): - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - with AviStreams.AviMediaReader(url1, url2, t=1, blocksize=0) as reader: - for st, data in reader: - print(st, data['shape'], data['dtype']) - - with AviStreams.AviMediaReader(url1, url2, t=1, blocksize=1) as reader: - for data in reader: - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) - - with AviStreams.AviMediaReader( - url1, url2, t=1, blocksize=1000, ref_stream="a:0" - ) as reader: - for data in reader: - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) - - with AviStreams.AviMediaReader(url1, url2, t=1) as reader: - print(reader.specs()) - print(reader.types()) - print(reader.rates()) - print(reader.dtypes()) - print(reader.shapes()) - print(reader.get_stream_info("v:0")) - print(reader.get_stream_info("a:0")) - - data = reader.readall() - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) - - -if __name__ == "__main__": - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - - from pprint import pprint - - with AviStreams.AviMediaReader(url1, url2, t=1) as reader: - reader._reader.wait() - print(f'thread is running {reader._reader.is_alive()}') - pprint(reader.specs()) - print(reader.types()) - print(reader.rates()) - print(reader.dtypes()) - print(reader.shapes()) - print(reader.get_stream_info("v:0")) - print(reader.get_stream_info("a:0")) - - data = reader.readall() - print({k: (v['shape'], v['dtype']) for k, v in data.items()}) diff --git a/tests/test_simplestreams.py b/tests/test_simplestreams.py index 6ca9d894..1214c76f 100644 --- a/tests/test_simplestreams.py +++ b/tests/test_simplestreams.py @@ -15,12 +15,14 @@ def test_read_video(): w = 420 h = 360 with streams.SimpleReader( - url, vf="transpose", pix_fmt="gray", s=(w, h), show_log=True, r=30 + [(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_rate == 30 - assert f.output_shape == (h, w, 1) - assert F["shape"] == (10, h, w, 1) + assert f.output_shape == (h, w) + assert F["shape"] == (10, h, w) assert F["dtype"] == f.output_dtype @@ -45,7 +47,7 @@ def test_read_write_video(): f.write(F1) f.wait() fs, F = ffmpegio.video.read(out_url) - assert len(F['buffer']) + assert len(F["buffer"]) def test_read_audio(caplog): @@ -116,12 +118,17 @@ def test_write_extra_inputs(): "shape": F["shape"], "dtype": F["dtype"], } - print(len(F['buffer'])) + print(len(F["buffer"])) with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) with streams.SimpleWriter( - out_url, fs, extra_inputs=[url_aud], map=["0:v", "1:a"], show_log=True,loglevel='debug' + out_url, + fs, + extra_inputs=[url_aud], + map=["0:v", "1:a"], + show_log=True, + loglevel="debug", ) as f: f.write(F) f.wait() From b0674b62ba3617cbde090a661d40a4f027356f8e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 12 Jan 2026 09:16:15 -0500 Subject: [PATCH 325/344] wip20 --- src/ffmpegio/streams/BaseFFmpegRunner.py | 109 ++++++++------- src/ffmpegio/streams/SimpleStreams.py | 5 + src/ffmpegio/streams/mixins.py | 165 +++++++++++++++-------- 3 files changed, 178 insertions(+), 101 deletions(-) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index f50b9202..8e6ecba6 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -217,7 +217,6 @@ class BaseFFmpegRunner(metaclass=ABCMeta): _args: dict[str, Any] _input_info: list[InputInfoDict] _output_info: list[OutputInfoDict] - _primary_output: int | str | None = None _use_std_pipes: bool = False # ffmpeg subprocess and associated objects @@ -231,7 +230,6 @@ def __init__( self, init_func: Callable, init_kws: dict, - primary_output: int | str | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, overwrite: bool | None = None, @@ -249,7 +247,6 @@ def __init__( self._init_func = staticmethod(init_func) self._init_kws = InitMediaKeywordsWithInputBuffer(init_kws) - self._primary_output = primary_output self._stack: ExitStack = ExitStack() @@ -326,6 +323,10 @@ def _on_exit(self, rc): self._stack.close() self._status = self._status.STOPPED + @property + def _output_rate(self) -> int | Fraction | None: + return None + def _run_ffmpeg(self): # set up and activate standard pipes and read/write threads @@ -360,7 +361,7 @@ def _run_ffmpeg(self): output_pipes, self._input_info, self._output_info, - update_rate=self.primary_output_rate, + update_rate=self._output_rate, **self._pipe_kws, ) @@ -413,22 +414,9 @@ def _write_encoded(self, index: int, data: bytes): except (KeyError, AssertionError) as e: raise ValueError(f"Input Stream #{index} is not an encoded stream.") from e + @abstractmethod def _write_raw(self, index: int, data: RawDataBlob): - """write a raw media data to a specified stream (backend)""" - - try: - info = self._input_info[index] - assert "media_type" in self._input_info[index] - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Input Stream #{index} is not a raw stream.") from e - - b = info["data2bytes"](obj=data) - if not len(b): - return - - self._input_pipes[index]["writer"].write(data) + """write a raw media data to a specified stream (abstract)""" def _read_encoded(self, index: int, n: int) -> bytes: """read selected output stream (shared backend)""" @@ -442,30 +430,6 @@ def _read_encoded(self, index: int, n: int) -> bytes: except (KeyError, AssertionError) as e: raise ValueError(f"Output Stream #{index} is not an encoded stream.") from e - def _read_raw(self, index: int, n: int) -> RawDataBlob: - """read selected output stream (shared backend)""" - - try: - info = self._output_info[index] - assert "media_type" in self._output_info[index] - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Input Stream #{index} is not a raw stream.") from e - - (dtype, shape, _) = info["raw_info"] - b = self._output_pipes[index]["reader"].read(n) - - data = info["bytes2data"]( - b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] - ) - - # update the frame/sample counter - # n = counter(obj=data) # actual number read - # self._n0[stream_id] += n - - return data - def _terminate(self): """Kill FFmpeg process and close the streams""" @@ -581,13 +545,61 @@ def wait(self, timeout: float | None = None) -> int | None: return rc @property - def _args_ready(self): + def _args_not_ready(self): return self._status < self._status.ARGUMENTS_SET ########################################################## ### INPUT PROPERTIES ########################################################## + @cached_property + def _piped_input_stream_indices(self) -> list[int]: + + if self._status < self._status.ARGUMENTS_SET: + + kws = self._init_kws + + if "input_stream_types" in kws: # raw output streams (+extra encoded) + kw = kws["input_stream_types"] + assert kw is not None + streams = list(range(len(kws["input_stream_types"]))) + + if "extra_inputs" in kws: + nin = len(streams) + streams.extend( + [ + nin + i + for i, (url, _) in enumerate(kws["extra_inputs"]) + if utils.is_pipe(url) + ] + ) + else: # encoded output streams + streams = [ + i + for i, (url, _) in enumerate(kws["input_urls"]) + if utils.is_pipe(url) + ] + + else: + # FFmpeg already configured + streams = [ + i + for i, info in enumerate(self._output_info) + if info["dst_type"] == "buffer" and "buffer" not in info + ] + + return streams + + def _iter_piped_input_info( + self, encoded: bool | None = None + ) -> Iterator[tuple[int, InputInfoDict]]: + + assert self._status >= self._status.ARGUMENTS_SET + for i in self._piped_input_stream_indices: + info = self._input_info[i] + if encoded is None or (encoded == ("media_type" not in info)): + yield i, info + @property def input_types(self) -> dict[int, MediaType | Literal["encoded"]]: """input pipe types (lists both encoded and raw media pipes) @@ -703,10 +715,15 @@ def _piped_output_stream_indices(self) -> list[int]: return streams - def _iter_piped_output_info(self) -> Iterator[tuple[int, OutputInfoDict]]: + def _iter_piped_output_info( + self, encoded: bool | None = None + ) -> Iterator[tuple[int, OutputInfoDict]]: + assert self._status >= self._status.ARGUMENTS_SET for i in self._piped_output_stream_indices: - yield i, self._output_info[i] + info = self._output_info[i] + if encoded is None or (encoded == ("media_type" not in info)): + yield i, info @property def output_types(self) -> dict[int, MediaType | Literal["encoded"] | None] | None: diff --git a/src/ffmpegio/streams/SimpleStreams.py b/src/ffmpegio/streams/SimpleStreams.py index d2501e55..481bdbe0 100644 --- a/src/ffmpegio/streams/SimpleStreams.py +++ b/src/ffmpegio/streams/SimpleStreams.py @@ -100,6 +100,7 @@ def _try_config_ffmpeg( return ready + class SimpleReader(BaseRawOutputsMixin, StdFFmpegRunner): """queue-less SISO media reader class""" @@ -215,6 +216,10 @@ def output_rate(self) -> int | Fraction | None: orates = self.output_rates return None if orates is None else orates[0] + @property + def _output_rate(self) -> int | Fraction | None: + return self.output_rate + @property def output_dtype(self) -> DTypeString | None: """frame/sample data type associated with the output streams (key)""" diff --git a/src/ffmpegio/streams/mixins.py b/src/ffmpegio/streams/mixins.py index 2a994e5c..071f64e4 100644 --- a/src/ffmpegio/streams/mixins.py +++ b/src/ffmpegio/streams/mixins.py @@ -62,7 +62,7 @@ def input_rates(self) -> dict[int, int | Fraction | None]: def input_dtypes(self) -> dict[int, DTypeString | None]: """frame/sample data type associated with the output streams (key)""" kws = self._init_kws - if self._input_info is not None: + if self._args_not_ready: return { i: v["raw_info"][0] for i, v in enumerate(self._input_info) @@ -87,7 +87,7 @@ def input_dtypes(self) -> dict[int, DTypeString | None]: def input_shapes(self) -> dict[int, ShapeTuple | None]: """frame/sample shape associated with the output streams (key)""" kws = self._init_kws - if self._input_info is not None: + if self._args_not_ready: # ffmpeg configured return { i: v["raw_info"][1] @@ -110,6 +110,23 @@ def input_shapes(self) -> dict[int, ShapeTuple | None]: if kw == "input_stream_args" } + def _write_raw(self, index: int, data: RawDataBlob): + """write a raw media data to a specified stream (backend)""" + + try: + info = self._input_info[index] + assert "media_type" in self._input_info[index] + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{index} is not a raw stream.") from e + + b = info["data2bytes"](obj=data) + if not len(b): + return + + self._input_pipes[index]["writer"].write(data) + ################################################################################ @@ -117,22 +134,57 @@ def input_shapes(self) -> dict[int, ShapeTuple | None]: class BaseRawOutputsMixin(metaclass=ABCMeta): _init_kws: configure.MediaReadKwsDict | configure.MediaFilterKwsDict - _status: FFmpegStatus _output_info: list[RawOutputInfoDict] _output_pipes: list[OutputPipeInfoDict] + _primary_output: int | str | None = None _read_size_in: int | None = None _read_size: int = 1 - def __init__(self, blocksize: int | None = None, **kwargs): + def __init__( + self, + primary_output: int | str | None = None, + blocksize: int | None = None, + **kwargs, + ): super().__init__(**kwargs) + self._primary_output = primary_output + # set the default read block size for the reference stream self._read_size_in = blocksize if blocksize is not None: self._read_size = blocksize + @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_index + return st and self._output_info and self._output_info[st].get("user_map") + + @property + def primary_output_index(self) -> int | None: + """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" + + return configure.find_primary_output_index( + self._output_info, self._primary_output + ) + + @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_index + try: + return self._output_info[st]["raw_info"][-1] + except (AttributeError, IndexError): + return None + + @property + def _output_rate(self) -> int | Fraction | None: + return self.primary_output_rate + def _try_config_ffmpeg( self, stream: int = -1, data: bytes | RawDataBlob | None = None ) -> bool: @@ -165,39 +217,37 @@ def _try_config_ffmpeg( return ready @property - def output_rates(self) -> dict[int | Fraction | None] | None: + def output_rates(self) -> list[int | Fraction] | None: """sample or frame rates associated with the output streams (key)""" - if self._output_info is None: + if self._args_not_ready: if not self._all_output_streams_defined: return None + kws = self._init_kws + if "output_streams" not in kws: # raw output streams (+extra encoded) - return {} + return [] # shouldn't get here kw = self._init_kws["output_streams"] - rates = {} - for i, mtype in self.output_types.items(): - if mtype == "encoded": - # skip encoded output stream - continue - - rates[i] = kw[i][1].pop("r" if mtype == "video" else "ar", None) + rates = [ + kw[i][1].pop("r" if mtype == "video" else "ar", None) + for i, mtype in self.output_types.items() + if mtype != "encoded" + ] - return rates + return rates else: - return { - i: v["raw_info"][2] - for i, v in self._iter_piped_output_info() - if "raw_info" in v - } + return [ + v["raw_info"][2] if "raw_info" in v else None for v in self._output_info + ] @property - def output_dtypes(self) -> dict[int, DTypeString | None]: + def output_dtypes(self) -> dict[int, DTypeString] | None: """frame/sample data type associated with the output streams (key)""" - if self._output_info is None: + if self._args_not_ready: if not self._all_output_streams_defined: return None @@ -205,7 +255,7 @@ def output_dtypes(self) -> dict[int, DTypeString | None]: return {} kw = self._init_kws["output_streams"] - dtypes = {} + dtypes = [] for i, mtype in self.output_types.items(): if mtype == "encoded": # skip encoded output stream @@ -229,17 +279,13 @@ def output_dtypes(self) -> dict[int, DTypeString | None]: return dtypes else: - return { - i: v["raw_info"][0] - for i, v in self._iter_piped_output_info() - if "raw_info" in v - } + return [v["raw_info"][0] for v in self._iter_piped_output_info()] @property def output_shapes(self) -> list[ShapeTuple | None]: """frame/sample shape associated with the output streams (key)""" - if self._output_info is None: + if self._args_not_ready: if not self._all_output_streams_defined: return None @@ -277,11 +323,16 @@ def output_shapes(self) -> list[ShapeTuple | None]: return shapes else: - return { - i: v["raw_info"][1] + return [ + v["raw_info"][1] if "raw_info" in v else None for i, v in self._iter_piped_output_info() - if "raw_info" in v - } + ] + + def output_sample_sizes(self) -> list[int] | None: + if self._args_not_ready: + return None + + return [] def __iter__(self): return self @@ -292,28 +343,6 @@ def __next__(self): raise StopIteration return F - @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_index - return st and self._output_info and self._output_info[st].get("user_map") - - @property - def primary_output_index(self) -> int | None: - """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" - - - return configure.find_primary_output_index( - self._output_info, self._primary_output - ) - - @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_index - return st and self._output_info and self._output_info[st]["raw_info"][-1] - @abstractmethod def read(self, n: int) -> RawDataBlob | dict[int | str, RawDataBlob]: """Read and return a raw data blob (e.g., a numpy.ndarray if @@ -348,3 +377,29 @@ def output_counts(self) -> list[int]: # def output_bytesizes(self) -> list[int | None]: # """number of bytes per output sample/pixel""" # return [get_bytesize(self.output_shape, self.output_dtype)] + + def _read_raw(self, index: int, n: int) -> RawDataBlob: + """read selected output stream (shared backend)""" + + try: + info = self._output_info[index] + assert "media_type" in self._output_info[index] + except AttributeError as e: + raise FFmpegioError(f"FFmpeg is not running yet.") from e + except (KeyError, AssertionError) as e: + raise ValueError(f"Input Stream #{index} is not a raw stream.") from e + + (dtype, shape, _) = info["raw_info"] + b = self._output_pipes[index]["reader"].read( + n * self.output_samplesizes[index] if n > 0 else n + ) + + data = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) + + # update the frame/sample counter + # n = counter(obj=data) # actual number read + # self._n0[stream_id] += n + + return data From da5c1c113ab786f6e9d381186ee84fe8e5a7b654 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Fri, 16 Jan 2026 21:50:33 -0500 Subject: [PATCH 326/344] wip21 --- src/ffmpegio/__init__.py | 2 +- src/ffmpegio/_typing.py | 7 + src/ffmpegio/configure.py | 6 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 953 ++++++++++++--------- src/ffmpegio/streams/mixins.py | 90 -- src/ffmpegio/{_open.py => streams/open.py} | 10 +- 6 files changed, 547 insertions(+), 521 deletions(-) rename src/ffmpegio/{_open.py => streams/open.py} (99%) diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 42413003..9f231097 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -61,7 +61,7 @@ def __getattr__(name): from . import devices, ffmpegprocess, caps, probe, audio, image, video, media from .transcode import transcode from .utils.parser import FLAG -from ._open import open +from .streams.open import open # check if ffmpegio-core is installed, if it is warn its deprecation from ._utils import deprecate_core diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 552b3b09..87eeb2bc 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -216,6 +216,7 @@ class RawInputInfoDict(TypedDict): `'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 `'buffer'` (optional) known media data blobs to be input (typically for a batch operation) @@ -230,6 +231,8 @@ class RawInputInfoDict(TypedDict): """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""" buffer: NotRequired[object] @@ -308,6 +311,7 @@ class RawDirectOutputInfoDict(TypedDict): `'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 @@ -322,6 +326,7 @@ class RawDirectOutputInfoDict(TypedDict): 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 @@ -339,6 +344,7 @@ class RawFilteredOutputInfoDict(TypedDict): `'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 @@ -351,6 +357,7 @@ class RawFilteredOutputInfoDict(TypedDict): 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 diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index f1bd9cd8..7693dc31 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -2041,6 +2041,8 @@ def get_callables(media_type): 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"])) @@ -2164,6 +2166,7 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: "src_type": "buffer", "media_type": media_type, "raw_info": (*raw_info, opts[ropt]), + "item_size": utils.get_samplesize(*raw_info[1::-1]), **get_callables(media_type), } @@ -2552,14 +2555,11 @@ def init_named_pipes( assert 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: # set the number of frames/samples to enqueue at a time 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) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 8e6ecba6..71e571d0 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -15,6 +15,8 @@ Literal, Callable, Iterator, + ShapeTuple, + DTypeString, MediaType, RawDataBlob, ProgressCallable, @@ -187,13 +189,15 @@ def num_encoded_inputs(self) -> int: def num_raw_inputs(self) -> int: return self._nraw + def iter_encoded_input_pipes(self) -> Iterator[int]: + + n0 = self._nraw + return (i + n0 for i in self._enc_pipe_buffer) + @cached_property def input_pipes(self) -> list[int]: - raw_streams = range(self._nraw) - n0 = self._nraw - enc_streams = [i + n0 for i in self._enc_pipe_buffer] - return [*raw_streams, *enc_streams] + return [*range(self._nraw), *self.iter_encoded_input_pipes()] class BaseFFmpegRunner(metaclass=ABCMeta): @@ -201,8 +205,6 @@ class BaseFFmpegRunner(metaclass=ABCMeta): Status = FFmpegStatus - _pipe_kws: dict - # configure.init_media_xxx function & its keyword arguments _init_func: Callable _init_kws: InitMediaKeywordsWithInputBuffer @@ -217,6 +219,8 @@ class BaseFFmpegRunner(metaclass=ABCMeta): _args: dict[str, Any] _input_info: list[InputInfoDict] _output_info: list[OutputInfoDict] + + _dynamic_output: bool = False _use_std_pipes: bool = False # ffmpeg subprocess and associated objects @@ -262,8 +266,6 @@ def __init__( if overwrite is not None: self._args["overwrite"] = overwrite - self._pipe_kws = {} - def _try_config_ffmpeg( self, stream: int = -1, data: bytes | RawDataBlob | None = None ) -> bool: @@ -328,9 +330,11 @@ def _output_rate(self) -> int | Fraction | None: return None def _run_ffmpeg(self): + """configure pipes and run ffmpeg - # set up and activate standard pipes and read/write threads - # configure named pipes + ``BaseFFmpegRunner`` neither configure/start pipes nor dump the pre-buffer + in ``_init_kws``. + """ if self._status != self._status.ARGUMENTS_SET: if self._status < self._status.ARGUMENTS_SET: @@ -339,43 +343,11 @@ def _run_ffmpeg(self): ) raise FFmpegioError("FFmpeg pipes have already configured.") - args = self._args["ffmpeg_args"] - more_args = {} - input_pipes = {} - output_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) - - # find the primary output stream's rate - configure.init_named_pipes( - input_pipes, - output_pipes, - self._input_info, - self._output_info, - update_rate=self._output_rate, - **self._pipe_kws, - ) + # set up and activate standard pipes and read/write threads + # configure named pipes - self._input_pipes = input_pipes - self._output_pipes = output_pipes + self._input_pipes, self._output_pipes, more_args = self._configure_pipes() self._args.update(more_args) - self._status = self._status.PIPES_SET - - if self._status != self._status.PIPES_SET: - if self._status < self._status.PIPES_SET: - raise FFmpegioError( - "FFmpeg configuration not set. Run `config_ffmpeg()` first." - ) - raise FFmpegioError("FFmpeg pipes have already configured.") # run the FFmpeg try: @@ -390,45 +362,63 @@ def _run_ffmpeg(self): self._logger.stderr = self._proc.stderr self._logger.start() - # if stdin/stdout is used, attach StdWriter/StdReader object to each - configure.init_std_pipes(self._input_pipes, self._output_pipes, self._proc) + # # if stdin/stdout is used, attach StdWriter/StdReader object to each + # configure.init_std_pipes(self._input_pipes, self._output_pipes, self._proc) - # write pre-buffered data - for st, data in self._init_kws.iter_raw_data(): - self._write_raw(st, data) - for st, data in self._init_kws.iter_enc_data(): - self._write_encoded(st, data) + # # write pre-buffered data + # for st, data in self._init_kws.iter_raw_data(): + # self._write_raw(st, data) + # for st, data in self._init_kws.iter_enc_data(): + # self._write_encoded(st, data) - # clear pre-buffered data - self._init_kws.clear_data() + # # clear pre-buffered data + # self._init_kws.clear_data() - def _write_encoded(self, index: int, data: bytes): - """backend mixin for raw media writer and filter""" + def _configure_pipes( + self, + ) -> tuple[dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict], dict]: + """configure pipes (both std and named) - try: - info = self._input_pipes[index] - assert "media_type" not in self._input_info[index] - info["writer"].write(data) - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Input Stream #{index} is not an encoded stream.") from e + :return input_pipes: input pipes and their writer thread, keyed by input + index (i.e., index for the ``_input_info`` list) + :return output_pipes: output pipes and their reader thread, keyed by output + index (i.e., index for the ``_output_info`` list) + :return more_fp_kwargs: additional keyword arguments for ``Popen`` call + ``_run_ffmpeg()`` to configure std pipes - @abstractmethod - def _write_raw(self, index: int, data: RawDataBlob): - """write a raw media data to a specified stream (abstract)""" + The base implementation here only configures the pipes, opening named pipes. - def _read_encoded(self, index: int, n: int) -> bytes: - """read selected output stream (shared backend)""" + To use named pipes, this method must be extended to call + ``configure.init_named_pipes()`` at the end. - try: - info = self._output_pipes[index] - assert "media_type" not in self._output_info[index] - return info["reader"].read(n) - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Output Stream #{index} is not an encoded stream.") from e + """ + + args = self._args["ffmpeg_args"] + more_args = {} + input_pipes = {} + output_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) + + return input_pipes, output_pipes, more_args + + def _write_prebuffer_to_pipes(self): + """write pre-buffered data to FFmpeg pipes + + By default this function does nothing (suitable for readers) + a derived writer class should reimplement this function to write + data buffered in self._init_kws. + """ + pass def _terminate(self): """Kill FFmpeg process and close the streams""" @@ -463,6 +453,7 @@ def open(self): if ok: # ready to roll self._run_ffmpeg() + else: # need input data to start ffmpeg self._status = self._status.BUFFERING @@ -475,25 +466,6 @@ def close(self): self._terminate() - def __enter__(self): - - self.open() - return self - - 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 - return False - @property def closed(self) -> bool: """True if the stream is closed.""" @@ -549,317 +521,475 @@ def _args_not_ready(self): return self._status < self._status.ARGUMENTS_SET ########################################################## - ### INPUT PROPERTIES + ### 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 _piped_input_stream_indices(self) -> list[int]: - - if self._status < self._status.ARGUMENTS_SET: - - kws = self._init_kws - - if "input_stream_types" in kws: # raw output streams (+extra encoded) - kw = kws["input_stream_types"] - assert kw is not None - streams = list(range(len(kws["input_stream_types"]))) - - if "extra_inputs" in kws: - nin = len(streams) - streams.extend( - [ - nin + i - for i, (url, _) in enumerate(kws["extra_inputs"]) - if utils.is_pipe(url) - ] - ) - else: # encoded output streams - streams = [ - i - for i, (url, _) in enumerate(kws["input_urls"]) - if utils.is_pipe(url) - ] + 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_stream_types"]) + except KeyError: + return 0 + + def write(self, data: RawDataBlob, stream: int = 0): + """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_stream_types`` + input array, defaults to 0 (write to the first stream). + """ + + try: + data2bytes = self._input_info[stream]["data2bytes"] + except AttributeError: + # _input_info wouldn't exist if FFmpeg is not running, write to prebuffer + self._init_kws.put_data(stream, data) + except KeyError as e: + raise FFmpegioError(f"Specified {stream=} is not a raw stream.") from e else: - # FFmpeg already configured - streams = [ - i - for i, info in enumerate(self._output_info) - if info["dst_type"] == "buffer" and "buffer" not in info - ] + b = data2bytes(obj=data) + if len(b): + self._input_pipes[stream].write(b) - return streams + @property + def input_types(self) -> list[MediaType]: + """media types (list of 'audio' or 'video') of raw input pipes""" - def _iter_piped_input_info( - self, encoded: bool | None = None - ) -> Iterator[tuple[int, InputInfoDict]]: + lut: dict[Literal["a", "v"], MediaType] = {"a": "audio", "v": "video"} - assert self._status >= self._status.ARGUMENTS_SET - for i in self._piped_input_stream_indices: - info = self._input_info[i] - if encoded is None or (encoded == ("media_type" not in info)): - yield i, info + try: + return [lut[av] for av in self._init_kws["input_stream_types"]] + except KeyError: + return [] @property - def input_types(self) -> dict[int, MediaType | Literal["encoded"]]: - """input pipe types (lists both encoded and raw media pipes) + def input_rates(self) -> list[int | Fraction]: + """audio sample or video frame rates associated with the input media streams""" - - only piped inputs are returned - - integer keys is the unique input index (this index is not contiguous - if non-piped inputs are also used.) - - values are either 'video' or 'audio' if raw media stream or 'encoded' - if encoded byte stream + kws = self._init_kws + try: + stypes = kws["input_stream_types"] + sargs = kws["input_stream_args"] + except KeyError: + return [] # no input streams + lut: dict[Literal["a", "v"], Literal["ar", "r"]] = {"a": "ar", "v": "r"} + return [args[lut[av]] for av, args in zip(stypes, sargs)] + + @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. """ - kws = self._init_kws + nin = self.num_input_streams + if nin == 0: + return [] - intypes = { - i: "encoded" - for i in range( - kws.num_raw_inputs, kws.num_raw_inputs + kws.num_encoded_inputs + 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 ) - } - if not kws.encoded_inputs_only: + @property + def input_shapes(self) -> dict[int, 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. + """ - intypes = { - i: {"a": "audio", "v": "video"}[av] - for i, av in enumerate(kws["input_stream_types"]) - } | intypes + nin = self.num_input_streams + if nin == 0: + return [] - return intypes + 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 + ) ########################################################## - ### OUTPUT PROPERTIES + ### ENCODED INPUT STREAM PROPERTIES/METHODS ########################################################## + @property + def decodable(self) -> bool: + """Return ``True`` if there is at least one encoded stream to write. + If ``False``, ``write_encoded()`` will raise ``FFmpegioError``.""" + + return self.num_encoded_input_streams > 0 + @cached_property - def _all_output_streams_defined(self) -> bool: - """check and remember if user provided concrete raw output streams + def num_encoded_input_streams(self) -> int: + """Return the number of encoded input streams. + If ``0``, ``write_encoded()`` will raise ``FFmpegioError``.""" - :return: True if `output_streams` is not defined in init_kws - OR every elements of `output_streams` defines `map` option - AND the option values all points to a unique stream + return len(self.encoded_input_streams) - The outcome informs whether output properties can be evaluated solely - from init_kws - """ + @cached_property + def encoded_input_streams(self) -> list[int]: + """Return a list of encoded piped input streams. + If empty, write_encoded() will raise FFmpegioError.""" - # not yet configured, deducible only if only encoded outputs or well-defined input arguments 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(kws[url_kw_or_none]) if utils.is_pipe(url)] + ) + + def write_encoded(self, data: bytes, stream: int = 0): + """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. + + """ + + if stream not in self.encoded_input_streams: + raise FFmpegioError(f"Specified {st=} is not a valid input encoded stream.") + if len(data): + return # no data to write + + st = stream + self.num_input_streams + try: + self._input_pipes[st].write(data) + except AttributeError: + # _input_info wouldn't exist if FFmpeg is not running, write to prebuffer + self._init_kws.put_data(st, data) + + ########################################################## + ### OUTPUT PROPERTIES + ########################################################## + + @cached_property + def readable(self) -> bool: + """Return ``True`` if there is at least one raw media stream to read from. + If ``False``, ``read()`` will raise ``FFmpegioError``.""" + return self.num_output_streams > 0 + + @cached_property + def num_output_streams(self) -> int: + """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: + return len(self._init_kws["output_streams"]) + except KeyError: + return 0 + + 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(f"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 * info['item_size'] if n > 0 else n + ) + + data = info["bytes2data"]( + b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] + ) - if "output_streams" in kws: # raw output streams (+extra encoded) - kw = kws["output_streams"] - if kw is None: - # output streams are entirely to be autodetected - return False + # update the frame/sample counter + # n = counter(obj=data) # actual number read + # self._n0[stream_id] += n + + return data + + + def __iter__(self): + if not self.readable: + raise FFmpegioError('No output stream to create a frame iterator') + + return self - for _, opts in ( - kw if isinstance(kw, list) else iter(v[1] for v in kw.values()) + def __next__(self): + # read all streams + F = self.read(self._read_size) + if self._output_info[self.primary_output_index]["data_is_empty"](obj=F): + raise StopIteration + return F + + @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" not in mapopts: - # output media type - media_type = stream_spec.is_unique_stream( - mapopts["stream_specifier"] - ) - if media_type is False: - return False + 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.get("media_type", "encoded") for info in stream_info[:nout]] - return True + @property + def output_labels(self) -> list[str] | None: + """labels of the raw media output pipes. - @cached_property - def _piped_output_stream_indices(self) -> list[int]: + If the same input stream is mapped to multiple outputs without unique + user labels, ``None`` is returned prior to FFmpeg starts""" - if self._status < self._status.ARGUMENTS_SET: - if not self._all_output_streams_defined: - raise FFmpegioError( - "Should not call this function before FFmpeg arguments are ready." - ) + nout = self.num_output_streams - # not yet configured but all piped streams are concretely identifiable - - kws = self._init_kws - - if "output_streams" in kws: # raw output streams (+extra encoded) - kw = kws["output_streams"] - assert kw is not None - streams = list(range(len(kws["output_streams"]))) - - if "extra_outputs" in kws: - nout = len(streams) - streams.extend( - [ - nout + i - for i, (url, _) in enumerate(kws["extra_outputs"]) - if utils.is_pipe(url) - ] - ) - else: # encoded output streams - streams = [ - i - for i, (url, _) in enumerate(kws["output_urls"]) - if utils.is_pipe(url) - ] + 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: - # FFmpeg already configured - streams = [ - i - for i, info in enumerate(self._output_info) - if info["dst_type"] == "buffer" and "buffer" not in info - ] + 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). + """ - return streams + nout = self.num_output_streams - def _iter_piped_output_info( - self, encoded: bool | None = None - ) -> Iterator[tuple[int, OutputInfoDict]]: + if nout == 0: # no output media stream + return [] - assert self._status >= self._status.ARGUMENTS_SET - for i in self._piped_output_stream_indices: - info = self._output_info[i] - if encoded is None or (encoded == ("media_type" not in info)): - yield i, info + 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_types(self) -> dict[int, MediaType | Literal["encoded"] | None] | None: - """output piped types (lists both encoded and raw media pipes) + def output_dtypes(self) -> list[DTypeString] | None: + """frame/sample data type associated with the output streams - - None if output streams are not yet concretely known - - only piped streams are returned - - integer keys are unique input indices (their continuity is not guaranteed - if non-piped inputs are also used.) - - values are either 'video' or 'audio' if raw media stream or 'encoded' - if encoded byte stream + 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``. """ - if self._status < self._status.ARGUMENTS_SET: + nout = self.num_output_streams - if not self._all_output_streams_defined: - return None + if nout == 0: # no output media stream + return [] - # not yet configured, deducible only if only encoded outputs or well-defined input arguments - kws = self._init_kws + 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]] - if "output_streams" in kws: # raw output streams (+extra encoded) - kw = kws["output_streams"] + @property + def output_shapes(self) -> list[ShapeTuple] | None: + """frame/sample shape associated with the output streams - outtypes = {} - for i, (_, opts) in enumerate( - kw if isinstance(kw, list) else iter(v[1] for v in kw.values()) - ): - mapopts = stream_spec.parse_map_option( - opts["map"], input_file_id=0, parse_stream=True - ) - if "linklabel" in mapopts: - outtypes[i] = None # linklabel requires filtergraph analysis + 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,). - else: # if "stream_specifier" not in mapopts: - media_type = stream_spec.is_unique_stream( - mapopts["stream_specifier"] - ) - outtypes[i] = None if media_type is False else media_type + If FFmpeg process has not been started, this property returns ``None``. + """ - if "extra_outputs" in kws: # encoded output also specified - nout = len(kw) - for i, (url, _) in enumerate(kws["extra_outputs"]): - if utils.is_pipe(url): - outtypes[i + nout] = "encoded" + nout = self.num_output_streams - return outtypes - else: - return { - i: "encoded" - for i, (url, _) in enumerate(kws["output_urls"]) - if utils.is_pipe(url) - } + if nout == 0: # no output media stream + return [] + try: + stream_info = self._output_info + except AttributeError: + # ffmpeg not configured yet + return None else: - return { - i: info.get("media_type", "encoded") - for i, info in enumerate(self._output_info) - if info["dst_type"] == "buffer" - } + return [v["raw_info"][1] for v in stream_info[:nout]] @property - def output_labels(self) -> dict[int, str | None] | None: - """FFmpeg/custom labels of output streams + def primary_output_label(self) -> str | None: + """primary raw media stream label (None if FFmpeg not started or no output raw stream)""" - before loading: look for map + st = self.primary_output_index + return st and self._output_info and self._output_info[st].get("user_map") - """ + @property + def primary_output_index(self) -> int | None: + """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" - if self._status < self._status.ARGUMENTS_SET: - if not self._all_output_streams_defined: - return None + return configure.find_primary_output_index( + self._output_info, self._primary_output + ) - # not yet configured, deducible only if only encoded outputs or well-defined input arguments - kws = self._init_kws + @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_index + try: + return self._output_info[st]["raw_info"][-1] + except (AttributeError, IndexError): + return None - if "output_streams" in kws: # raw output streams (+extra encoded) - kw = kws["output_streams"] + ########################################################## + ### ENCODED INPUT STREAM PROPERTIES/METHODS + ########################################################## - outlabels = {} + @property + def encodable(self) -> bool: + """Return ``True`` if there is at least one encoded stream to read. + If ``False``, ``read_encoded()`` will raise ``FFmpegioError``.""" - for i, (user_map, opts) in enumerate( - ((None, v) for v in kw) - if isinstance(kw, list) - else iter(v[1] for k, v in kw.items()) - ): - if user_map is not None: - outlabels[i] in user_map - else: - outlabels[i] = opts["map"] + return self.num_encoded_output_streams > 0 - if "extra_outputs" in kws: # encoded output also specified - nout = len(kw) - for i, (url, _) in enumerate(kws["extra_outputs"]): - if utils.is_pipe(url): - outlabels[i + nout] = f"e:{i}" + @cached_property + def num_encoded_output_streams(self) -> int: + """Return the number of encoded output streams. + If ``0``, ``read_encoded()`` will raise ``FFmpegioError``.""" - return outlabels - else: + return len(self.encoded_output_streams) - return { - i: f"e:{i}" - for i in self._piped_output_stream_indices - if utils.is_pipe(url) - } + @cached_property + def encoded_output_streams(self) -> list[int]: + """Return a list of encoded piped output streams. + If empty, ``read_encoded()`` will raise ``FFmpegioError``.""" - else: - return { - i: v.get("user_map", None) if "user_map" in v else f"e:{i}" - for i, v in self._iter_piped_output_info() - } + 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(kws[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 + """ -class PipedFFmpegRunner(BaseFFmpegRunner): - """Base class to run FFmpeg with pipes and manage its multiple I/O's""" + if stream not in self.encoded_output_streams: + raise FFmpegioError( + f"Specified {stream=} is not a valid output encoded stream." + ) - # pre-analysis/buffering variables - _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_inputs"]] - _piped_inputs_buffer: dict[int, bytes | list[RawDataBlob]] - # _piped_outputs_type: dict[int, MediaType | Literal["encoded"]] | None = None + st = stream + self.num_output_streams + self._output_pipes[st].read(n) + + +class StdFFmpegRunner(BaseFFmpegRunner): + + _use_std_pipes: bool = True def __init__( self, init_func: Callable, init_kws: dict, - primary_output: int | str | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - blocksize: int | None = None, - queue_size: int | None = None, - timeout: float | None = None, ): - """Base FFmpeg runner + """Base FFmpeg runner for reading/writing with only 1 std pipe, no piped encoded I/O :param timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param progress: progress callback function, defaults to None @@ -869,121 +999,100 @@ def __init__( to None """ - super().__init__( - init_func, - init_kws, - primary_output, - progress, - show_log, - overwrite, - sp_kwargs, - blocksize, - queue_size, - timeout, - ) + super().__init__(init_func, init_kws, progress, show_log, overwrite, sp_kwargs) - # set the default read block size for the reference stream - self._pipe_kws = {"stack": self._stack} - if timeout is not None: - self._pipe_kws["timeout"] = timeout - if blocksize is not None: - self._pipe_kws["blocksize"] = blocksize - if queue_size is not None: - self._pipe_kws["queue_size"] = queue_size - - self._piped_inputs = {} - self._piped_inputs_buffer = {} + def _try_config_ffmpeg( + self, stream: int = -1, data: bytes | RawDataBlob | None = None + ) -> bool: + """Configure FFmpeg options and populate stream information - def _analyze_inputs(self): - """identify which input init_fun keyword arguments require data from pipe""" - kws = self._init_kws - pipes = self._piped_inputs - if "input_urls" in kws: - # encoded: list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - for i, (url, _) in enumerate(kws["input_urls"]): - if utils.is_pipe(url): - pipes[i] = "input_urls" - self._nb_inputs = (0, len(kws["input_urls"])) - if "input_stream_args" in kws: - # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] - n_in = len(kws["input_stream_args"]) - for i in range(n_in): - pipes[i] = "input_stream_args" - if "extra_inputs" in kws: - # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - for i, (url, _) in enumerate(kws["extra_inputs"]): - if utils.is_pipe(url): - pipes[i + n_in] = "extra_inputs" - self._nb_inputs = (n_in, n_in + len(kws["extra_inputs"])) - - def _put_aside_input(self, stream: int, data: RawDataBlob | bytes) -> ( - tuple[ - Literal["input_urls", "input_stream_args", "extra_inputs"], - bytes | RawDataBlob, - ] - | None - ): - """write data to a buffer prior to running ffmpeg + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :return: ``True`` if FFmpeg arguments are successfully configured + and `_input_info` and `_output_info` lists are fully + populated. Excludes the pipe information. - :param stream: input stream id, index to self._input_info - :param data: data blob if raw media data or bytes if encoded data - :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 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 it contains data for a new stream, attempts to configure ffmpeg args """ - assert stream < self._nb_inputs[1] - assert self._status == self._status.NOTHING_SET + ok = super()._try_config_ffmpeg(stream, data) + if ok: + # validate + nin = len(self._input_pipes) + nout = len(self._output_pipes) + if nin + nout != 1: + raise FFmpegioError( + "StdFFmpegRunner can only use either stdin or stdout" + ) - if stream in self._piped_inputs_buffer: - buf = self._piped_inputs_buffer[stream] - if isinstance(data, bytes): - assert isinstance(buf, bytes) - self._piped_inputs_buffer[stream] = buf = buf + data - return self._piped_inputs[stream], buf + def _run_ffmpeg(self): - else: - assert not isinstance(buf, bytes) - self._piped_inputs_buffer[stream].append(data) + super()._run_ffmpeg() - else: # first write -> update the kws - self._piped_inputs_buffer[stream] = ( - data if isinstance(data, bytes) else [data] - ) - return self._piped_inputs[stream], data + # if stdin/stdout is used, attach StdWriter/StdReader object to each + configure.init_std_pipes(self._input_pipes, self._output_pipes, self._proc) - return None + # write pre-buffered data + for st, data in self._init_kws.iter_raw_data(): + self.write(data, st) - def _iter_input_buffer( + # clear pre-buffered data + self._init_kws.clear_data() + + @property + def _args_not_ready(self): + return self._status < self._status.ARGUMENTS_SET + + +class BasePipedFFmpegRunner(BaseFFmpegRunner): + """Base class to run FFmpeg and manage its multiple I/O's""" + + _use_std_pipes: bool = False + + _pipe_kws: dict + + def __init__( self, - ) -> Iterator[ - tuple[int, Literal["input_urls", "input_stream_args", "extra_inputs"]] - ]: + init_func: Callable, + init_kws: dict, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + ): + """Base FFmpeg runner for reading/writing with only 1 std pipe, no piped encoded I/O + + :param 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 sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + """ - for itm in self._piped_inputs.items(): - yield itm + super().__init__(init_func, init_kws, progress, show_log, overwrite, sp_kwargs) - def _write_from_buffer(self): + self._pipe_kws = {} - for st, kw in self._piped_inputs.items(): - i = st - self._nb_inputs[0] if kw == "extra_inputs" else st + def _configure_pipes( + self, + ) -> tuple[dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict], dict]: - # remove the data from the init keyword args - self._init_kws[kw][i] = ("-", self._init_kws[kw][i][1]) + input_pipes, output_pipes, more_args = super()._configure_pipes() - # write all the buffered data to the stream - buf = self._piped_inputs_buffer[st] - if isinstance(buf, bytes): # bytes -> encoded stream - self._input_pipes[i]["writer"].write(buf) - else: # raw data blob -> raw data stream - for frame in buf: - self._input_pipes[i]["writer"].write(frame) + # find the primary output stream's rate + configure.init_named_pipes( + input_pipes, + output_pipes, + self._input_info, + self._output_info, + update_rate=self._output_rate, + **self._pipe_kws, + ) - del self._piped_inputs_buffer[buf] + return input_pipes, output_pipes, more_args diff --git a/src/ffmpegio/streams/mixins.py b/src/ffmpegio/streams/mixins.py index 071f64e4..3c952af2 100644 --- a/src/ffmpegio/streams/mixins.py +++ b/src/ffmpegio/streams/mixins.py @@ -46,69 +46,6 @@ class BaseRawInputsMixin: _input_info: list[RawInputInfoDict] _input_pipes: list[InputPipeInfoDict] - @property - def input_rates(self) -> dict[int, int | Fraction | None]: - """audio sample or video frame rates associated with the input media streams""" - kws = self._init_kws - return { - i: kws["input_stream_args"][i][1][ - {"a": "ar", "v": "r"}[kws["input_stream_types"][i]] - ] - for i, kw in self._piped_inputs.items() - if kw == "input_stream_args" - } - - @property - def input_dtypes(self) -> dict[int, DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - kws = self._init_kws - if self._args_not_ready: - return { - i: v["raw_info"][0] - for i, v in enumerate(self._input_info) - if "raw_info" in v - } - elif "input_dtypes" in kws: # dtypes maybe given - dtypes = kws["input_dtypes"] - return { - i: dtypes[i] - for i, kw in self._piped_inputs.items() - if kw == "input_stream_args" - } - else: - # not known yet - return { - i: None - for i, kw in self._piped_inputs.items() - if kw == "input_stream_args" - } - - @property - def input_shapes(self) -> dict[int, ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - kws = self._init_kws - if self._args_not_ready: - # ffmpeg configured - return { - i: v["raw_info"][1] - for i, v in enumerate(self._input_info) - if "raw_info" in v - } - elif "input_shapes" in kws: # dtypes maybe given - # pre-configure, given by user - dtypes = kws["input_shapes"] - return { - i: dtypes[i] - for i, kw in self._piped_inputs.items() - if kw == "input_stream_args" - } - else: - # pre-configure, not given by user - return { - i: None - for i, kw in self._piped_inputs.items() - if kw == "input_stream_args" - } def _write_raw(self, index: int, data: RawDataBlob): """write a raw media data to a specified stream (backend)""" @@ -157,33 +94,6 @@ def __init__( if blocksize is not None: self._read_size = blocksize - @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_index - return st and self._output_info and self._output_info[st].get("user_map") - - @property - def primary_output_index(self) -> int | None: - """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" - - return configure.find_primary_output_index( - self._output_info, self._primary_output - ) - - @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_index - try: - return self._output_info[st]["raw_info"][-1] - except (AttributeError, IndexError): - return None - - @property - def _output_rate(self) -> int | Fraction | None: - return self.primary_output_rate def _try_config_ffmpeg( self, stream: int = -1, data: bytes | RawDataBlob | None = None diff --git a/src/ffmpegio/_open.py b/src/ffmpegio/streams/open.py similarity index 99% rename from src/ffmpegio/_open.py rename to src/ffmpegio/streams/open.py index 4cb5da36..8f9b9f50 100644 --- a/src/ffmpegio/_open.py +++ b/src/ffmpegio/streams/open.py @@ -53,21 +53,21 @@ logger = logging.getLogger("ffmpegio") from typing_extensions import overload, Literal, Sequence, Unpack, LiteralString -from ._typing import DTypeString, ShapeTuple +from .._typing import DTypeString, ShapeTuple from fractions import Fraction import re -from ._typing import ProgressCallable, Literal, FFmpegOptionDict, FFmpegUrlType -from .configure import ( +from .._typing import ProgressCallable, Literal, FFmpegOptionDict, FFmpegUrlType +from ..configure import ( IO, Buffer, FFmpegInputUrlComposite, FFmpegOutputUrlComposite, FFConcat, ) -from .filtergraph.abc import FilterGraphObject +from ..filtergraph.abc import FilterGraphObject -from . import streams, utils +from .. import streams, utils @overload From 83a6bb17f760331b3675ce8a57964e41a4e68e19 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 21 Jan 2026 12:03:32 +0900 Subject: [PATCH 327/344] wip22 --- pyproject.toml | 4 +- src/ffmpegio/__init__.py | 26 +- src/ffmpegio/_typing.py | 10 +- src/ffmpegio/_utils.py | 14 +- src/ffmpegio/analyze.py | 19 +- src/ffmpegio/audio.py | 14 +- src/ffmpegio/caps.py | 6 +- src/ffmpegio/configure.py | 342 ++--- src/ffmpegio/devices.py | 6 +- src/ffmpegio/ffmpegprocess.py | 17 +- src/ffmpegio/filtergraph/Chain.py | 7 +- src/ffmpegio/filtergraph/Filter.py | 12 +- src/ffmpegio/filtergraph/Graph.py | 16 +- src/ffmpegio/filtergraph/GraphLinks.py | 5 +- src/ffmpegio/filtergraph/__init__.py | 17 +- src/ffmpegio/filtergraph/abc.py | 7 +- src/ffmpegio/filtergraph/build.py | 8 +- src/ffmpegio/filtergraph/convert.py | 6 +- src/ffmpegio/filtergraph/presets.py | 13 +- src/ffmpegio/filtergraph/typing.py | 2 +- src/ffmpegio/filtergraph/utils.py | 5 +- src/ffmpegio/image.py | 14 +- src/ffmpegio/media.py | 51 +- src/ffmpegio/path.py | 14 +- src/ffmpegio/plugins/__init__.py | 8 +- src/ffmpegio/plugins/devices/dshow.py | 10 +- src/ffmpegio/plugins/finder_ffdl.py | 2 +- src/ffmpegio/plugins/finder_static.py | 1 + src/ffmpegio/plugins/finder_syspath.py | 3 +- src/ffmpegio/plugins/finder_win32.py | 4 +- src/ffmpegio/plugins/hookspecs.py | 4 +- src/ffmpegio/plugins/rawdata_bytes.py | 5 +- src/ffmpegio/plugins/rawdata_mpl.py | 4 +- src/ffmpegio/plugins/rawdata_numpy.py | 5 +- src/ffmpegio/probe.py | 19 +- src/ffmpegio/std_runners.py | 35 +- src/ffmpegio/stream_spec.py | 14 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 1164 +++++++++++++---- src/ffmpegio/streams/PipedStreams.py | 856 ------------ src/ffmpegio/streams/SimpleStreams.py | 405 ------ src/ffmpegio/streams/__init__.py | 21 +- src/ffmpegio/streams/mixins.py | 315 ----- src/ffmpegio/streams/open.py | 138 +- src/ffmpegio/streams/typing.py | 144 -- src/ffmpegio/threading.py | 88 +- src/ffmpegio/transcode.py | 16 +- src/ffmpegio/typing.py | 5 +- src/ffmpegio/utils/__init__.py | 280 +++- src/ffmpegio/utils/avi.py | 10 +- src/ffmpegio/utils/concat.py | 9 +- src/ffmpegio/utils/log.py | 6 +- src/ffmpegio/utils/parser.py | 6 +- src/ffmpegio/video.py | 15 +- tests/test_open.py | 8 +- ..._pipedstreams.py => test_streams_piped.py} | 29 +- ...implestreams.py => test_streams_simple.py} | 77 +- 56 files changed, 1729 insertions(+), 2612 deletions(-) delete mode 100644 src/ffmpegio/streams/PipedStreams.py delete mode 100644 src/ffmpegio/streams/SimpleStreams.py delete mode 100644 src/ffmpegio/streams/mixins.py delete mode 100644 src/ffmpegio/streams/typing.py rename tests/{test_pipedstreams.py => test_streams_piped.py} (88%) rename tests/{test_simplestreams.py => test_streams_simple.py} (67%) diff --git a/pyproject.toml b/pyproject.toml index 722009b1..9a2a0894 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", diff --git a/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 9f231097..0a3d85be 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, ffmpegprocess, 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 .streams.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"] # fmt:on __version__ = "0.11.1" diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 87eeb2bc..605bff74 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -2,16 +2,16 @@ from __future__ import annotations -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, CopyFileObjThread + + from .threading import CopyFileObjThread, ReaderThread, WriterThread # from typing_extensions import * @@ -326,7 +326,7 @@ class RawDirectOutputInfoDict(TypedDict): media_type: MediaType # raw_info: RawStreamInfoTuple bytes2data: FromBytesCallable - item_size: int, + item_size: int data_is_empty: IsEmptyCallable data_count: CountDataCallable user_map: str # user specified map option @@ -357,7 +357,7 @@ class RawFilteredOutputInfoDict(TypedDict): dst_type: Literal["buffer"] # True if file path/url media_type: MediaType # raw_info: RawStreamInfoTuple - item_size: int, + item_size: int bytes2data: FromBytesCallable data_is_empty: IsEmptyCallable data_count: CountDataCallable diff --git a/src/ffmpegio/_utils.py b/src/ffmpegio/_utils.py index 3893c9dc..d138064e 100644 --- a/src/ffmpegio/_utils.py +++ b/src/ffmpegio/_utils.py @@ -2,16 +2,16 @@ 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 namedpipe import NPopen -import urllib.parse +from typing import Any, Sequence -import re import numpy as np +from namedpipe import NPopen + +from ._typing import DTypeString, ShapeTuple try: from math import prod @@ -110,8 +110,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") diff --git a/src/ffmpegio/analyze.py b/src/ffmpegio/analyze.py index 8a3dea8d..5c0b5f4e 100644 --- a/src/ffmpegio/analyze.py +++ b/src/ffmpegio/analyze.py @@ -3,22 +3,23 @@ """ from __future__ import annotations -from collections import namedtuple -from abc import ABC + import logging +from abc import ABC +from collections import namedtuple logger = logging.getLogger("ffmpegio") -from . import configure -from .filtergraph import Graph, Filter, Chain, as_filtergraph -from .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/audio.py b/src/ffmpegio/audio.py index 1b5bd053..ee8cef66 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -5,22 +5,20 @@ import logging import warnings -from . import configure, plugins, analyze, FFmpegioError, utils - -from ._typing import TYPE_CHECKING, Any, ProgressCallable, RawDataBlob - +from . import analyze, configure, utils +from . import filtergraph as fgb +from ._typing import Any, ProgressCallable, RawDataBlob from .configure import ( FFmpegInputOptionTuple, FFmpegInputUrlComposite, FFmpegInputUrlNoPipe, FFmpegNoPipeInputOptionTuple, - FFmpegOutputUrlNoPipe, FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, ) +from .errors import FFmpegioError from .filtergraph.abc import FilterGraphObject -from . import filtergraph as fgb - -from .std_runners import run_and_return_raw, run_and_return_encoded +from .std_runners import run_and_return_encoded, run_and_return_raw logger = logging.getLogger("ffmpegio") diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index dffef3c6..c7278ef7 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -4,13 +4,15 @@ logger = logging.getLogger("ffmpegio") -import re, fractions, subprocess as sp +import fractions +import re +import subprocess as sp from collections import namedtuple from fractions import Fraction from functools import partial -from .path import ffmpeg as _ffmpeg from .errors import FFmpegError +from .path import ffmpeg as _ffmpeg # fmt:off __all__ = ["options", "filters", "codecs", "coders", "formats", "devices", diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 7693dc31..34b4b7be 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -34,87 +34,73 @@ 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 fractions import Fraction from functools import cache +from io import IOBase from itertools import count -from collections import Counter +from namedpipe import NPopen + +from . import filtergraph as fgb +from . import plugins, probe, utils from ._typing import ( - IO, - Literal, - LiteralString, - get_args, - cast, Any, - TypedDict, - NotRequired, - Unpack, + Buffer, Callable, + CountDataCallable, DTypeString, - ShapeTuple, - RawStreamInfoTuple, - Buffer, - MediaType, + EncodedInputInfoDict, + EncodedOutputInfoDict, + FFmpegOptionDict, FFmpegUrlType, + FilterGraphInfoDict, FromBytesCallable, - ToBytesCallable, - IsEmptyCallable, - CountDataCallable, - RawInputInfoDict, - RawInputInfoDict, - EncodedInputInfoDict, InputInfoDict, InputPipeInfoDict, + IsEmptyCallable, + Literal, + MediaType, + NotRequired, OutputInfoDict, - RawOutputInfoDict, - EncodedOutputInfoDict, OutputPipeInfoDict, - RawStreamDef, RawDataBlob, - FFmpegOptionDict, - FilterGraphInfoDict, + RawInputInfoDict, + RawOutputInfoDict, + RawStreamDef, + RawStreamInfoTuple, + ShapeTuple, + ToBytesCallable, + TypedDict, + Unpack, + cast, + get_args, ) -from collections.abc import Sequence -from .utils import FFmpegInputUrlComposite, FFmpegOutputUrlComposite - - -from fractions import Fraction -import re, logging - -logger = logging.getLogger("ffmpegio") - -from io import IOBase - -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 -from .utils.concat import FFConcat # for typing from ._utils import as_multi_option, is_non_str_sequence +from .errors import ( + FFmpegError, + FFmpegioError, + FFmpegioInsufficientInputData, + FFmpegioNoPipeAllowed, +) +from .filtergraph.abc import FilterGraphObject +from .stream_spec import StreamSpecDict, parse_map_option, stream_type_to_media_type +from .stream_spec import stream_spec as compose_stream_spec +from .threading import CopyFileObjThread, ReaderThread, WriterThread from .utils import ( FFmpegInputUrlComposite, - FFmpegOutputUrlComposite, FFmpegInputUrlNoPipe, + FFmpegOutputUrlComposite, FFmpegOutputUrlNoPipe, ) +from .utils.concat import FFConcat # for typing -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, - FFmpegioInsufficientInputData, - FFmpegError, -) -from .threading import ReaderThread, WriterThread, CopyFileObjThread +logger = logging.getLogger("ffmpegio") ################################# ## module types @@ -210,38 +196,44 @@ class FFmpegArgs(TypedDict): class MediaReadKwsDict(TypedDict): - input_urls: list[FFmpegInputOptionTuple] - output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + input_urls: Sequence[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ] + output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None options: FFmpegOptionDict squeeze: bool - extra_outputs: list[FFmpegOutputOptionTuple] | None + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None class MediaWriteKwsDict(TypedDict): - output_urls: list[FFmpegOutputOptionTuple] - input_stream_types: list[Literal["a", "v"]] - input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]] - options: dict[str, Any] - input_dtypes: NotRequired[list[DTypeString | None] | None] - input_shapes: NotRequired[list[ShapeTuple | None] | None] - extra_inputs: list[FFmpegInputOptionTuple] + output_urls: Sequence[FFmpegOutputOptionTuple] + input_stream_types: Sequence[Literal["a", "v"]] + input_stream_args: Sequence[tuple[RawDataBlob | None, FFmpegOptionDict]] + options: FFmpegOptionDict + input_dtypes: NotRequired[Sequence[DTypeString | None] | None] + input_shapes: NotRequired[Sequence[ShapeTuple | None] | None] + extra_inputs: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None class MediaFilterKwsDict(TypedDict): expr: str | FilterGraphObject | list[str | FilterGraphObject] | None - input_stream_types: list[Literal["a", "v"]] - input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]] - output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + input_stream_types: Sequence[Literal["a", "v"]] + input_stream_args: Sequence[tuple[RawDataBlob | None, FFmpegOptionDict]] + output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None options: FFmpegOptionDict - input_dtypes: NotRequired[list[DTypeString] | None] - input_shapes: NotRequired[list[ShapeTuple] | None] + input_dtypes: NotRequired[Sequence[DTypeString] | None] + input_shapes: NotRequired[Sequence[ShapeTuple] | None] squeeze: bool - extra_inputs: list[FFmpegInputOptionTuple] - extra_outputs: list[FFmpegOutputOptionTuple] + extra_inputs: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None class MediaTranscoderKwsDict(TypedDict): - input_urls: list[FFmpegInputOptionTuple] + input_urls: Sequence[ + FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + ] output_urls: list[FFmpegOutputOptionTuple] options: FFmpegOptionDict @@ -523,7 +515,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.") # analyze and assign outputs @@ -620,7 +612,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}, ) @@ -643,7 +635,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}, ) @@ -854,7 +846,6 @@ def gather_video_read_opts( 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"] @@ -1015,7 +1006,6 @@ def gather_audio_read_opts( or (not skip_rate and ar is None) and args is not None ): - # run input analysis try: map_spec = options["map"] @@ -1271,7 +1261,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 @@ -1298,7 +1288,7 @@ def config_input_fg( # 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 fg, dopt, kwargs @@ -1439,7 +1429,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 @@ -1517,7 +1506,6 @@ def resolve_raw_output_streams( output_opts = [] output_info = [] for i, opts in enumerate(stream_opts): - spec = opts["map"] user_map = stream_names.get(i, spec) @@ -1547,7 +1535,6 @@ def resolve_raw_output_streams( } ) else: - if "negative" in opt: raise ValueError("negative map is not supported.") @@ -1639,59 +1626,6 @@ def resolve_raw_output_streams( return output_opts, output_info -def format_raw_output_stream_defs( - streams: Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, - options: FFmpegOptionDict | None, -) -> tuple[list[FFmpegOptionDict], dict[int, str]]: - """convert user-supplied streams arguments to the standard form - - :param streams: 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 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. - - None to select all available streams - :param options: default output options - :return stream_options: list of stream options - :return stream_alias: list of pairs of stream map options and user-supplied stream labels - """ - - # depending on user's streams input, label output streams differently - # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists - streams_: list[FFmpegOptionDict] - stream_names: dict[int, str] = ( - {} - ) # dict of user-specified stream name (only via dict streams input) - - if isinstance(streams, dict): # dict[str,FFmpegOptionDict] - # dict key is used as both stream names (labels) and map option. - # * If FFmpegOptionDict in the dict value contains 'map' option, the key - # would only be used as the stream name - # * Note that if the map option is not unique the stream name will - # be renamed with an appended index. - streams_ = [] - for i, (k, v) in enumerate(streams.items()): - if "map" in v: # user provided non-map stream name - stream_names[i] = k - streams_.append({**options, "map": k, **v}) - elif "map" in options: - streams_ = [options] - else: # isinstance(stream,list[str|FFmpegOptionDict]) - # if an item is a str, it is the map option value - # if FFmpegOptionDict, it must contain a 'map' option - - streams_ = [ - {**options, **({"map": v} if isinstance(v, str) else v)} for v in streams - ] - - return streams_, stream_names - - def auto_map( args: FFmpegArgs, options: FFmpegOptionDict, @@ -1999,7 +1933,9 @@ def get_fg_info() -> dict[str, FilterGraphInfoDict] | None: # gather all available streams keyed by their map specifier stream_opts, stream_info = auto_map(args, options, input_info, get_fg_info()) else: - stream_opts, stream_names = format_raw_output_stream_defs(streams, options) + stream_opts, stream_names = utils.format_raw_output_stream_defs( + streams, options + ) # expand all streams (targetting ) stream_opts, stream_info = resolve_raw_output_streams( @@ -2013,12 +1949,10 @@ def get_callables(media_type): return get_raw_output_plugin_callables(media_type) for opts, info in zip(stream_opts, stream_info): - media_type = info.get("media_type", None) # if media_type is unknown (must be a linklabel not yet analyzed) if media_type is None: - fg_info = get_fg_info() pad_info = fg_info[info["linklabel"]] info["media_type"] = media_type = pad_info["media_type"] @@ -2123,29 +2057,28 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: opts = {**inopts_default, **opts} more_opts = None - raw_info = None + shape_dtype = None if mtype == "a": # audio media_type = "audio" - opts[ropt] = round(opts[ropt]) # force int sampling rate + opts[ropt] = rate = round(opts[ropt]) # force int sampling rate if data is not None: - more_opts, raw_info = utils.array_to_audio_options(data) + more_opts, shape_dtype = utils.array_to_audio_options(data) data = plugins.get_hook().audio_bytes(obj=data) elif dtypes and shapes and shapes[i] is not None and dtypes[i] is not None: - raw_info = (shapes[i], dtypes[i]) + shape_dtype = (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} - raw_info = (*raw_info, opts["ar"]) if raw_info else (None, None, opts["ar"]) - else: # video media_type = "video" + opts[ropt] = rate = opts[ropt] # force int sampling rate if data is not None: - more_opts, raw_info = utils.array_to_video_options(data) + more_opts, shape_dtype = utils.array_to_video_options(data) data = plugins.get_hook().video_bytes(obj=data) elif dtype and shape: - raw_info = shape, dtype + shape_dtype = (shape, dtype) pix_fmt, s = utils.guess_video_format(*raw_info) more_opts = { "f": "rawvideo", @@ -2154,11 +2087,13 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: "s": s, } - if raw_info is None: + if shape_dtype is None: raise FFmpegioInsufficientInputData( - "Failed to resolve raw input data format." + "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) @@ -2166,7 +2101,7 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: "src_type": "buffer", "media_type": media_type, "raw_info": (*raw_info, opts[ropt]), - "item_size": utils.get_samplesize(*raw_info[1::-1]), + "item_size": utils.get_samplesize(*raw_info[:-1]), **get_callables(media_type), } @@ -2274,7 +2209,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 @@ -2397,7 +2331,7 @@ def assign_output_pipes( and there is at least one piped output """ - pipe_info = {} + pipe_info: dict[int, OutputPipeInfoDict] = {} sp_kwargs = {} if output_info is None: @@ -2406,10 +2340,8 @@ def assign_output_pipes( # configure output pipes use_stdout = False has_pipeout = False - pipe_info = {} for i, (info, arg) in enumerate(zip(output_info, args["outputs"])): - if arg[0]: # url already configured continue @@ -2470,7 +2402,6 @@ def assign_input_pipes( # configure input pipes (if needed) for i, (info, arg) in enumerate(zip(input_info, args["inputs"])): - if arg[0]: # url already configured continue @@ -2504,8 +2435,9 @@ def init_named_pipes( outpipe_info: dict[int, OutputPipeInfoDict], input_info: list[InputInfoDict], output_info: list[OutputInfoDict], - update_rate: int | Fraction | None = None, - blocksize: int | None = None, + ref_stream: int | Fraction | 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, @@ -2515,9 +2447,10 @@ 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 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) + :param ref_stream: index of reference raw media output stream, defaults to 0 + :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 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 @@ -2537,6 +2470,7 @@ def init_named_pipes( wr_kws = {"queuesize": queue_size, "timeout": timeout} if queue_size else {} # configure output pipes + ref_rate = None if ref_stream is None else output_info[ref_stream]["raw_info"][-1] for i, pinfo in outpipe_info.items(): info = output_info[i] @@ -2555,12 +2489,20 @@ def init_named_pipes( assert dst_type == "buffer" kws = {**wr_kws} if "raw_info" in info: - if update_rate is not None: - # set the number of frames/samples to enqueue at a time - kws["nmin"] = round(rate / update_rate) or 1 + 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: - # assume encoded output - kws["nmin"] = blocksize or 2**16 + # encoded output in bytes + kws["itemsize"] = 1 + kws["nmin"] = enc_blocksize or 2**16 reader = ReaderThread(pipe, **kws) pinfo["reader"] = reader @@ -2610,71 +2552,33 @@ def write(self, data: bytes | None): class StdReader: - def __init__(self, proc: fp.Popen) -> None: + def __init__(self, proc: fp.Popen, itemsize: int) -> None: self._proc = proc + self._itemsize = itemsize def read(self, n: int = -1) -> bytes: - return self._proc.stdout.read(n) + return self._proc.stdout.read(n if n <= 0 else n * self._itemsize) def init_std_pipes( input_pipes: dict[int, InputPipeInfoDict], output_pipes: dict[int, OutputPipeInfoDict], + output_info: list[OutputInfoDict], proc: fp.Popen, ): + """initialize std pipe reader or writer + :param input_pipes: _description_ + :param output_pipes: _description_ + :param output_info: FFmpeg output information, its length matches that of `args['outputs']` + :param proc: _description_ + """ stdin = next((st for st, p in input_pipes.items() if p["pipe"] == "stdin"), None) if stdin is not None: input_pipes[stdin]["writer"] = StdWriter(proc) stdout = next((st for st, p in output_pipes.items() if p["pipe"] == "stdout"), None) if stdout is not None: - output_pipes[stdout]["reader"] = StdReader(proc) - - -def find_primary_output_index( - # output_pipes: dict[int, OutputPipeInfoDict], - output_info: list[OutputInfoDict], - primary_output: int | str | None = None, -) -> int | None: - """find index of the primary raw media output stream - - :param output_pipes: output pipe information dicts, keyed by output stream index - :param output_info: output stream information list - :param primary_output: primary output index or label, defaults to the first - output media stream - :return: primary output index or None if not found - """ - - if primary_output is None: - # use first raw stream - return next( - (i for i, info in enumerate(output_info) if "buffer" in info["dst_type"]), - None, + output_pipes[stdout]["reader"] = StdReader( + proc, output_info[stdout]["item_size"] ) - else: - # validate the specified stream (convert to int idx if str label given) - st_ = primary_output - if isinstance(st_, str): - try: - st = next( - i - for i, info in enumerate(output_info) - if "buffer" in info["dst_type"] and info["user_map"] == st_ - ) - except StopIteration as e: - raise ValueError( - f'Primary media output stream "{st_}" is not found.' - ) from e - else: - st = st_ - - # if invalid output stream index, return None - try: - assert "media_type" not in output_info[st] - except AssertionError as e: - raise ValueError( - f"Primary media output stream {st} is not found." - ) from e - - return st diff --git a/src/ffmpegio/devices.py b/src/ffmpegio/devices.py index dbac80ae..75c804d9 100644 --- a/src/ffmpegio/devices.py +++ b/src/ffmpegio/devices.py @@ -19,10 +19,12 @@ logger = logging.getLogger("ffmpegio") +import re +from subprocess import DEVNULL, PIPE + from ffmpegio.path import ffmpeg -from subprocess import PIPE, DEVNULL + from . import plugins -import re SOURCES = {} SINKS = {} diff --git a/src/ffmpegio/ffmpegprocess.py b/src/ffmpegio/ffmpegprocess.py index 0e08d642..0d88d1d3 100644 --- a/src/ffmpegio/ffmpegprocess.py +++ b/src/ffmpegio/ffmpegprocess.py @@ -19,21 +19,22 @@ """ -from collections import abc -from os import path, name as os_name -from threading import Thread +import logging +import signal import subprocess as sp +from collections import abc from copy import deepcopy +from os import name as os_name +from os import path from tempfile import TemporaryDirectory -import logging -import signal +from threading import Thread logger = logging.getLogger("ffmpegio") -from .utils.parser import parse, compose, FLAG -from .threading import ProgressMonitorThread from .configure import move_global_options -from .path import ffmpeg, DEVNULL, PIPE, devnull +from .path import DEVNULL, PIPE, devnull, ffmpeg +from .threading import ProgressMonitorThread +from .utils.parser import FLAG, compose, parse __all__ = ["versions", "run", "Popen", "FLAG", "PIPE", "DEVNULL", "devnull"] diff --git a/src/ffmpegio/filtergraph/Chain.py b/src/ffmpegio/filtergraph/Chain.py index d6356396..48f55697 100644 --- a/src/ffmpegio/filtergraph/Chain.py +++ b/src/ffmpegio/filtergraph/Chain.py @@ -2,15 +2,12 @@ from collections import UserList from collections.abc import Callable, Generator, Sequence - from itertools import chain -from . 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"] diff --git a/src/ffmpegio/filtergraph/Filter.py b/src/ffmpegio/filtergraph/Filter.py index e8d29a2b..99ea12d9 100644 --- a/src/ffmpegio/filtergraph/Filter.py +++ b/src/ffmpegio/filtergraph/Filter.py @@ -1,18 +1,18 @@ from __future__ import annotations -from collections.abc import Generator, Sequence import re +from collections.abc import Generator, Sequence from functools import partial from itertools import chain -from ..caps import filters as list_filters, filter_info, layouts, FilterInfo -from . import utils as filter_utils - from .. import filtergraph as fgb +from ..caps import FilterInfo, filter_info +from ..caps import filters as list_filters +from ..caps import layouts from ..stream_spec import parse_stream_spec - -from .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 56796fcc..bf49b26a 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"] diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index d7823c44..671b1d29 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 """ diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index ea89d6f9..0501aee1 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -102,19 +102,14 @@ 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, - as_filtergraph, - as_filtergraph_object, - as_filtergraph_object_like, - atleast_filterchain, -) +from .convert import (as_filter, as_filterchain, as_filtergraph, + as_filtergraph_object, as_filtergraph_object_like, + 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 5a5095f5..3022925b 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -3,13 +3,10 @@ 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 .typing import JOIN_HOW, PAD_INDEX, Literal __all__ = ["FilterGraphObject"] diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index 45d9253f..f0ae395f 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -1,14 +1,12 @@ from __future__ import annotations -from itertools import islice from copy import copy +from itertools import islice -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 .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 d2d379e2..4bc321ca 100644 --- a/src/ffmpegio/filtergraph/convert.py +++ b/src/ffmpegio/filtergraph/convert.py @@ -1,9 +1,9 @@ from __future__ import annotations -from . import utils as filter_utils - -from .exceptions import FiltergraphConversionError, FiltergraphInvalidExpression from .. import filtergraph as fgb +from . import utils as filter_utils +from .exceptions import (FiltergraphConversionError, + FiltergraphInvalidExpression) def as_filter( diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index ee0ee732..61fff130 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -2,19 +2,18 @@ from __future__ import annotations -from .._typing import TYPE_CHECKING, Any, Sequence, Literal -from ..stream_spec import StreamSpecDict -from .abc import FilterGraphObject -from ..path import check_version - -from functools import reduce from fractions import Fraction +from functools import reduce from .. import filtergraph as fgb +from .._typing import TYPE_CHECKING, Any, Literal, Sequence +from ..path import check_version +from ..stream_spec import StreamSpecDict +from .abc import FilterGraphObject if TYPE_CHECKING: - from .Graph import Graph from .Chain import Chain + from .Graph import Graph def remove_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 de46224e..ccb79742 100644 --- a/src/ffmpegio/filtergraph/utils.py +++ b/src/ffmpegio/filtergraph/utils.py @@ -1,6 +1,7 @@ -from fractions import Fraction -import re, itertools +import itertools +import re from collections.abc import Sequence +from fractions import Fraction # Filter string parser/composer # For FilterGraph class, see ../filtergraph.py diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 07e1d3df..340ac0ba 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -1,21 +1,19 @@ -import warnings import logging from fractions import Fraction -from . import configure, plugins, analyze, FFmpegioError, utils -from .std_runners import run_and_return_raw, run_and_return_encoded - -from ._typing import Any, ProgressCallable, RawDataBlob, FFmpegOptionDict - +from . import configure, utils +from . import filtergraph as fgb +from ._typing import Any, ProgressCallable, RawDataBlob from .configure import ( FFmpegInputOptionTuple, FFmpegInputUrlComposite, FFmpegInputUrlNoPipe, FFmpegNoPipeInputOptionTuple, - FFmpegOutputUrlNoPipe, FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, ) -from . import filtergraph as fgb +from .errors import FFmpegioError +from .std_runners import run_and_return_encoded, run_and_return_raw __all__ = ["create", "read", "write", "filter", "detect"] diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index cee81694..15bdde2e 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -1,44 +1,38 @@ from __future__ import annotations import logging - -logger = logging.getLogger("ffmpegio") - from collections.abc import Sequence +from fractions import Fraction + +from . import configure, ffmpegprocess from ._typing import ( + FFmpegOptionDict, + InputInfoDict, + InputPipeInfoDict, Literal, - RawStreamDef, + OutputInfoDict, + OutputPipeInfoDict, ProgressCallable, RawDataBlob, - Unpack, - FFmpegUrlType, - InputInfoDict, - RawInputInfoDict, - OutputInfoDict, RawOutputInfoDict, - OutputPipeInfoDict, - FFmpegOptionDict, - DTypeString, - ShapeTuple, - InputPipeInfoDict, + RawStreamDef, + Unpack, ) from .configure import ( FFmpegArgs, - FFmpegOutputUrlComposite, FFmpegInputUrlComposite, - FFmpegOutputUrlNoPipe, - FFmpegNoPipeOutputOptionTuple, FFmpegInputUrlNoPipe, FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, + FFmpegOutputUrlNoPipe, ) - -from fractions import Fraction - -from . import ffmpegprocess, utils, configure, FFmpegError, plugins -from .utils import log -from .errors import FFmpegioError +from .errors import FFmpegError from .filtergraph.abc import FilterGraphObject +logger = logging.getLogger("ffmpegio") + __all__ = ["read", "write"] @@ -53,12 +47,12 @@ def _runner( ) -> tuple[ ffmpegprocess.Popen, dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict] ]: - # convert show_log to capture_log capture_log = None if show_log else True # configure named pipes - input_pipes = output_pipes = {} + input_pipes: dict[int, InputPipeInfoDict] = {} + output_pipes: dict[int, OutputPipeInfoDict] = {} if len(input_info): input_pipes, sp_kwargs = configure.assign_input_pipes(args, input_info, False) if len(output_info): @@ -101,14 +95,15 @@ def _gather_outputs( output_info: list[RawOutputInfoDict], pipe_info: dict[int, OutputPipeInfoDict], ) -> tuple[dict[str, int | Fraction], dict[str, RawDataBlob]]: - rates = {} data = {} for i, pinfo in pipe_info.items(): info = output_info[i] + if "media_type" not in info: + continue spec = info["user_map"] - b = pinfo["reader"].read_all() + b = pinfo["reader"].read() dtype, shape, rate = info["raw_info"] data[spec] = info["bytes2data"]( @@ -131,7 +126,7 @@ def read( | None ) = None, extra_outputs: ( - Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None ) = None, squeeze: bool = False, show_log: bool | None = None, diff --git a/src/ffmpegio/path.py b/src/ffmpegio/path.py index 329f43f1..a5c29ca5 100644 --- a/src/ffmpegio/path.py +++ b/src/ffmpegio/path.py @@ -1,14 +1,18 @@ -from os import path as _path, name as _os_name, devnull +import logging +import re +import shlex +from os import devnull +from os import name as _os_name +from os import path as _path from shutil import which -from subprocess import run, DEVNULL, PIPE, STDOUT -import re, shlex +from subprocess import DEVNULL, PIPE, STDOUT, run + from packaging.version import Version -import logging logger = logging.getLogger("ffmpegio") -from .errors import FFmpegioError from . import plugins +from .errors import FFmpegioError # fmt:off __all__ = [ diff --git a/src/ffmpegio/plugins/__init__.py b/src/ffmpegio/plugins/__init__.py index 28f14c07..ece2631a 100644 --- a/src/ffmpegio/plugins/__init__.py +++ b/src/ffmpegio/plugins/__init__.py @@ -5,12 +5,12 @@ logger = logging.getLogger(__name__) logger.addHandler(logging.NullHandler()) -from typing import Literal, Any - +import os +import re from importlib import import_module -import re, os -import pluggy +from typing import Any, Literal +import pluggy from . import hookspecs diff --git a/src/ffmpegio/plugins/devices/dshow.py b/src/ffmpegio/plugins/devices/dshow.py index 6ea704c5..6449261f 100644 --- a/src/ffmpegio/plugins/devices/dshow.py +++ b/src/ffmpegio/plugins/devices/dshow.py @@ -1,11 +1,13 @@ """ DirectShow device""" -from subprocess import PIPE -from ffmpegio import path +import logging import re -from pluggy import HookimplMarker +from subprocess import PIPE + from packaging.version import Version -import logging +from pluggy import HookimplMarker + +from ffmpegio import path logger = logging.getLogger("ffmpegio") diff --git a/src/ffmpegio/plugins/finder_ffdl.py b/src/ffmpegio/plugins/finder_ffdl.py index 3c48c347..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") 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 568c76d6..c2bc7659 100644 --- a/src/ffmpegio/plugins/finder_syspath.py +++ b/src/ffmpegio/plugins/finder_syspath.py @@ -1,11 +1,10 @@ """ffmpegio plugin to find ffmpeg and ffprobe on system path""" import logging +from shutil import which from pluggy import HookimplMarker -from shutil import which - hookimpl = HookimplMarker("ffmpegio") __all__ = ["finder"] diff --git a/src/ffmpegio/plugins/finder_win32.py b/src/ffmpegio/plugins/finder_win32.py index c680862b..aefab7ff 100644 --- a/src/ffmpegio/plugins/finder_win32.py +++ b/src/ffmpegio/plugins/finder_win32.py @@ -1,4 +1,6 @@ -import os, shutil +import os +import shutil + from pluggy import HookimplMarker hookimpl = HookimplMarker("ffmpegio") diff --git a/src/ffmpegio/plugins/hookspecs.py b/src/ffmpegio/plugins/hookspecs.py index 50de1b66..08c8e25a 100644 --- a/src/ffmpegio/plugins/hookspecs.py +++ b/src/ffmpegio/plugins/hookspecs.py @@ -1,7 +1,9 @@ from __future__ import annotations -import pluggy from typing import Callable + +import pluggy + from .._typing import DTypeString, ShapeTuple hookspec = pluggy.HookspecMarker("ffmpegio") diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index 04f1a7e4..024966ef 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", diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py index b606bd94..fa9d18e2 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -1,9 +1,11 @@ """ffmpegio plugin to use `numpy.ndarray` objects for media data I/O""" +import io + import matplotlib as Figure from pluggy import HookimplMarker + from .._typing import DTypeString, ShapeTuple -import io __all__ = ["video_info", "video_bytes"] diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index bb8e5a42..0866d0b5 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") diff --git a/src/ffmpegio/probe.py b/src/ffmpegio/probe.py index a79f58b3..adf8456c 100644 --- a/src/ffmpegio/probe.py +++ b/src/ffmpegio/probe.py @@ -1,22 +1,23 @@ from __future__ import annotations -from typing import BinaryIO, Any, Literal, Union, Tuple, Dict -from numbers import Number +import json +import logging +import re from collections.abc import Sequence -from typing_extensions import Buffer, IO -from io import IOBase - -import json, re from fractions import Fraction from functools import lru_cache +from io import IOBase +from numbers import Number +from typing import Any, BinaryIO, Dict, Literal, Tuple, Union -import logging +from typing_extensions import IO, Buffer logger = logging.getLogger("ffmpegio") -from .path import ffprobe, PIPE from .errors import FFmpegError -from .stream_spec import StreamSpecDict, stream_spec as compose_stream_spec +from .path import PIPE, ffprobe +from .stream_spec import StreamSpecDict +from .stream_spec import stream_spec as compose_stream_spec # fmt:off __all__ = ['full_details', 'format_basic', 'streams_basic', diff --git a/src/ffmpegio/std_runners.py b/src/ffmpegio/std_runners.py index 4d3da6b1..21e99d7c 100644 --- a/src/ffmpegio/std_runners.py +++ b/src/ffmpegio/std_runners.py @@ -4,43 +4,21 @@ import logging -from . import ( - ffmpegprocess as fp, - configure, - FFmpegError, - FFmpegioError, - plugins, - analyze, -) -from .utils import log as log_utils +from . import configure +from . import ffmpegprocess as fp from ._typing import ( - Sequence, TYPE_CHECKING, - Buffer, Any, + EncodedInputInfoDict, + EncodedOutputInfoDict, ProgressCallable, - FFmpegUrlType, - FFmpegOptionDict, - RawDataBlob, RawInputInfoDict, - EncodedInputInfoDict, RawOutputInfoDict, - EncodedOutputInfoDict, ) +from .errors import FFmpegError, FFmpegioError if TYPE_CHECKING: - from .configure import ( - FFmpegInputOptionTuple, - FFmpegInputUrlComposite, - FFmpegInputUrlNoPipe, - FFmpegNoPipeInputOptionTuple, - FFmpegOutputOptionTuple, - FFmpegOutputUrlNoPipe, - FFmpegNoPipeOutputOptionTuple, - FFmpegArgs, - ) - from .filtergraph.abc import FilterGraphObject - from .utils.concat import FFConcat + from .configure import FFmpegArgs logger = logging.getLogger("ffmpegio") @@ -55,7 +33,6 @@ def run_and_return_raw( 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( diff --git a/src/ffmpegio/stream_spec.py b/src/ffmpegio/stream_spec.py index 8e308150..a2fb9f2e 100644 --- a/src/ffmpegio/stream_spec.py +++ b/src/ffmpegio/stream_spec.py @@ -6,19 +6,11 @@ from __future__ import annotations -from ._typing import ( - get_args, - Literal, - TypedDict, - Union, - Tuple, - FFmpegMediaType, - MediaType, - NotRequired, -) - import re +from ._typing import (FFmpegMediaType, Literal, MediaType, NotRequired, Tuple, + TypedDict, Union, get_args) + StreamSpecStreamType = Literal["v", "a", "s", "d", "t", "V"] # libavformat/avformat.c:match_stream_specifier() diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 71e571d0..8405be04 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -1,33 +1,45 @@ from __future__ import annotations import logging -import sys +from abc import ABCMeta from contextlib import ExitStack from enum import IntEnum from fractions import Fraction from functools import cached_property -from abc import ABCMeta, abstractmethod - -from .. import ffmpegprocess, configure, utils, stream_spec +from .. import configure, ffmpegprocess, stream_spec, utils from .._typing import ( Any, - Literal, Callable, - Iterator, - ShapeTuple, DTypeString, - MediaType, - RawDataBlob, - ProgressCallable, + FFmpegOptionDict, InputInfoDict, - OutputInfoDict, InputPipeInfoDict, + Iterator, + Literal, + MediaType, + OutputInfoDict, OutputPipeInfoDict, + ProgressCallable, + RawDataBlob, + Sequence, + ShapeTuple, + override, +) +from ..configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegMediaKwsDict, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, + MediaFilterKwsDict, + MediaReadKwsDict, + MediaTranscoderKwsDict, + MediaWriteKwsDict, ) - -from ..threading import LoggerThread from ..errors import FFmpegError, FFmpegioError, FFmpegioInsufficientInputData +from ..filtergraph.abc import FilterGraphObject +from ..threading import LoggerThread logger = logging.getLogger("ffmpegio") @@ -35,12 +47,30 @@ class FFmpegStatus(IntEnum): - NOTHING_SET = 0 + """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 - ARGUMENTS_SET = 2 - PIPES_SET = 3 - RUNNING = 4 - STOPPED = 5 + ANALYSIS_DONE = 2 + RUNNING = 3 + STOPPED = 4 class InitMediaKeywordsWithInputBuffer(dict): @@ -66,7 +96,7 @@ def __init__(self, init_kws: dict): self._nraw = len(self["input_stream_args"]) self._raw_pipe_buffer = [None] * self._nraw - if "extra_inputs" in self: + if "extra_inputs" in self and self["extra_inputs"] is not None: # encoded:list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] self["extra_inputs"] = [*self["extra_inputs"]] @@ -100,7 +130,6 @@ def put_data(self, stream: int, data: RawDataBlob | bytes) -> bool: """ if self._raw_pipe_buffer is None: # encoded input - buf = self._enc_pipe_buffer[stream] if buf is None: # first write buf = data @@ -126,17 +155,18 @@ def put_data(self, stream: int, data: RawDataBlob | bytes) -> bool: urls = self["extra_inputs"] urls[stream] = (buf, urls[stream][1]) else: - if self._raw_pipe_buffer[stream] is None: # first write + buffer = self._raw_pipe_buffer[stream] + if buffer is None: # first write self._raw_pipe_buffer[stream] = [data] kw = self["input_stream_args"] kw[stream] = (data, kw[stream][1]) else: - self._raw_pipe_buffer[stream].append(data) + buffer.append(data) return False return True def clear_keywords(self): - # remove all the buffered data from the keywords + """remove all the buffered data from the keywords""" if self._raw_pipe_buffer is not None: kw = self["input_stream_args"] @@ -155,6 +185,14 @@ def clear_keywords(self): kw[i] = ("-", kw[i][1]) def iter_raw_data(self) -> Iterator[tuple[int, RawDataBlob]]: + """iterate over all items in the raw media pipe buffer + + :yield index: raw stream index + :yield data: buffered data blob + + If multiple blobs are buffered for a stream, iterator yields one blob at + a time. + """ if self._raw_pipe_buffer is None: return @@ -165,64 +203,72 @@ def iter_raw_data(self) -> Iterator[tuple[int, RawDataBlob]]: yield i, blob def iter_enc_data(self) -> Iterator[tuple[int, bytes]]: + """iterate over all items in the encoded pipe buffer - n0 = self._nraw + :yield index: encoded stream index + :yield data: buffered data + """ for i, buf in self._enc_pipe_buffer.items(): if buf is not None: - yield i + n0, buf + yield i, buf def clear_data(self): - 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): - """Base class to run FFmpeg and manage its multiple I/O's""" - Status = FFmpegStatus - # configure.init_media_xxx function & its keyword arguments - _init_func: Callable - _init_kws: InitMediaKeywordsWithInputBuffer + _probesize: int = 32 + _dynamic_output: bool = False + _use_std_pipes: bool = False + _use_named_pipes: bool = False # object status enum - _status: Status = Status.NOTHING_SET + _status: Status = Status.PREOPEN - # pre-analysis/buffering variables - _nb_inputs: tuple[int, int] = (0, 0) # (raw, raw+encoded) + # 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] - _dynamic_output: bool = False - _use_std_pipes: bool = False - # ffmpeg subprocess and associated objects _proc: ffmpegprocess.Popen | None = None _input_pipes: dict[int, InputPipeInfoDict] @@ -233,17 +279,42 @@ class BaseFFmpegRunner(metaclass=ABCMeta): def __init__( self, init_func: Callable, - init_kws: dict, + 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, ): - """Base FFmpeg runner - - :param timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + """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 + 1 MB (1024**2 bytes). + :param queuesize: the depth of named pipe queues, defaults to None (unlimited) + :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 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 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 @@ -251,6 +322,13 @@ def __init__( 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() @@ -266,6 +344,11 @@ def __init__( if overwrite is not None: self._args["overwrite"] = overwrite + @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 ) -> bool: @@ -285,7 +368,7 @@ def _try_config_ffmpeg( """ - if self._status > self._status.BUFFERING: + if self._status > FFmpegStatus.BUFFERING: raise FFmpegioError("FFmpeg options have already been configured.") kws = self._init_kws @@ -315,15 +398,22 @@ def _try_config_ffmpeg( 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 = self._status.ARGUMENTS_SET + self._status = FFmpegStatus.ANALYSIS_DONE return True def _on_exit(self, rc): - if self._status.RUNNING: + if self._status == FFmpegStatus.RUNNING: self._stack.close() - self._status = self._status.STOPPED + self._status = FFmpegStatus.STOPPED @property def _output_rate(self) -> int | Fraction | None: @@ -336,22 +426,51 @@ def _run_ffmpeg(self): in ``_init_kws``. """ - if self._status != self._status.ARGUMENTS_SET: - if self._status < self._status.ARGUMENTS_SET: + 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._input_pipes, self._output_pipes, more_args = self._configure_pipes() 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 = self._status.RUNNING + self._status = FFmpegStatus.RUNNING self._proc = ffmpegprocess.Popen(**self._args, on_exit=self._on_exit) except: if self._stack is not None: @@ -363,62 +482,22 @@ def _run_ffmpeg(self): self._logger.start() # # if stdin/stdout is used, attach StdWriter/StdReader object to each - # configure.init_std_pipes(self._input_pipes, self._output_pipes, self._proc) - - # # write pre-buffered data - # for st, data in self._init_kws.iter_raw_data(): - # self._write_raw(st, data) - # for st, data in self._init_kws.iter_enc_data(): - # self._write_encoded(st, data) - - # # clear pre-buffered data - # self._init_kws.clear_data() - - def _configure_pipes( - self, - ) -> tuple[dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict], dict]: - """configure pipes (both std and named) - - :return input_pipes: input pipes and their writer thread, keyed by input - index (i.e., index for the ``_input_info`` list) - :return output_pipes: output pipes and their reader thread, keyed by output - index (i.e., index for the ``_output_info`` list) - :return more_fp_kwargs: additional keyword arguments for ``Popen`` call - ``_run_ffmpeg()`` to configure std pipes - - The base implementation here only configures the pipes, opening named pipes. - - To use named pipes, this method must be extended to call - ``configure.init_named_pipes()`` at the end. - - """ - - args = self._args["ffmpeg_args"] - more_args = {} - input_pipes = {} - output_pipes = {} - - if len(self._input_info): - input_pipes, more_args = configure.assign_input_pipes( - args, self._input_info, self._use_std_pipes + if self._use_std_pipes: + configure.init_std_pipes( + input_pipes, output_pipes, self._output_info, self._proc ) - 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) - - return input_pipes, output_pipes, more_args + self._input_pipes = input_pipes + self._output_pipes = output_pipes - def _write_prebuffer_to_pipes(self): - """write pre-buffered data to FFmpeg pipes + # write pre-buffered data + for st, data in self._init_kws.iter_raw_data(): + self.write(data, st) + for st, data in self._init_kws.iter_enc_data(): + self.write_encoded(data, st) - By default this function does nothing (suitable for readers) - a derived writer class should reimplement this function to write - data buffered in self._init_kws. - """ - pass + # clear pre-buffered data + self._init_kws.clear_data() def _terminate(self): """Kill FFmpeg process and close the streams""" @@ -443,7 +522,7 @@ def open(self): """ - if self._status != self._status.NOTHING_SET: + if self._status != FFmpegStatus.PREOPEN: raise FFmpegioError("Already opened once.") # try configure FFmpeg arguments without any pre-buffered data @@ -456,26 +535,33 @@ def open(self): else: # need input data to start ffmpeg - self._status = self._status.BUFFERING + self._status = FFmpegStatus.BUFFERING def close(self): """Kill FFmpeg process and close the streams""" - if self._status != self._status.RUNNING: - raise FFmpegioError("FFmpeg is not running.") - - self._terminate() + 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): + 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 and self._logger.Exception + return self._logger.Exception else: return None @@ -501,7 +587,6 @@ def wait(self, timeout: float | None = None) -> int | None: """ if self._proc: - # write the sentinel to each input queue for pinfo in self._input_pipes.values(): pinfo["writer"].write(None) @@ -518,7 +603,7 @@ def wait(self, timeout: float | None = None) -> int | None: @property def _args_not_ready(self): - return self._status < self._status.ARGUMENTS_SET + return self._status < FFmpegStatus.ANALYSIS_DONE ########################################################## ### RAW MEDIA INPUT STREAM PROPERTIES/METHODS @@ -558,15 +643,20 @@ def write(self, data: RawDataBlob, stream: int = 0): try: data2bytes = self._input_info[stream]["data2bytes"] - except AttributeError: - # _input_info wouldn't exist if FFmpeg is not running, write to prebuffer - self._init_kws.put_data(stream, data) + except AttributeError as e: + if self._status == FFmpegStatus.BUFFERING: + if self._try_config_ffmpeg(stream, data): + 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) if len(b): - self._input_pipes[stream].write(b) + self._input_pipes[stream]["writer"].write(b) @property def input_types(self) -> list[MediaType]: @@ -620,7 +710,7 @@ def input_dtypes(self) -> list[DTypeString] | None: ) @property - def input_shapes(self) -> dict[int, ShapeTuple] | None: + 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 @@ -651,7 +741,7 @@ def input_shapes(self) -> dict[int, ShapeTuple] | None: @property def decodable(self) -> bool: - """Return ``True`` if there is at least one encoded stream to write. + """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 @@ -673,7 +763,7 @@ def encoded_input_streams(self) -> list[int]: return ( [] if url_kw_or_none is None - else [i for i, url in enumerate(kws[url_kw_or_none]) if utils.is_pipe(url)] + 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): @@ -696,10 +786,16 @@ def write_encoded(self, data: bytes, stream: int = 0): st = stream + self.num_input_streams try: - self._input_pipes[st].write(data) - except AttributeError: + self._input_pipes[st]["writer"].write(data) + except AttributeError as e: # _input_info wouldn't exist if FFmpeg is not running, write to prebuffer - self._init_kws.put_data(st, data) + if self._status == FFmpegStatus.BUFFERING: + if self._try_config_ffmpeg(st, data): + self._run_ffmpeg() + else: + raise FFmpegioError( + "unknown error occurred (_input_info missing)" + ) from e ########################################################## ### OUTPUT PROPERTIES @@ -723,46 +819,26 @@ def num_output_streams(self) -> int: except KeyError: return 0 - def read(self, n: int, stream: int=0) -> RawDataBlob: + 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(f"FFmpeg is not running yet.") from 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 * info['item_size'] if n > 0 else n - ) + b = self._output_pipes[stream]["reader"].read(n) data = info["bytes2data"]( b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] ) - # update the frame/sample counter - # n = counter(obj=data) # actual number read - # self._n0[stream_id] += n - return data - - def __iter__(self): - if not self.readable: - raise FFmpegioError('No output stream to create a frame iterator') - - return self - - def __next__(self): - # read all streams - F = self.read(self._read_size) - if self._output_info[self.primary_output_index]["data_is_empty"](obj=F): - raise StopIteration - return F - @property def output_types(self) -> list[MediaType] | None: """media types of the raw media output pipes. @@ -795,7 +871,7 @@ def output_types(self) -> list[MediaType] | None: out[i] = media_type return out else: - return [info.get("media_type", "encoded") for info in stream_info[:nout]] + return [info["media_type"] for info in stream_info[:nout]] @property def output_labels(self) -> list[str] | None: @@ -900,29 +976,117 @@ def output_shapes(self) -> list[ShapeTuple] | None: return [v["raw_info"][1] for v in stream_info[:nout]] @property - def primary_output_label(self) -> str | None: - """primary raw media stream label (None if FFmpeg not started or no output raw stream)""" + 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]] - st = self.primary_output_index - return st and self._output_info and self._output_info[st].get("user_map") + ### PRIMARY OUTPUT SETTING @property - def primary_output_index(self) -> int | None: - """primary raw media stream index (None if FFmpeg not started or no output raw stream)""" + def primary_output(self) -> int: + """index of the primary output stream or ``-1`` if no output raw media stream""" - return configure.find_primary_output_index( - self._output_info, self._primary_output - ) + _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_index + st = self.primary_output try: return self._output_info[st]["raw_info"][-1] except (AttributeError, IndexError): return None + 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") + + ref_st = self.primary_output + ref_sz = self.primary_output_blocksize / self.primary_output_rate + + rates = self.output_rates + nperread = [ref_sz * r for r in rates] + count = [self._output_info[i]["data_count"] for i in range(nout)] + nf = nperread.copy() + + # read the first block of the reference stream + out = [self.read(round(ni), st) for st, ni in zip(range(nout), nf)] + nread = [counti(obj=Fi) for counti, Fi in zip(count, out)] + + # loop until all reference frames are read + while nread[ref_st] > 0: + # 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)] + + # read the next block of the reference stream + out = [self.read(round(ni), st) for st, ni in zip(range(nout), nf)] + nread = [counti(obj=Fi) for counti, Fi in zip(count, out)] + + # if there is any secondary streams with leftover frames, do the last yield + if any(n > 0 for n in nread): + yield out + ########################################################## ### ENCODED INPUT STREAM PROPERTIES/METHODS ########################################################## @@ -951,7 +1115,7 @@ def encoded_output_streams(self) -> list[int]: return ( [] if url_kw_or_none is None - else [i for i, url in enumerate(kws[url_kw_or_none]) if utils.is_pipe(url)] + 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: @@ -973,33 +1137,102 @@ def read_encoded(self, n: int, stream: int = 0) -> bytes: ) st = stream + self.num_output_streams - self._output_pipes[st].read(n) + return self._output_pipes[st].read(n) -class StdFFmpegRunner(BaseFFmpegRunner): +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: dict, + 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, ): - """Base FFmpeg runner for reading/writing with only 1 std pipe, no piped encoded I/O + """FFmpeg runner with only 1 buffered std pipe - :param timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :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 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, progress, show_log, overwrite, sp_kwargs) + """ + 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 @@ -1009,90 +1242,583 @@ def _try_config_ffmpeg( :param stream: optional new stream written since last try :param data: optional newly written stream data :return: ``True`` if FFmpeg arguments are successfully configured - and `_input_info` and `_output_info` lists are fully - populated. Excludes the pipe information. + and ``_input_info`` and ``_output_info`` lists are fully + populated. - - 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. + 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) if ok: # validate - nin = len(self._input_pipes) - nout = len(self._output_pipes) - if nin + nout != 1: - raise FFmpegioError( - "StdFFmpegRunner can only use either stdin or stdout" - ) + 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" + ) - def _run_ffmpeg(self): + return ok - super()._run_ffmpeg() + @override + def __iter__(self) -> Iterator[RawDataBlob]: + """iterator to read raw media data - # if stdin/stdout is used, attach StdWriter/StdReader object to each - configure.init_std_pipes(self._input_pipes, self._output_pipes, self._proc) + :yield: data blob containing at most ``primary_output_blocksize`` + frames/samples of the output stream. - # write pre-buffered data - for st, data in self._init_kws.iter_raw_data(): - self.write(data, st) + 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. + """ - # clear pre-buffered data - self._init_kws.clear_data() + 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 create_simple_reader( + input_urls: list[FFmpegInputOptionTuple], + output_options: FFmpegOptionDict, + 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, + **options: FFmpegOptionDict, + ) -> StdFFmpegRunner: + """create a single-pipe media reader + + :param input_urls: list of input urls + :param output_options: dict of FFmpeg output options. One of it items must + be the ``'map'`` option to uniquely specify a 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 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 + """ - @property - def _args_not_ready(self): - return self._status < self._status.ARGUMENTS_SET + init_kws: MediaReadKwsDict = { + "input_urls": input_urls, + "output_streams": [output_options], + "options": options, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + } + return 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, + ) + @staticmethod + def create_simple_writer( + input_stream_type: Literal["a", "v"], + input_stream_options: FFmpegOptionDict, + output_urls: FFmpegOutputUrlComposite + | list[ + FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ], + input_dtype: DTypeString | None = None, + input_shape: ShapeTuple | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, + overwrite: bool | None = None, + sp_kwargs: dict | None = None, + **options: FFmpegOptionDict, + ) -> StdFFmpegRunner: + """single-pipe media writer + + :param input_stream_type: specify raw media input type + :param input_stream_options: ffmpeg input options for the raw media input + must contain a rate option (``r`` or ``ar``). + :param output_urls: pairs of output url and options + :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 input_dtype: input media data type as a numpy dtype string, + defaults to ``None`` to autodetect + :param input_shape: input media shape (height x width x components) for + video or (channels,) for audio, defaults to ``None`` + to autodetect + :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 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 output_urls if they exist, defaults to ``False`` + :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or + `subprocess.Popen()` call used to run the FFmpeg, defaults + to None + """ + + init_kws: MediaWriteKwsDict = { + "input_stream_types": [input_stream_type], + "input_stream_args": [(None, input_stream_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], + } + return StdFFmpegRunner( + init_func=configure.init_media_write, + init_kws=init_kws, + progress=progress, + show_log=show_log, + overwrite=overwrite, + sp_kwargs=sp_kwargs, + ) -class BasePipedFFmpegRunner(BaseFFmpegRunner): - """Base class to run FFmpeg and manage its multiple I/O's""" +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 + + @staticmethod + def create_media_reader( + input_urls: list[FFmpegInputOptionTuple], + output_streams: list[FFmpegOptionDict] + | dict[str, FFmpegOptionDict] + | None = None, + squeeze: bool = True, + 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, + **options: FFmpegOptionDict, + ) -> 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, + } + return 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, + ) + + @staticmethod + def create_media_writer( + output_urls: list[FFmpegOutputOptionTuple], + input_stream_types: list[Literal["a", "v"]], + input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]], + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | 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, + **options: FFmpegOptionDict, + ) -> PipedFFmpegRunner: + init_kws: MediaWriteKwsDict = { + "output_urls": output_urls, + "input_stream_types": input_stream_types, + "input_stream_args": input_stream_args, + "options": options, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + "extra_inputs": extra_inputs, + } + return 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, + ) + + @staticmethod + def create_media_filter( + expr: str | FilterGraphObject | list[str | FilterGraphObject] | None, + input_stream_types: list[Literal["a", "v"]], + input_stream_opts: list[FFmpegOptionDict], + output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict], + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + squeeze: bool = True, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[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, + **options: FFmpegOptionDict, + ) -> PipedFFmpegRunner: + init_kws: MediaFilterKwsDict = { + "expr": expr, # str | FilterGraphObject | Sequence[str | FilterGraphObject] | None + "input_stream_types": input_stream_types, + "input_stream_args": [(None, opts) for opts in input_stream_opts], + "output_streams": output_streams, + "options": options, + "extra_inputs": extra_inputs, + "extra_outputs": extra_outputs, + "squeeze": squeeze, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + } + return 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, + ) - _pipe_kws: dict + @staticmethod + def create_media_encoder( + input_stream_types: list[Literal["a", "v"]], + input_stream_opts: list[FFmpegOptionDict], + output_options: list[FFmpegOptionDict], + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[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, + **options: FFmpegOptionDict, + ) -> 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_stream_types": input_stream_types, + "input_stream_args": [(None, opts) for opts in input_stream_opts], + "options": options, + "input_dtypes": input_dtypes, + "input_shapes": input_shapes, + "extra_inputs": extra_inputs, + } + return 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, + ) + + @staticmethod + def create_media_decoder( + input_options: Sequence[FFmpegOptionDict], + output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict], + 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, + **options: FFmpegOptionDict, + ) -> 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, + } + return 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, + ) + + @staticmethod + def create_media_transcoder( + input_urls: list[FFmpegInputOptionTuple], + output_urls: list[FFmpegOutputOptionTuple], + 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, + **options: FFmpegOptionDict, + ) -> PipedFFmpegRunner: + init_kws: MediaTranscoderKwsDict = { + "input_urls": input_urls, + "output_urls": output_urls, + "options": options, + "extra_inputs": extra_inputs, + "extra_outputs": extra_outputs, + } + return 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, + ) + + +class SimpleFFmpegFilter(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`. + """ def __init__( self, - init_func: Callable, - init_kws: dict, - progress: ProgressCallable | None = None, + expr: str | FilterGraphObject | None, + input_stream_type: Literal["a", "v"], + input_stream_opt: FFmpegOptionDict, + output_stream: str | FFmpegOptionDict | None, + *, + extra_inputs: ( + list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + 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, + **options, ): - """Base FFmpeg runner for reading/writing with only 1 std pipe, no piped encoded I/O + init_func = configure.init_media_filter + init_kws: MediaFilterKwsDict = { + "expr": expr, # str | FilterGraphObject | Sequence[str | FilterGraphObject] | None + "input_stream_types": [input_stream_type], + "input_stream_args": [(None, input_stream_opt)], + "output_streams": [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 + ) -> bool: + """Configure FFmpeg options and populate stream information + + :param stream: optional new stream written since last try + :param data: optional newly written stream data + :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. - :param 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 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, progress, show_log, overwrite, sp_kwargs) + ok = super()._try_config_ffmpeg(stream, data) + if ok: + # validate + nin = self.num_input_streams + nout = self.num_output_streams + if nin + nout != 1: + raise FFmpegioError( + "SimpleFFmpegFilter takes only one each of raw input and output " + ) + + return ok - self._pipe_kws = {} + def filter(self, data: RawDataBlob) -> RawDataBlob: + """filter a raw media data blob to the specified stream - def _configure_pipes( - self, - ) -> tuple[dict[int, InputPipeInfoDict], dict[int, OutputPipeInfoDict], dict]: + :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. + :returns: filter output blob. - input_pipes, output_pipes, more_args = super()._configure_pipes() + 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. + """ - # find the primary output stream's rate - configure.init_named_pipes( - input_pipes, - output_pipes, - self._input_info, - self._output_info, - update_rate=self._output_rate, - **self._pipe_kws, - ) + if self.rate_in is None or self.rate is None: + raise FFmpegioError("FFmpeg is not running yet.") + + n = self._output_info[0]["data_count"](obj=data) + nout = int((n / self.rate_in * self.rate)) - return input_pipes, output_pipes, more_args + self.write(data) + return self.read(nout) diff --git a/src/ffmpegio/streams/PipedStreams.py b/src/ffmpegio/streams/PipedStreams.py deleted file mode 100644 index e0a08841..00000000 --- a/src/ffmpegio/streams/PipedStreams.py +++ /dev/null @@ -1,856 +0,0 @@ -from __future__ import annotations - -import logging -from collections.abc import Sequence -from fractions import Fraction -from time import time - -from typing_extensions import Literal, Unpack -from .._typing import ( - ProgressCallable, - InputInfoDict, - OutputInfoDict, - FFmpegOptionDict, - RawDataBlob, - ShapeTuple, - DTypeString, -) - - -from .. import configure, plugins, utils -from ..configure import ( - FFmpegArgs, - FFmpegInputUrlComposite, - FFmpegUrlType, - FFmpegOutputUrlComposite, - InitMediaOutputsCallable, -) -from ..filtergraph.abc import FilterGraphObject -from ..errors import FFmpegioError - -from .BaseFFmpegRunner import BaseFFmpegRunner as _BaseFFmpegRunner -from .mixins import ( - BaseRawInputsMixin as _BaseRawInputsMixin, - BaseRawOutputsMixin as _BaseRawOutputsMixin, -) - -logger = (logging.getLogger("ffmpegio"),) - -__all__ = [ - "MediaReader", - "MediaWriter", - "MediaTranscoder", - "SISOMediaFilter", - "MISOMediaFilter", - "SIMOMediaFilter", - "MIMOMediaFilter", -] - - -class _PipedFFmpegRunner(_BaseFFmpegRunner): - """Base class to run FFmpeg and manage its multiple I/O's""" - - def __init__( - self, - ffmpeg_args: FFmpegArgs, - input_info: list[InputInfoDict], - output_info: list[OutputInfoDict], - input_ready: Literal[True] | list[bool], - init_deferred_outputs: InitMediaOutputsCallable | None, - deferred_output_args: list[FFmpegOptionDict | None], - *, - timeout: float | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - queuesize: int | None = None, - sp_kwargs: dict | None = 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 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. - """ - - super().__init__( - ffmpeg_args, - input_info, - output_info, - input_ready, - init_deferred_outputs, - deferred_output_args, - timeout, - progress, - show_log, - sp_kwargs, - ) - - # set the default read block size for the referenc stream - self._pipe_kws = {"queue_size": queuesize} - - def _assign_pipes(self): - """pre-popen pipe assignment and initialization - - All named pipes must be - """ - if len(self._input_info): - inpipe_info = configure.assign_input_pipes( - self._args["ffmpeg_args"], - self._input_info, - self._args["sp_kwargs"], - )[0] - - if len(self._output_info): - outpipe_info = configure.assign_output_pipes( - self._args["ffmpeg_args"], - self._output_info, - self._args["sp_kwargs"], - )[0] - - configure.init_named_pipes( - self._input_info, self._output_info, **self._pipe_kws, stack=self._stack - ) - - -class _RawInputsMixin(_BaseRawInputsMixin): - - _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_stream( - self, - info: OutputInfoDict, - stream_id: int, - data: RawDataBlob, - timeout: float | None, - ): - """write a raw media data to a specified stream (backend)""" - - media_type = info["media_type"] - self._write_stream_bytes(self._get_bytes[media_type], stream_id, data, timeout) - - 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 - `timeout` property. If `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.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 - `timeout` property. If `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.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 _EncodedInputsMixin: - - 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 - `timeout` property. If `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.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 - `timeout` property. If `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.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 _RawOutputsMixin(_BaseRawOutputsMixin): - def __init__(self, blocksize, ref_output, **kwargs): - super().__init__(blocksize=blocksize, ref_output=ref_output, **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} - - def _read_stream( - self, - info: OutputInfoDict, - stream_id: int | str, - n: int, - timeout: float | None = None, - squeeze: bool = False, - ) -> RawDataBlob: - """read selected output stream (shared backend)""" - - converter = self._converters[info["media_type"]] - dtype, shape, _ = info["raw_info"] - counter = self._get_num[info["media_type"]] - - return self._read_stream_bytes( - converter, counter, dtype, shape, info, stream_id, n, timeout, squeeze - ) - - def read( - self, n: int, stream_id: int | str = 0, timeout: float | None = None - ) -> RawDataBlob: - """read selected output stream - - :param n: number of frames/samples to read, defaults to -1 to read as many as available - :param stream_id: stream index or label, defaults to 0 - :param timeout: timeout in seconds or defaults to `None` to use the - `timeout` property. If `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.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 readall(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 - `timeout` property. If `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.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 _EncodedOutputsMixin: - - def read_encoded( - self, n: int, stream_id: int = 0, 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 - `timeout` property. If `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.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 - `timeout` property. If `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.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 MediaReader(_EncodedInputsMixin, _RawOutputsMixin, _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, - 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 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 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, - timeout=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.timeout) - # if not any( - # len(self._get_bytes[info["media_type"]](obj=f)) - # for f, info in zip(F.values(), self._output_info) - # ): - if plugins.get_hook().is_empty(obj=F): - raise StopIteration - return F - - -class MediaWriter(_EncodedOutputsMixin, _RawInputsMixin, _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, - 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 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, - timeout=timeout, - progress=progress, - show_log=show_log, - blocksize=blocksize, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - -class MediaTranscoder(_EncodedOutputsMixin, _EncodedInputsMixin, _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, - 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 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_transcode( - [("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, - timeout=timeout, - progress=progress, - show_log=show_log, - blocksize=blocksize, - queuesize=queuesize, - sp_kwargs=sp_kwargs, - ) - - -class SISOMediaFilter: ... - - -class MISOMediaFilter: ... - - -class SIMOMediaFilter: ... - - -class MIMOMediaFilter: ... - - -class MediaFilter(_RawOutputsMixin, _RawInputsMixin, _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, - 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. - `MediaFilter.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 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, - timeout=timeout, - progress=progress, - show_log=show_log, - 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 481bdbe0..00000000 --- a/src/ffmpegio/streams/SimpleStreams.py +++ /dev/null @@ -1,405 +0,0 @@ -"""SimpleStreams Module: FFmpeg""" - -from __future__ import annotations - -import logging - -logger = logging.getLogger("ffmpegio") - -from typing_extensions import Unpack, Literal -from collections.abc import Sequence -from .._typing import ( - overload, - Callable, - Iterator, - DTypeString, - ShapeTuple, - ProgressCallable, - RawDataBlob, - FFmpegOptionDict, - InputInfoDict, - RawOutputInfoDict, - FromBytesCallable, - CountDataCallable, - ToBytesCallable, -) - -from fractions import Fraction -from math import prod - -from .._utils import get_bytesize -from .. import configure, plugins -from ..stream_spec import stream_spec_to_map_option, StreamSpecDict -from ..errors import FFmpegioError -from ..configure import ( - FFmpegArgs, - MediaType, - FFmpegUrlType, - InitMediaOutputsCallable, - init_media_read, - init_media_write, - MediaReadKwsDict, - MediaWriteKwsDict, - FFmpegInputOptionTuple, -) -from .BaseFFmpegRunner import BaseFFmpegRunner -from .mixins import ( - BaseRawInputsMixin, - BaseRawOutputsMixin, -) - -# fmt:off -__all__ = [ "SimpleReader", "SimpleWriter"] -# fmt:on - - -# info["reader"].read(n, timeout) -# info["writer"].write(None, None if timeout is None else timeout - time()) - - -class StdFFmpegRunner(BaseFFmpegRunner): - """Base class to run FFmpeg only with one std pipe""" - - _use_std_pipes: bool = True - - def _try_config_ffmpeg( - self, stream: int = -1, data: bytes | RawDataBlob | None = None - ) -> bool: - """Configure FFmpeg options and populate stream information - - :param stream: optional new stream written since last try - :param data: optional newly written stream data - :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. - - For ``StdFFmpegRunner``, the number of pipes is validated (1 raw - output and no encoded input or output) - - """ - - ready = super()._try_config_ffmpeg(stream, data) - if ready: # validate - ninputs = sum( - info["src_type"] in ("buffer", "fileobj") for info in self._input_info - ) - noutputs = sum( - info["dst_type"] in ("buffer", "fileobj") for info in self._output_info - ) - - if ninputs + noutputs > 1: - raise FFmpegioError( - "Only 1 pipe (stdin OR stdout) can be used in StdFFmpegRunner." - ) - - return ready - - -class SimpleReader(BaseRawOutputsMixin, StdFFmpegRunner): - """queue-less SISO media reader class""" - - @overload - def __init__( - self, - input_urls: list[FFmpegInputOptionTuple], - output_options: FFmpegOptionDict, - options: FFmpegOptionDict | None = None, - squeeze: bool = True, - extra_outputs: list[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, - ): - """create a single-pipe media reader - - :param input_urls: list of input urls - :param output_stream: dict of FFmpeg output options. One of it items must - be the ``'map'`` option to uniquely specify a 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 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 - """ - - def __init__( - self, - input_urls: list[FFmpegInputOptionTuple], - output_options: FFmpegOptionDict, - options: FFmpegOptionDict | None = None, - extra_outputs: list[FFmpegOutputOptionTuple] | None = None, - squeeze: bool = True, - **kwargs, - ): - - super().__init__( - init_func=init_media_read, - init_kws={ - "input_urls": input_urls, - "output_streams": [output_options], - "options": options or {}, - "extra_outputs": extra_outputs, - "squeeze": squeeze, - }, - **kwargs, - ) - - def _try_config_ffmpeg( - self, stream: int = -1, data: bytes | RawDataBlob | None = None - ) -> bool: - """Configure FFmpeg options and populate stream information - - :param stream: optional new stream written since last try - :param data: optional newly written stream data - :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. - - For ``SimpleReader``, the number of pipes is validated (1 raw - output and no encoded input or output) - - """ - - ready = super()._try_config_ffmpeg(stream, data) - if ready: # validate - - is_raw = next( - "media_type" in info - for info in self._output_info - if info["dst_type"] == "buffer" - ) - if not is_raw: - raise FFmpegioError("The output stream must a raw media stream.") - - return ready - - @property - def output_label(self) -> str | None: - """FFmpeg/custom labels of output streams""" - olabels = self.output_labels - return None if olabels is None else olabels[0] - - @property - def output_type(self) -> MediaType | None: - """media type associated with the output streams (key)""" - otypes = self.output_types - return None if otypes is None else otypes[0] - - @property - def output_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the output streams (key)""" - orates = self.output_rates - return None if orates is None else orates[0] - - @property - def _output_rate(self) -> int | Fraction | None: - return self.output_rate - - @property - def output_dtype(self) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - odtypes = self.output_dtypes - return None if odtypes is None else odtypes[0] - - @property - def output_shape(self) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - oshapes = self.output_shapes - return None if oshapes is None else oshapes[0] - - def read(self, n: int) -> RawDataBlob: - """Read and return a raw data blob (e.g., a numpy.ndarray if - ``ffmpegio.use('numpy')``) containing 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._read_raw(0, n) - - -########################################################################### - - -class SimpleWriter(BaseRawInputsMixin, StdFFmpegRunner): - """single-pipe media writer""" - - @overload - def __init__( - self, - output_urls: list[FFmpegOutputOptionTuple], - input_stream_type: Literal["a", "v"], - input_stream_options: FFmpegOptionDict, - options: FFmpegOptionDict | None = None, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, - extra_inputs: list[FFmpegInputOptionTuple] | None = None, - progress: ProgressCallable | None = None, - show_log: bool | None = None, - overwrite: bool | None = None, - sp_kwargs: dict | None = None, - ): - """single-pipe media writer - - :param output_urls: pairs of output url and options - :param input_stream_type: specify raw media input type - :param input_stream_options: ffmpeg input options for the raw media input - :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 input_dtype: input media data type as a numpy dtype string, - defaults to ``None`` to autodetect - :param input_shape: input media shape (height x width x components) for - video or (channels,) for audio, defaults to ``None`` - to autodetect - :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 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 output_urls if they exist, defaults to ``False`` - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None - """ - - def __init__( - self, - output_urls: list[FFmpegOutputOptionTuple], - input_stream_type: Literal["a", "v"], - input_stream_options: FFmpegOptionDict, - options: FFmpegOptionDict | None = None, - input_dtype: DTypeString | None = None, - input_shape: ShapeTuple | None = None, - extra_inputs: list[FFmpegInputOptionTuple] | None = None, - **kwargs, - ): - super().__init__( - init_func=init_media_write, - init_kws={ - "output_urls": output_urls, - "input_stream_types": [input_stream_type], - "input_stream_args": [(None, input_stream_options)], - "extra_inputs": extra_inputs, - "options": options or {}, - "input_dtypes": input_dtype and [input_dtype], - "input_shapes": input_shape and [input_shape], - }, - **kwargs, - ) - - def _try_config_ffmpeg( - self, stream: int = -1, data: bytes | RawDataBlob | None = None - ) -> bool: - """Configure FFmpeg options and populate stream information - - :param stream: optional new stream written since last try - :param data: optional newly written stream data - :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. - - For ``SimpleReader``, the number of pipes is validated (1 raw - output and no encoded input or output) - - """ - - ready = super()._try_config_ffmpeg(stream, data) - if ready: # validate - - is_raw = next( - "media_type" in info - for info in self._input_info - if info["src_type"] == "buffer" - ) - if not is_raw: - raise FFmpegioError("The input stream must a raw media stream.") - - @property - def input_type(self) -> MediaType | None: - """media type associated with the input streams""" - vals = self.input_types - return None if vals is None else vals[0] - - @property - def input_rate(self) -> int | Fraction | None: - """sample or frame rates associated with the input streams""" - vals = self.input_rates - return None if vals is None else vals[0] - - @property - def input_dtype(self) -> DTypeString | None: - """frame/sample data type of the input stream""" - vals = self.input_dtypes - return None if vals is None else vals[0] - - @property - def input_shape(self) -> ShapeTuple | None: - """frame/sample shape of the input stream""" - vals = self.input_shapes - return None if vals is None else vals[0] - - # @property - # def input_count(self) -> int: - # """number of input frames/samples written""" - # return self._n0 - - def write(self, data: RawDataBlob): - """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._status == self._status.BUFFERING: - if self._try_config_ffmpeg(0, data): - self._run_ffmpeg(True) - else: - self._write_raw(0, data) diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index 954ef235..bdd79183 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -16,23 +16,18 @@ =============== ==================== ==================== """ -from .SimpleStreams import SimpleReader, SimpleWriter -from .PipedStreams import ( - MediaReader, - MediaWriter, - MediaTranscoder, - SISOMediaFilter, - MISOMediaFilter, - SIMOMediaFilter, - MIMOMediaFilter, +from .BaseFFmpegRunner import ( + BaseFFmpegRunner, + PipedFFmpegRunner, + SimpleFFmpegFilter, + StdFFmpegRunner, ) - +from .open import open # TODO multi-stream write # TODO Buffered reverse video read # fmt: off -__all__ = ["SimpleReader", "SimpleWriter", - "MediaReader", "MediaWriter", "MediaTranscoder", - "SISOMediaFilter", "MISOMediaFilter", "SIMOMediaFilter", "MIMOMediaFilter"] +__all__ = ['StdFFmpegRunner', 'PipedFFmpegRunner', 'BaseFFmpegRunner', + "SimpleFFmpegFilter", "open"] # fmt: on diff --git a/src/ffmpegio/streams/mixins.py b/src/ffmpegio/streams/mixins.py deleted file mode 100644 index 3c952af2..00000000 --- a/src/ffmpegio/streams/mixins.py +++ /dev/null @@ -1,315 +0,0 @@ -from __future__ import annotations - -import logging - -from contextlib import ExitStack -from fractions import Fraction -from abc import ABCMeta, abstractmethod - -from typing_extensions import Callable, Literal - -from .. import configure, probe, stream_spec, utils - -from .._typing import ( - OutputInfoDict, - InputPipeInfoDict, - PipedEncodedInputInfoDict, - RawInputInfoDict, - OutputPipeInfoDict, - RawOutputInfoDict, - EncodedOutputInfoDict, - RawDataBlob, - ShapeTuple, - DTypeString, - MediaType, -) - -from ..threading import LoggerThread -from ..errors import FFmpegError, FFmpegioError -from .._typing import FromBytesCallable, CountDataCallable, ToBytesCallable -from .BaseFFmpegRunner import FFmpegStatus, BaseFFmpegRunner - -logger = logging.getLogger("ffmpegio") - -__all__ = [ - "BaseRawInputsMixin", - "BaseRawOutputsMixin", -] - - -class BaseRawInputsMixin: - """write a raw media data to a specified stream (backend)""" - - _status: FFmpegStatus - _init_kws: dict - _piped_inputs: dict[int, Literal["input_urls", "input_stream_args", "extra_input"]] - _input_info: list[RawInputInfoDict] - _input_pipes: list[InputPipeInfoDict] - - - def _write_raw(self, index: int, data: RawDataBlob): - """write a raw media data to a specified stream (backend)""" - - try: - info = self._input_info[index] - assert "media_type" in self._input_info[index] - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Input Stream #{index} is not a raw stream.") from e - - b = info["data2bytes"](obj=data) - if not len(b): - return - - self._input_pipes[index]["writer"].write(data) - - -################################################################################ - - -class BaseRawOutputsMixin(metaclass=ABCMeta): - - _init_kws: configure.MediaReadKwsDict | configure.MediaFilterKwsDict - _status: FFmpegStatus - _output_info: list[RawOutputInfoDict] - _output_pipes: list[OutputPipeInfoDict] - - _primary_output: int | str | None = None - _read_size_in: int | None = None - _read_size: int = 1 - - def __init__( - self, - primary_output: int | str | None = None, - blocksize: int | None = None, - **kwargs, - ): - super().__init__(**kwargs) - - self._primary_output = primary_output - - # set the default read block size for the reference stream - self._read_size_in = blocksize - if blocksize is not None: - self._read_size = blocksize - - - def _try_config_ffmpeg( - self, stream: int = -1, data: bytes | RawDataBlob | None = None - ) -> bool: - """Configure FFmpeg options and populate stream information - - :param stream: optional new stream written since last try - :param data: optional newly written stream data - :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. - - For ``SimpleReader``, the number of piped outputs are validated (1 raw - output and no encoded input or output) - - """ - - ready = super()._try_config_ffmpeg(stream, data) - - if ready and self._read_size_in is None: # set read size - index = self.primary_output_index - media_type = self._output_info[index]["media_type"] - self._read_size = 1 if media_type == "video" else 1024 - - return ready - - @property - def output_rates(self) -> list[int | Fraction] | None: - """sample or frame rates associated with the output streams (key)""" - - if self._args_not_ready: - if not self._all_output_streams_defined: - return None - - kws = self._init_kws - - if "output_streams" not in kws: # raw output streams (+extra encoded) - return [] # shouldn't get here - - kw = self._init_kws["output_streams"] - rates = [ - kw[i][1].pop("r" if mtype == "video" else "ar", None) - for i, mtype in self.output_types.items() - if mtype != "encoded" - ] - - return rates - - else: - return [ - v["raw_info"][2] if "raw_info" in v else None for v in self._output_info - ] - - @property - def output_dtypes(self) -> dict[int, DTypeString] | None: - """frame/sample data type associated with the output streams (key)""" - - if self._args_not_ready: - if not self._all_output_streams_defined: - return None - - if "output_streams" not in kws: # raw output streams (+extra encoded) - return {} - - kw = self._init_kws["output_streams"] - dtypes = [] - for i, mtype in self.output_types.items(): - if mtype == "encoded": - # skip encoded output stream - continue - - opts = kw[i][1] - - if mtype == "video": - if "pix_fmt" in opts: - pix_fmt = opts["pix_fmt"] - dtypes[i] = utils.get_pixel_format(pix_fmt)[0] - else: - dtypes[i] = None - else: # if mtype=='audio' - if "sample_fmt" in opts: - sample_fmt = opts["sample_fmt"] - dtypes[i] = utils.get_audio_format(sample_fmt)[0] - else: - dtypes[i] = None - - return dtypes - - else: - return [v["raw_info"][0] for v in self._iter_piped_output_info()] - - @property - def output_shapes(self) -> list[ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - - if self._args_not_ready: - if not self._all_output_streams_defined: - return None - - if "output_streams" not in kws: # raw output streams (+extra encoded) - return {} - - kw = self._init_kws["output_streams"] - shapes = {} - for i, mtype in self.output_types.items(): - if mtype == "encoded": - # skip encoded output stream - continue - - opts = kw[i][1] - - if mtype == "video": - if "pix_fmt" in opts: - pix_fmt = opts["pix_fmt"] - s = opts["s"] - shapes[i] = utils.get_video_format(pix_fmt, s)[1] - else: - shapes[i] = None - else: # if mtype=='audio' - has_opt = [k in opts for k in ("ac", "channel_layout", "ch_layout")] - if has_opt[0] or has_opt[1]: - layout = ( - opts["channel_layout"] if has_opt[0] else opts["ch_layout"] - ) - shapes[i] = (utils.layout_to_channels(layout),) - elif has_opt[2]: - shapes[i] = (int(opts["ac"]),) - else: - shapes[i] = None - - return shapes - - else: - return [ - v["raw_info"][1] if "raw_info" in v else None - for i, v in self._iter_piped_output_info() - ] - - def output_sample_sizes(self) -> list[int] | None: - if self._args_not_ready: - return None - - return [] - - def __iter__(self): - return self - - def __next__(self): - F = self.read(self._read_size) - if self._output_info[self.primary_output_index]["data_is_empty"](obj=F): - raise StopIteration - return F - - @abstractmethod - def read(self, n: int) -> RawDataBlob | dict[int | str, RawDataBlob]: - """Read and return a raw data blob (e.g., a numpy.ndarray if - ``ffmpegio.use('numpy')``) containing up to n frames/samples. If a - reader outputs multiple raw streams, its output is a dict keyed by - stream identifiers of raw data blobs. - - 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.""" - - @property - def output_counts(self) -> list[int]: - """number of frames/samples read""" - return [0] * len(self._output_info) if self._n0 is None else list(self._n0) - - # @property - # def output_counts(self) -> list[int]: - # """number of frames/samples read""" - # return [self._n0] - - # @property - # def output_bytesizes(self) -> list[int | None]: - # """number of bytes per output sample/pixel""" - # return [get_bytesize(self.output_shape, self.output_dtype)] - - def _read_raw(self, index: int, n: int) -> RawDataBlob: - """read selected output stream (shared backend)""" - - try: - info = self._output_info[index] - assert "media_type" in self._output_info[index] - except AttributeError as e: - raise FFmpegioError(f"FFmpeg is not running yet.") from e - except (KeyError, AssertionError) as e: - raise ValueError(f"Input Stream #{index} is not a raw stream.") from e - - (dtype, shape, _) = info["raw_info"] - b = self._output_pipes[index]["reader"].read( - n * self.output_samplesizes[index] if n > 0 else n - ) - - data = info["bytes2data"]( - b=b, dtype=dtype, shape=shape, squeeze=info["squeeze"] - ) - - # update the frame/sample counter - # n = counter(obj=data) # actual number read - # self._n0[stream_id] += n - - return data diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py index 8f9b9f50..2bf0be2f 100644 --- a/src/ffmpegio/streams/open.py +++ b/src/ffmpegio/streams/open.py @@ -52,22 +52,28 @@ logger = logging.getLogger("ffmpegio") -from typing_extensions import overload, Literal, Sequence, Unpack, LiteralString -from .._typing import DTypeString, ShapeTuple -from fractions import Fraction import re +from fractions import Fraction -from .._typing import ProgressCallable, Literal, FFmpegOptionDict, FFmpegUrlType +from typing_extensions import Literal, LiteralString, Sequence, Unpack, overload + +from .. import utils +from .._typing import ( + DTypeString, + FFmpegOptionDict, + FFmpegUrlType, + Literal, + ProgressCallable, + ShapeTuple, +) from ..configure import ( - IO, Buffer, + FFConcat, FFmpegInputUrlComposite, FFmpegOutputUrlComposite, - FFConcat, ) from ..filtergraph.abc import FilterGraphObject - -from .. import streams, utils +from .BaseFFmpegRunner import PipedFFmpegRunner, SimpleFFmpegFilter, StdFFmpegRunner @overload @@ -81,7 +87,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleReader: +) -> StdFFmpegRunner: """open a single-stream video reader :param urls_fgs: URL of the file or format/device object to obtain a video stream from. @@ -117,7 +123,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleReader: +) -> StdFFmpegRunner: """open a single-source audio reader :param urls_fgs: URL of the file or format/device object to obtain a media stream from. @@ -158,7 +164,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleReader: +) -> StdFFmpegRunner: """open a single-destination video writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output @@ -205,7 +211,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.SimpleWriter: +) -> StdFFmpegRunner: """open a single-destination audio writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output @@ -238,8 +244,12 @@ def open( @overload def open( - urls_fgs: FFmpegInputUrlComposite | Literal["pipe", "-"] | Sequence[FFmpegOutputUrlComposite | Literal["pipe", "-"]], - mode: LiteralString, # r(v|a){2,} or '(v|a)+->e+ + urls_fgs: ( + FFmpegInputUrlComposite + | Literal["pipe", "-"] + | Sequence[FFmpegOutputUrlComposite | Literal["pipe", "-"]] + ), + mode: LiteralString, # r(v|a){2,} or '(v|a)+->e+ *, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -248,7 +258,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MediaReader: +) -> PipedFFmpegRunner: """open a piped single-source reader (`mode = "rv" | "ra" | "e->v" | "e->a"`) :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. @@ -274,8 +284,12 @@ def open( @overload def open( - urls_fgs: FFmpegOutputUrlComposite | Literal["-", "pipe"] | Sequence[FFmpegOutputUrlComposite | Literal["-", "pipe"]], - mode: LiteralString, # ["w(v|a)+", "(v|a)+->e+"], + urls_fgs: ( + FFmpegOutputUrlComposite + | Literal["-", "pipe"] + | Sequence[FFmpegOutputUrlComposite | Literal["-", "pipe"]] + ), + mode: LiteralString, # ["w(v|a)+", "(v|a)+->e+"], input_rate: Sequence[int | Fraction], *, input_shape: ShapeTuple | None = None, @@ -289,7 +303,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MediaWriter: +) -> PipedFFmpegRunner: """open a piped single-destination writer (`mode = "wv" | "wa" | "v->e" | "a->e"`) :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. @@ -324,7 +338,7 @@ def open( @overload def open( urls_fgs: Literal[None], - mode: Literal['e->e']|LiteralString, # 'e+->e+' + mode: Literal["e->e"] | LiteralString, # 'e+->e+' *, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, @@ -335,7 +349,7 @@ def open( timeout: float | None = None, sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MediaTranscoder: +) -> PipedFFmpegRunner: """open a single-input, single-output streamed transcoder :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` @@ -363,21 +377,22 @@ def open( :return: transcoder stream object """ + @overload def open( urls_fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], - mode: LiteralString, #["f(v|a)+", "(v|a)+->(v|a)+"], + mode: LiteralString, # ["f(v|a)+", "(v|a)+->(v|a)+"], input_rate: int | Fraction, *, - input_shape: ShapeTuple|None = None, - input_dtype: DTypeString|None = None, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, queuesize: int | None = None, timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MIMOMediaFilter|streams.MISOMediaFilter|streams.SIMOMediaFilter|streams.SISOMediaFilter: +) -> PipedFFmpegRunner | SimpleFFmpegFilter: """open media stream filter :param urls_fgs: a filtergraph expression @@ -405,11 +420,10 @@ def open( """ - @overload def open( urls_fgs: str | FilterGraphObject, - mode: LiteralString, #["f(v|a)+", "fa", "v->v", "a->a"], + mode: LiteralString, # ["f(v|a)+", "fa", "v->v", "a->a"], input_rate: int | Fraction, *, input_shape: ShapeTuple = None, @@ -420,7 +434,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MIMOMediaFilter: +) -> PipedFFmpegRunner: """open a single-input, single-output (SISO) filter :param urls_fgs: a filtergraph expression @@ -447,6 +461,7 @@ def open( :return: filter stream object """ + @overload def open( urls_fgs: Sequence[ @@ -463,7 +478,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MediaReader: +) -> PipedFFmpegRunner: """open a multi-stream reader :param urls_fgs: a list of input sources @@ -520,7 +535,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MediaWriter: +) -> PipedFFmpegRunner: """open a multi-stream writer :param urls_fgs: a list of output encoded streams. Specific FFmpeg output options could be specified for @@ -575,7 +590,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MediaFilter: +) -> PipedFFmpegRunner: """open a multi-stream filter :param urls_fgs: _description_ @@ -623,7 +638,7 @@ def open( timeout: float | None = None, sp_kwargs: dict = None, **options: Unpack[FFmpegOptionDict], -) -> streams.MediaTranscoder: +) -> PipedFFmpegRunner: """open a streamed transcoder :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` @@ -667,7 +682,7 @@ def open( mode: LiteralString, *args, **kwargs, -): +) -> PipedFFmpegRunner | SimpleFFmpegFilter | StdFFmpegRunner: """Open a multimedia file/stream for read/write :param url_fg: URL of the media source/destination for file read/write or filtergraph definition @@ -755,7 +770,6 @@ def open( def _parse_mode(mode: str) -> tuple[str, str, str]: - it = re.finditer(r"([rwft])|(-\>)", mode) try: m = next(it) @@ -768,7 +782,7 @@ def _parse_mode(mode: str) -> tuple[str, str, str]: raise ValueError( f'{mode=} specifies multiple the operation specifiers ("r", "w", "f", "t", or "->")' ) - except StopIteration as e: + except StopIteration: pass inputs = mode[: m.start()] @@ -822,17 +836,10 @@ def _create_reader( urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], args: tuple, kwargs: dict, -) -> ( - streams.MediaReader - | streams.StdAudioDecoder - | streams.StdVideoDecoder - | streams.SimpleReader - | streams.SimpleReader -): - +) -> StdFFmpegRunner | PipedFFmpegRunner: if len(args): raise TypeError( - f"ffmpegio.open() takes two arguments ({2+len(args)} given) to open a reader" + f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a reader" ) single_url = utils.is_valid_input_url(urls) # else a sequence of urls @@ -849,14 +856,10 @@ def _create_reader( is_siso = single_url and len(map_option) == 1 if is_siso and utils.is_pipe(urls[0]): - StreamClass = streams.StdAudioDecoder if is_audio else streams.StdVideoDecoder + StreamClass = PipedFFmpegRunner reader = StreamClass(**kwargs) else: - StreamClass = ( - streams.MediaReader - if not is_siso - else streams.SimpleReader if is_audio else streams.SimpleReader - ) + StreamClass = PipedFFmpegRunner if not is_siso else StdFFmpegRunner reader = StreamClass(*urls, **kwargs) return reader @@ -867,17 +870,10 @@ def _create_writer( urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], args: tuple, kwargs: dict, -) -> ( - streams.MediaWriter - | streams.StdAudioEncoder - | streams.StdVideoEncoder - | streams.SimpleWriter - | streams.SimpleWriter -): - +) -> PipedFFmpegRunner | StdFFmpegRunner: if len(args) > 1: raise TypeError( - f"ffmpegio.open() takes two arguments ({2+len(args)} given) to open a writer" + f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a writer" ) single_output = utils.is_valid_output_url(urls) # else a sequence of urls @@ -893,14 +889,12 @@ def _create_writer( if not is_siso: rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") - writer = streams.MediaWriter(urls, in_types, *rates, **kwargs) + writer = PipedFFmpegRunner.create_media_writer(urls, in_types, *rates, **kwargs) elif utils.is_pipe(urls[0]): StreamClass = streams.StdAudioEncoder if is_audio else streams.StdVideoEncoder writer = StreamClass(*args, **kwargs) else: - StreamClass = ( - streams.SimpleWriter if is_audio else streams.SimpleWriter - ) + StreamClass = streams.SimpleWriter if is_audio else streams.SimpleWriter writer = StreamClass(*urls, *args, **kwargs) return writer @@ -911,11 +905,10 @@ def _create_filter( fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], args: tuple, kwargs: dict, -) -> streams.MediaFilter | streams.StdAudioFilter | streams.StdVideoFilter: - +) -> SimpleFFmpegFilter: if len(args) > 1: raise TypeError( - f"ffmpegio.open() takes two arguments ({2+len(args)} given) to open a writer" + f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a writer" ) single_input = len(in_types) > 1 @@ -935,23 +928,14 @@ def _create_filter( return filter -def _create_transcoder( - urls: None, args: tuple, kwargs: dict -) -> streams.MediaTranscoder | streams.StdMediaTranscoder: - +def _create_transcoder(urls: None, args: tuple, kwargs: dict) -> PipedFFmpegRunner: if urls is not None: raise TypeError("urls_fgs argument for a filter must be None.") nargs = len(args) if nargs not in (0, 2) or (nargs == 3 and "output_options" in kwargs): raise TypeError( - f"ffmpegio.open() takes two or four arguments ({2+len(args)} given) to open a filter." + f"ffmpegio.open() takes two or four arguments ({2 + len(args)} given) to open a filter." ) - use_piped = args[0] if nargs else kwargs.get("input_options", None) - - return ( - streams.MediaTranscoder(*args, **kwargs) - if use_piped - else streams.StdMediaTranscoder(*args, **kwargs) - ) + return PipedFFmpegRunner.create_media_transcoder(*args, **kwargs) diff --git a/src/ffmpegio/streams/typing.py b/src/ffmpegio/streams/typing.py deleted file mode 100644 index d33f9645..00000000 --- a/src/ffmpegio/streams/typing.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -from fractions import Fraction -from .._typing import ( - MediaType, - DTypeString, - ShapeTuple, - Any, - Protocol, - RawInputInfoDict, - RawOutputInfoDict, - EncodedInputInfoDict, - EncodedOutputInfoDict, -) - - -class FrameReaderProtocol(Protocol): - - def output_label(self, stream_index: int = 0) -> str | None: - """FFmpeg/custom label of the output stream in FFmpeg""" - - def output_type(self, stream_index: int = 0) -> MediaType | None: - """media type associated with the output stream (key)""" - - def output_rate(self, stream_index: int = 0) -> int | Fraction | None: - """sample or frame rates associated with the output stream (key)""" - - def output_dtype(self, stream_index: int = 0) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - - def output_shape(self, stream_index: int = 0) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - - def output_count(self, stream_index: int = 0) -> int: - """number of frames/samples read""" - - def output_bytesize(self, stream_index: int = 0) -> int | None: - """number of bytes per output sample/pixel""" - - @property - def output_labels(self) -> list[str | None]: - """FFmpeg/custom labels of output streams if specified""" - ... - - @property - def output_types(self) -> list[MediaType | None]: - """media type associated with the output streams (key)""" - ... - - @property - def output_rates(self) -> list[int | Fraction | None]: - """sample or frame rates associated with the output streams (key)""" - ... - - @property - def output_dtypes(self) -> list[DTypeString | None]: - """frame/sample data type associated with the output streams (key)""" - ... - - @property - def output_shapes(self) -> list[ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - ... - - @property - def output_counts(self) -> list[int]: - """number of frames/samples read""" - ... - - @property - def output_bytesizes(self) -> list[int | None]: - """number of bytes per output sample/pixel""" - ... - - def read(self, n: int = -1, timeout: float | None = None) -> Any: - """read n raw frames from FFmpeg - - :param n: number of samples/frames to read, if negative, read all frames, - defaults to -1 - :param timeout: timeout in seconds, defaults to None - :return: n frames of data data type depending on the active plugin. A frame - is one video image or a set of audio samples at one sample time. - """ - - -class FrameWriterProtocol(Protocol): - """to write raw frame data to FFmpeg""" - - def input_type(self, stream_id: int = 0) -> MediaType | None: - """media type associated with the input streams""" - - def input_rate(self, stream_id: int = 0) -> int | Fraction | None: - """sample or frame rates associated with the input streams""" - - def input_dtype(self, stream_id: int = 0) -> DTypeString | None: - """frame/sample data type associated with the output streams (key)""" - - def input_shape(self, stream_id: int = 0) -> ShapeTuple | None: - """frame/sample shape associated with the output streams (key)""" - - def input_count(self, stream_id: int = 0) -> int: - """number of input frames/samples written""" - - def input_bytesize(self, stream_id: int = 0) -> int | None: - """input sample/pixel count per frame""" - - @property - def input_types(self) -> list[MediaType]: - """media type associated with the input streams""" - - @property - def input_rates(self) -> list[int | Fraction]: - """sample or frame rates associated with the input streams""" - - @property - def input_dtypes(self) -> list[DTypeString]: - """frame/sample data type associated with the output streams (key)""" - - @property - def input_shapes(self) -> list[ShapeTuple | None]: - """frame/sample shape associated with the output streams (key)""" - - @property - def input_counts(self) -> list[int]: - """number of input frames/samples written""" - - @property - def input_bytesizes(self) -> list[int | None]: - """input sample/pixel count per frame""" - - def write(self, data: Any, timeout: float | None = None): - """write raw frame data - - :param data: _description_ - :param timeout: _description_, defaults to None - """ - - 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 - """ diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index dc2de207..3a150af1 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -2,30 +2,29 @@ from __future__ import annotations -from typing import BinaryIO - -from copy import deepcopy -import re, os -from threading import Thread, Condition, Lock, Event +import logging +import os +import re from io import TextIOBase, TextIOWrapper -from time import sleep, time -from tempfile import TemporaryDirectory from queue import Empty, Full, Queue -from math import ceil 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', - 'LoggerThread', 'ReaderThread', 'WriterThread', 'AviReaderThread', 'Empty', 'Full'] + 'LoggerThread', 'ReaderThread', 'WriterThread', 'Empty', 'Full'] # fmt:on @@ -273,7 +272,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: @@ -317,11 +316,13 @@ def __init__( 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._carryover: bytes | None = ( + None #:bytes: extra data that was not previously read by user + ) self._halt = Event() self._running = Event() self._retry_delay = 0.001 if retry_delay is None else retry_delay - self._timeout = float(timeout) + self._timeout = float(timeout) if timeout else None def start(self): if self.itemsize is None: @@ -336,7 +337,6 @@ def cool_down(self): self._halt.set() def join(self, timeout=None): - if timeout is None: timeout = self._timeout @@ -491,6 +491,55 @@ 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() + assert b is not None # encountered sentinel + except (Empty, AssertionError): + 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. @@ -539,10 +588,9 @@ def __init__( self._empty_cond = Condition() self._empty = True self._no_more = False # true if sentinel has been written to the queue - self._timeout = float(timeout) + 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"): @@ -564,7 +612,6 @@ def __exit__(self, *_): return False def run(self): - if self.stdin is None: self.stdin = self.pipe.wait() @@ -633,7 +680,6 @@ def run(self): def write(self, data, timeout=None): with self._empty_cond: - if self._no_more: if data is None: return diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index e8d4966b..b7003425 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -4,16 +4,12 @@ logger = logging.getLogger("ffmpegio") -from ._typing import Sequence, ProgressCallable, Unpack, FFmpegOptionDict -from .configure import ( - FFmpegOutputUrlComposite, - FFmpegInputUrlComposite, - FFmpegInputOptionTuple, - FFmpegOutputOptionTuple, -) - - -from . import ffmpegprocess as fp, configure, utils, FFmpegError +from . import FFmpegError, configure +from . import ffmpegprocess as fp +from . import utils +from ._typing import FFmpegOptionDict, ProgressCallable, Sequence, Unpack +from .configure import (FFmpegInputOptionTuple, FFmpegInputUrlComposite, + FFmpegOutputOptionTuple, FFmpegOutputUrlComposite) from .path import check_version __all__ = ["transcode"] diff --git a/src/ffmpegio/typing.py b/src/ffmpegio/typing.py index 03db86e2..cba2436e 100644 --- a/src/ffmpegio/typing.py +++ b/src/ffmpegio/typing.py @@ -2,11 +2,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 - -from .configure import FFmpegArgs, FFmpegUrlType diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 14eaaa5d..6e366050 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -2,41 +2,52 @@ 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, - InputInfoDict, - RawDataBlob, - OutputInfoDict, - FFmpegUrlType, IO, + Any, Buffer, - FFmpegOptionDict, - ShapeTuple, DTypeString, + FFmpegOptionDict, + FFmpegUrlType, FilterGraphInfoDict, + InputInfoDict, + Literal, + MediaType, + OutputInfoDict, + RawDataBlob, + ShapeTuple, +) +from .._utils import ( + escape, + get_samplesize, + is_fileobj, + is_namedpipe, + is_non_str_sequence, + is_pipe, + is_url, + prod, ) +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 @@ -212,7 +223,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) @@ -269,8 +280,8 @@ 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) -> tuple[DTypeString, ShapeTuple]: @@ -312,7 +323,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: @@ -331,7 +342,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: @@ -378,7 +388,6 @@ def parse_color(expr) -> tuple[int, int, int, int | None]: 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) @@ -534,7 +543,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( @@ -552,9 +561,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, ) @@ -793,7 +802,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]) @@ -802,7 +810,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) @@ -823,7 +831,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)) @@ -1012,14 +1020,13 @@ def get_output_stream_id(output_info: list[OutputInfoDict], stream: str | int) - ) 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 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: @@ -1028,7 +1035,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 @@ -1037,7 +1044,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) @@ -1093,7 +1099,9 @@ def find_filter_simple_option( return next((o for o in optnames if o in options), None) -def find_filter_complex_option(options: FFmpegOptionDict) -> ( +def find_filter_complex_option( + options: FFmpegOptionDict, +) -> ( Literal[ "filter_complex", "/filter_complex", @@ -1103,9 +1111,9 @@ def find_filter_complex_option(options: FFmpegOptionDict) -> ( ] | None ): - """True if FFmpeg arguments specify a complex filter graph + """Return FFmpeg option name, which specifies a complex filter graph - :param options: FFmpeg argument dict + :param options: FFmpeg option argument dict :return: FFmpeg option name if filter graph is specified else None """ @@ -1118,3 +1126,193 @@ def find_filter_complex_option(options: FFmpegOptionDict) -> ( ) return next((o for o in optnames if o in options), None) + + +def format_raw_output_stream_defs( + streams: Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, + options: FFmpegOptionDict | None, +) -> tuple[list[FFmpegOptionDict], dict[int, str]]: + """convert user-supplied streams arguments to the standard form + + :param streams: 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 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. + - None to select all available streams + :param options: default output options + :return stream_options: list of stream options + :return stream_alias: list of pairs of stream map options and user-supplied stream labels + """ + + # depending on user's streams input, label output streams differently + # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists + streams_: list[FFmpegOptionDict] + stream_names: dict[ + int, str + ] = {} # dict of user-specified stream name (only via dict streams input) + + if isinstance(streams, dict): # dict[str,FFmpegOptionDict] + # dict key is used as both stream names (labels) and map option. + # * If FFmpegOptionDict in the dict value contains 'map' option, the key + # would only be used as the stream name + # * Note that if the map option is not unique the stream name will + # be renamed with an appended index. + streams_ = [] + for i, (k, v) in enumerate(streams.items()): + if "map" in v: # user provided non-map stream name + stream_names[i] = k + streams_.append({**options, "map": k, **v}) + elif "map" in options: + streams_ = [options] + else: # isinstance(stream,list[str|FFmpegOptionDict]) + # if an item is a str, it is the map option value + # if FFmpegOptionDict, it must contain a 'map' option + + streams_ = [ + {**options, **({"map": v} if isinstance(v, str) else v)} for v in streams + ] + + return streams_, stream_names + + +def are_output_streams_unique( + output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, +) -> bool: + """True if output raw stream specification uniquely defines all streams + + :param output_streams: a list of FFmpeg output stream options, or the options + dict keyed by user-specified stream name, or ``None`` + to autodetect all streams in input sources + """ + + if output_streams is None: + return False + + for opts in ( + output_streams.values() if isinstance(output_streams, dict) else output_streams + ): + map_opt = parse_map_option(opts["map"], input_file_id=0, parse_stream=True) + if "linklabel" in map_opt or not is_unique_stream(map_opt["stream_specifier"]): + return False + return True + + +def input_file_stream_specs(url: str, stream_spec: str | 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. + """ + streams = [ + st + for st in analyze_input_file( + ["index", "codec_type"], url, {}, {"src_type": "url"}, stream=stream_spec + ) + if st["codec_type"] in ("audio", "video") + ] + + 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": "{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 diff --git a/src/ffmpegio/utils/avi.py b/src/ffmpegio/utils/avi.py index 3d9485e9..e69d227e 100644 --- a/src/ffmpegio/utils/avi.py +++ b/src/ffmpegio/utils/avi.py @@ -1,11 +1,13 @@ -from io import SEEK_CUR -import fractions, re -from struct import Struct +import fractions +import re from collections import namedtuple +from io import SEEK_CUR from itertools import accumulate +from struct import Struct -from ..utils import get_video_format, get_audio_format, stream_spec, get_samplesize from .. import plugins +from ..utils import (get_audio_format, get_samplesize, get_video_format, + stream_spec) # https://docs.microsoft.com/en-us/previous-versions//dd183376(v=vs.85)?redirectedfrom=MSDN diff --git a/src/ffmpegio/utils/concat.py b/src/ffmpegio/utils/concat.py index 50394df6..52eaa010 100644 --- a/src/ffmpegio/utils/concat.py +++ b/src/ffmpegio/utils/concat.py @@ -1,12 +1,13 @@ """FFConcat class to build/use ffconcat list file for concat demuxer """ -from glob import glob -import io, re +import io +import logging import os -from tempfile import NamedTemporaryFile +import re from functools import partial -import logging +from glob import glob +from tempfile import NamedTemporaryFile logger = logging.getLogger("ffmpegio") diff --git a/src/ffmpegio/utils/log.py b/src/ffmpegio/utils/log.py index 0da429a1..708e6397 100644 --- a/src/ffmpegio/utils/log.py +++ b/src/ffmpegio/utils/log.py @@ -1,10 +1,10 @@ import re from fractions import Fraction -from . import layout_to_channels -from ..caps import sample_fmts -from .._typing import RawStreamInfoTuple, Sequence 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, )?(.+)") diff --git a/src/ffmpegio/utils/parser.py b/src/ffmpegio/utils/parser.py index b26f1203..509a3953 100644 --- a/src/ffmpegio/utils/parser.py +++ b/src/ffmpegio/utils/parser.py @@ -1,8 +1,10 @@ -import re, os, shlex +import os +import re +import shlex from collections import abc -from ..filtergraph import Graph, Chain, Filter from .. import devices +from ..filtergraph import Chain, Filter, Graph __all__ = ["parse", "compose", "FLAG"] diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 2133f3fa..1e4678bd 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -1,21 +1,20 @@ -import warnings import logging +import warnings from fractions import Fraction -from . import configure, plugins, analyze, FFmpegioError, utils -from .std_runners import run_and_return_raw, run_and_return_encoded - -from ._typing import Any, ProgressCallable, RawDataBlob, FFmpegOptionDict - +from . import analyze, configure, utils +from . import filtergraph as fgb +from ._typing import Any, FFmpegOptionDict, ProgressCallable, RawDataBlob from .configure import ( FFmpegInputOptionTuple, FFmpegInputUrlComposite, FFmpegInputUrlNoPipe, FFmpegNoPipeInputOptionTuple, - FFmpegOutputUrlNoPipe, FFmpegNoPipeOutputOptionTuple, + FFmpegOutputUrlNoPipe, ) -from . import filtergraph as fgb +from .errors import FFmpegioError +from .std_runners import run_and_return_encoded, run_and_return_raw __all__ = ["create", "read", "write", "filter", "detect"] diff --git a/tests/test_open.py b/tests/test_open.py index 7091ddbc..19c8d0c4 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -15,10 +15,10 @@ def test_fg(): @pytest.mark.parametrize( "src,mode,Cls", [ - (url, "rv", ff_streams.SimpleReader), - (url, "ra", ff_streams.SimpleReader), - (url, "e->v", ff_streams.SimpleReader), - (url, "e->a", ff_streams.SimpleReader), + (url, "rv", ff_streams.StdFFmpegRunner), + (url, "ra", ff_streams.StdFFmpegRunner), + (url, "e->v", ff_streams.PipedFFmpegRunner), + (url, "e->a", ff_streams.PipedFFmpegRunner), ], ) def test_readers(src, mode, Cls): diff --git a/tests/test_pipedstreams.py b/tests/test_streams_piped.py similarity index 88% rename from tests/test_pipedstreams.py rename to tests/test_streams_piped.py index 2d6c9403..10316803 100644 --- a/tests/test_pipedstreams.py +++ b/tests/test_streams_piped.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 @@ -16,30 +13,32 @@ def test_MediaReader(): - with streams.MediaReader(mult_url, t=1) as reader: + with streams.PipedFFmpegRunner.create_media_reader( + [(mult_url, {})], None, t=1, queuesize=4 + ) as reader: # data = reader.read(2) for data in reader: for k, v in data.items(): print(f"{k}: {len(v['buffer'])}") + break def test_MediaWriter_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.MediaWriter( - "pipe", - stream_types, + with streams.PipedFFmpegRunner.create_media_encoder( + ["a"], + [(None, {"ar": rates[0]})], *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) + writer.write(frame, i) # close the input and wait for FFmpeg to finish encoding and terminate writer.wait(10) @@ -49,14 +48,17 @@ def test_MediaWriter_audio(): 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] with streams.MediaWriter( - "pipe", stream_types, *rates.values(), show_log=True, f="matroska", + "pipe", + stream_types, + *rates.values(), + show_log=True, + f="matroska", ) as writer: # write full audio streams video_frames = {} @@ -68,7 +70,9 @@ def test_MediaWriter(): # 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())): + 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] @@ -82,7 +86,6 @@ def test_MediaWriter(): def test_MediaFilter(): - ff.use("read_bytes") fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) diff --git a/tests/test_simplestreams.py b/tests/test_streams_simple.py similarity index 67% rename from tests/test_simplestreams.py rename to tests/test_streams_simple.py index 1214c76f..3920ffcb 100644 --- a/tests/test_simplestreams.py +++ b/tests/test_streams_simple.py @@ -2,10 +2,13 @@ logging.basicConfig(level=logging.DEBUG) -import ffmpegio -import tempfile, re +import re +import tempfile from os import path -from ffmpegio import streams, utils + +import ffmpegio +from ffmpegio import utils +from ffmpegio.streams import StdFFmpegRunner url = "tests/assets/testmulti-1m.mp4" outext = ".mp4" @@ -14,16 +17,16 @@ def test_read_video(): w = 420 h = 360 - with streams.SimpleReader( + with StdFFmpegRunner.create_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_rate == 30 - assert f.output_shape == (h, w) + 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_dtype + assert F["dtype"] == f.output_dtypes[0] def test_read_write_video(): @@ -42,7 +45,7 @@ def test_read_write_video(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleWriter(out_url, fs) as f: + with StdFFmpegRunner.create_simple_writer("v", {"r": fs}, [(out_url, {})]) as f: f.write(F0) f.write(F1) f.wait() @@ -50,33 +53,37 @@ def test_read_write_video(): assert len(F["buffer"]) -def test_read_audio(caplog): - # caplog.set_level(logging.DEBUG) - +def test_read_audio(): fs, x = ffmpegio.audio.read(url) bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) - with streams.SimpleReader(url, show_log=True, blocksize=1024**2) as f: + # validate read iterator obtains all the samples + with StdFFmpegRunner.create_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 streams.SimpleReader( - url, ss_in=t0, to_in=t1, show_log=True, blocksize=1024**2 + with StdFFmpegRunner.create_simple_reader( + [(url, {})], + {"map": "0:a:0"}, + show_log=True, + blocksize=1024**2, + ss_in=t0, + to_in=t1, ) as f: blks, shapes = zip(*[(blk["buffer"], blk["shape"][0]) for blk in f]) - log = f.readlog(-1) shape = sum(shapes) - print(log) - x2 = b"".join(blks) # # print("# of blks: ", len(blks), x1['shape']) # for i, xi in enumerate(x2): @@ -89,12 +96,12 @@ def test_read_audio(caplog): def test_read_write_audio(): outext = ".flac" - with streams.SimpleReader(url) as f: + with StdFFmpegRunner.create_simple_reader([(url, {})], {"map": "0:a:0"}) as f: 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_bytesize + 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} @@ -102,7 +109,9 @@ def test_read_write_audio(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleWriter(out_url, fs, show_log=True) as f: + with StdFFmpegRunner.create_simple_writer( + "a", {"ar": fs}, [(out_url, {})], show_log=True + ) as f: f.write({**out, "buffer": F[: 100 * bps]}) f.write({**out, "buffer": F[100 * bps :]}) f.wait() @@ -122,13 +131,13 @@ def test_write_extra_inputs(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with streams.SimpleWriter( - out_url, - fs, - extra_inputs=[url_aud], - map=["0:v", "1:a"], + with StdFFmpegRunner.create_simple_writer( + "v", + {"r": fs}, + [(out_url, {})], + extra_inputs=[(url_aud, {})], show_log=True, - loglevel="debug", + **{"map": ["0:v", "1:a"], "loglevel": "debug"}, ) as f: f.write(F) f.wait() @@ -137,14 +146,14 @@ def test_write_extra_inputs(): info = ffmpegio.probe.streams_basic(out_url) assert len(info) == 2 - with streams.SimpleWriter( - out_url, - fs, + with StdFFmpegRunner.create_simple_writer( + "v", + {"r": fs}, + [(out_url, {})], extra_inputs=[("anoisesrc", {"f": "lavfi"})], - map=["0:v", "1:a"], - shortest=None, show_log=True, overwrite=True, + **{"map": ["0:v", "1:a"], "shortest": None}, ) as f: f.write(F) f.wait() From 6e876e175036b65feb6c7c316d0ec3d0eb514645 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 28 Jan 2026 21:46:26 -0500 Subject: [PATCH 328/344] wip23 --- src/ffmpegio/audio.py | 13 +- src/ffmpegio/configure.py | 46 +++-- src/ffmpegio/image.py | 12 +- src/ffmpegio/media.py | 8 +- src/ffmpegio/plugins/rawdata_bytes.py | 43 ++++- src/ffmpegio/plugins/rawdata_mpl.py | 15 +- src/ffmpegio/plugins/rawdata_numpy.py | 35 +++- src/ffmpegio/streams/BaseFFmpegRunner.py | 206 +++++++++++++++++------ src/ffmpegio/threading.py | 84 +++++---- src/ffmpegio/utils/__init__.py | 1 + src/ffmpegio/video.py | 14 +- tests/test_media.py | 32 +--- tests/test_streams_piped.py | 159 +++++++++++------ 13 files changed, 442 insertions(+), 226 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index ee8cef66..d41463fd 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -290,15 +290,16 @@ def filter( """ - if expr and extra_inputs is None and extra_outputs is None: - # guaranteed SISO filtering - options["filter:a"] = expr - options["map"] = "0:a:0" - expr = None + 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( - expr, ["a"], [(input_rate, input)], extra_inputs, diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 34b4b7be..5b5ca62f 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -14,15 +14,7 @@ `init_media_transcode()` encoded data to encoded data ======================== ================================ -These functions call ffprobe to get raw media information best it could. However, -read calls with a non-seekable input requires to defer setting the output shape -and dtype until the necessary information is posted on the ffmpeg stderr log stream. -Likewise, filter calls with unknown input shape and dtype requires the arrival -of the first input raw data blob. In those cases, the following function must -be called after the ffmpeg operation initiates: - -- `init_media_read_outputs()` -- `init_media_filter_outputs()` +These functions call ffprobe to get raw media information best it could. The above functions do not initialize the pipes and IO threads. @@ -217,7 +209,6 @@ class MediaWriteKwsDict(TypedDict): class MediaFilterKwsDict(TypedDict): - expr: str | FilterGraphObject | list[str | FilterGraphObject] | None input_stream_types: Sequence[Literal["a", "v"]] input_stream_args: Sequence[tuple[RawDataBlob | None, FFmpegOptionDict]] output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None @@ -305,6 +296,7 @@ def init_media_read( raise ValueError("Cannot have an `n` option set to output to named pipes.") # separate the options + options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -397,6 +389,7 @@ def init_media_write( raise FFmpegioError("At least one URL must be given.") # separate the options + options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -433,7 +426,6 @@ def init_media_write( def init_media_filter( - expr: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, input_stream_types: Sequence[Literal["a", "v"]], input_stream_args: Sequence[RawStreamDef], extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, @@ -450,8 +442,6 @@ def init_media_filter( ) -> tuple[FFmpegArgs, list[RawInputInfoDict], list[RawOutputInfoDict]]: """Prepare FFmpeg arguments for media read - :param expr: filtergraph definition(s), may be None to perform implicit filtering - via output options (e.g., rate or format changes) :param input_stream_types: list/string of 'a' or 'v', specifying the input raw streams' media types :param input_stream_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 @@ -484,12 +474,9 @@ def init_media_filter( 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 already set." - ) # separate the options + options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -497,11 +484,6 @@ def init_media_filter( gopts = args["global_options"] # global options dict gopts["y"] = None - # complex filtergraph may not be used - # (siso filtergraph or implicit filter like -s or -r) - if expr is not None: - gopts["filter_complex"] = expr - # analyze and assign inputs input_info = process_raw_inputs( args, @@ -569,6 +551,7 @@ def init_media_transcode( raise ValueError("Cannot have an `n` option set to output to named pipes.") # separate the options + options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -2435,7 +2418,7 @@ def init_named_pipes( outpipe_info: dict[int, OutputPipeInfoDict], input_info: list[InputInfoDict], output_info: list[OutputInfoDict], - ref_stream: int | Fraction | None = None, + ref_stream: int | None = None, ref_blocksize: int | None = None, enc_blocksize: int | None = None, queue_size: int | None = None, @@ -2448,9 +2431,15 @@ 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 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 None (4). 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 @@ -2467,10 +2456,17 @@ def init_named_pipes( if stack is None: stack = ExitStack() - wr_kws = {"queuesize": queue_size, "timeout": timeout} if queue_size else {} + + wr_kws = {"queuesize": queue_size, "timeout": timeout} # configure output pipes - ref_rate = None if ref_stream is None else output_info[ref_stream]["raw_info"][-1] + if ref_stream is None and len(output_info): + ref_stream = 0 if "raw_info" in output_info[0] else -1 + + ref_rate = 1 + if ref_stream is not None and ref_stream >= 0: + ref_rate = output_info[ref_stream]["raw_info"][-1] + for i, pinfo in outpipe_info.items(): info = output_info[i] diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 340ac0ba..0d006905 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -240,11 +240,13 @@ def filter( """ - if expr and extra_inputs is None and extra_outputs is None: - # guaranteed SISO filtering - options["filter:v"] = expr - options["map"] = "0:V:0" - expr = None + 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( diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 15bdde2e..9fea4cf3 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -33,7 +33,7 @@ logger = logging.getLogger("ffmpegio") -__all__ = ["read", "write"] +__all__ = ["read", "write", "filter"] def _runner( @@ -60,7 +60,7 @@ def _runner( args, output_info, False ) stack = configure.init_named_pipes( - input_pipes, output_pipes, input_info, output_info + input_pipes, output_pipes, input_info, output_info, queue_size=0 ) def on_exit(rc): @@ -288,9 +288,11 @@ def filter( """ + if expr is not None: + options["filter_complex"] = expr + # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_filter( - expr, input_types, input_args, extra_inputs, diff --git a/src/ffmpegio/plugins/rawdata_bytes.py b/src/ffmpegio/plugins/rawdata_bytes.py index 024966ef..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]: @@ -98,13 +109,24 @@ 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: - 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: @@ -112,12 +134,16 @@ 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: - return obj["shape"][0] - except: + shape = obj["shape"] + except KeyError: return None + else: + return shape[0] @hookimpl @@ -139,7 +165,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 @@ -164,15 +190,16 @@ 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 + @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']) + return not bool(obj["buffer"]) diff --git a/src/ffmpegio/plugins/rawdata_mpl.py b/src/ffmpegio/plugins/rawdata_mpl.py index fa9d18e2..c2cbbc5a 100644 --- a/src/ffmpegio/plugins/rawdata_mpl.py +++ b/src/ffmpegio/plugins/rawdata_mpl.py @@ -5,7 +5,7 @@ import matplotlib as Figure from pluggy import HookimplMarker -from .._typing import DTypeString, ShapeTuple +from .._typing import DTypeString, Literal, ShapeTuple __all__ = ["video_info", "video_bytes"] @@ -42,6 +42,7 @@ def video_bytes(obj: Figure) -> memoryview: except: None + @hookimpl def is_empty(obj: Figure) -> bool: """True if data blob object has no data @@ -49,3 +50,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 diff --git a/src/ffmpegio/plugins/rawdata_numpy.py b/src/ffmpegio/plugins/rawdata_numpy.py index 0866d0b5..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 @@ -56,10 +64,19 @@ 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: - 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 @@ -70,10 +87,12 @@ 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: - return obj.shape[0] + return np.asarray(obj).shape[0] except: return None @@ -87,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 @@ -101,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 @@ -127,7 +146,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/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 8405be04..584a085b 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -38,7 +38,6 @@ MediaWriteKwsDict, ) from ..errors import FFmpegError, FFmpegioError, FFmpegioInsufficientInputData -from ..filtergraph.abc import FilterGraphObject from ..threading import LoggerThread logger = logging.getLogger("ffmpegio") @@ -80,6 +79,9 @@ class InitMediaKeywordsWithInputBuffer(dict): _nraw = 0 _raw_pipe_buffer: None | list[list[RawDataBlob] | None] # for 'input_stream_args' _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""" @@ -87,6 +89,8 @@ def __init__(self, init_kws: dict): self._raw_input = "input_stream_args" 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: @@ -95,6 +99,7 @@ def __init__(self, init_kws: dict): self._nraw = len(self["input_stream_args"]) 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]] @@ -103,6 +108,7 @@ def __init__(self, init_kws: dict): 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 else: # encoded: list[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] @@ -111,12 +117,14 @@ def __init__(self, init_kws: dict): 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) -> bool: + 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 @@ -130,6 +138,9 @@ def put_data(self, stream: int, data: RawDataBlob | bytes) -> bool: """ 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 @@ -141,8 +152,13 @@ def put_data(self, stream: int, data: RawDataBlob | bytes) -> bool: 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] @@ -151,10 +167,14 @@ def put_data(self, stream: int, data: RawDataBlob | bytes) -> bool: 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] @@ -163,6 +183,8 @@ def put_data(self, stream: int, data: RawDataBlob | bytes) -> bool: else: buffer.append(data) return False + if last: + self._raw_pipe_eos[stream] = True return True def clear_keywords(self): @@ -184,11 +206,12 @@ def clear_keywords(self): if buf is not None: kw[i] = ("-", kw[i][1]) - def iter_raw_data(self) -> Iterator[tuple[int, RawDataBlob]]: + 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. @@ -197,22 +220,25 @@ def iter_raw_data(self) -> Iterator[tuple[int, RawDataBlob]]: if self._raw_pipe_buffer is None: return - for i, buf in enumerate(self._raw_pipe_buffer): + for i, (buf, eos) in enumerate(zip(self._raw_pipe_buffer, self._raw_pipe_eos)): if buf is not None: - for blob in buf: - yield i, blob + for blob in buf[:-1]: + yield i, blob, False + yield i, buf[-1], eos - def iter_enc_data(self) -> Iterator[tuple[int, bytes]]: + 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 + 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} @@ -307,7 +333,8 @@ def __init__( :param enc_blocksize: (only for decodable with named pipes) the queue item size of encoded output stream in bytes, defaults to 1 MB (1024**2 bytes). - :param queuesize: the depth of named pipe queues, defaults to None (unlimited) + :param queuesize: the depth of named pipe queues, defaults to None (4). + 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. @@ -344,18 +371,27 @@ def __init__( 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 + 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. @@ -375,7 +411,7 @@ def _try_config_ffmpeg( 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): + 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) @@ -412,8 +448,10 @@ def _try_config_ffmpeg( 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: @@ -491,10 +529,10 @@ def _run_ffmpeg(self): self._output_pipes = output_pipes # write pre-buffered data - for st, data in self._init_kws.iter_raw_data(): - self.write(data, st) - for st, data in self._init_kws.iter_enc_data(): - self.write_encoded(data, st) + 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() @@ -629,7 +667,7 @@ def num_input_streams(self) -> int: except KeyError: return 0 - def write(self, data: RawDataBlob, stream: int = 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 @@ -638,6 +676,9 @@ def write(self, data: RawDataBlob, stream: int = 0): compatible with the stream's shape and pix_fmt/sample_fmt. :param stream: stream index in accordance to the ``input_stream_types`` 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. """ @@ -645,7 +686,7 @@ def write(self, data: RawDataBlob, stream: int = 0): data2bytes = self._input_info[stream]["data2bytes"] except AttributeError as e: if self._status == FFmpegStatus.BUFFERING: - if self._try_config_ffmpeg(stream, data): + if self._try_config_ffmpeg(stream, data, last): self._run_ffmpeg() else: raise FFmpegioError( @@ -655,8 +696,11 @@ def write(self, data: RawDataBlob, stream: int = 0): 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): - self._input_pipes[stream]["writer"].write(b) + writer.write(b) + if last: + writer.write(None) # write the sentinel @property def input_types(self) -> list[MediaType]: @@ -681,7 +725,7 @@ def input_rates(self) -> list[int | Fraction]: return [] # no input streams lut: dict[Literal["a", "v"], Literal["ar", "r"]] = {"a": "ar", "v": "r"} - return [args[lut[av]] for av, args in zip(stypes, sargs)] + return [args[1][lut[av]] for av, args in zip(stypes, sargs)] @property def input_dtypes(self) -> list[DTypeString] | None: @@ -766,7 +810,7 @@ def encoded_input_streams(self) -> list[int]: 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): + 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. @@ -776,26 +820,33 @@ def write_encoded(self, data: bytes, stream: int = 0): ``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): + if len(data) == 0: return # no data to write st = stream + self.num_input_streams try: - self._input_pipes[st]["writer"].write(data) + 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): + 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 @@ -1043,6 +1094,26 @@ def primary_output_rate(self) -> int | Fraction | None: 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 __iter__(self) -> Iterator[list[RawDataBlob]]: """iterator to read raw media data @@ -1060,10 +1131,7 @@ def __iter__(self) -> Iterator[list[RawDataBlob]]: raise FFmpegioError("Frame iterator is only supported for a pure reader") ref_st = self.primary_output - ref_sz = self.primary_output_blocksize / self.primary_output_rate - - rates = self.output_rates - nperread = [ref_sz * r for r in rates] + nperread = self.output_frames() count = [self._output_info[i]["data_count"] for i in range(nout)] nf = nperread.copy() @@ -1080,7 +1148,7 @@ def __iter__(self) -> Iterator[list[RawDataBlob]]: nf = [nfi - nr + nnext for nfi, nr, nnext in zip(nf, nread, nperread)] # read the next block of the reference stream - out = [self.read(round(ni), st) for st, ni in zip(range(nout), nf)] + 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)] # if there is any secondary streams with leftover frames, do the last yield @@ -1137,7 +1205,13 @@ def read_encoded(self, n: int, stream: int = 0) -> bytes: ) st = stream + self.num_output_streams - return self._output_pipes[st].read(n) + + 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: @@ -1235,12 +1309,16 @@ def __init__( ) def _try_config_ffmpeg( - self, stream: int = -1, data: bytes | RawDataBlob | None = None + 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. @@ -1250,7 +1328,7 @@ def _try_config_ffmpeg( """ - ok = super()._try_config_ffmpeg(stream, data) + ok = super()._try_config_ffmpeg(stream, data, last) if ok: # validate nin = self.num_input_streams @@ -1448,6 +1526,33 @@ def read_nowait(self, n: int, stream: int = 0) -> RawDataBlob: 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." + ) + + st = stream + self.num_output_streams + + try: + pipe = self._output_pipes[st] + except AttributeError: + return b"" + + return pipe["reader"].read_nowait(n) + @staticmethod def create_media_reader( input_urls: list[FFmpegInputOptionTuple], @@ -1537,7 +1642,6 @@ def create_media_writer( @staticmethod def create_media_filter( - expr: str | FilterGraphObject | list[str | FilterGraphObject] | None, input_stream_types: list[Literal["a", "v"]], input_stream_opts: list[FFmpegOptionDict], output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict], @@ -1558,7 +1662,6 @@ def create_media_filter( **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: init_kws: MediaFilterKwsDict = { - "expr": expr, # str | FilterGraphObject | Sequence[str | FilterGraphObject] | None "input_stream_types": input_stream_types, "input_stream_args": [(None, opts) for opts in input_stream_opts], "output_streams": output_streams, @@ -1619,7 +1722,7 @@ def create_media_encoder( "extra_inputs": extra_inputs, } return PipedFFmpegRunner( - configure.init_media_read, + configure.init_media_write, init_kws, primary_output=primary_output, blocksize=blocksize, @@ -1679,8 +1782,8 @@ def create_media_decoder( @staticmethod def create_media_transcoder( - input_urls: list[FFmpegInputOptionTuple], - output_urls: list[FFmpegOutputOptionTuple], + input_options: list[FFmpegOptionDict], + output_options: list[FFmpegOptionDict], extra_inputs: list[FFmpegInputOptionTuple] | None = None, extra_outputs: list[FFmpegOutputOptionTuple] | None = None, enc_blocksize: int | None = None, @@ -1692,12 +1795,18 @@ def create_media_transcoder( sp_kwargs: dict | None = None, **options: FFmpegOptionDict, ) -> 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, - "extra_inputs": extra_inputs, - "extra_outputs": extra_outputs, } return PipedFFmpegRunner( configure.init_media_transcode, @@ -1721,10 +1830,9 @@ class SimpleFFmpegFilter(SISOMixin, PipedFFmpegRunner): def __init__( self, - expr: str | FilterGraphObject | None, input_stream_type: Literal["a", "v"], input_stream_opt: FFmpegOptionDict, - output_stream: str | FFmpegOptionDict | None, + output_stream: str | FFmpegOptionDict | None = None, *, extra_inputs: ( list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None @@ -1748,10 +1856,9 @@ def __init__( ): init_func = configure.init_media_filter init_kws: MediaFilterKwsDict = { - "expr": expr, # str | FilterGraphObject | Sequence[str | FilterGraphObject] | None "input_stream_types": [input_stream_type], "input_stream_args": [(None, input_stream_opt)], - "output_streams": [output_stream], + "output_streams": [{}] if output_stream is None else [output_stream], "options": options, "extra_inputs": extra_inputs, "extra_outputs": extra_outputs, @@ -1774,12 +1881,16 @@ def __init__( ) def _try_config_ffmpeg( - self, stream: int = -1, data: bytes | RawDataBlob | None = None + 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. @@ -1789,12 +1900,12 @@ def _try_config_ffmpeg( """ - ok = super()._try_config_ffmpeg(stream, data) + ok = super()._try_config_ffmpeg(stream, data, last) if ok: # validate nin = self.num_input_streams nout = self.num_output_streams - if nin + nout != 1: + if nin != 1 or nout != 1: raise FFmpegioError( "SimpleFFmpegFilter takes only one each of raw input and output " ) @@ -1814,11 +1925,12 @@ def filter(self, data: RawDataBlob) -> RawDataBlob: rates are not the same. It is recommended to set a timeout. """ + self.write(data) + if self.rate_in is None or self.rate is None: raise FFmpegioError("FFmpeg is not running yet.") n = self._output_info[0]["data_count"](obj=data) nout = int((n / self.rate_in * self.rate)) - self.write(data) - return self.read(nout) + return self.read_nowait(nout) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 3a150af1..88f59cb6 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -315,13 +315,13 @@ 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._queue = Queue(4 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._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): @@ -349,15 +349,18 @@ def join(self, timeout=None): ... self.pipe.close() + # set flag to terminate the thread loop 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) @@ -394,28 +397,27 @@ def run(self): while not self._halt.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 not self._halt.is_set(): # True until self.cooloff + if data: + logger.debug("ReaderThread putting data into the queue") 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._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 @@ -427,7 +429,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 """ @@ -457,8 +459,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 = 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 @@ -521,8 +522,17 @@ def read_nowait(self, n: int = -1) -> bytes: while read_all or m > 0: try: b = self._queue.get_nowait() - assert b is not None # encountered sentinel - except (Empty, AssertionError): + 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 @@ -584,7 +594,7 @@ def __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(4 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 @@ -620,6 +630,7 @@ def run(self): while True: # get next data block + logger.info("WriterThread getting data to the queue") try: data = queue.get_nowait() except Empty: @@ -628,24 +639,25 @@ def run(self): self._empty = True self._empty_cond.notify_all() data = queue.get() + logger.info("WriterThread getting data to the queue") queue.task_done() if data is None: - logger.info("writer thread: received a sentinel to stop the writer") + logger.info("WriterThread: received a sentinel to stop the writer") break else: - logger.info("writer thread: received %d bytes to write", len(data)) + logger.info("WriterThread: writing %d bytes", len(data)) try: nwritten = 0 nwritten = stream.write(data) - logger.info("writer thread: written %d written", nwritten) + logger.info("WriterThread: written %d written", nwritten) except Exception as e: # stdout stream closed/FFmpeg terminated, end the thread as well - logger.info("writer thread exception: %s", e) + logger.info("WriterThread exception: %s", e) break if not nwritten and stream.closed: # just in case - logger.info("writer thread: somethin' else happened") + logger.info("WriterThread: somethin' else happened") break # set flag to prevent any more writes @@ -676,7 +688,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: diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 6e366050..c711e641 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -28,6 +28,7 @@ ShapeTuple, ) from .._utils import ( + as_multi_option, escape, get_samplesize, is_fileobj, diff --git a/src/ffmpegio/video.py b/src/ffmpegio/video.py index 1e4678bd..ad300a5b 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -267,15 +267,17 @@ def filter( """ - if expr and extra_inputs is None and extra_outputs is None: - # guaranteed SISO filtering - options["filter:v"] = expr - options["map"] = "0:V:0" - expr = None + 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( - expr, ["v"], [(input_rate, input)], extra_inputs, diff --git a/tests/test_media.py b/tests/test_media.py index 9da403bd..4accc3f2 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,10 +1,10 @@ -from tempfile import TemporaryDirectory from os import path from pprint import pprint +from tempfile import TemporaryDirectory + import pytest import ffmpegio as ff -import ffmpegio.filtergraph as fgb url = "tests/assets/testmulti-1m.mp4" url1 = "tests/assets/testvideo-1m.mp4" @@ -21,7 +21,7 @@ ], ) def test_media_read(urls, kwargs, nout): - rates, data = ff.media.read(*urls, **kwargs) + rates, data = ff.media.read(*urls, **kwargs, timeout=1.0) assert len(rates) == nout print(rates) print([(k, x["shape"], x["dtype"]) for k, x in data.items()]) @@ -58,30 +58,6 @@ def test_media_write(): pprint(ff.probe.format_basic(outfile)) pprint(ff.probe.streams_basic(outfile)) -@pytest.mark.skip(reason='To be implemented - merge_audio preset filtergraph needs more work.') -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" - - fg = fgb.presets.merge_audio(["0:a", "1:a", "2:a"]) - with TemporaryDirectory() as tmpdirname: - outfile = path.join(tmpdirname, f"out{outext}") - ff.media.write( - outfile, - "aaa", - stream1, - stream2, - stream3, - filter_complex=fg, - show_log=True, - shortest=ff.FLAG, - ) - 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") @@ -97,7 +73,7 @@ def test_media_filter(): (fps, F), (fs, x), (fs, x), - output_args={"[out0]": {},"out1":{}, "audio": {"map": "[out2]"}}, + output_args={"[out0]": {}, "out1": {}, "audio": {"map": "[out2]"}}, show_log=True, shortest=ff.FLAG, ) diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py index 10316803..a3313fcc 100644 --- a/tests/test_streams_piped.py +++ b/tests/test_streams_piped.py @@ -1,11 +1,10 @@ import logging -logging.basicConfig(level=logging.DEBUG) - - 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" @@ -14,13 +13,12 @@ def test_MediaReader(): with streams.PipedFFmpegRunner.create_media_reader( - [(mult_url, {})], None, t=1, queuesize=4 + [(mult_url, {})], None, t=1 ) as reader: # data = reader.read(2) for data in reader: - for k, v in data.items(): + for k, v in enumerate(data): print(f"{k}: {len(v['buffer'])}") - break def test_MediaWriter_audio(): @@ -30,21 +28,21 @@ def test_MediaWriter_audio(): stream_types = [spec.split(":", 2)[1] for spec in data] with streams.PipedFFmpegRunner.create_media_encoder( - ["a"], - [(None, {"ar": rates[0]})], - *rates.values(), + stream_types, + [{"ar": rates["0:a:0"]}], + [{"f": "matroska"}], show_log=True, - f="matroska", - # loglevel="debug", ) as writer: - for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): + 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(10) + writer.wait(timeout=10) - # read the encoded bytes - b = writer.readall_encoded() + # read the rest + b = writer.read_encoded(0) def test_MediaWriter(): @@ -53,18 +51,22 @@ def test_MediaWriter(): rates, data = ff.media.read(mult_url, t=1) stream_types = [spec.split(":", 2)[1] for spec in data] - with streams.MediaWriter( - "pipe", + 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.create_media_encoder( stream_types, - *rates.values(), + stream_opts, + [{"f": "matroska", "map": range(len(stream_types))}], show_log=True, - f="matroska", ) as writer: # write full audio streams video_frames = {} for i, (mtype, frame) in enumerate(zip(stream_types, data.values())): if mtype == "a": - writer.write_stream(i, frame) + writer.write(frame, i) else: video_frames[i] = frame.shape[0] @@ -77,14 +79,34 @@ def test_MediaWriter(): if i in frame_count: j = frame_count[i] print(j) - writer.write_stream(i, frame[j]) - frame_count[i] = j + 1 + 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, 10) + b = writer.read_encoded(-1) assert isinstance(b, bytes) and len(b) > 0 +def test_SimpleMediaFilter(): + ff.use("read_bytes") + + fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) + + with ff.streams.SimpleFFmpegFilter( + "a", {"ar": fs}, {"map": "[out]"}, filter_complex="[0:a:0]showcqt[out]" + ) as f: + out = f.filter(x) + f.wait(1) + out1 = f.read_nowait(-1) + + print(out.shape) + + def test_MediaFilter(): ff.use("read_bytes") @@ -94,49 +116,78 @@ def test_MediaFilter(): print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") - with streams.MediaFilter( - ["[0:V:0][1:V:0]vstack,split", "[2:a:0][3:a:0]amerge"], + with ff.streams.PipedFFmpegRunner.create_media_filter( "vvaa", - fps, - fps, - fs, - fs, - output_options={"[out0]": {}, "audio": {"map": "[out2]"}}, + [{"r": fps}, {"r": fps}, {"ar": fs}, {"ar": fs}], + output_streams={"[out0]": {}, "audio": {"map": "[out1]"}}, + filter_complex=["[0:V:0][1:V:0]vstack", "[2:a:0][3:a:0]amerge"], show_log=True, - loglevel="debug", + # loglevel="debug", # queuesize=4, ) as f: # f.write([F, F]) - f.write([F, F, x, x]) + for i, frame in enumerate([F, F, x, x]): + f.write(frame, i, last=True) # 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()) + assert ["[out0]", "audio"] == 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/testmulti-1m.mp4" + url = "tests/assets/sample.mp4" + + data = b"" - with streams.MediaTranscoder( + # 1. transcode from a file to pipe + with streams.PipedFFmpegRunner.create_media_transcoder( [], - [{"f": "matroska", "codec": "copy", "to": 1}], - extra_inputs=[url], - show_log=False, + [{"f": "matroska", "to": 1}], + extra_inputs=[(url, {})], + show_log=True, + # loglevel="debug", ) as f: - if f.wait(timeout=10): - raise f.lasterror - data = f.read_encoded(-1, timeout=10) + while f: + b = f.read_encoded_nowait(-1) + data += b + b = f.read_encoded_nowait(-1) + data += b - with streams.MediaTranscoder( - [{"f": "matroska"}], - [{"f": "flac"}, {"f": "matroska", "codec": "copy"}], - show_log=False, + assert len(data) > 0 + + print(f"FIRST TRANCODING YIELDED {len(data)} bytes") + + with streams.PipedFFmpegRunner.create_media_transcoder( + [{}], + [{"f": "flac", "vn": None}, {"f": "matroska", "codec": "copy"}], + show_log=True, + # loglevel="debug", ) as f: - f.write_encoded(data, timeout=10) - if f.wait(timeout=10): - raise f.lasterror - enc_data = f.readall_encoded(timeout=10) - assert len(enc_data) == 2 + 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" + ) From b570e06f9735196dcf6acf5f9a06307b0b0facbc Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 1 Feb 2026 17:07:57 -0600 Subject: [PATCH 329/344] wip24 - piped filter working! --- src/ffmpegio/_typing.py | 16 ++- src/ffmpegio/configure.py | 33 ++++- src/ffmpegio/streams/BaseFFmpegRunner.py | 154 +++++++++++++---------- src/ffmpegio/threading.py | 68 +++++++--- tests/test_streams_piped.py | 57 +++++++-- 5 files changed, 222 insertions(+), 106 deletions(-) diff --git a/src/ffmpegio/_typing.py b/src/ffmpegio/_typing.py index 605bff74..c9cafdf4 100644 --- a/src/ffmpegio/_typing.py +++ b/src/ffmpegio/_typing.py @@ -3,15 +3,13 @@ from __future__ import annotations 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 CopyFileObjThread, ReaderThread, WriterThread + from .threading import CopyFileObjThread # from typing_extensions import * @@ -218,6 +216,8 @@ class RawInputInfoDict(TypedDict): `'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 @@ -232,9 +232,13 @@ class RawInputInfoDict(TypedDict): raw_info: RawStreamInfoTuple """tuple of (rate, shape, dtype)""" item_size: int - '''size of each frame/sample in bytes''' + """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)""" @@ -279,10 +283,14 @@ class FileObjEncodedInputInfoDict(TypedDict): 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): diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 5b5ca62f..dd5a0a39 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -1426,6 +1426,8 @@ def add_filtergraph( class RawInputCallablesDict(TypedDict): data2bytes: ToBytesCallable + data_count: CountDataCallable + data_is_empty: IsEmptyCallable class RawOutputCallablesDict(TypedDict): @@ -1997,9 +1999,17 @@ def process_raw_inputs( def get_callables(media_type: MediaType) -> RawInputCallablesDict: hook = plugins.pm.hook return ( - {"data2bytes": cast(ToBytesCallable, hook.audio_bytes)} + { + "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)} + else { + "data2bytes": cast(ToBytesCallable, hook.video_bytes), + "data_is_empty": cast(IsEmptyCallable, hook.is_empty), + "data_count": cast(CountDataCallable, hook.video_frames), + } ) for i, (mtype, arg, dtype, shape) in enumerate( @@ -2435,7 +2445,7 @@ def init_named_pipes( :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 None (4). For + :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 @@ -2541,11 +2551,18 @@ def __init__(self, proc: fp.Popen) -> None: def write(self, data: bytes | None): if data is None: - self._proc.stdin.flush() - self._proc.stdin.close() + self.join() else: self._proc.stdin.write(data) + def join(self): + # no thread, just close the stdin + self._proc.stdin.flush() + self._proc.stdin.close() + + def closed(self) -> bool: + return self._proc.stdin.closed + class StdReader: def __init__(self, proc: fp.Popen, itemsize: int) -> None: @@ -2555,6 +2572,12 @@ def __init__(self, proc: fp.Popen, itemsize: int) -> None: def read(self, n: int = -1) -> bytes: return self._proc.stdout.read(n if n <= 0 else n * self._itemsize) + def cool_down(self): + pass + + def join(self): + pass + def init_std_pipes( input_pipes: dict[int, InputPipeInfoDict], diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 584a085b..530d7501 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -517,7 +517,7 @@ def _run_ffmpeg(self): # set the log source and start the logger self._logger.stderr = self._proc.stderr - self._logger.start() + self._stack.enter_context(self._logger) # # if stdin/stdout is used, attach StdWriter/StdReader object to each if self._use_std_pipes: @@ -540,13 +540,25 @@ def _run_ffmpeg(self): def _terminate(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() + 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() - self._logger.join() + # 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 @@ -1114,46 +1126,11 @@ def output_frames( fr = Fraction(primary_frames, rate0) return [r * fr for r in rates] - 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") - - ref_st = self.primary_output - nperread = self.output_frames() - count = [self._output_info[i]["data_count"] for i in range(nout)] - nf = nperread.copy() - - # read the first block of the reference stream - out = [self.read(round(ni), st) for st, ni in zip(range(nout), nf)] - nread = [counti(obj=Fi) for counti, Fi in zip(count, out)] - - # loop until all reference frames are read - while nread[ref_st] > 0: - # 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)] - - # 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)] - - # if there is any secondary streams with leftover frames, do the last yield - if any(n > 0 for n in nread): - yield out + 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 @@ -1553,6 +1530,47 @@ def read_encoded_nowait(self, n: int, stream: int = 0) -> bytes: 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") + + ref_st = self.primary_output + 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 create_media_reader( input_urls: list[FFmpegInputOptionTuple], @@ -1832,7 +1850,7 @@ def __init__( self, input_stream_type: Literal["a", "v"], input_stream_opt: FFmpegOptionDict, - output_stream: str | FFmpegOptionDict | None = None, + output_stream: FFmpegOptionDict | None = None, *, extra_inputs: ( list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None @@ -1907,30 +1925,36 @@ def _try_config_ffmpeg( nout = self.num_output_streams if nin != 1 or nout != 1: raise FFmpegioError( - "SimpleFFmpegFilter takes only one each of raw input and output " + "SimpleFFmpegFilter takes only one each of raw input and output." + ) + if self.num_encoded_input_streams or self.num_encoded_output_streams: + raise FFmpegioError( + "SimpleFFmpegFilter does not accept any encoded input or output." ) return ok - def filter(self, data: RawDataBlob) -> RawDataBlob: - """filter a raw media data blob to the specified stream + # 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. - :returns: filter output blob. + # :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. - """ + # 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) + # self.write(data, last=last) - if self.rate_in is None or self.rate is None: - raise FFmpegioError("FFmpeg is not running yet.") + # if self.rate_in is None or self.rate is None: + # raise FFmpegioError("FFmpeg is not running yet.") - n = self._output_info[0]["data_count"](obj=data) - nout = int((n / self.rate_in * self.rate)) + # n = self._input_info[0]["data_count"](obj=data) + # nout = int((n * self.rate / self.rate_in)) - return self.read_nowait(nout) + # return self.read(nout) diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 88f59cb6..0b63d9bc 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -315,11 +315,12 @@ 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(4 if queuesize is None else queuesize) + self._queue = Queue(16 if queuesize is None else queuesize) self._carryover: bytes | None = ( None #:bytes: extra data that was not previously read by user ) self._halt = Event() + self._cooling = Event() self._running = Event() self._retry_delay = 0.01 if retry_delay is None else retry_delay self._timeout = float(timeout) if timeout else None @@ -334,7 +335,7 @@ 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: @@ -350,6 +351,7 @@ def join(self, timeout=None): self.pipe.close() # set flag to terminate the thread loop + self._cooling.set() self._halt.set() # if queue is full, the thread loop is likely stuck. @@ -394,7 +396,7 @@ 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)) @@ -405,11 +407,19 @@ def run(self): # print(f"reader thread: read {len(data)} bytes") if data: logger.debug("ReaderThread putting data into the queue") - queue.put(data) + while not self._cooling.is_set(): + try: + queue.put(data, timeout=0.01) + break + except Full: + if self._cooling.is_set(): + break + logger.debug("ReaderThread data in the reader queue") elif stream.closed: # just in case logger.info("ReaderThread no data, stream is closed, exiting") + self._cooling.set() self._halt.set() break else: @@ -419,7 +429,18 @@ def run(self): 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() @@ -461,8 +482,15 @@ def read(self, n: int = -1, timeout: float | None = None) -> bytes: tout = timeout and max(timeout - time(), 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) @@ -471,8 +499,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) @@ -594,7 +620,7 @@ def __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(4 if queuesize is None else queuesize) + 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 @@ -613,6 +639,10 @@ def join(self, timeout: float | None = None): # if queue is full, 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() return self @@ -630,7 +660,7 @@ def run(self): while True: # get next data block - logger.info("WriterThread getting data to the queue") + logger.debug("WriterThread getting data to the queue") try: data = queue.get_nowait() except Empty: @@ -639,25 +669,25 @@ def run(self): self._empty = True self._empty_cond.notify_all() data = queue.get() - logger.info("WriterThread getting data to the queue") + logger.debug("WriterThread getting data from the queue") queue.task_done() if data is None: - logger.info("WriterThread: received a sentinel to stop the writer") + logger.debug("WriterThread: received a sentinel to stop the writer") break else: - logger.info("WriterThread: writing %d bytes", len(data)) + logger.debug("WriterThread: writing %d bytes", len(data)) try: nwritten = 0 nwritten = stream.write(data) - logger.info("WriterThread: written %d written", nwritten) + logger.debug("WriterThread: written %d written", nwritten) except Exception as e: # stdout stream closed/FFmpeg terminated, end the thread as well - logger.info("WriterThread exception: %s", e) + logger.debug("WriterThread exception: %s", e) break if not nwritten and stream.closed: # just in case - logger.info("WriterThread: somethin' else happened") + logger.debug("WriterThread: somethin' else happened") break # set flag to prevent any more writes @@ -677,10 +707,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: diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py index a3313fcc..4e5dfc3c 100644 --- a/tests/test_streams_piped.py +++ b/tests/test_streams_piped.py @@ -1,5 +1,7 @@ import logging +import numpy as np + import ffmpegio as ff from ffmpegio import streams @@ -13,12 +15,13 @@ def test_MediaReader(): with streams.PipedFFmpegRunner.create_media_reader( - [(mult_url, {})], None, t=1 + [(mult_url, {})], None, t_in=1, squeeze=False ) as reader: - # data = reader.read(2) - for data in reader: - for k, v in enumerate(data): - print(f"{k}: {len(v['buffer'])}") + 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(): @@ -93,18 +96,48 @@ def test_MediaWriter(): def test_SimpleMediaFilter(): - ff.use("read_bytes") + ff.use("read_numpy") fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) + nin = 1024 + nblocks = len(x) // nin + + X = x[: nin * nblocks, ...].reshape(nblocks, nin, -1) + with ff.streams.SimpleFFmpegFilter( - "a", {"ar": fs}, {"map": "[out]"}, filter_complex="[0:a:0]showcqt[out]" + "a", + {"ar": fs}, + {"map": "[out]"}, + filter_complex="[0:a:0]showcqt=s=vga[out]", + show_log=True, + squeeze=False, ) as f: - out = f.filter(x) - f.wait(1) - out1 = f.read_nowait(-1) - - print(out.shape) + # 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(): From 46e2b18d802dadfed77ad0e45c97249f211fcc90 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 1 Feb 2026 22:22:28 -0600 Subject: [PATCH 330/344] wip25 --- src/ffmpegio/image.py | 2 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 41 +++-- src/ffmpegio/streams/__init__.py | 4 +- src/ffmpegio/streams/open.py | 215 +++++++++++++++++++---- src/ffmpegio/utils/__init__.py | 7 +- tests/test_configure.py | 108 ++++++------ tests/test_ffmpegprocess.py | 11 +- tests/test_streams_piped.py | 9 +- tests/test_utils_avi.py | 136 -------------- 9 files changed, 283 insertions(+), 250 deletions(-) delete mode 100644 tests/test_utils_avi.py diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 0d006905..5979cd10 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -250,7 +250,7 @@ def filter( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_filter( - expr, ["v"], [(1.0, input)], extra_inputs, None, extra_outputs, options, True + ["v"], [(1.0, input)], extra_inputs, None, extra_outputs, options, True ) if output_info is None: diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 530d7501..801c209c 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -42,7 +42,12 @@ logger = logging.getLogger("ffmpegio") -__all__ = ["BaseFFmpegRunner"] +__all__ = [ + "BaseFFmpegRunner", + "StdFFmpegRunner", + "PipedFFmpegRunner", + "SISOFFmpegFilter", +] class FFmpegStatus(IntEnum): @@ -1363,8 +1368,9 @@ def create_simple_reader( input_urls: list[FFmpegInputOptionTuple], output_options: FFmpegOptionDict, squeeze: bool = True, - extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] - | None = None, + extra_outputs: ( + Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, blocksize: int | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, @@ -1416,10 +1422,13 @@ def create_simple_reader( def create_simple_writer( input_stream_type: Literal["a", "v"], input_stream_options: FFmpegOptionDict, - output_urls: FFmpegOutputUrlComposite - | list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ], + output_urls: ( + FFmpegOutputUrlComposite + | list[ + FFmpegOutputUrlComposite + | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] + ] + ), input_dtype: DTypeString | None = None, input_shape: ShapeTuple | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, @@ -1574,13 +1583,13 @@ def __iter__(self) -> Iterator[list[RawDataBlob]]: @staticmethod def create_media_reader( input_urls: list[FFmpegInputOptionTuple], - output_streams: list[FFmpegOptionDict] - | dict[str, FFmpegOptionDict] - | None = None, + output_streams: ( + list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + ) = None, squeeze: bool = True, - extra_outputs: list[FFmpegOutputOptionTuple] - | dict[str, FFmpegOptionDict] - | None = None, + extra_outputs: ( + list[FFmpegOutputOptionTuple] | dict[str, FFmpegOptionDict] | None + ) = None, primary_output: int | None = None, blocksize: int | None = None, enc_blocksize: int | None = None, @@ -1839,7 +1848,7 @@ def create_media_transcoder( ) -class SimpleFFmpegFilter(SISOMixin, PipedFFmpegRunner): +class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): """Streaming FFmpeg runner for a SISO filtering using named pipes. This class mixes in the single input convenience properties to @@ -1925,11 +1934,11 @@ def _try_config_ffmpeg( nout = self.num_output_streams if nin != 1 or nout != 1: raise FFmpegioError( - "SimpleFFmpegFilter takes only one each of raw input and output." + "SISOFFmpegFilter takes only one each of raw input and output." ) if self.num_encoded_input_streams or self.num_encoded_output_streams: raise FFmpegioError( - "SimpleFFmpegFilter does not accept any encoded input or output." + "SISOFFmpegFilter does not accept any encoded input or output." ) return ok diff --git a/src/ffmpegio/streams/__init__.py b/src/ffmpegio/streams/__init__.py index bdd79183..248c90b5 100644 --- a/src/ffmpegio/streams/__init__.py +++ b/src/ffmpegio/streams/__init__.py @@ -19,7 +19,7 @@ from .BaseFFmpegRunner import ( BaseFFmpegRunner, PipedFFmpegRunner, - SimpleFFmpegFilter, + SISOFFmpegFilter, StdFFmpegRunner, ) from .open import open @@ -29,5 +29,5 @@ # fmt: off __all__ = ['StdFFmpegRunner', 'PipedFFmpegRunner', 'BaseFFmpegRunner', - "SimpleFFmpegFilter", "open"] + "SISOFFmpegFilter", "open"] # fmt: on diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py index 2bf0be2f..a831acac 100644 --- a/src/ffmpegio/streams/open.py +++ b/src/ffmpegio/streams/open.py @@ -50,8 +50,6 @@ import logging -logger = logging.getLogger("ffmpegio") - import re from fractions import Fraction @@ -73,13 +71,109 @@ FFmpegOutputUrlComposite, ) from ..filtergraph.abc import FilterGraphObject -from .BaseFFmpegRunner import PipedFFmpegRunner, SimpleFFmpegFilter, StdFFmpegRunner +from .BaseFFmpegRunner import PipedFFmpegRunner, SISOFFmpegFilter, StdFFmpegRunner + +logger = logging.getLogger("ffmpegio") + + +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 +=============== ========================= +""" + +MultiWriterModeLiteral = LiteralString +"""multiple-input writer mode + +To specify writing multiple media streams, use + +=============== ========================== +mode (regexp) description +=============== ========================== +``'w[va]{2,}'`` write more than one stream +=============== ========================== +""" + +MIMOFilterModeLiteral = LiteralString +"""multiple-input, multiple-output filter mode + +To specify MIMO filter + +========================= ================================================ +mode (regexp) description +========================= ================================================ +``'f'`` according to input and output filtergraph labels +``'f[va]{2,}'`` specify the input media types +``'[va]{2,}-\>[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: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, - mode: Literal["rv"], + url: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, + mode: Literal["rv", "ra"], *, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -88,12 +182,12 @@ def open( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> StdFFmpegRunner: - """open a single-stream video reader + """open a single-stream reader :param urls_fgs: URL of the file or format/device object to obtain a video stream from. It can also be an input filtergraph object or string. The input could also be fed by a buffered bytes-like data object or a readable file object. - :param mode: `'rv'` to read video data + :param mode: ``'rv'`` to read video data or ``'ra'`` to read audio :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 queue's item size in bytes, defaults to `None` (auto-set) @@ -114,9 +208,14 @@ def open( @overload def open( - urls_fgs: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, - mode: Literal["ra"], + url: FFmpegUrlType, + mode: Literal["wv", "wa"], + input_rate: int | Fraction, *, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, @@ -124,12 +223,17 @@ def open( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> StdFFmpegRunner: - """open a single-source audio reader + """open a single-stream media writer - :param urls_fgs: URL of the file or format/device object to obtain a media stream from. - It can also be an input filtergraph object or string. The input - could also be fed by a buffered bytes-like data object or a readable file object. - :param mode: `'rv'` or `'e->v'` to read video data, `'ra'` or `'e->a'` to read audio data + :param urls_fgs: URL of the file or format/device object to write media stream to. The output + could also be written to a bytes object or a writable file object. + :param mode: ``'wv'`` to create a video file or ``'wa'`` to create an audio file + :param input_rate: Input frame rate (video) or sampling rate (audio) + :param input_shape: input video frame size (height, width) or number of input audio channel, defaults + to None (auto-detect) + :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) + :param extra_inputs: extra media source files/urls, defaults to None + :param overwrite: True to overwrite output URL, defaults to False. :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 queue's item size in bytes, defaults to `None` (auto-set) @@ -144,14 +248,15 @@ def open( 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 + :return: writer stream object + """ @overload def open( - urls_fgs: FFmpegUrlType, - mode: Literal["wv"], + fg: str | FilterGraphObject, + mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], input_rate: int | Fraction, *, input_shape: ShapeTuple | None = None, @@ -165,7 +270,7 @@ def open( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> StdFFmpegRunner: - """open a single-destination video writer + """open a single-destination audio writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output could also be written to a bytes object or a writable file object. @@ -190,15 +295,51 @@ def open( 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 + :return: audio writer stream object + + """ + +@overload +def open( + urls_fgs: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, + mode: Literal["ra"], + *, + show_log: bool | None = None, + progress: ProgressCallable | None = None, + blocksize: int | None = None, + timeout: float | None = None, + sp_kwargs: dict | None = None, + **options: Unpack[FFmpegOptionDict], +) -> StdFFmpegRunner: + """open a single-stream reader + + :param urls_fgs: URL of the file or format/device object to obtain a video stream from. + It can also be an input filtergraph object or string. The input + could also be fed by a buffered bytes-like data object or a readable file object. + :param mode: ``'rv'`` to read video data or ``'ra'`` to read audio + :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 queue's item size in bytes, defaults to `None` (auto-set) + :param 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. + :return: reader stream object """ @overload def open( urls_fgs: FFmpegUrlType, - mode: Literal["wa"], + mode: Literal["wv", "wa"], input_rate: int | Fraction, *, input_shape: ShapeTuple | None = None, @@ -212,11 +353,11 @@ def open( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> StdFFmpegRunner: - """open a single-destination audio writer + """open a single-stream media writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output could also be written to a bytes object or a writable file object. - :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file + :param mode: ``'wv'`` to create a video file or ``'wa'`` to create an audio file :param input_rate: Input frame rate (video) or sampling rate (audio) :param input_shape: input video frame size (height, width) or number of input audio channel, defaults to None (auto-detect) @@ -237,32 +378,32 @@ def open( 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: audio writer stream object + :return: writer stream object """ @overload def open( - urls_fgs: ( - FFmpegInputUrlComposite - | Literal["pipe", "-"] - | Sequence[FFmpegOutputUrlComposite | Literal["pipe", "-"]] - ), - mode: LiteralString, # r(v|a){2,} or '(v|a)+->e+ + fg: str | FilterGraphObject, + mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], + input_rate: int | Fraction, *, + input_shape: ShapeTuple | None = None, + input_dtype: DTypeString | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, - queuesize: int | None = None, timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> PipedFFmpegRunner: - """open a piped single-source reader (`mode = "rv" | "ra" | "e->v" | "e->a"`) +) -> SISOFFmpegFilter: + """Open a single-input-single-output media filter - :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. - :param mode: `'rv'` or `'e->v'` to read video data, `'ra'` or `'e->a'` to read audio data + :param fg: Filtergraph expression or object. + :param mode: `'fv'` or `'v->v'` to filter video data, `'fa'` or `'a->a'` to filter audio data, :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 queue's item size in bytes, defaults to `None` (auto-set) @@ -392,7 +533,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> PipedFFmpegRunner | SimpleFFmpegFilter: +) -> PipedFFmpegRunner | SISOFFmpegFilter: """open media stream filter :param urls_fgs: a filtergraph expression @@ -682,7 +823,7 @@ def open( mode: LiteralString, *args, **kwargs, -) -> PipedFFmpegRunner | SimpleFFmpegFilter | StdFFmpegRunner: +) -> PipedFFmpegRunner | SISOFFmpegFilter | StdFFmpegRunner: """Open a multimedia file/stream for read/write :param url_fg: URL of the media source/destination for file read/write or filtergraph definition @@ -905,7 +1046,7 @@ def _create_filter( fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], args: tuple, kwargs: dict, -) -> SimpleFFmpegFilter: +) -> SISOFFmpegFilter: if len(args) > 1: raise TypeError( f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a writer" diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index c711e641..a5566527 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -30,6 +30,7 @@ from .._utils import ( as_multi_option, escape, + unescape, get_samplesize, is_fileobj, is_namedpipe, @@ -1154,9 +1155,9 @@ def format_raw_output_stream_defs( # depending on user's streams input, label output streams differently # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists streams_: list[FFmpegOptionDict] - stream_names: dict[ - int, str - ] = {} # dict of user-specified stream name (only via dict streams input) + stream_names: dict[int, str] = ( + {} + ) # dict of user-specified stream name (only via dict streams input) if isinstance(streams, dict): # dict[str,FFmpegOptionDict] # dict key is used as both stream names (labels) and map option. diff --git a/tests/test_configure.py b/tests/test_configure.py index 53f6831d..11fd4c08 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -193,51 +193,63 @@ def get_output_callables(media_type): [(mul_url, {})], [{"src_type": "url"}], None, - { - f"0:{mtype[0]}:{j}": { - "media_type": mtype, - "input_file_id": 0, - "input_stream_id": i, - **get_output_callables(mtype), - } - for (i, mtype), j in zip(mul_streams, [0, 0, 1, 1]) - }, + ( + [ + {"map": f"0:{mtype[0]}:{j}"} + for (i, mtype), j in zip(mul_streams, [0, 0, 1, 1]) + ], + [ + { + "user_map": f"0:{mtype[0]}:{j}", + "media_type": mtype, + "input_file_id": 0, + "input_stream_id": i, + } + for (i, mtype), j in zip(mul_streams, [0, 0, 1, 1]) + ], + ), ), ( [(vid_url, None), (aud_url, {})], [{"src_type": "url"}, {"src_type": "url"}], None, - { - "0:v:0": { - "media_type": "video", - "input_file_id": 0, - "input_stream_id": 0, - **get_output_callables("video"), - }, - "1:a:0": { - "media_type": "audio", - "input_file_id": 1, - "input_stream_id": 0, - **get_output_callables("audio"), - }, - }, + ( + [{"map": "0:v:0"}, {"map": "1:a:0"}], + [ + { + "user_map": "0:v:0", + "media_type": "video", + "input_file_id": 0, + "input_stream_id": 0, + }, + { + "user_map": "1:a:0", + "media_type": "audio", + "input_file_id": 1, + "input_stream_id": 0, + }, + ], + ), ), ( [(mul_url, {})], [{"src_type": "url"}], ["split=outputs=2"], - { - "[out0]": { - "media_type": "video", - "linklabel": "[out0]", - **get_output_callables("video"), - }, - "[out1]": { - "media_type": "video", - "linklabel": "[out1]", - **get_output_callables("video"), - }, - }, + ( + [{"map": "[out0]"}, {"map": "[out1]"}], + [ + { + "user_map": "out0", + "media_type": "video", + "linklabel": "[out0]", + }, + { + "user_map": "out1", + "media_type": "video", + "linklabel": "[out1]", + }, + ], + ), ), ], ) @@ -249,15 +261,8 @@ def test_auto_map(inputs, input_info, filters_complex, ret): fgb.as_filtergraph(filters_complex), args["inputs"], input_info ) 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": spec[1:-1] if "linklabel" in info else spec, - **info, - } - for spec, info in ret.items() - } + out = configure.auto_map(args, {}, input_info, filters_complex and fg_info) + assert out == ret @pytest.mark.parametrize( @@ -286,19 +291,20 @@ def ffmpeg_url_inputs_vid_aud(): @pytest.mark.parametrize( - ("ffmpeg_url_inputs", "filters_complex", "streams"), + ("ffmpeg_url_inputs", "filters_complex", "stream_opts", "stream_names"), [ - ("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", None, [{"map": "v"}], {}), + ("ffmpeg_url_inputs_vid_aud", None, [{"map": "0:v:0"}, {"map": "1:a:0"}], {}), ( "ffmpeg_url_inputs_mul", ["split=2"], - {"[out0]": None, "[out1]": "out1", "a:0": None}, + [{'map':"[out0]"}, {'map':"[out1]"}, {'map':"a:0"}], + {0:"out0", 1:"out1"}, ), ], ) def test_resolve_raw_output_streams( - ffmpeg_url_inputs, filters_complex, streams, request + ffmpeg_url_inputs, filters_complex, stream_opts, stream_names, request ): args, input_info = request.getfixturevalue(ffmpeg_url_inputs) @@ -310,5 +316,7 @@ def test_resolve_raw_output_streams( 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, fg_info, streams) + out = configure.resolve_raw_output_streams( + stream_opts, stream_names, args, input_info + ) pprint(out) diff --git a/tests/test_ffmpegprocess.py b/tests/test_ffmpegprocess.py index e5d80db8..51a387c0 100644 --- a/tests/test_ffmpegprocess.py +++ b/tests/test_ffmpegprocess.py @@ -100,13 +100,16 @@ def progress(*args): ffmpeg_args = configure.empty() configure.add_url(ffmpeg_args, "input", url) - configure.add_url(ffmpeg_args, "output", "-", {"map": "0:v:0"}) - dtype, shape, r = configure.finalize_video_read_opts( - ffmpeg_args, input_info=[{"src_type": "url"}] + input_info = configure.process_url_inputs(ffmpeg_args, [url], {}) + + raw_info, more_opts = configure.gather_video_read_opts( + {"map": "0:v:0"}, False, ffmpeg_args, input_info, None ) - samplesize = utils.get_samplesize(shape, dtype) + configure.add_url(ffmpeg_args, "output", "-", {"map": "0:v:0", **more_opts}) + + samplesize = utils.get_samplesize(*raw_info[1::-1]) with ffmpegprocess.Popen( ffmpeg_args, diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py index 4e5dfc3c..918f12f6 100644 --- a/tests/test_streams_piped.py +++ b/tests/test_streams_piped.py @@ -1,5 +1,6 @@ import logging +import pytest import numpy as np import ffmpegio as ff @@ -13,6 +14,7 @@ outext = ".mp4" +@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaReader(): with streams.PipedFFmpegRunner.create_media_reader( [(mult_url, {})], None, t_in=1, squeeze=False @@ -24,6 +26,7 @@ def test_MediaReader(): assert nframes == [30, 44100, 25, 44100] +@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaWriter_audio(): ff.use("read_numpy") @@ -48,6 +51,7 @@ def test_MediaWriter_audio(): b = writer.read_encoded(0) +@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaWriter(): ff.use("read_numpy") @@ -95,6 +99,7 @@ def test_MediaWriter(): assert isinstance(b, bytes) and len(b) > 0 +@pytest.mark.xdist_group(name="group_named_pipe") def test_SimpleMediaFilter(): ff.use("read_numpy") @@ -105,7 +110,7 @@ def test_SimpleMediaFilter(): X = x[: nin * nblocks, ...].reshape(nblocks, nin, -1) - with ff.streams.SimpleFFmpegFilter( + with ff.streams.SISOFFmpegFilter( "a", {"ar": fs}, {"map": "[out]"}, @@ -140,6 +145,7 @@ def test_SimpleMediaFilter(): assert nread == ntotal +@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaFilter(): ff.use("read_bytes") @@ -181,6 +187,7 @@ def test_MediaFilter(): f.wait(1) +@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaTranscoder(): url = "tests/assets/sample.mp4" diff --git a/tests/test_utils_avi.py b/tests/test_utils_avi.py deleted file mode 100644 index ca0ad1db..00000000 --- a/tests/test_utils_avi.py +++ /dev/null @@ -1,136 +0,0 @@ -from ffmpegio import ffmpegprocess -from ffmpegio.utils import avi as aviutils -import io -from pprint import pprint - -import pytest - - -@pytest.fixture( - scope="module", - params=( - ("gray", "s32"), # 1 - ("gray16le", "flt"), # 2 - ("ya8", "s64"), # 2 - ("rgb24", "u8"), # 3 - ("rgba", "s16"), # 4 - ("grayf32le", "s16"), # 4 - ("ya16le", "s16"), # 4 - ("rgb48le", "dbl"), # 6 - ("rgba64le", "s16"), # 8 - ), -) -def avi_stream(request): - url = "tests/assets/testmulti-1m.mp4" - codecs = dict( - u8="pcm_u8", - s16="pcm_s16le", - s32="pcm_s32le", - s64="pcm_s64le", - flt="pcm_f32le", - dbl="pcm_f64le", - ) - vframes = 16 - f = io.BytesIO( - ffmpegprocess.run( - { - "inputs": [(url, None)], - "outputs": [ - ( - "-", - { - "f": "avi", - "vcodec": "rawvideo", - "pix_fmt": request.param[0], - "acodec": codecs[request.param[1]], - "vframes": vframes, - }, - ) - ], - } - ).stdout - ) - return [f, vframes, *request.param] - - -def test_base_func(avi_stream): - f, vframes, pix_fmt, sample_fmt = avi_stream - f.seek(0) - - streams = aviutils.read_header(f, pix_fmt.startswith("ya"))[0] - assert streams[0]["pix_fmt"] == pix_fmt - assert streams[1]["sample_fmt"] == sample_fmt - - i = 0 - while True: - try: - sid, data = aviutils.read_frame(f) - except: - break - if sid == 0: - i += 1 - assert i == vframes - - -def test_avireader(avi_stream): - f, vframes, pix_fmt, sample_fmt = avi_stream - f.seek(0) - reader = aviutils.AviReader() - reader.start(f, pix_fmt.startswith("ya")) - streams = reader.streams - i = 0 - for id, frame in reader: - if id == 0: - i += 1 - - assert i == vframes - - -if __name__ == "__main__": - url = "tests/assets/testmulti-1m.mp4" - url1 = "tests/assets/testvideo-1m.mp4" - url2 = "tests/assets/testaudio-1m.mp3" - - # 3840 × 2160 x 3 - # 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()}) - - # create a short example with both audio & video - f = io.BytesIO( - ffmpegprocess.run( - { - "inputs": [(url1, None), (url2, None)], - "outputs": [ - ( - "-", - { - "f": "avi", - "vcodec": "rawvideo", - "pix_fmt": "rgb24", - "acodec": "pcm_s16le", - "vframes": 16, - }, - ) - ], - } - ).stdout - ) - - streams, hdrl = aviutils.read_header(f) - pprint(streams[0]["pix_fmt"]) - pprint(streams[1]["sample_fmt"]) - pprint(hdrl) - i = 0 - while True: - try: - sid, data = aviutils.read_frame(f) - except: - break - print(sid, len(data)) - if sid == 0: - i += 1 - - print(f"read {i} frames") From e480d5728ef0c6f1ed98ee284fcb1765502b56e9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 3 Feb 2026 22:40:25 -0600 Subject: [PATCH 331/344] wip26 - FFmpegRunners can be simultaneously created and opened via static methods --- src/ffmpegio/streams/BaseFFmpegRunner.py | 118 ++++++++++++++++++----- tests/test_streams_piped.py | 18 ++-- tests/test_streams_simple.py | 16 +-- 3 files changed, 109 insertions(+), 43 deletions(-) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 801c209c..e6639753 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -606,7 +606,8 @@ def closed(self) -> bool: return self._proc is None or self._proc.poll() is not None def __enter__(self): - self.open() + if self._status == FFmpegStatus.PREOPEN: + self.open() return self def __exit__(self, exc_type, exc_value, traceback): @@ -1364,13 +1365,13 @@ def __iter__(self) -> Iterator[RawDataBlob]: F = self.read(ref_sz, ref_st) @staticmethod - def create_simple_reader( + def open_simple_reader( input_urls: list[FFmpegInputOptionTuple], output_options: FFmpegOptionDict, - squeeze: bool = True, extra_outputs: ( Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None ) = None, + squeeze: bool = True, blocksize: int | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, @@ -1383,19 +1384,20 @@ def create_simple_reader( :param input_urls: list of input urls :param output_options: dict of FFmpeg output options. One of it items must be the ``'map'`` option to uniquely specify a 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 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 options: optional ffmpeg option dict including input, output, and + global options. For input options, append '_in' to the + end of ffmpeg option names. :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 None (no show/capture) + :param show_log: ``True`` to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param overwrite: ``True`` to overwrite extra_outputs if they exist, defaults to ``False`` :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None @@ -1408,7 +1410,7 @@ def create_simple_reader( "extra_outputs": extra_outputs, "squeeze": squeeze, } - return StdFFmpegRunner( + runner = StdFFmpegRunner( init_func=configure.init_media_read, init_kws=init_kws, blocksize=blocksize, @@ -1417,9 +1419,11 @@ def create_simple_reader( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner @staticmethod - def create_simple_writer( + def open_simple_writer( input_stream_type: Literal["a", "v"], input_stream_options: FFmpegOptionDict, output_urls: ( @@ -1473,7 +1477,7 @@ def create_simple_writer( "input_dtypes": None if input_dtype is None else [input_dtype], "input_shapes": None if input_shape is None else [input_shape], } - return StdFFmpegRunner( + runner = StdFFmpegRunner( init_func=configure.init_media_write, init_kws=init_kws, progress=progress, @@ -1481,6 +1485,8 @@ def create_simple_writer( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner class PipedFFmpegRunner(BaseFFmpegRunner): @@ -1555,7 +1561,6 @@ def __iter__(self) -> Iterator[list[RawDataBlob]]: 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 nperread = self.output_frames() count = [self._output_info[i]["data_count"] for i in range(nout)] nf = nperread.copy() @@ -1581,7 +1586,7 @@ def __iter__(self) -> Iterator[list[RawDataBlob]]: yield out @staticmethod - def create_media_reader( + def open_media_reader( input_urls: list[FFmpegInputOptionTuple], output_streams: ( list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None @@ -1611,7 +1616,7 @@ def create_media_reader( "squeeze": squeeze, "extra_outputs": extra_outputs, } - return PipedFFmpegRunner( + runner = PipedFFmpegRunner( configure.init_media_read, init_kws, primary_output=primary_output, @@ -1624,9 +1629,11 @@ def create_media_reader( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner @staticmethod - def create_media_writer( + def open_media_writer( output_urls: list[FFmpegOutputOptionTuple], input_stream_types: list[Literal["a", "v"]], input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]], @@ -1653,7 +1660,7 @@ def create_media_writer( "input_shapes": input_shapes, "extra_inputs": extra_inputs, } - return PipedFFmpegRunner( + runner = PipedFFmpegRunner( configure.init_media_read, init_kws, primary_output=primary_output, @@ -1666,9 +1673,11 @@ def create_media_writer( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner @staticmethod - def create_media_filter( + def open_media_filter( input_stream_types: list[Literal["a", "v"]], input_stream_opts: list[FFmpegOptionDict], output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict], @@ -1699,7 +1708,7 @@ def create_media_filter( "input_dtypes": input_dtypes, "input_shapes": input_shapes, } - return PipedFFmpegRunner( + runner = PipedFFmpegRunner( configure.init_media_filter, init_kws, primary_output=primary_output, @@ -1712,9 +1721,11 @@ def create_media_filter( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner @staticmethod - def create_media_encoder( + def open_media_encoder( input_stream_types: list[Literal["a", "v"]], input_stream_opts: list[FFmpegOptionDict], output_options: list[FFmpegOptionDict], @@ -1748,7 +1759,7 @@ def create_media_encoder( "input_shapes": input_shapes, "extra_inputs": extra_inputs, } - return PipedFFmpegRunner( + runner = PipedFFmpegRunner( configure.init_media_write, init_kws, primary_output=primary_output, @@ -1761,9 +1772,11 @@ def create_media_encoder( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner @staticmethod - def create_media_decoder( + def open_media_decoder( input_options: Sequence[FFmpegOptionDict], output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict], squeeze: bool = True, @@ -1793,7 +1806,7 @@ def create_media_decoder( "squeeze": squeeze, "extra_outputs": extra_outputs, } - return PipedFFmpegRunner( + runner = PipedFFmpegRunner( configure.init_media_read, init_kws, primary_output=primary_output, @@ -1806,9 +1819,11 @@ def create_media_decoder( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner @staticmethod - def create_media_transcoder( + def open_media_transcoder( input_options: list[FFmpegOptionDict], output_options: list[FFmpegOptionDict], extra_inputs: list[FFmpegInputOptionTuple] | None = None, @@ -1835,7 +1850,7 @@ def create_media_transcoder( "output_urls": output_urls, "options": options, } - return PipedFFmpegRunner( + runner = PipedFFmpegRunner( configure.init_media_transcode, init_kws, enc_blocksize=enc_blocksize, @@ -1846,6 +1861,8 @@ def create_media_transcoder( overwrite=overwrite, sp_kwargs=sp_kwargs, ) + runner.open() + return runner class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): @@ -1855,6 +1872,55 @@ class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): the py::class`PipedFFmpegRunner`. """ + @staticmethod + def create_and_open( + input_stream_type: Literal["a", "v"], + input_stream_opt: FFmpegOptionDict, + output_stream: FFmpegOptionDict | None = None, + *, + extra_inputs: ( + list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None + ) = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + squeeze: bool = True, + 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, + **options, + ) -> SISOFFmpegFilter: + runner = SISOFFmpegFilter( + input_stream_type, + input_stream_opt, + output_stream, + extra_inputs=extra_inputs, + extra_outputs=extra_outputs, + squeeze=squeeze, + 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, + ) + runner.open() + return runner + def __init__( self, input_stream_type: Literal["a", "v"], diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py index 918f12f6..9976cff1 100644 --- a/tests/test_streams_piped.py +++ b/tests/test_streams_piped.py @@ -1,7 +1,7 @@ import logging -import pytest import numpy as np +import pytest import ffmpegio as ff from ffmpegio import streams @@ -16,7 +16,7 @@ @pytest.mark.xdist_group(name="group_named_pipe") def test_MediaReader(): - with streams.PipedFFmpegRunner.create_media_reader( + with streams.PipedFFmpegRunner.open_media_reader( [(mult_url, {})], None, t_in=1, squeeze=False ) as reader: nframes = [0] * reader.num_output_streams @@ -33,7 +33,7 @@ def test_MediaWriter_audio(): 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.PipedFFmpegRunner.create_media_encoder( + with streams.PipedFFmpegRunner.open_media_encoder( stream_types, [{"ar": rates["0:a:0"]}], [{"f": "matroska"}], @@ -63,7 +63,7 @@ def test_MediaWriter(): {rate_opt_name[mtype]: r} for mtype, r in zip(stream_types, rates.values()) ] - with streams.PipedFFmpegRunner.create_media_encoder( + with streams.PipedFFmpegRunner.open_media_encoder( stream_types, stream_opts, [{"f": "matroska", "map": range(len(stream_types))}], @@ -103,14 +103,14 @@ def test_MediaWriter(): def test_SimpleMediaFilter(): ff.use("read_numpy") - fs, x = ff.audio.read("tests/assets/testaudio-1m.mp3", to=1) + 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( + with ff.streams.SISOFFmpegFilter.create_and_open( "a", {"ar": fs}, {"map": "[out]"}, @@ -155,7 +155,7 @@ def test_MediaFilter(): print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") - with ff.streams.PipedFFmpegRunner.create_media_filter( + with ff.streams.PipedFFmpegRunner.open_media_filter( "vvaa", [{"r": fps}, {"r": fps}, {"ar": fs}, {"ar": fs}], output_streams={"[out0]": {}, "audio": {"map": "[out1]"}}, @@ -194,7 +194,7 @@ def test_MediaTranscoder(): data = b"" # 1. transcode from a file to pipe - with streams.PipedFFmpegRunner.create_media_transcoder( + with streams.PipedFFmpegRunner.open_media_transcoder( [], [{"f": "matroska", "to": 1}], extra_inputs=[(url, {})], @@ -211,7 +211,7 @@ def test_MediaTranscoder(): print(f"FIRST TRANCODING YIELDED {len(data)} bytes") - with streams.PipedFFmpegRunner.create_media_transcoder( + with streams.PipedFFmpegRunner.open_media_transcoder( [{}], [{"f": "flac", "vn": None}, {"f": "matroska", "codec": "copy"}], show_log=True, diff --git a/tests/test_streams_simple.py b/tests/test_streams_simple.py index 3920ffcb..d7b211a8 100644 --- a/tests/test_streams_simple.py +++ b/tests/test_streams_simple.py @@ -17,7 +17,7 @@ def test_read_video(): w = 420 h = 360 - with StdFFmpegRunner.create_simple_reader( + with StdFFmpegRunner.open_simple_reader( [(url, {})], {"map": "0:V:0", "vf": "transpose", "pix_fmt": "gray", "s": (w, h), "r": 30}, show_log=True, @@ -45,7 +45,7 @@ def test_read_write_video(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with StdFFmpegRunner.create_simple_writer("v", {"r": fs}, [(out_url, {})]) as f: + with StdFFmpegRunner.open_simple_writer("v", {"r": fs}, [(out_url, {})]) as f: f.write(F0) f.write(F1) f.wait() @@ -58,7 +58,7 @@ def test_read_audio(): bps = utils.get_samplesize(x["shape"][-1:], x["dtype"]) # validate read iterator obtains all the samples - with StdFFmpegRunner.create_simple_reader( + with StdFFmpegRunner.open_simple_reader( [(url, {})], {"map": "0:a:0"}, show_log=True, blocksize=1024**2 ) as f: # x = f.read(1024) @@ -73,7 +73,7 @@ def test_read_audio(): t0 = n0 / fs t1 = n1 / fs - with StdFFmpegRunner.create_simple_reader( + with StdFFmpegRunner.open_simple_reader( [(url, {})], {"map": "0:a:0"}, show_log=True, @@ -96,7 +96,7 @@ def test_read_audio(): def test_read_write_audio(): outext = ".flac" - with StdFFmpegRunner.create_simple_reader([(url, {})], {"map": "0:a:0"}) as f: + 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] @@ -109,7 +109,7 @@ def test_read_write_audio(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with StdFFmpegRunner.create_simple_writer( + with StdFFmpegRunner.open_simple_writer( "a", {"ar": fs}, [(out_url, {})], show_log=True ) as f: f.write({**out, "buffer": F[: 100 * bps]}) @@ -131,7 +131,7 @@ def test_write_extra_inputs(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with StdFFmpegRunner.create_simple_writer( + with StdFFmpegRunner.open_simple_writer( "v", {"r": fs}, [(out_url, {})], @@ -146,7 +146,7 @@ def test_write_extra_inputs(): info = ffmpegio.probe.streams_basic(out_url) assert len(info) == 2 - with StdFFmpegRunner.create_simple_writer( + with StdFFmpegRunner.open_simple_writer( "v", {"r": fs}, [(out_url, {})], From bb85f340f7681d418ac7d4358a532e971f0f736f Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Wed, 4 Feb 2026 08:14:22 -0600 Subject: [PATCH 332/344] wip27 --- src/ffmpegio/streams/open.py | 870 +++++++++++++++-------------------- tests/test_open.py | 72 ++- 2 files changed, 429 insertions(+), 513 deletions(-) diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py index a831acac..62d0d893 100644 --- a/src/ffmpegio/streams/open.py +++ b/src/ffmpegio/streams/open.py @@ -1,3 +1,72 @@ +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 @@ -46,10 +115,8 @@ In addition, `open()` accepts the standard FFmpeg option keyword arguments. """ -from __future__ import annotations import logging - import re from fractions import Fraction @@ -65,8 +132,6 @@ ShapeTuple, ) from ..configure import ( - Buffer, - FFConcat, FFmpegInputUrlComposite, FFmpegOutputUrlComposite, ) @@ -75,6 +140,8 @@ logger = logging.getLogger("ffmpegio") +MapString = LiteralString +"""ffmpeg map option value""" MultiReaderModeLiteral = LiteralString """multiple-output reader mode @@ -87,6 +154,8 @@ ``'r'`` read all streams ``'r[va]{2,}'`` read more than one stream =============== ========================= + +For example, ``'rvaa'`` produces three raw streams, video, audio, and audio """ MultiWriterModeLiteral = LiteralString @@ -99,6 +168,9 @@ =============== ========================== ``'w[va]{2,}'`` write more than one stream =============== ========================== + +For example, ``'wvva'`` takes three raw streams, video, video, and audio + """ MIMOFilterModeLiteral = LiteralString @@ -170,74 +242,91 @@ ============== ========================= """ + @overload def open( - url: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, + urls_fgs: FFmpegInputUrlNoPipe + | IO + | list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple], mode: Literal["rv", "ra"], + /, *, - show_log: bool | None = None, - progress: ProgressCallable | None = None, + map: str | None = None, + extra_outputs: list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, + squeeze: bool = False, blocksize: int | None = None, - timeout: float | None = None, + progress: ProgressCallable | None = None, + show_log: bool | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> StdFFmpegRunner: """open a single-stream reader - :param urls_fgs: URL of the file or format/device object to obtain a video stream from. - It can also be an input filtergraph object or string. The input - could also be fed by a buffered bytes-like data object or a readable file object. + :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 be assigned to feed a complex + filtergraph. :param mode: ``'rv'`` to read video data or ``'ra'`` to read audio - :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 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 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 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 blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) - :param 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 overwrite: ``True`` to overwrite extra_outputs if they exist, defaults to ``False`` :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. + to 2000 frames/s (see :doc:`options`). :return: reader stream object """ @overload def open( - url: FFmpegUrlType, + urls: FFmpegOutputUrlNoPipe + | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], mode: Literal["wv", "wa"], + /, input_rate: int | Fraction, *, input_shape: ShapeTuple | None = None, input_dtype: DTypeString | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - overwrite: bool = False, - show_log: bool | None = None, progress: ProgressCallable | None = None, - blocksize: int | None = None, - timeout: float | None = None, + show_log: bool | None = None, + overwrite: bool = False, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> StdFFmpegRunner: """open a single-stream media writer - :param urls_fgs: URL of the file or format/device object to write media stream to. The output - could also be written to a bytes object or a writable file object. - :param mode: ``'wv'`` to create a video file or ``'wa'`` to create an audio file - :param input_rate: Input frame rate (video) or sampling rate (audio) - :param input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) - :param extra_inputs: extra media source files/urls, defaults to None - :param overwrite: True to overwrite output URL, defaults to False. - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param urls_fgs: URL of the output file or format/device object. The output + could also be written to a writable file object. Multiple + files (and optionally their options) are specified, they + are generated simultaneously. + :param mode: ``'wv'`` to create a video file or ``'wa'`` to create an audio + file + :param input_rate: input frame rate (video) or sampling rate (audio) + :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 extra_inputs: extra media source files/urls, defaults to None. A tuple + of an url and input option dict may be assigned. :param progress: progress callback function, defaults to None - :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) - :param timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely + :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :param overwrite: True to overwrite output URL, defaults to False. :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None @@ -257,11 +346,16 @@ def open( def open( fg: str | FilterGraphObject, mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], + /, input_rate: int | Fraction, *, input_shape: ShapeTuple | None = None, input_dtype: DTypeString | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: ( + list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + ) = None, + squeeze: bool = False, overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -269,8 +363,8 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> StdFFmpegRunner: - """open a single-destination audio writer +) -> SISOFFmpegFilter: + """open a single-input single-output media filter :param urls_fgs: URL of the file or format/device object to write media stream to. The output could also be written to a bytes object or a writable file object. @@ -302,17 +396,19 @@ def open( @overload def open( - urls_fgs: FFmpegUrlType | FilterGraphObject | FFConcat | Buffer, - mode: Literal["ra"], + urls: FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict], + mode: MultiReaderModeLiteral, + /, *, + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> StdFFmpegRunner: - """open a single-stream reader +) -> PipedFFmpegRunner: + """open a multi-stream reader :param urls_fgs: URL of the file or format/device object to obtain a video stream from. It can also be an input filtergraph object or string. The input @@ -339,11 +435,12 @@ def open( @overload def open( urls_fgs: FFmpegUrlType, - mode: Literal["wv", "wa"], - input_rate: int | Fraction, + mode: MultiWriterModeLiteral, + /, + input_rates: list[int | Fraction], *, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, + input_shapes: list[ShapeTuple] | None = None, + input_dtypes: list[DTypeString] | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, overwrite: bool = False, show_log: bool | None = None, @@ -352,7 +449,7 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> StdFFmpegRunner: +) -> PipedFFmpegRunner: """open a single-stream media writer :param urls_fgs: URL of the file or format/device object to write media stream to. The output @@ -385,13 +482,15 @@ def open( @overload def open( - fg: str | FilterGraphObject, - mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], - input_rate: int | Fraction, + urls_fgs: str | FilterGraphObject | list[str | FilterGraphObject], + mode: MIMOFilterModeLiteral, + /, + input_rates: list[int | Fraction], *, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, + input_shapes: list[ShapeTuple] | None = None, + input_dtypes: list[DTypeString] | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -399,11 +498,16 @@ def open( timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], -) -> SISOFFmpegFilter: - """Open a single-input-single-output media filter +) -> PipedFFmpegRunner: + """Open a multiple-input-multiple-output media filter :param fg: Filtergraph expression or object. - :param mode: `'fv'` or `'v->v'` to filter video data, `'fa'` or `'a->a'` to filter audio data, + :param mode: `'f'` with a combination of input media types (e.g., ``'rvva'`` + if two video input streams and one audio input stream. The output + media types are automatically detected. Alternately, an arrow + convention specifying input and output media types, e.g., + `'vva->v'` to output a video stream, which stacks the two input + video streams and the spectrum of the audio input stream. :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 queue's item size in bytes, defaults to `None` (auto-set) @@ -425,17 +529,13 @@ def open( @overload def open( - urls_fgs: ( - FFmpegOutputUrlComposite - | Literal["-", "pipe"] - | Sequence[FFmpegOutputUrlComposite | Literal["-", "pipe"]] - ), - mode: LiteralString, # ["w(v|a)+", "(v|a)+->e+"], - input_rate: Sequence[int | Fraction], + urls_fgs: Literal["-"], + mode: DecoderModeLiteral, # r"e+-\>[av]+", + /, *, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + output_options: list[MapString, FFmpegOptionDict] | None = None, + extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + squeeze: bool = False, overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -445,9 +545,9 @@ def open( sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> PipedFFmpegRunner: - """open a piped single-destination writer (`mode = "wv" | "wa" | "v->e" | "a->e"`) + """open a media decoder (encoded streams in, raw streams out) - :param urls_fgs: A pipe path or `None` to indicate input is provided by `write_encoded()`. + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file :param f: FFmpeg format option for the output stream :param input_rate: Input frame rate (video) or sampling rate (audio) @@ -478,115 +578,37 @@ def open( @overload def open( - urls_fgs: Literal[None], - mode: Literal["e->e"] | LiteralString, # 'e+->e+' + 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, + overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, queuesize: int | None = None, timeout: float | None = None, - sp_kwargs: dict = None, - **options: Unpack[FFmpegOptionDict], -) -> PipedFFmpegRunner: - """open a single-input, single-output streamed transcoder - - :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` - and `read()` operations. - :param mode: transcoding mode is activated by setting `mode = 't'` - :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 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 queue's item size in bytes, defaults to `None` (64 kB) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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. - :return: transcoder stream object - """ - - -@overload -def open( - urls_fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], - mode: LiteralString, # ["f(v|a)+", "(v|a)+->(v|a)+"], - input_rate: int | Fraction, - *, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - queuesize: int | None = None, - timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], -) -> PipedFFmpegRunner | SISOFFmpegFilter: - """open media stream filter - - :param urls_fgs: a filtergraph expression - :param mode: `"fv"` or `"v->v"` to specify video filter, and `"fa"` or `"a->a"` to specify audio filter - :param input_rate: input frame rate (video) or sampling rate (audio) - :param input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, 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 queue's item size in bytes, defaults to `None` (auto) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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. - :return: filter stream object - """ - - -@overload -def open( - urls_fgs: str | FilterGraphObject, - mode: LiteralString, # ["f(v|a)+", "fa", "v->v", "a->a"], - input_rate: int | Fraction, - *, - input_shape: ShapeTuple = None, - input_dtype: DTypeString = None, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - queuesize: int | None = None, - timeout: float | None = None, sp_kwargs: dict | None = None, **options: Unpack[FFmpegOptionDict], ) -> PipedFFmpegRunner: - """open a single-input, single-output (SISO) filter + """open a media encoder (raw streams in, encoded streams out) - :param urls_fgs: a filtergraph expression - :param mode: `"fv"` or `"v->v"` to specify video filter, and `"fa"` or `"a->a"` to specify audio filter - :param input_rate: input frame rate (video) or sampling rate (audio) + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file + :param f: FFmpeg format option for the output stream + :param input_rate: Input frame rate (video) or sampling rate (audio) :param input_shape: input video frame size (height, width) or number of input audio channel, defaults to None (auto-detect) :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) + :param extra_inputs: _description_, defaults to None + :param overwrite: True to overwrite output URL, defaults to False. :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 queue's item size in bytes, defaults to `None` (auto) + :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) :param timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or @@ -599,154 +621,48 @@ def open( 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: filter stream object - """ - - -@overload -def open( - urls_fgs: Sequence[ - FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] - ], - mode: LiteralString, - *, - 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, - timeout: float | None = None, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], -) -> PipedFFmpegRunner: - """open a multi-stream reader + :return: writer stream object - :param urls_fgs: a list of input sources - :param mode: `'r'` + an optional sequence of `'v'`s and `'a'`s for each output streams. Alternately, - `eee->vva` format could be used with the left hand side repeating the `'e'`s to indicate - the number of inputs.) - :param map: a list of FFmpeg stream specifiers to specify the streams to retrieve, defaults to `None` - to retrieve all streams if `mode='r'` or as many streams as `mode` specifies in the order - of appearances. - :param ref_stream: index of the output stream, which is used as a reference stream to pace the read - operations, defaults to 0 - :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 queue's item size in bytes, defaults to `None` (64 kB) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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. - :return: _description_ """ @overload def open( - urls_fgs: ( - FFmpegOutputUrlComposite - | list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ] - ), - mode: LiteralString, - rates_or_opts_in: Sequence[int | Fraction | FFmpegOptionDict], + urls_fgs: Literal["-"], + mode: EncoderModeLiteral, + /, *, + input_options: list[FFmpegOptionDict], + output_options: list[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, - merge_audio_outpad: str | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - overwite: bool = False, - show_log: bool | None = None, - progress: ProgressCallable | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[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, - sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], -) -> PipedFFmpegRunner: - """open a multi-stream writer - - :param urls_fgs: a list of output encoded streams. Specific FFmpeg output options could be specified for - an output by providing a pair of the url and its option `dict`. - :param mode: `'w'` followed by a sequence of input stream types, e.g., `'vav'` if video, audio, and video - raw data streams will be written (in that order). Alternately, `vav->ee` format could be used. - (The right hand side has `'e'` repeated for as many outputs as written.) - :param rates_or_opts_in: _description_ - :param input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) - :param merge_audio_streams: _description_, defaults to False - :param merge_audio_ar: _description_, defaults to None - :param merge_audio_sample_fmt: _description_, defaults to None - :param merge_audio_outpad: _description_, defaults to None - :param extra_inputs: extra media source files/urls, defaults to None - :param overwrite: True to overwrite destination file. Ignored if any of the - output is streamed. - :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 queue's item size in bytes, defaults to `None` (64 kB) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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. - :return: _description_ - """ - - -@overload -def open( - urls_fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], - mode: LiteralString, - input_rates_or_opts: Sequence[int | Fraction | FFmpegOptionDict], - *, - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, - ref_output: int = 0, - output_options: dict[str, FFmpegOptionDict] | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - timeout: float | None = None, + overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: Unpack[FFmpegOptionDict], + **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: - """open a multi-stream filter + """open a media encoder (raw streams in, encoded streams out) - :param urls_fgs: _description_ - :param mode: _description_ - :param input_rates_or_opts: _description_ + :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation + :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file + :param f: FFmpeg format option for the output stream + :param input_rate: Input frame rate (video) or sampling rate (audio) :param input_shape: input video frame size (height, width) or number of input audio channel, defaults to None (auto-detect) :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) - :param extra_inputs: extra media source files/urls, defaults to None - :param ref_output: index of the output stream, which is used as a reference stream to pace the read - operations, defaults to 0 - :param output_options: _description_, defaults to None + :param extra_inputs: _description_, defaults to None + :param overwrite: True to overwrite output URL, defaults to False. :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 queue's item size in bytes, defaults to `None` (64 kB) + :param blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) :param timeout: Default read timeout in seconds, defaults to `None` to wait indefinitely :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or @@ -759,220 +675,98 @@ def open( 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: transcoder stream object - """ - + :return: writer stream object -@overload -def open( - urls_fgs: Literal[None], - mode: LiteralString, - 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, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | None = None, - queuesize: int | None = None, - timeout: float | None = None, - sp_kwargs: dict = None, - **options: Unpack[FFmpegOptionDict], -) -> PipedFFmpegRunner: - """open a streamed transcoder - - :param urls_fgs: set to `None` as the primary I/O is conducted via `write()` - and `read()` operations. - :param mode: transcoding mode is activated by setting `mode = 't'` or '`ee->e'` The `'->'` - operator optionally specifies the number of input and output files. - :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 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 queue's item size in bytes, defaults to `None` (64 kB) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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. - :return: transcoder stream object """ def open( - urls_fgs: ( - FFmpegInputUrlComposite - | FFmpegOutputUrlComposite - | Sequence[FFmpegInputUrlComposite | FFmpegOutputUrlComposite] - | None - ), - mode: LiteralString, + urls_fgs, + mode, + /, *args, **kwargs, ) -> PipedFFmpegRunner | SISOFFmpegFilter | StdFFmpegRunner: - """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: + # 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, + # overwrite: bool = False, + # show_log: bool | None = None, + # progress: ProgressCallable | None = None, + # blocksize: int | None = None, + # queuesize: int | None = None, + # timeout: float | None = None, + # sp_kwargs: dict | None = None, + # **options: Unpack[FFmpegOptionDict], + + 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." + ) - Open an MP4 file and process all the frames:: + if op_mode == "r": + runner = _open_reader(out_types, urls_fgs, args, kwargs) + elif op_mode == "w": + runner = _open_writer(in_types, urls_fgs, args, kwargs) + elif op_mode == "f": + runner = _open_filter(in_types, out_types, urls_fgs, args, kwargs) + elif op_mode == "d": + runner = _open_decoder(len(in_types), out_types, args, kwargs) + elif op_mode == "e": + runner = _open_encoder(in_types, len(out_types), args, kwargs) + else: + runner = _open_transcoder(len(in_types), len(out_types), args, kwargs) - with ffmpegio.open('video_source.mp4', 'rv') as f: - frame = f.read() - while frame: - # process the captured frame data - frame = f.read() + return runner - 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'`. +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 """ - - try: - op_mode, in_types, out_types = _parse_mode(mode) - if op_mode == "r": - runner = _create_reader(out_types, urls_fgs, args, kwargs) - elif op_mode == "w": - runner = _create_writer(in_types, urls_fgs, args, kwargs) - elif op_mode == "f": - runner = _create_filter(in_types, out_types, urls_fgs, args, kwargs) + 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 "ew": + # writer & (single-output) decoder -> output media types + inputs = inputs + outputs + outputs = "e" if op_mode == "e" else "" else: - runner = _create_transcoder(urls_fgs, args, kwargs) - - # TODO - check io types, display warning if mismatched - - except: - raise - - return runner - - -def _parse_mode(mode: str) -> tuple[str, str, str]: - it = re.finditer(r"([rwft])|(-\>)", mode) - try: - m = next(it) - except StopIteration as e: - raise ValueError( - f'{mode=} is missing the operation specifier ("r", "w", "f", "t", or "->")' - ) from e - try: - next(it) - raise ValueError( - f'{mode=} specifies multiple the operation specifiers ("r", "w", "f", "t", or "->")' - ) - except StopIteration: - pass - - inputs = mode[: m.start()] - outputs = mode[m.end() :] - - op_mode = m[1] - if op_mode: - if op_mode == "r": - inputs = "" + # others -> input media types outputs = inputs + outputs - else: - inputs = inputs + outputs - outputs = "" - in_encoded = out_encoded = None - else: + 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 = ( - ("t" if out_encoded else "r") - if in_encoded - else ("w" if out_encoded else "f") - ) - - if op_mode in "rt": # encoded in - if not in_encoded and any(c != "e" for c in inputs): - raise ValueError( - f"{mode=} specifies a raw input, which is not valid for the specified operation." - ) - else: # raw in - if not all(c in "av" for c in inputs): - raise ValueError( - f"{mode=} specifies an encoded input, which is not valid for the specified operation." - ) - - if op_mode in "wt": # encoded out - if not out_encoded and any(c != "e" for c in outputs): - raise ValueError( - f"{mode=} specifies a raw output, which is not valid for the specified operation." - ) - else: # raw out - if not all(c in "av" for c in outputs): - raise ValueError( - f"{mode=} specifies an encoded output, which is not valid for the specified operation." - ) + op_mode = { + (False, False): "f", + (False, True): "e", + (True, False): "d", + (True, True): "t", + }[(in_encoded, out_encoded)] return op_mode, inputs, outputs -def _create_reader( +def _open_reader( + in_types: str, out_types: str, urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], args: tuple, @@ -983,6 +777,9 @@ def _create_reader( f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a reader" ) + single_input = len(in_types) == 1 # single raw stream + single_output = len(out_types) == 1 # single encoded stream + single_url = utils.is_valid_input_url(urls) # else a sequence of urls if single_url: urls = [urls] @@ -1006,8 +803,9 @@ def _create_reader( return reader -def _create_writer( +def _open_writer( in_types: str, + out_types: str, urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], args: tuple, kwargs: dict, @@ -1017,7 +815,8 @@ def _create_writer( f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a writer" ) - single_output = utils.is_valid_output_url(urls) # else a sequence of urls + single_input = len(in_types) == 1 # + single_output = len(out_types) == 1 # else a sequence of urls if single_output: urls = [urls] elif len(urls) == 1 and utils.is_valid_output_url(urls[0]): @@ -1028,22 +827,19 @@ def _create_writer( is_siso = single_output and single_input is_audio = in_types == "a" - if not is_siso: - rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") - writer = PipedFFmpegRunner.create_media_writer(urls, in_types, *rates, **kwargs) - elif utils.is_pipe(urls[0]): - StreamClass = streams.StdAudioEncoder if is_audio else streams.StdVideoEncoder - writer = StreamClass(*args, **kwargs) - else: - StreamClass = streams.SimpleWriter if is_audio else streams.SimpleWriter + if single_input: + StreamClass = SISOFFmpegFilter.create_and_open writer = StreamClass(*urls, *args, **kwargs) + else: + rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") + writer = PipedFFmpegRunner.open_media_writer(urls, in_types, *rates, **kwargs) return writer -def _create_filter( +def _open_filter( in_types: str, out_types: str, - fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject], + fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, args: tuple, kwargs: dict, ) -> SISOFFmpegFilter: @@ -1057,19 +853,21 @@ def _create_filter( matched_io = in_types == out_types is_siso = single_output and single_input and matched_io - is_audio = in_types == "a" if is_siso: - StreamClass = streams.StdAudioFilter if is_audio else streams.StdVideoFilter + StreamClass = SISOFFmpegFilter filter = StreamClass(fgs, *args, **kwargs) else: + StreamClass = PipedFFmpegRunner rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") - filter = streams.MediaFilter(fgs, in_types, *rates, **kwargs) + filter = StreamClass(fgs, in_types, *rates, **kwargs) return filter -def _create_transcoder(urls: None, args: tuple, kwargs: dict) -> PipedFFmpegRunner: +def _open_decoder( + nb_in: int, out_types: str, urls: Literal["-"], args: tuple, kwargs: dict +) -> PipedFFmpegRunner: if urls is not None: raise TypeError("urls_fgs argument for a filter must be None.") @@ -1079,4 +877,90 @@ def _create_transcoder(urls: None, args: tuple, kwargs: dict) -> PipedFFmpegRunn f"ffmpegio.open() takes two or four arguments ({2 + len(args)} given) to open a filter." ) - return PipedFFmpegRunner.create_media_transcoder(*args, **kwargs) + return PipedFFmpegRunner.open_media_decoder(*args, **kwargs) + + +def _open_encoder( + in_types: str, nb_out: int, urls: Litera["-"], args: tuple, kwargs: dict +) -> PipedFFmpegRunner: + # input_rates: list[int|Fraction]|None = None, + # input_options: list[FFmpegOptionDict]|None=None, + # output_options: list[FFmpegOptionDict]|None=None, + # input_dtypes: list[DTypeString] | None = None, + # input_shapes: list[ShapeTuple] | None = None, + # extra_inputs: list[FFmpegInputOptionTuple] | None = None, + # extra_outputs: list[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, + # **options: FFmpegOptionDict, + nargs = len(args) + if nargs == 1: + input_rates = args[0] + else: + input_rates = kwargs.pop("input_rates", None) + if nargs != 0: + raise TypeError( + "ffmpegio.open() takes only three positional arguments for encoder mode." + ) + # check kwargs for unsupported keyword arguments + + input_options = kwargs.pop("input_options", None) or [] + + output_options = kwargs.pop("output_options", None) or [] + if len(output_options) == 0: + output_options = [{} for i in range(nb_out)] + elif len(output_options) != nb_out: + raise ValueError( + f"output_options argument must have {nb_out} elements to match the specified transcoder mode." + ) + + # input_stream_types: list[Literal["a", "v"]], + # input_stream_opts: list[FFmpegOptionDict], + return PipedFFmpegRunner.open_media_encoder(*args, **kwargs) + + +def _open_transcoder( + nb_in: int, nb_out: int, urls: Litera["-"], args: tuple, kwargs: dict +) -> PipedFFmpegRunner: + # 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, + # overwrite: bool = False, + # show_log: bool | None = None, + # progress: ProgressCallable | None = None, + # blocksize: int | None = None, + # queuesize: int | None = None, + # timeout: float | None = None, + # sp_kwargs: dict | None = None, + # **options: Unpack[FFmpegOptionDict], + + if len(args): + raise TypeError("ffmpegio.open() takes only two positional arguments.") + + input_options = kwargs.pop("input_options", None) or [] + if len(input_options) == 0: + input_options = [{} for i in range(nb_in)] + elif len(input_options) != nb_in: + raise ValueError( + f"input_options argument must have {nb_in} elements to match the specified transcoder mode." + ) + + output_options = kwargs.pop("output_options", None) or [] + if len(output_options) == 0: + output_options = [{} for i in range(nb_out)] + elif len(output_options) != nb_out: + raise ValueError( + f"output_options argument must have {nb_out} elements to match the specified transcoder mode." + ) + + return PipedFFmpegRunner.open_media_transcoder( + input_options, output_options, **kwargs + ) diff --git a/tests/test_open.py b/tests/test_open.py index 19c8d0c4..47c475f7 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -1,35 +1,67 @@ import pytest -import ffmpegio as ff -import ffmpegio.streams as ff_streams - -def test_fg(): - with ff.open("color=c=red:d=1:r=10", "rv", f_in="lavfi", pix_fmt="rgb24") as f: - I = f.read(-1) - assert I["shape"][0] == 10 - - -url = "tests/assets/testmulti-1m.mp4" +# import ffmpegio as ff +# import ffmpegio.streams as ff_streams +from ffmpegio.streams.open import _parse_mode @pytest.mark.parametrize( - "src,mode,Cls", + "mode,ret", [ - (url, "rv", ff_streams.StdFFmpegRunner), - (url, "ra", ff_streams.StdFFmpegRunner), - (url, "e->v", ff_streams.PipedFFmpegRunner), - (url, "e->a", ff_streams.PipedFFmpegRunner), + ("r", ("r", "", "")), + ("w", ("w", "", "")), + ("f", ("f", "", "")), + ("d", ("d", "e", "")), + ("e", ("e", "", "e")), + ("t", ("t", "e", "e")), + ("re", None), + ("rav", ("r", "", "av")), + ("avra", ("r", "", "ava")), + ("wva", ("w", "va", "")), + ("awv", ("w", "av", "")), + ("dav", ("d", "e", "av")), + ("eav", ("e", "av", "e")), + ("ea->ev", None), + ("ee->av", ("d", "ee", "av")), + ("av->ee", ("e", "av", "ee")), + ("av->va", ("f", "av", "va")), ], ) -def test_readers(src, mode, Cls): +def test_mode_parser(mode, ret): + if ret is None: + with pytest.raises(ValueError): + _parse_mode(mode) + else: + assert _parse_mode(mode) == ret + + +# def test_fg(): +# with ff.open("color=c=red:d=1:r=10", "rv", f_in="lavfi", pix_fmt="rgb24") as f: +# I = f.read(-1) +# assert I["shape"][0] == 10 + + +# url = "tests/assets/testmulti-1m.mp4" + + +# @pytest.mark.parametrize( +# "src,mode,Cls", +# [ +# (url, "rv", ff_streams.StdFFmpegRunner), +# (url, "ra", ff_streams.StdFFmpegRunner), +# (url, "e->v", ff_streams.PipedFFmpegRunner), +# (url, "e->a", ff_streams.PipedFFmpegRunner), +# ], +# ) +# def test_readers(src, mode, Cls): - assert isinstance(ff.open(url, mode), Cls) +# assert isinstance(ff.open(url, mode), Cls) -def test_writers(): ... +# def test_writers(): ... -def test_filters(): ... +# def test_filters(): ... -def test_transcoders(): ... +# def test_transcoders(): ... From 405487d81f76fb04888082379893577d40c81c9b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 8 Feb 2026 11:33:51 -0600 Subject: [PATCH 333/344] wip28 - working on open writer --- src/ffmpegio/audio.py | 10 +- src/ffmpegio/configure.py | 257 +++++++-------- src/ffmpegio/image.py | 11 +- src/ffmpegio/media.py | 79 +++-- src/ffmpegio/path.py | 30 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 138 ++++----- src/ffmpegio/streams/open.py | 378 +++++++++++++++++------ src/ffmpegio/utils/__init__.py | 60 +++- src/ffmpegio/video.py | 20 +- tests/test_streams_piped.py | 11 +- tests/test_streams_simple.py | 13 +- 11 files changed, 643 insertions(+), 364 deletions(-) diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index d41463fd..6b81db23 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -214,10 +214,6 @@ def write( :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - # single input, put it in a list - if utils.is_valid_output_url(url): - url = [url] - # if filter_complex is not defined use '0:a:0' as default mapping if ( not any( @@ -236,7 +232,7 @@ def write( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_write( - url, ["a"], [(rate_in, data)], extra_inputs, options + url, [{"ar": rate_in}], extra_inputs, options, [data] ) return run_and_return_encoded( @@ -300,13 +296,13 @@ def filter( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_filter( - ["a"], - [(input_rate, input)], + [{"ar": input_rate}], extra_inputs, None, extra_outputs, options, squeeze, + [input], ) if output_info is None: diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index dd5a0a39..6a454830 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -64,7 +64,6 @@ RawDataBlob, RawInputInfoDict, RawOutputInfoDict, - RawStreamDef, RawStreamInfoTuple, ShapeTuple, ToBytesCallable, @@ -200,24 +199,32 @@ class MediaReadKwsDict(TypedDict): class MediaWriteKwsDict(TypedDict): output_urls: Sequence[FFmpegOutputOptionTuple] - input_stream_types: Sequence[Literal["a", "v"]] - input_stream_args: Sequence[tuple[RawDataBlob | None, FFmpegOptionDict]] + 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] - extra_inputs: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None class MediaFilterKwsDict(TypedDict): - input_stream_types: Sequence[Literal["a", "v"]] - input_stream_args: Sequence[tuple[RawDataBlob | None, FFmpegOptionDict]] + 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 - extra_inputs: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None - extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None class MediaTranscoderKwsDict(TypedDict): @@ -239,14 +246,15 @@ class MediaTranscoderKwsDict(TypedDict): def init_media_read( - input_urls: Sequence[ - FFmpegInputUrlComposite - | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] + input_urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ + FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] ], output_streams: ( - Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None + Sequence[str | FFmpegOptionDict] | dict[str, str | FFmpegOptionDict] | None ), - options: FFmpegOptionDict, + options: FFmpegOptionDict | None, extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), @@ -288,6 +296,8 @@ def init_media_read( 'pix_fmt' option is not explicitly set, 'rgb24' is used. """ + options = {} if options is None else {**options} + ninputs = len(input_urls) if not ninputs: raise ValueError("At least one URL must be given.") @@ -296,7 +306,6 @@ def init_media_read( raise ValueError("Cannot have an `n` option set to output to named pipes.") # separate the options - options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -335,18 +344,18 @@ def init_media_read( def init_media_write( - output_urls: list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ], - input_stream_types: Sequence[Literal["a", "v"]], - input_stream_args: Sequence[RawStreamDef], + output_urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], + input_options: Sequence[FFmpegOptionDict], extra_inputs: ( Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] ] | None ), - options: dict[str, Any], + 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[ @@ -357,12 +366,15 @@ def init_media_write( """write multiple streams to a url/file :param output_url: output url - :param input_stream_types: list/string of 'a' or 'v', specifying the input raw streams' media types - :param input_stream_args: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :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, @@ -384,12 +396,9 @@ def init_media_write( """ - noutputs = len(output_urls) - if not noutputs: - raise FFmpegioError("At least one URL must be given.") + options = {} if options is None else {**options} # separate the options - options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -397,12 +406,7 @@ def init_media_write( # analyze and assign inputs input_info = process_raw_inputs( - args, - input_stream_types, - input_stream_args, - inopts_default, - input_dtypes, - input_shapes, + args, input_options, inopts_default, input_data, input_dtypes, input_shapes ) # append extra (not-piped) inputs @@ -426,8 +430,7 @@ def init_media_write( def init_media_filter( - input_stream_types: Sequence[Literal["a", "v"]], - input_stream_args: Sequence[RawStreamDef], + input_options: Sequence[FFmpegOptionDict], extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, output_streams: ( Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None @@ -435,15 +438,17 @@ def init_media_filter( extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), - options: FFmpegOptionDict, + options: FFmpegOptionDict | None, squeeze: bool, - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, + input_data: list[RawDataBlob | None] | None = None, + input_dtypes: list[DTypeString | None] | None = None, + input_shapes: list[ShapeTuple | None] | None = None, ) -> tuple[FFmpegArgs, list[RawInputInfoDict], list[RawOutputInfoDict]]: """Prepare FFmpeg arguments for media read - :param input_stream_types: list/string of 'a' or 'v', specifying the input raw streams' media types - :param input_stream_args: list of input option dict must include `'ar'` (audio) or `'r'` (video) to specify the rate. + :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: @@ -459,24 +464,27 @@ def init_media_filter( :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. - :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 :return ffmpeg_args: FFmpeg argument dict (partial) :return input_info: input stream information :return output_info: output stream information, None if outputs not initialized """ + 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 - options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -486,12 +494,7 @@ def init_media_filter( # analyze and assign inputs input_info = process_raw_inputs( - args, - input_stream_types, - input_stream_args, - inopts_default, - input_dtypes, - input_shapes, + args, input_options, inopts_default, input_data, input_dtypes, input_shapes ) if extra_inputs is not None: @@ -532,9 +535,15 @@ def init_media_filter( def init_media_transcode( - input_urls: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], - output_urls: list[FFmpegOutputUrlComposite | FFmpegInputOptionTuple], - options: FFmpegOptionDict, + 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 @@ -547,11 +556,12 @@ def init_media_transcode( :return output_info: list of output stream information """ + 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 - options = {**options} inopts_default = utils.pop_extra_options(options, "_in") # create a new FFmpeg dict @@ -1769,7 +1779,9 @@ def analyze_fg_outputs(args: FFmpegArgs) -> dict[str, MediaType | None]: def process_url_inputs( args: FFmpegArgs, - urls: Sequence[ + urls: FFmpegInputUrlComposite + | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] + | Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] ], inopts_default: FFmpegOptionDict, @@ -1781,12 +1793,17 @@ def process_url_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 @@ -1859,7 +1876,7 @@ def process_url_inputs( def process_raw_outputs( args: FFmpegArgs, input_info: list[RawInputInfoDict | EncodedInputInfoDict], - streams: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + streams: Sequence[str | FFmpegOptionDict] | None, options: FFmpegOptionDict, squeeze: bool, ) -> list[OutputInfoDict]: @@ -1870,9 +1887,8 @@ def process_raw_outputs( :param input_info: list of input information (same length as `args['inputs']) :param streams: output stream mappings: - `None` to include all input streams OR all filtergraph outputs - - a sequence of str to specify stream specifiers with file id's - - a sequence of output option dict with `'map'` item to output-specific - options + - a sequence of either a map option or an output ffmpeg option + dict with `'map'` item - 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 @@ -1973,27 +1989,24 @@ def get_callables(media_type): def process_raw_inputs( args: FFmpegArgs, - stream_types: Sequence[Literal["a", "v"]], - stream_args: Sequence[RawStreamDef], - inopts_default: FFmpegOptionDict, + 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: _description_ - :param stream_types: _description_ - :param stream_args: _description_ - :param inopts_default: _description_ - :param dtypes: _description_, defaults to None - :param shapes: _description_, defaults to None + :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[RawInputInfoDict] = [] - if dtypes is None: - dtypes = [None] * len(stream_types) - if shapes is None: - shapes = [None] * len(stream_types) @cache def get_callables(media_type: MediaType) -> RawInputCallablesDict: @@ -2012,64 +2025,46 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: } ) - for i, (mtype, arg, dtype, shape) in enumerate( - zip(stream_types, stream_args, dtypes, shapes) + nstreams = len(stream_options) + none_list = [None] * nstreams + input_info: list[RawInputInfoDict] = [] + + for opts, blob, dtype, shape in zip( + stream_options, + none_list if data is None else data, + none_list if dtypes is None else dtypes, + none_list if shapes is None else shapes, ): - ropt = {"v": "r", "a": "ar"}.get(mtype, None) # rate option - try: - a1, a2 = arg - if isinstance(a1, (int, float, Fraction)): - data = a2 - opts = {ropt: a1} - if ropt is None: - raise FFmpegioError( - "stream_type not specified, cannot resolve the `rate` input." - ) - else: - assert isinstance(a2, dict) - data, opts = a1, a2 - if ropt is None: # unknown - if "ar" in opts: - mtype = "a" - ropt = "ar" - elif "r" in opts: - mtype = "v" - ropt = "r" - else: - raise FFmpegioError("unknown input stream media type") - - except FFmpegioError: - raise - except Exception as e: - 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 - """ - ) from e - - opts = {**inopts_default, **opts} + # combine the default & per-stream options + opts = {**default_options, **opts} + mtype = "v" if "r" in opts else "a" more_opts = None shape_dtype = None if mtype == "a": # audio + if "r" in opts or "ar" not in opts: + raise ValueError( + "audio stream option dict must contain 'ar' option and must not contain 'r' option." + ) media_type = "audio" - opts[ropt] = rate = round(opts[ropt]) # force int sampling rate - if data is not None: - more_opts, shape_dtype = utils.array_to_audio_options(data) - data = plugins.get_hook().audio_bytes(obj=data) - - elif dtypes and shapes and shapes[i] is not None and dtypes[i] is not None: - shape_dtype = (shapes[i], dtypes[i]) - sample_fmt, ac = utils.guess_audio_format(shapes[i], dtypes[i]) + opts["ar"] = rate = round(opts["ar"]) # force int sampling rate + if blob is not None: + more_opts, shape_dtype = utils.array_to_audio_options(blob) + + elif dtypes and shapes and shape is not None and dtype is not None: + shape_dtype = (shape, dtype) + sample_fmt, ac = utils.guess_audio_format(shape, dtype) acodec, f = utils.get_audio_codec(sample_fmt) more_opts = {"sample_fmt": sample_fmt, "ac": ac, "c:a": acodec, "f": f} else: # video + if "ar" in opts: + raise ValueError( + "video stream option dict must not contain 'ar' option." + ) media_type = "video" - opts[ropt] = rate = opts[ropt] # force int sampling rate - if data is not None: - more_opts, shape_dtype = utils.array_to_video_options(data) - data = plugins.get_hook().video_bytes(obj=data) + 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) @@ -2093,13 +2088,14 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: info = { "src_type": "buffer", "media_type": media_type, - "raw_info": (*raw_info, opts[ropt]), + "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) @@ -2141,9 +2137,9 @@ def update_raw_input( def process_url_outputs( args: FFmpegArgs, input_info: list[RawInputInfoDict | EncodedInputInfoDict], - urls: list[ - FFmpegOutputUrlComposite | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ], + urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], options: FFmpegOptionDict, skip_automapping: bool = False, no_pipe: bool = False, @@ -2163,6 +2159,13 @@ def process_url_outputs( :return output_info: list of output information """ + urls = ( + [urls] if utils.is_valid_output_url(urls) or isinstance(urls, tuple) else urls + ) + + if len(urls) == 0: + raise FFmpegioError("At least one URL must be given.") + missing_map = False output_info_list = [None] * len(urls) for i, url in enumerate(urls): # add inputs @@ -2264,7 +2267,7 @@ def retrieve_input_stream_ids( """ # check raw formats first - if info["src_type"] == "buffer" and "buffer" not in info: + if "media_type" in info: # raw input format, single-stream return [[0, info["media_type"]]] diff --git a/src/ffmpegio/image.py b/src/ffmpegio/image.py index 5979cd10..41ad3157 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -3,7 +3,7 @@ from . import configure, utils from . import filtergraph as fgb -from ._typing import Any, ProgressCallable, RawDataBlob +from ._typing import Any, DTypeString, ProgressCallable, RawDataBlob, ShapeTuple from .configure import ( FFmpegInputOptionTuple, FFmpegInputUrlComposite, @@ -149,6 +149,8 @@ def write( 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, @@ -171,9 +173,6 @@ def write( :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - if utils.is_valid_output_url(url): - url = [url] - # if filter_complex is not defined use '0:V:0' as default mapping if ( not any( @@ -194,7 +193,7 @@ def write( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_write( - url, ["v"], [(1.0, data)], extra_inputs, options + url, [{"r": 1}], extra_inputs, options, [data], [dtype], [shape] ) return run_and_return_encoded( @@ -250,7 +249,7 @@ def filter( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_filter( - ["v"], [(1.0, input)], extra_inputs, None, extra_outputs, options, True + [{"r": 1}], extra_inputs, None, extra_outputs, options, True, [input] ) if output_info is None: diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 9fea4cf3..e954d79a 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -4,8 +4,9 @@ from collections.abc import Sequence from fractions import Fraction -from . import configure, ffmpegprocess +from . import configure, ffmpegprocess, utils from ._typing import ( + DTypeString, FFmpegOptionDict, InputInfoDict, InputPipeInfoDict, @@ -16,6 +17,7 @@ RawDataBlob, RawOutputInfoDict, RawStreamDef, + ShapeTuple, Unpack, ) from .configure import ( @@ -115,10 +117,7 @@ def _gather_outputs( def read( - *urls: *tuple[ - FFmpegInputUrlComposite - | tuple[FFmpegInputUrlComposite, FFmpegOptionDict | None] - ], + *urls: *tuple[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]], streams: ( Sequence[str] | Sequence[FFmpegOptionDict] @@ -166,7 +165,7 @@ def read( """ args, input_info, output_info = configure.init_media_read( - urls, streams, options, extra_outputs, squeeze + list(urls), streams, options, extra_outputs, squeeze ) # run FFmpeg @@ -188,6 +187,8 @@ def write( stream_types: Sequence[Literal["a", "v"]], *stream_args: *tuple[RawStreamDef, ...], extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + stream_dtypes: list[DTypeString | None] | None = None, + stream_shapes: list[ShapeTuple | None] | None = None, overwrite: bool | None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -197,15 +198,22 @@ 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 @@ -225,11 +233,16 @@ def write( """ - if not isinstance(urls, list): - urls = [urls] + input_options, input_data = utils.raw_input_options(stream_types, stream_args) args, input_info, output_info = configure.init_media_write( - urls, stream_types, stream_args, extra_inputs, options + urls, + input_options, + extra_inputs, + options, + input_data, + stream_dtypes, + stream_shapes, ) # run FFmpeg @@ -256,6 +269,8 @@ def filter( 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, @@ -264,12 +279,22 @@ 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 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_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) @@ -291,15 +316,19 @@ def filter( 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_types, - input_args, + input_options, extra_inputs, output_args, extra_outputs, options, squeeze, + input_data, + input_dtypes, + input_shapes, ) if output_info is None: diff --git a/src/ffmpegio/path.py b/src/ffmpegio/path.py index a5c29ca5..9a65221a 100644 --- a/src/ffmpegio/path.py +++ b/src/ffmpegio/path.py @@ -232,7 +232,7 @@ def versions(): def check_version(ver, cond=None): - """check FFmpeg version + """check FFmpeg version against the given version for the specified condition :param ver: desired version string :type ver: str @@ -240,7 +240,35 @@ def check_version(ver, cond=None): :type cond: "==", "!=", "<", "<=", ">", ">=", optional :return: True if condition is met :rtype: bool + + Note "nightly" builds are assumed to be the latest. """ + + ver_nightly = ver == "nightly" + + # ffmpeg version is a nightly (assumed the latest) + if FFMPEG_VER == "nightly": + return { + "==": ver_nightly, + "!=": not ver_nightly, + "<": False, + "<=": ver_nightly, + ">": not ver_nightly, + ">=": True, + }[cond or ">="] + + # ffmpeg version is a release compared to nightly + if ver_nightly: + return { + "==": False, + "!=": True, + "<": True, + "<=": True, + ">": False, + ">=": False, + }[cond or ">="] + + # both are releases return { "==": FFMPEG_VER.__eq__, "!=": FFMPEG_VER.__ne__, diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index e6639753..61f830e0 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -16,7 +16,6 @@ InputInfoDict, InputPipeInfoDict, Iterator, - Literal, MediaType, OutputInfoDict, OutputPipeInfoDict, @@ -82,7 +81,7 @@ class InitMediaKeywordsWithInputBuffer(dict): # pre-analysis/buffering variables _nraw = 0 - _raw_pipe_buffer: None | list[list[RawDataBlob] | None] # for 'input_stream_args' + _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] @@ -91,7 +90,7 @@ class InitMediaKeywordsWithInputBuffer(dict): 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_stream_args" in self + self._raw_input = "input_options" in self self._enc_pipe_buffer = {} self._raw_pipe_buffer = None self._enc_pipe_eos = {} @@ -100,9 +99,10 @@ def __init__(self, init_kws: dict): # analyze the keywords and replace items to be tweaked if self._raw_input: # raw: list[tuple[RawDataBlob, FFmpegOptionDict]] - self["input_stream_args"] = [*self["input_stream_args"]] + self._nraw = nin = len(self["input_options"]) + + self["input_data"] = [None for _ in range(nin)] - self._nraw = len(self["input_stream_args"]) self._raw_pipe_buffer = [None] * self._nraw self._raw_pipe_eos = [False] * self._nraw @@ -183,8 +183,7 @@ def put_data(self, stream: int, data: RawDataBlob | bytes, last: bool) -> bool: buffer = self._raw_pipe_buffer[stream] if buffer is None: # first write self._raw_pipe_buffer[stream] = [data] - kw = self["input_stream_args"] - kw[stream] = (data, kw[stream][1]) + self["input_data"][stream] = data else: buffer.append(data) return False @@ -196,10 +195,7 @@ def clear_keywords(self): """remove all the buffered data from the keywords""" if self._raw_pipe_buffer is not None: - kw = self["input_stream_args"] - for i, buf in enumerate(self._raw_pipe_buffer): - if buf is not None: - kw[i] = (None, kw[i][1]) + del self["input_data"] kw = self["extra_inputs"] for i, buf in self._enc_pipe_buffer.items(): @@ -681,7 +677,7 @@ def num_input_streams(self) -> int: If ``0``, ``write()`` will raise ``FFmpegioError``.""" try: - return len(self._init_kws["input_stream_types"]) + return len(self._init_kws["input_options"]) except KeyError: return 0 @@ -692,7 +688,7 @@ def write(self, data: RawDataBlob, stream: int = 0, *, last: bool = False): 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_stream_types`` + :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 @@ -724,10 +720,11 @@ def write(self, data: RawDataBlob, stream: int = 0, *, last: bool = False): def input_types(self) -> list[MediaType]: """media types (list of 'audio' or 'video') of raw input pipes""" - lut: dict[Literal["a", "v"], MediaType] = {"a": "audio", "v": "video"} - try: - return [lut[av] for av in self._init_kws["input_stream_types"]] + return [ + "video" if "r" in opts else "audio" + for opts in self._init_kws["input_options"] + ] except KeyError: return [] @@ -737,13 +734,11 @@ def input_rates(self) -> list[int | Fraction]: kws = self._init_kws try: - stypes = kws["input_stream_types"] - sargs = kws["input_stream_args"] + sopts = kws["input_options"] except KeyError: return [] # no input streams - lut: dict[Literal["a", "v"], Literal["ar", "r"]] = {"a": "ar", "v": "r"} - return [args[1][lut[av]] for av, args in zip(stypes, sargs)] + return [opts["r"] if "r" in opts else opts["ar"] for opts in sopts] @property def input_dtypes(self) -> list[DTypeString] | None: @@ -1366,24 +1361,26 @@ def __iter__(self) -> Iterator[RawDataBlob]: @staticmethod def open_simple_reader( - input_urls: list[FFmpegInputOptionTuple], + input_urls: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], output_options: FFmpegOptionDict, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, extra_outputs: ( Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None ) = None, - squeeze: bool = True, blocksize: int | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> StdFFmpegRunner: """create a single-pipe media reader :param input_urls: list of input urls :param output_options: dict of FFmpeg output options. One of it items must - be the ``'map'`` option to uniquely specify a stream. + be the ``'map'`` option to uniquely specify a stream. :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. @@ -1424,29 +1421,25 @@ def open_simple_reader( @staticmethod def open_simple_writer( - input_stream_type: Literal["a", "v"], - input_stream_options: FFmpegOptionDict, + input_options: FFmpegOptionDict, output_urls: ( FFmpegOutputUrlComposite - | list[ - FFmpegOutputUrlComposite - | tuple[FFmpegOutputUrlComposite, FFmpegOptionDict] - ] + | FFmpegOutputOptionTuple + | list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] ), + options: FFmpegOptionDict | None = None, + extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, input_dtype: DTypeString | None = None, input_shape: ShapeTuple | None = None, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> StdFFmpegRunner: """single-pipe media writer - :param input_stream_type: specify raw media input type - :param input_stream_options: ffmpeg input options for the raw media input - must contain a rate option (``r`` or ``ar``). + :param input_options: ffmpeg input options for the raw media input + must contain a rate option (``r`` or ``ar``). :param output_urls: pairs of output url and options :param options: optional ffmpeg option dict including input, output, and global options. For input options, append ``'_in'`` to the @@ -1469,8 +1462,7 @@ def open_simple_writer( """ init_kws: MediaWriteKwsDict = { - "input_stream_types": [input_stream_type], - "input_stream_args": [(None, input_stream_options)], + "input_options": [input_options], "output_urls": output_urls, "extra_inputs": extra_inputs, "options": options, @@ -1587,10 +1579,13 @@ def __iter__(self) -> Iterator[list[RawDataBlob]]: @staticmethod def open_media_reader( - input_urls: list[FFmpegInputOptionTuple], + input_urls: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], output_streams: ( - list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None ) = None, + options: FFmpegOptionDict | None = None, squeeze: bool = True, extra_outputs: ( list[FFmpegOutputOptionTuple] | dict[str, FFmpegOptionDict] | None @@ -1604,7 +1599,6 @@ def open_media_reader( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: output_streams = utils.expand_raw_output_streams( output_streams, input_urls, options @@ -1634,12 +1628,16 @@ def open_media_reader( @staticmethod def open_media_writer( - output_urls: list[FFmpegOutputOptionTuple], - input_stream_types: list[Literal["a", "v"]], - input_stream_args: list[tuple[RawDataBlob | None, FFmpegOptionDict]], + 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, - extra_inputs: list[FFmpegInputOptionTuple] | None = None, primary_output: int | None = None, blocksize: int | None = None, enc_blocksize: int | None = None, @@ -1649,12 +1647,10 @@ def open_media_writer( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: init_kws: MediaWriteKwsDict = { "output_urls": output_urls, - "input_stream_types": input_stream_types, - "input_stream_args": input_stream_args, + "input_options": input_options, "options": options, "input_dtypes": input_dtypes, "input_shapes": input_shapes, @@ -1678,14 +1674,14 @@ def open_media_writer( @staticmethod def open_media_filter( - input_stream_types: list[Literal["a", "v"]], - input_stream_opts: list[FFmpegOptionDict], + input_options: list[FFmpegOptionDict], output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict], - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, + 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, @@ -1695,11 +1691,9 @@ def open_media_filter( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: init_kws: MediaFilterKwsDict = { - "input_stream_types": input_stream_types, - "input_stream_args": [(None, opts) for opts in input_stream_opts], + "input_options": input_options, "output_streams": output_streams, "options": options, "extra_inputs": extra_inputs, @@ -1726,13 +1720,13 @@ def open_media_filter( @staticmethod def open_media_encoder( - input_stream_types: list[Literal["a", "v"]], - input_stream_opts: list[FFmpegOptionDict], + input_options: list[FFmpegOptionDict], output_options: list[FFmpegOptionDict], - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, + 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, @@ -1742,7 +1736,6 @@ def open_media_encoder( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: output_urls: list[FFmpegOutputOptionTuple] = [ ("-", opts) for opts in output_options @@ -1752,8 +1745,7 @@ def open_media_encoder( init_kws: MediaWriteKwsDict = { "output_urls": output_urls, - "input_stream_types": input_stream_types, - "input_stream_args": [(None, opts) for opts in input_stream_opts], + "input_options": input_options, "options": options, "input_dtypes": input_dtypes, "input_shapes": input_shapes, @@ -1779,6 +1771,7 @@ def open_media_encoder( def open_media_decoder( input_options: Sequence[FFmpegOptionDict], output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict], + options: FFmpegOptionDict | None = None, squeeze: bool = True, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, extra_outputs: Sequence[FFmpegOutputOptionTuple] | None = None, @@ -1791,7 +1784,6 @@ def open_media_decoder( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: input_urls: list[FFmpegInputOptionTuple] = [ ("-", opts) for opts in input_options @@ -1826,6 +1818,7 @@ def open_media_decoder( 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, @@ -1835,7 +1828,6 @@ def open_media_transcoder( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options: FFmpegOptionDict, ) -> PipedFFmpegRunner: input_urls = [("pipe", opts) for opts in input_options] output_urls = [("pipe", opts) for opts in output_options] @@ -1874,10 +1866,10 @@ class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): @staticmethod def create_and_open( - input_stream_type: Literal["a", "v"], - input_stream_opt: FFmpegOptionDict, + input_options: FFmpegOptionDict, output_stream: FFmpegOptionDict | None = None, *, + options: FFmpegOptionDict | None = None, extra_inputs: ( list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None ) = None, @@ -1896,11 +1888,9 @@ def create_and_open( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options, ) -> SISOFFmpegFilter: runner = SISOFFmpegFilter( - input_stream_type, - input_stream_opt, + input_options, output_stream, extra_inputs=extra_inputs, extra_outputs=extra_outputs, @@ -1916,24 +1906,24 @@ def create_and_open( show_log=show_log, overwrite=overwrite, sp_kwargs=sp_kwargs, - **options, + options=options, ) runner.open() return runner def __init__( self, - input_stream_type: Literal["a", "v"], - input_stream_opt: FFmpegOptionDict, + input_options: FFmpegOptionDict, output_stream: FFmpegOptionDict | None = None, *, + options: FFmpegOptionDict | None = None, + squeeze: bool = True, extra_inputs: ( list[FFmpegInputUrlComposite | FFmpegInputOptionTuple] | None ) = None, extra_outputs: ( list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None ) = None, - squeeze: bool = True, input_dtype: DTypeString | None = None, input_shape: ShapeTuple | None = None, primary_output: int | None = None, @@ -1945,12 +1935,10 @@ def __init__( show_log: bool | None = None, overwrite: bool | None = None, sp_kwargs: dict | None = None, - **options, ): init_func = configure.init_media_filter init_kws: MediaFilterKwsDict = { - "input_stream_types": [input_stream_type], - "input_stream_args": [(None, input_stream_opt)], + "input_options": [input_options], "output_streams": [{}] if output_stream is None else [output_stream], "options": options, "extra_inputs": extra_inputs, diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py index 62d0d893..08cfa43c 100644 --- a/src/ffmpegio/streams/open.py +++ b/src/ffmpegio/streams/open.py @@ -120,20 +120,29 @@ import re from fractions import Fraction -from typing_extensions import Literal, LiteralString, Sequence, Unpack, overload - from .. import utils from .._typing import ( + IO, DTypeString, FFmpegOptionDict, FFmpegUrlType, Literal, + LiteralString, ProgressCallable, + Sequence, ShapeTuple, + Unpack, + overload, ) from ..configure import ( + FFmpegInputOptionTuple, FFmpegInputUrlComposite, + FFmpegInputUrlNoPipe, + FFmpegNoPipeInputOptionTuple, + FFmpegNoPipeOutputOptionTuple, + FFmpegOutputOptionTuple, FFmpegOutputUrlComposite, + FFmpegOutputUrlNoPipe, ) from ..filtergraph.abc import FilterGraphObject from .BaseFFmpegRunner import PipedFFmpegRunner, SISOFFmpegFilter, StdFFmpegRunner @@ -344,7 +353,7 @@ def open( @overload def open( - fg: str | FilterGraphObject, + fg: str | FilterGraphObject | Literal["-"], mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], /, input_rate: int | Fraction, @@ -400,7 +409,12 @@ def open( mode: MultiReaderModeLiteral, /, *, + output_options: Sequence[MapString | FFmpegOptionDict] + | dict[str, MapString | FFmpegOptionDict] + | None = None, extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, + squeeze: bool = False, + primary_output: int | None = None, show_log: bool | None = None, progress: ProgressCallable | None = None, blocksize: int | None = None, @@ -414,6 +428,17 @@ def open( It can also be an input filtergraph object or string. The input could also be fed by a buffered bytes-like data object or a readable file object. :param mode: ``'rv'`` to read video data or ``'ra'`` to read audio + :param output_options: output stream options: + - `None` to include all input streams OR all filtergraph outputs + - a sequence of str to specify stream specifiers with file id's + - 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. + - None to select all available streams :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 queue's item size in bytes, defaults to `None` (auto-set) @@ -489,8 +514,13 @@ def open( *, input_shapes: list[ShapeTuple] | None = None, input_dtypes: list[DTypeString] | None = None, + output_options: Sequence[MapString | FFmpegOptionDict] + | dict[str, MapString | FFmpegOptionDict] + | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + squeeze: bool = False, + primary_output: int | None = None, overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -533,9 +563,12 @@ def open( mode: DecoderModeLiteral, # r"e+-\>[av]+", /, *, - output_options: list[MapString, FFmpegOptionDict] | None = None, + output_options: Sequence[MapString | FFmpegOptionDict] + | dict[str, MapString | FFmpegOptionDict] + | None = None, extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, squeeze: bool = False, + primary_output: int | None = None, overwrite: bool = False, show_log: bool | None = None, progress: ProgressCallable | None = None, @@ -579,28 +612,32 @@ def open( @overload def open( urls_fgs: Literal["-"], - mode: TranscoderModeLiteral, # r"e+-\>e+", + mode: EncoderModeLiteral, /, + input_rates: list[int | Fraction], *, + output_options: list[FFmpegOptionDict], 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, - overwrite: bool = False, - show_log: bool | None = None, - progress: ProgressCallable | None = None, + input_dtypes: list[DTypeString] | None = None, + input_shapes: list[ShapeTuple] | None = None, + extra_inputs: list[FFmpegInputOptionTuple] | None = None, + extra_outputs: list[FFmpegOutputOptionTuple] | 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, - **options: Unpack[FFmpegOptionDict], + **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: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file :param f: FFmpeg format option for the output stream - :param input_rate: Input frame rate (video) or sampling rate (audio) + :param input_rates: Input frame rate (video) or sampling rate (audio) :param input_shape: input video frame size (height, width) or number of input audio channel, defaults to None (auto-detect) :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) @@ -629,35 +666,27 @@ def open( @overload def open( urls_fgs: Literal["-"], - mode: EncoderModeLiteral, + mode: TranscoderModeLiteral, # r"e+-\>e+", /, *, - input_options: list[FFmpegOptionDict], - output_options: list[FFmpegOptionDict], - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, - extra_inputs: list[FFmpegInputOptionTuple] | None = None, - extra_outputs: list[FFmpegOutputOptionTuple] | None = None, - primary_output: int | None = None, + 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, + overwrite: bool = False, + show_log: bool | None = None, + progress: ProgressCallable | 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, - **options: FFmpegOptionDict, + **options: Unpack[FFmpegOptionDict], ) -> PipedFFmpegRunner: - """open a media encoder (raw streams in, encoded streams out) + """open a media transcoder (encoded streams in, encoded streams out) :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file :param f: FFmpeg format option for the output stream - :param input_rate: Input frame rate (video) or sampling rate (audio) - :param input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) :param extra_inputs: _description_, defaults to None :param overwrite: True to overwrite output URL, defaults to False. :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) @@ -687,18 +716,11 @@ def open( *args, **kwargs, ) -> PipedFFmpegRunner | SISOFFmpegFilter | StdFFmpegRunner: - # 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, - # overwrite: bool = False, - # show_log: bool | None = None, - # progress: ProgressCallable | None = None, - # blocksize: int | None = None, - # queuesize: int | None = None, - # timeout: float | None = None, - # sp_kwargs: dict | None = None, - # **options: Unpack[FFmpegOptionDict], + + # possible keywords, excluding FFmpeg options + # 'input_shape', 'input_dtype', 'input_rate', 'input_rates', + # 'input_options', 'input_dtypes', 'input_shapes', 'extra_inputs', + # 'output_options', 'extra_outputs', 'squeeze' op_mode, in_types, out_types = _parse_mode(mode) if urls_fgs == "-" and op_mode in "rw": @@ -706,18 +728,39 @@ def open( 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[k] + for k in ( + "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, args, kwargs) + runner = _open_reader(out_types, urls_fgs, kwargs, runner_kws) elif op_mode == "w": - runner = _open_writer(in_types, urls_fgs, args, kwargs) + 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 = _open_filter(in_types, out_types, urls_fgs, args, kwargs, runner_kws) elif op_mode == "d": - runner = _open_decoder(len(in_types), out_types, args, kwargs) + runner = _open_decoder(len(in_types), out_types, kwargs, runner_kws) elif op_mode == "e": - runner = _open_encoder(in_types, len(out_types), args, kwargs) + runner = _open_encoder(in_types, len(out_types), args, kwargs, runner_kws) else: - runner = _open_transcoder(len(in_types), len(out_types), args, kwargs) + runner = _open_transcoder(len(in_types), len(out_types), kwargs, runner_kws) return runner @@ -765,75 +808,148 @@ def _parse_mode(mode: str) -> tuple[Literal["r", "w", "f", "d", "e", "t"], str, 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_options", + "extra_outputs", + "squeeze", + ] + ) + + def _open_reader( - in_types: str, out_types: str, urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], - args: tuple, kwargs: dict, + runner_kws: dict, ) -> StdFFmpegRunner | PipedFFmpegRunner: - if len(args): - raise TypeError( - f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a reader" - ) + # # single reader + # urls_fgs, + # mode: Literal["rv", "ra"], + # /, + # *, + # map: str | None = None, + # extra_outputs: list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, + # squeeze: bool = False, + # **options: Unpack[FFmpegOptionDict], - single_input = len(in_types) == 1 # single raw stream - single_output = len(out_types) == 1 # single encoded stream + # # multi reader + # urls: FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict], + # mode: MultiReaderModeLiteral, + # /, + # *, + # output_options: Sequence[FFmpegOptionDict] + # extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, + # **options: Unpack[FFmpegOptionDict], - single_url = utils.is_valid_input_url(urls) # else a sequence of urls - if single_url: - urls = [urls] - elif len(urls) == 1 and utils.is_valid_input_url(urls[0]): - single_url = True + nout = len(out_types) + single_output = nout == 1 # single encoded stream - map_option = utils.as_multi_option(kwargs.get("map", None)) - if map_option is None: - map_option = out_types + urls = [urls] if utils.is_valid_input_url(urls) or isinstance(urls, tuple) else urls - is_audio = out_types == "a" - is_siso = single_url and len(map_option) == 1 + output_options = [{}] if single_output else kwargs.pop("output_options", None) + extra_outputs = kwargs.pop("extra_outputs", None) + squeeze = kwargs.pop("squeeze", None) - if is_siso and utils.is_pipe(urls[0]): - StreamClass = PipedFFmpegRunner - reader = StreamClass(**kwargs) + used_kws = set("extra_outputs", "squeeze") + if single_output: + used_kws.add("output_options") + open_kws = _open_kws_set() - used_kws + if len(open_kws): + raise TypeError("Invalid keyword inputs found") + + if len(out_types) == 0: # autodetect (unless map is specified) + single_output = single_output and "map" in kwargs else: - StreamClass = PipedFFmpegRunner if not is_siso else StdFFmpegRunner - reader = StreamClass(*urls, **kwargs) + if output_options is None: + output_options = [{} for _ in range(nout)] + elif nout != len(output_options): + raise ValueError( + "number of outputs in mode does not match the number of output options specified." + ) - return reader + # use default map options + if ( + "map" not in kwargs + and len(urls) == 1 + and not utils.find_filter_complex_option(kwargs) + ): + stream_counts = {"a": 0, "v": 0} + for mtype, opts in zip(out_types, output_options): + st = stream_counts[mtype] + stream_counts[mtype] += 1 + if "map" not in opts: + opts["map"] = f"0:{mtype}:{st}" + + return ( + StdFFmpegRunner.open_simple_reader( + urls, + output_options[0], + kwargs, + squeeze, + extra_outputs, + **runner_kws, + ) + if single_output + else PipedFFmpegRunner.open_media_reader( + urls, output_options, kwargs, squeeze, extra_outputs, **runner_kws + ) + ) def _open_writer( in_types: str, - out_types: str, urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], args: tuple, kwargs: dict, + runner_kws: dict, ) -> PipedFFmpegRunner | StdFFmpegRunner: - if len(args) > 1: + # # single writer + # urls: FFmpegOutputUrlNoPipe + # | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], + # mode: Literal["wv", "wa"], + # /, + # input_rate: int | Fraction, + # *, + # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + # options: Unpack[FFmpegOptionDict], + + # # multi-writer + # urls_fgs: FFmpegUrlType, + # mode: MultiWriterModeLiteral, + # /, + # input_rates: list[int | Fraction], + # *, + # output_options: Sequence[MapString | FFmpegOptionDict] + # | dict[str, MapString | FFmpegOptionDict] + # | None = None, + # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + # options: Unpack[FFmpegOptionDict], + + nargs = len(args) + if nargs > 1: raise TypeError( - f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a writer" + f"ffmpegio.open() takes two to three positional arguments ({2 + len(args)} given) to open a writer" ) single_input = len(in_types) == 1 # - single_output = len(out_types) == 1 # else a sequence of urls - if single_output: - urls = [urls] - elif len(urls) == 1 and utils.is_valid_output_url(urls[0]): - single_output = True - single_input = len(in_types) > 1 + used_kws = ["output_options", "extra_inputs"] + output_options = kwargs.pop("output_options", None) + # extra_inputs - is_siso = single_output and single_input - is_audio = in_types == "a" - - if single_input: - StreamClass = SISOFFmpegFilter.create_and_open - writer = StreamClass(*urls, *args, **kwargs) - else: - rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") - writer = PipedFFmpegRunner.open_media_writer(urls, in_types, *rates, **kwargs) - return writer + # if single_input: + # input_rate + # input_rates def _open_filter( @@ -842,7 +958,47 @@ def _open_filter( fgs: str | FilterGraphObject | Sequence[str | FilterGraphObject] | None, args: tuple, kwargs: dict, + runner_kws: dict, ) -> SISOFFmpegFilter: + # siso filter + # fg: str | FilterGraphObject | Literal['-'], + # mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], + # /, + # input_rate: int | Fraction, + # *, + # input_shape: ShapeTuple | None = None, + # input_dtype: DTypeString | None = None, + # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + # extra_outputs: ( + # list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None + # ) = None, + # squeeze: bool = False, + # overwrite: bool = False, + # show_log: bool | None = None, + # progress: ProgressCallable | None = None, + # blocksize: int | None = None, + # timeout: float | None = None, + # sp_kwargs: dict | None = None, + # **options: Unpack[FFmpegOptionDict], + + # mimo filter + # urls_fgs: str | FilterGraphObject | list[str | FilterGraphObject], + # mode: MIMOFilterModeLiteral, + # /, + # input_rates: list[int | Fraction], + # *, + # input_shapes: list[ShapeTuple] | None = None, + # input_dtypes: list[DTypeString] | None = None, + # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + # extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + # overwrite: bool = False, + # show_log: bool | None = None, + # progress: ProgressCallable | None = None, + # blocksize: int | None = None, + # timeout: float | None = None, + # sp_kwargs: dict | None = None, + # **options: Unpack[FFmpegOptionDict], + if len(args) > 1: raise TypeError( f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a writer" @@ -866,8 +1022,30 @@ def _open_filter( def _open_decoder( - nb_in: int, out_types: str, urls: Literal["-"], args: tuple, kwargs: dict + nb_in: int, + out_types: str, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, ) -> PipedFFmpegRunner: + # decoder + # urls_fgs: Literal["-"], + # mode: DecoderModeLiteral, # r"e+-\>[av]+", + # /, + # *, + # output_options: list[MapString, FFmpegOptionDict] | None = None, + # extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, + # squeeze: bool = False, + # overwrite: bool = False, + # show_log: bool | None = None, + # progress: ProgressCallable | None = None, + # blocksize: int | None = None, + # queuesize: int | None = None, + # timeout: float | None = None, + # sp_kwargs: dict | None = None, + # **options: Unpack[FFmpegOptionDict], + if urls is not None: raise TypeError("urls_fgs argument for a filter must be None.") @@ -881,7 +1059,12 @@ def _open_decoder( def _open_encoder( - in_types: str, nb_out: int, urls: Litera["-"], args: tuple, kwargs: dict + in_types: str, + nb_out: int, + urls: Litera["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, ) -> PipedFFmpegRunner: # input_rates: list[int|Fraction]|None = None, # input_options: list[FFmpegOptionDict]|None=None, @@ -927,7 +1110,12 @@ def _open_encoder( def _open_transcoder( - nb_in: int, nb_out: int, urls: Litera["-"], args: tuple, kwargs: dict + nb_in: int, + nb_out: int, + urls: Literal["-"], + args: tuple, + kwargs: dict, + runner_kws: dict, ) -> PipedFFmpegRunner: # input_options: list[FFmpegOptionDict] | None = None, # output_options: list[FFmpegOptionDict] | None = None, diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index a5566527..3cddee4b 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -25,12 +25,12 @@ MediaType, OutputInfoDict, RawDataBlob, + RawStreamDef, ShapeTuple, ) from .._utils import ( as_multi_option, escape, - unescape, get_samplesize, is_fileobj, is_namedpipe, @@ -38,6 +38,7 @@ is_pipe, is_url, prod, + unescape, ) from ..errors import FFmpegioError from ..filtergraph.abc import FilterGraphObject @@ -1131,7 +1132,9 @@ def find_filter_complex_option( def format_raw_output_stream_defs( - streams: Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, + streams: Sequence[str | FFmpegOptionDict] + | dict[str, str | FFmpegOptionDict] + | None, options: FFmpegOptionDict | None, ) -> tuple[list[FFmpegOptionDict], dict[int, str]]: """convert user-supplied streams arguments to the standard form @@ -1155,9 +1158,9 @@ def format_raw_output_stream_defs( # depending on user's streams input, label output streams differently # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists streams_: list[FFmpegOptionDict] - stream_names: dict[int, str] = ( - {} - ) # dict of user-specified stream name (only via dict streams input) + stream_names: dict[ + int, str + ] = {} # dict of user-specified stream name (only via dict streams input) if isinstance(streams, dict): # dict[str,FFmpegOptionDict] # dict key is used as both stream names (labels) and map option. @@ -1167,6 +1170,8 @@ def format_raw_output_stream_defs( # be renamed with an appended index. streams_ = [] for i, (k, v) in enumerate(streams.items()): + if isinstance(v, str): + v = {"map": v} if "map" in v: # user provided non-map stream name stream_names[i] = k streams_.append({**options, "map": k, **v}) @@ -1318,3 +1323,48 @@ def expand_raw_output_streams( 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/video.py b/src/ffmpegio/video.py index ad300a5b..802637a1 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -4,7 +4,14 @@ from . import analyze, configure, utils from . import filtergraph as fgb -from ._typing import Any, FFmpegOptionDict, ProgressCallable, RawDataBlob +from ._typing import ( + Any, + DTypeString, + FFmpegOptionDict, + ProgressCallable, + RawDataBlob, + ShapeTuple, +) from .configure import ( FFmpegInputOptionTuple, FFmpegInputUrlComposite, @@ -166,6 +173,8 @@ def write( 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, @@ -196,9 +205,6 @@ def write( :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ - if utils.is_valid_output_url(url): - url = [url] - # if filter_complex is not defined use '0:V:0' as default mapping if ( not any( @@ -217,7 +223,7 @@ def write( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_write( - url, ["v"], [(rate_in, data)], extra_inputs, options + url, [{"r": rate_in}], extra_inputs, options, [data] ) return run_and_return_encoded( @@ -278,13 +284,13 @@ def filter( # initialize FFmpeg argument dict and get input & output information args, input_info, output_info = configure.init_media_filter( - ["v"], - [(input_rate, input)], + [{"r": input_rate}], extra_inputs, None, extra_outputs, options, squeeze, + [input], ) if output_info is None: diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py index 9976cff1..bfabe70d 100644 --- a/tests/test_streams_piped.py +++ b/tests/test_streams_piped.py @@ -17,7 +17,7 @@ @pytest.mark.xdist_group(name="group_named_pipe") def test_MediaReader(): with streams.PipedFFmpegRunner.open_media_reader( - [(mult_url, {})], None, t_in=1, squeeze=False + [(mult_url, {})], None, options={"t_in": 1}, squeeze=False ) as reader: nframes = [0] * reader.num_output_streams for i, data in enumerate(reader): @@ -31,10 +31,8 @@ def test_MediaWriter_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.PipedFFmpegRunner.open_media_encoder( - stream_types, [{"ar": rates["0:a:0"]}], [{"f": "matroska"}], show_log=True, @@ -64,7 +62,6 @@ def test_MediaWriter(): ] with streams.PipedFFmpegRunner.open_media_encoder( - stream_types, stream_opts, [{"f": "matroska", "map": range(len(stream_types))}], show_log=True, @@ -111,10 +108,9 @@ def test_SimpleMediaFilter(): X = x[: nin * nblocks, ...].reshape(nblocks, nin, -1) with ff.streams.SISOFFmpegFilter.create_and_open( - "a", {"ar": fs}, {"map": "[out]"}, - filter_complex="[0:a:0]showcqt=s=vga[out]", + options={"filter_complex": "[0:a:0]showcqt=s=vga[out]"}, show_log=True, squeeze=False, ) as f: @@ -156,10 +152,9 @@ def test_MediaFilter(): print(f"video: {len(F['buffer'])} bytes | audio: {len(x['buffer'])} bytes") with ff.streams.PipedFFmpegRunner.open_media_filter( - "vvaa", [{"r": fps}, {"r": fps}, {"ar": fs}, {"ar": fs}], output_streams={"[out0]": {}, "audio": {"map": "[out1]"}}, - filter_complex=["[0:V:0][1:V:0]vstack", "[2:a:0][3:a:0]amerge"], + options={"filter_complex": ["[0:V:0][1:V:0]vstack", "[2:a:0][3:a:0]amerge"]}, show_log=True, # loglevel="debug", # queuesize=4, diff --git a/tests/test_streams_simple.py b/tests/test_streams_simple.py index d7b211a8..020d9600 100644 --- a/tests/test_streams_simple.py +++ b/tests/test_streams_simple.py @@ -45,7 +45,7 @@ def test_read_write_video(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with StdFFmpegRunner.open_simple_writer("v", {"r": fs}, [(out_url, {})]) as f: + with StdFFmpegRunner.open_simple_writer({"r": fs}, [(out_url, {})]) as f: f.write(F0) f.write(F1) f.wait() @@ -78,8 +78,7 @@ def test_read_audio(): {"map": "0:a:0"}, show_log=True, blocksize=1024**2, - ss_in=t0, - to_in=t1, + options={"ss_in": t0, "to_in": t1}, ) as f: blks, shapes = zip(*[(blk["buffer"], blk["shape"][0]) for blk in f]) shape = sum(shapes) @@ -110,7 +109,7 @@ def test_read_write_audio(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) with StdFFmpegRunner.open_simple_writer( - "a", {"ar": fs}, [(out_url, {})], show_log=True + {"ar": fs}, [(out_url, {})], show_log=True ) as f: f.write({**out, "buffer": F[: 100 * bps]}) f.write({**out, "buffer": F[100 * bps :]}) @@ -132,12 +131,11 @@ def test_write_extra_inputs(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) with StdFFmpegRunner.open_simple_writer( - "v", {"r": fs}, [(out_url, {})], extra_inputs=[(url_aud, {})], show_log=True, - **{"map": ["0:v", "1:a"], "loglevel": "debug"}, + options={"map": ["0:v", "1:a"], "loglevel": "debug"}, ) as f: f.write(F) f.wait() @@ -147,13 +145,12 @@ def test_write_extra_inputs(): assert len(info) == 2 with StdFFmpegRunner.open_simple_writer( - "v", {"r": fs}, [(out_url, {})], extra_inputs=[("anoisesrc", {"f": "lavfi"})], show_log=True, overwrite=True, - **{"map": ["0:v", "1:a"], "shortest": None}, + options={"map": ["0:v", "1:a"], "shortest": None}, ) as f: f.write(F) f.wait() From 5d06dc8647a05300bfc1fe4b8095d46d1823a00e Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Sun, 8 Feb 2026 22:52:09 -0600 Subject: [PATCH 334/344] wip29 - testing open() --- pyproject.toml | 3 + src/ffmpegio/configure.py | 191 +--- src/ffmpegio/media.py | 6 +- src/ffmpegio/streams/BaseFFmpegRunner.py | 141 +-- src/ffmpegio/streams/open.py | 1255 ++++++++++++++-------- src/ffmpegio/utils/__init__.py | 99 +- tests/test_media.py | 6 +- tests/test_open.py | 111 +- tests/test_streams_piped.py | 4 +- tests/test_streams_simple.py | 8 +- 10 files changed, 1051 insertions(+), 773 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9a2a0894..eeda756d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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/configure.py b/src/ffmpegio/configure.py index 6a454830..95f031d2 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -40,7 +40,7 @@ from namedpipe import NPopen from . import filtergraph as fgb -from . import plugins, probe, utils +from . import plugins, utils from ._typing import ( Any, Buffer, @@ -70,7 +70,6 @@ TypedDict, Unpack, cast, - get_args, ) from ._utils import as_multi_option, is_non_str_sequence from .errors import ( @@ -80,8 +79,7 @@ FFmpegioNoPipeAllowed, ) from .filtergraph.abc import FilterGraphObject -from .stream_spec import StreamSpecDict, parse_map_option, stream_type_to_media_type -from .stream_spec import stream_spec as compose_stream_spec +from .stream_spec import parse_map_option, stream_type_to_media_type from .threading import CopyFileObjThread, ReaderThread, WriterThread from .utils import ( FFmpegInputUrlComposite, @@ -251,9 +249,7 @@ def init_media_read( | Sequence[ FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict] ], - output_streams: ( - Sequence[str | FFmpegOptionDict] | dict[str, str | FFmpegOptionDict] | None - ), + output_streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, options: FFmpegOptionDict | None, extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None @@ -263,17 +259,14 @@ def init_media_read( """Initialize FFmpeg arguments for media read :param urls: URLs of the media files to read. - :param output_streams: 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 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. - - None to select all available streams + :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) @@ -432,9 +425,7 @@ def init_media_write( def init_media_filter( input_options: Sequence[FFmpegOptionDict], extra_inputs: Sequence[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None, - output_streams: ( - Sequence[str | FFmpegOptionDict] | dict[str, FFmpegOptionDict | None] | None - ), + output_streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, extra_outputs: ( Sequence[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ), @@ -452,15 +443,13 @@ def init_media_filter( :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 - - 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. - - None to select all available streams + + - ``None`` to map all filtergraph outputs + - (str) output map option string + - (dict) output ffmpeg options with the required ``'map'`` option + - (Sequence) a sequence of output map option string or ffmpeg option + dict with a ``'map'`` key. + :param extra_outputs: list of additional output destinations, defaults to None. Each source may be url string or a pair of a url string and an option dict. @@ -1469,14 +1458,12 @@ def get_raw_output_plugin_callables( def resolve_raw_output_streams( stream_opts: list[FFmpegOptionDict], - stream_names: dict[int, str], args: FFmpegArgs, input_info: list[RawInputInfoDict | EncodedInputInfoDict], ) -> tuple[list[FFmpegOptionDict], list[dict]]: """resolve the raw output streams from given sequence of map options :param stream_opts: output raw stream options - :param 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 @@ -1502,7 +1489,6 @@ def resolve_raw_output_streams( output_info = [] for i, opts in enumerate(stream_opts): spec = opts["map"] - user_map = stream_names.get(i, spec) try: opt = parse_map_option(spec, parse_stream=True, input_file_id=input_file_id) @@ -1525,7 +1511,7 @@ def resolve_raw_output_streams( output_opts.append(opts) output_info.append( { - "user_map": user_map, + "user_map": spec[1:-1], "linklabel": opt["linklabel"], } ) @@ -1542,7 +1528,7 @@ def resolve_raw_output_streams( output_opts.append(opts) output_info.append( { - "user_map": user_map, + "user_map": spec, "media_type": stream_type_to_media_type( stream_spec["stream_type"] ), @@ -1552,23 +1538,23 @@ def resolve_raw_output_streams( ) else: # case 3: generic stream spec, possibly resultsing in multiple output streams - stream_data = retrieve_input_stream_ids( - input_info[file_index], *inputs[file_index], stream_spec=stream_spec - ) - - # append all streams - for stream_index, media_type in stream_data: - output_opts.append({**opts, "map": f"{file_index}:{stream_index}"}) + 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": user_map, - "media_type": media_type, + "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 names + # 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()): @@ -1629,7 +1615,7 @@ def auto_map( ) -> tuple[list[FFmpegOptionDict], list[dict[str, Any]]]: """list all available streams from all FFmpeg input sources - This function complements `format_raw_output_stream_defs()` + 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 @@ -1653,25 +1639,17 @@ def auto_map( stream_info = [] if fg_info is None: - 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 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 {}): - spec = next_map_option(i, media_type) + 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": media_type, + "media_type": "audio" if st_spec[0] == "a" else "video", "input_file_id": i, "input_stream_id": j, } @@ -1876,7 +1854,7 @@ def process_url_inputs( def process_raw_outputs( args: FFmpegArgs, input_info: list[RawInputInfoDict | EncodedInputInfoDict], - streams: Sequence[str | FFmpegOptionDict] | None, + streams: str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None, options: FFmpegOptionDict, squeeze: bool, ) -> list[OutputInfoDict]: @@ -1886,21 +1864,20 @@ def process_raw_outputs( appended for each piped output. Output URLs are left `None`. :param input_info: list of input information (same length as `args['inputs']) :param streams: output stream mappings: - - `None` to include all input streams OR all filtergraph outputs - - a sequence of either a map option or an output ffmpeg option - dict with `'map'` item - - 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. - - None to select all available streams + + - `None` to include all input streams OR all filtergraph outputs + - a sequence of either a map option or an output ffmpeg option + dict with `'map'` item + :param options: default output options :param squeeze: True to remove shape dimensions with length 1 :return output_info: list of output information """ + if isinstance(streams, (str, dict)): + streams = [streams] + gopts = args["global_options"] # on-demand complex filtergraph analysis @@ -1934,13 +1911,17 @@ def get_fg_info() -> dict[str, FilterGraphInfoDict] | None: # gather all available streams keyed by their map specifier stream_opts, stream_info = auto_map(args, options, input_info, get_fg_info()) else: - stream_opts, stream_names = utils.format_raw_output_stream_defs( - streams, options - ) + 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, stream_names, args, input_info + stream_opts, args, input_info ) # finalize the output configuration @@ -2244,70 +2225,6 @@ def assign_output_url(args: FFmpegArgs, ofile: int, url: str): args["outputs"][ofile] = (url, args["outputs"][ofile][1]) -def retrieve_input_stream_ids( - info: RawInputInfoDict | EncodedInputInfoDict, - 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 - - Note: The stream ids are unique ids among all streams in a container. - - :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 stream indices and media types of the input streams. If - the stream_spec is uniquely specified and media type is known, the - index is not resolved. Maybe empty if failed to probe the media - (e.g., data inaccessible or in an ffprobe incompatible format, e.g., - ffconcat) - """ - - # check raw formats first - if "media_type" in info: - # raw input format, single-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 - ), - ) - - # 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 - - ######################################## diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index e954d79a..2e3340b6 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -264,7 +264,7 @@ def filter( extra_inputs: ( list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple] | None ) = None, - output_args: Sequence[str] | dict[str, FFmpegOptionDict | None] | None, + output_streams: Sequence[str | FFmpegOptionDict] | None, extra_outputs: ( list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple] | None ) = None, @@ -289,7 +289,7 @@ def filter( :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 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). @@ -322,7 +322,7 @@ def filter( args, input_info, output_info = configure.init_media_filter( input_options, extra_inputs, - output_args, + output_streams, extra_outputs, options, squeeze, diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index 61f830e0..e8ac3eea 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -115,9 +115,18 @@ def __init__(self, init_kws: dict): 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[tuple[FFmpegInputUrlComposite, FFmpegOptionDict]] - self["input_urls"] = [*self["input_urls"]] + # 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): @@ -331,14 +340,14 @@ def __init__( 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 - 1 MB (1024**2 bytes). - :param queuesize: the depth of named pipe queues, defaults to None (4). - 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 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) @@ -1361,10 +1370,8 @@ def __iter__(self) -> Iterator[RawDataBlob]: @staticmethod def open_simple_reader( - input_urls: FFmpegInputUrlComposite - | FFmpegInputOptionTuple - | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], - output_options: FFmpegOptionDict, + input_urls: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + output_stream: str | FFmpegOptionDict, options: FFmpegOptionDict | None = None, squeeze: bool = True, extra_outputs: ( @@ -1378,31 +1385,37 @@ def open_simple_reader( ) -> StdFFmpegRunner: """create a single-pipe media reader - :param input_urls: list of input urls - :param output_options: dict of FFmpeg output options. One of it items must - be the ``'map'`` option to uniquely specify a stream. - :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 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 options: optional ffmpeg option dict including input, output, and - global options. For input options, append '_in' to the - end of ffmpeg option names. - :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 None (no show/capture) - :param overwrite: ``True`` to overwrite extra_outputs if they exist, defaults to ``False`` - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None + 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_options], + "output_streams": [output_stream], "options": options, "extra_outputs": extra_outputs, "squeeze": squeeze, @@ -1421,12 +1434,12 @@ def open_simple_reader( @staticmethod def open_simple_writer( - input_options: FFmpegOptionDict, 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, @@ -1440,25 +1453,33 @@ def open_simple_writer( :param input_options: ffmpeg input options for the raw media input must contain a rate option (``r`` or ``ar``). - :param output_urls: pairs of output url and options + :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 input_dtype: input media data type as a numpy dtype string, - defaults to ``None`` to autodetect - :param input_shape: input media shape (height x width x components) for - video or (channels,) for audio, defaults to ``None`` - to autodetect :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. + 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 ``None`` (no show/capture) - :param overwrite: True to overwrite output_urls if they exist, defaults to ``False`` - :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or - `subprocess.Popen()` call used to run the FFmpeg, defaults - to None + 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 = { @@ -1579,14 +1600,12 @@ def __iter__(self) -> Iterator[list[RawDataBlob]]: @staticmethod def open_media_reader( - input_urls: FFmpegInputUrlComposite - | FFmpegInputOptionTuple - | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], + input_urls: Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], output_streams: ( - Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None + str | FFmpegOptionDict | Sequence[str | FFmpegOptionDict] | None ) = None, options: FFmpegOptionDict | None = None, - squeeze: bool = True, + squeeze: bool = False, extra_outputs: ( list[FFmpegOutputOptionTuple] | dict[str, FFmpegOptionDict] | None ) = None, @@ -1638,8 +1657,6 @@ def open_media_writer( extra_inputs: list[FFmpegInputOptionTuple] | 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, @@ -1657,10 +1674,8 @@ def open_media_writer( "extra_inputs": extra_inputs, } runner = PipedFFmpegRunner( - configure.init_media_read, + configure.init_media_write, init_kws, - primary_output=primary_output, - blocksize=blocksize, enc_blocksize=enc_blocksize, queuesize=queuesize, timeout=timeout, @@ -1675,7 +1690,7 @@ def open_media_writer( @staticmethod def open_media_filter( input_options: list[FFmpegOptionDict], - output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict], + output_streams: str | FFmpegOptionDict | Sequence[FFmpegOptionDict], options: FFmpegOptionDict | None = None, squeeze: bool = True, extra_inputs: list[FFmpegInputOptionTuple] | None = None, @@ -1770,7 +1785,7 @@ def open_media_encoder( @staticmethod def open_media_decoder( input_options: Sequence[FFmpegOptionDict], - output_streams: Sequence[FFmpegOptionDict] | dict[str, FFmpegOptionDict], + output_streams: str | FFmpegOptionDict | Sequence[FFmpegOptionDict], options: FFmpegOptionDict | None = None, squeeze: bool = True, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, @@ -1867,7 +1882,7 @@ class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): @staticmethod def create_and_open( input_options: FFmpegOptionDict, - output_stream: FFmpegOptionDict | None = None, + output_stream: str | FFmpegOptionDict | None = None, *, options: FFmpegOptionDict | None = None, extra_inputs: ( @@ -1914,7 +1929,7 @@ def create_and_open( def __init__( self, input_options: FFmpegOptionDict, - output_stream: FFmpegOptionDict | None = None, + output_stream: str | FFmpegOptionDict | None = None, *, options: FFmpegOptionDict | None = None, squeeze: bool = True, @@ -1939,7 +1954,7 @@ def __init__( init_func = configure.init_media_filter init_kws: MediaFilterKwsDict = { "input_options": [input_options], - "output_streams": [{}] if output_stream is None else [output_stream], + "output_streams": output_stream, "options": options, "extra_inputs": extra_inputs, "extra_outputs": extra_outputs, diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py index 08cfa43c..d4bc80eb 100644 --- a/src/ffmpegio/streams/open.py +++ b/src/ffmpegio/streams/open.py @@ -122,10 +122,8 @@ from .. import utils from .._typing import ( - IO, DTypeString, FFmpegOptionDict, - FFmpegUrlType, Literal, LiteralString, ProgressCallable, @@ -137,8 +135,6 @@ from ..configure import ( FFmpegInputOptionTuple, FFmpegInputUrlComposite, - FFmpegInputUrlNoPipe, - FFmpegNoPipeInputOptionTuple, FFmpegNoPipeOutputOptionTuple, FFmpegOutputOptionTuple, FFmpegOutputUrlComposite, @@ -254,98 +250,117 @@ @overload def open( - urls_fgs: FFmpegInputUrlNoPipe - | IO - | list[FFmpegInputUrlNoPipe | FFmpegNoPipeInputOptionTuple], + urls_fgs: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], mode: Literal["rv", "ra"], /, *, map: str | None = None, - extra_outputs: list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, squeeze: bool = False, + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] + | None = None, blocksize: int | None = None, progress: ProgressCallable | None = None, - show_log: bool | 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 be assigned to feed a complex - filtergraph. + 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 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 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 blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) - :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 extra_outputs if they exist, defaults to ``False`` - :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`). - :return: reader stream object + 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: FFmpegOutputUrlNoPipe + 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, - extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, progress: ProgressCallable | None = None, - show_log: bool | 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: URL of the output file or format/device object. The output - could also be written to a writable file object. Multiple - files (and optionally their options) are specified, they - are generated simultaneously. - :param mode: ``'wv'`` to create a video file or ``'wa'`` to create an audio - file - :param input_rate: input frame rate (video) or sampling rate (audio) - :param input_shape: input video frame size (height, width) or number of input - audio channel, defaults to auto-detect + :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 extra_inputs: extra media source files/urls, defaults to None. A tuple - of an url and input option dict may be assigned. - :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 output URL, defaults to False. - :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. + 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 """ @@ -353,51 +368,73 @@ def open( @overload def open( - fg: str | FilterGraphObject | Literal["-"], + urls_fgs: str | FilterGraphObject | Literal["-"], mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], /, input_rate: int | Fraction, *, - input_shape: ShapeTuple | None = None, - input_dtype: DTypeString | None = None, + squeeze: bool = False, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, extra_outputs: ( list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None ) = None, - squeeze: bool = False, - overwrite: bool = False, - show_log: bool | None = None, - progress: ProgressCallable | 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: URL of the file or format/device object to write media stream to. The output - could also be written to a bytes object or a writable file object. - :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file + :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 input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) - :param extra_inputs: extra media source files/urls, defaults to None - :param overwrite: True to overwrite output URL, defaults to False. - :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 queue's item size in bytes, defaults to `None` (auto-set) - :param 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. + :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 """ @@ -405,101 +442,165 @@ def open( @overload def open( - urls: FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict], + urls_fgs: FFmpegInputUrlComposite + | FFmpegInputOptionTuple + | Sequence[FFmpegInputUrlComposite | FFmpegInputOptionTuple], mode: MultiReaderModeLiteral, /, *, - output_options: Sequence[MapString | FFmpegOptionDict] - | dict[str, MapString | FFmpegOptionDict] - | None = None, - extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, + output_streams: Sequence[MapString | FFmpegOptionDict] | None = None, squeeze: bool = False, + extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, primary_output: int | None = None, - show_log: bool | None = None, - progress: ProgressCallable | 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: URL of the file or format/device object to obtain a video stream from. - It can also be an input filtergraph object or string. The input - could also be fed by a buffered bytes-like data object or a readable file object. - :param mode: ``'rv'`` to read video data or ``'ra'`` to read audio - :param output_options: output stream options: - - `None` to include all input streams OR all filtergraph outputs - - a sequence of str to specify stream specifiers with file id's - - 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. - - None to select all available streams - :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 queue's item size in bytes, defaults to `None` (auto-set) - :param 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. + :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: FFmpegUrlType, + urls_fgs: FFmpegOutputUrlNoPipe + | FFmpegNoPipeOutputOptionTuple + | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], mode: MultiWriterModeLiteral, /, input_rates: list[int | Fraction], *, - input_shapes: list[ShapeTuple] | None = None, - input_dtypes: list[DTypeString] | None = None, + input_options: Sequence[FFmpegOptionDict] | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - overwrite: bool = False, - show_log: bool | None = None, - progress: ProgressCallable | None = None, - blocksize: int | 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 single-stream media writer - - :param urls_fgs: URL of the file or format/device object to write media stream to. The output - could also be written to a bytes object or a writable file object. - :param mode: ``'wv'`` to create a video file or ``'wa'`` to create an audio file - :param input_rate: Input frame rate (video) or sampling rate (audio) - :param input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) - :param extra_inputs: extra media source files/urls, defaults to None - :param overwrite: True to overwrite output URL, defaults to False. - :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 queue's item size in bytes, defaults to `None` (auto-set) - :param 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. + """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 """ @@ -507,52 +608,110 @@ def open( @overload def open( - urls_fgs: str | FilterGraphObject | list[str | FilterGraphObject], + urls_fgs: str | FilterGraphObject | list[str | FilterGraphObject] | Literal["-"], mode: MIMOFilterModeLiteral, /, input_rates: list[int | Fraction], *, - input_shapes: list[ShapeTuple] | None = None, - input_dtypes: list[DTypeString] | None = None, - output_options: Sequence[MapString | FFmpegOptionDict] + 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, - squeeze: bool = False, + input_shapes: list[ShapeTuple] | None = None, + input_dtypes: list[DTypeString] | None = None, primary_output: int | None = None, - overwrite: bool = False, - show_log: bool | None = None, - progress: ProgressCallable | 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 fg: Filtergraph expression or object. - :param mode: `'f'` with a combination of input media types (e.g., ``'rvva'`` - if two video input streams and one audio input stream. The output - media types are automatically detected. Alternately, an arrow - convention specifying input and output media types, e.g., - `'vva->v'` to output a video stream, which stacks the two input - video streams and the spectrum of the audio input stream. - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :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 blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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 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. + 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 """ @@ -563,47 +722,80 @@ def open( mode: DecoderModeLiteral, # r"e+-\>[av]+", /, *, - output_options: Sequence[MapString | FFmpegOptionDict] + output_streams: Sequence[MapString | FFmpegOptionDict] | dict[str, MapString | FFmpegOptionDict] | None = None, - extra_outputs: Sequence[str | tuple[str, 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, - overwrite: bool = False, - show_log: bool | None = None, - progress: ProgressCallable | 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: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file - :param f: FFmpeg format option for the output stream - :param input_rate: Input frame rate (video) or sampling rate (audio) - :param input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) - :param extra_inputs: _description_, defaults to None - :param overwrite: True to overwrite output URL, defaults to False. - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :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 blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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 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. + 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 """ @@ -616,41 +808,58 @@ def open( /, input_rates: list[int | Fraction], *, - output_options: list[FFmpegOptionDict], input_options: list[FFmpegOptionDict] | None = None, - input_dtypes: list[DTypeString] | None = None, - input_shapes: list[ShapeTuple] | None = None, + output_options: list[FFmpegOptionDict], extra_inputs: list[FFmpegInputOptionTuple] | None = None, extra_outputs: list[FFmpegOutputOptionTuple] | None = None, - blocksize: int | 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 | None = None, - overwrite: bool | 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: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file - :param f: FFmpeg format option for the output stream - :param input_rates: Input frame rate (video) or sampling rate (audio) - :param input_shape: input video frame size (height, width) or number of input audio channel, defaults - to None (auto-detect) - :param input_dtype: input data format in a Numpy dtype string, defaults to None (auto-detect) - :param extra_inputs: _description_, defaults to None - :param overwrite: True to overwrite output URL, defaults to False. - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :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 blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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 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 @@ -673,37 +882,51 @@ def open( output_options: list[FFmpegOptionDict] | None = None, extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - overwrite: bool = False, - show_log: bool | None = None, - progress: ProgressCallable | 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 transcoder (encoded streams in, encoded streams out) :param urls_fgs: ``'-'`` to indicate pipe-in pipe-out operation - :param mode: `'wv'` or `'v->ev'` to create a video file, `'wa'` or `'a->e'` to create an audio file - :param f: FFmpeg format option for the output stream - :param extra_inputs: _description_, defaults to None - :param overwrite: True to overwrite output URL, defaults to False. - :param show_log: True to show FFmpeg log messages on the console, defaults to None (no show/capture) + :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 blocksize: Background reader queue's item size in bytes, defaults to `None` (auto-set) - :param queuesize: Background reader & writer threads queue size, defaults to `None` (unlimited) - :param 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 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. + 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 """ @@ -720,7 +943,7 @@ def open( # possible keywords, excluding FFmpeg options # 'input_shape', 'input_dtype', 'input_rate', 'input_rates', # 'input_options', 'input_dtypes', 'input_shapes', 'extra_inputs', - # 'output_options', 'extra_outputs', 'squeeze' + # 'output_streams', 'extra_outputs', 'squeeze' op_mode, in_types, out_types = _parse_mode(mode) if urls_fgs == "-" and op_mode in "rw": @@ -729,8 +952,12 @@ def open( ) runner_kws = { - k: kwargs[k] + k: kwargs.pop(k) for k in ( + "input_shape", + "input_dtype", + "input_shapes", + "input_dtypes", "primary_output", "blocksize", "enc_blocksize", @@ -819,81 +1046,184 @@ def _open_kws_set() -> list[str]: "input_dtypes", "input_shapes", "extra_inputs", - "output_options", + "output_streams", "extra_outputs", "squeeze", ] ) -def _open_reader( - out_types: str, - urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], - kwargs: dict, - runner_kws: dict, -) -> StdFFmpegRunner | PipedFFmpegRunner: - # # single reader - # urls_fgs, - # mode: Literal["rv", "ra"], - # /, - # *, - # map: str | None = None, - # extra_outputs: list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, - # squeeze: bool = False, - # **options: Unpack[FFmpegOptionDict], - - # # multi reader - # urls: FFmpegInputUrlComposite | tuple[FFmpegInputUrlComposite, FFmpegOptionDict], - # mode: MultiReaderModeLiteral, - # /, - # *, - # output_options: Sequence[FFmpegOptionDict] - # extra_outputs: Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None, - # **options: Unpack[FFmpegOptionDict], +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 output streams (mode='w').") + 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 - urls = [urls] if utils.is_valid_input_url(urls) or isinstance(urls, tuple) else urls - - output_options = [{}] if single_output else kwargs.pop("output_options", None) + output_streams = None extra_outputs = kwargs.pop("extra_outputs", None) squeeze = kwargs.pop("squeeze", None) - used_kws = set("extra_outputs", "squeeze") + used_kws = set(["extra_outputs", "squeeze"]) if single_output: - used_kws.add("output_options") - open_kws = _open_kws_set() - used_kws - if len(open_kws): - raise TypeError("Invalid keyword inputs found") + 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 (unless map is specified) - single_output = single_output and "map" in kwargs + if len(out_types) == 0: # autodetect + single_output = False # -> multi-output else: - if output_options is None: - output_options = [{} for _ in range(nout)] - elif nout != len(output_options): + # 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 + ] - # use default map options if ( "map" not in kwargs - and len(urls) == 1 + 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_options): + for mtype, opts in zip(out_types, output_streams): st = stream_counts[mtype] stream_counts[mtype] += 1 - if "map" not in opts: + + 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_options[0], + output_streams[0], kwargs, squeeze, extra_outputs, @@ -901,55 +1231,51 @@ def _open_reader( ) if single_output else PipedFFmpegRunner.open_media_reader( - urls, output_options, kwargs, squeeze, extra_outputs, **runner_kws + urls, output_streams, kwargs, squeeze, extra_outputs, **runner_kws ) ) def _open_writer( in_types: str, - urls: FFmpegInputUrlComposite | Sequence[FFmpegInputUrlComposite], + urls: FFmpegOutputUrlComposite + | FFmpegOutputOptionTuple + | Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple], args: tuple, kwargs: dict, runner_kws: dict, ) -> PipedFFmpegRunner | StdFFmpegRunner: - # # single writer - # urls: FFmpegOutputUrlNoPipe - # | list[FFmpegOutputUrlNoPipe | FFmpegNoPipeOutputOptionTuple], - # mode: Literal["wv", "wa"], - # /, - # input_rate: int | Fraction, - # *, - # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - # options: Unpack[FFmpegOptionDict], - - # # multi-writer - # urls_fgs: FFmpegUrlType, - # mode: MultiWriterModeLiteral, - # /, - # input_rates: list[int | Fraction], - # *, - # output_options: Sequence[MapString | FFmpegOptionDict] - # | dict[str, MapString | FFmpegOptionDict] - # | None = None, - # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - # options: Unpack[FFmpegOptionDict], - 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" - ) + used_kws, single_input, input_options, extra_inputs = _process_raw_input_args( + in_types, args, kwargs + ) - single_input = len(in_types) == 1 # + open_kws = _open_kws_set() - used_kws + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") - used_kws = ["output_options", "extra_inputs"] - output_options = kwargs.pop("output_options", None) - # extra_inputs + # 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)] - # if single_input: - # input_rate - # input_rates + 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( @@ -960,65 +1286,49 @@ def _open_filter( kwargs: dict, runner_kws: dict, ) -> SISOFFmpegFilter: - # siso filter - # fg: str | FilterGraphObject | Literal['-'], - # mode: Literal["fv", "fa", "v->v", "a->a", "v->a", "a->v"], - # /, - # input_rate: int | Fraction, - # *, - # input_shape: ShapeTuple | None = None, - # input_dtype: DTypeString | None = None, - # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - # extra_outputs: ( - # list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None - # ) = None, - # squeeze: bool = False, - # overwrite: bool = False, - # show_log: bool | None = None, - # progress: ProgressCallable | None = None, - # blocksize: int | None = None, - # timeout: float | None = None, - # sp_kwargs: dict | None = None, - # **options: Unpack[FFmpegOptionDict], - - # mimo filter - # urls_fgs: str | FilterGraphObject | list[str | FilterGraphObject], - # mode: MIMOFilterModeLiteral, - # /, - # input_rates: list[int | Fraction], - # *, - # input_shapes: list[ShapeTuple] | None = None, - # input_dtypes: list[DTypeString] | None = None, - # extra_inputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - # extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - # overwrite: bool = False, - # show_log: bool | None = None, - # progress: ProgressCallable | None = None, - # blocksize: int | None = None, - # timeout: float | None = None, - # sp_kwargs: dict | None = None, - # **options: Unpack[FFmpegOptionDict], - - if len(args) > 1: - raise TypeError( - f"ffmpegio.open() takes two arguments ({2 + len(args)} given) to open a writer" - ) - single_input = len(in_types) > 1 - single_output = len(out_types) > 1 - matched_io = in_types == out_types + 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 - is_siso = single_output and single_input and matched_io + for k in open_kws: + if k in kwargs: + raise TypeError(f"Invalid keyword inputs found: {k}") - if is_siso: - StreamClass = SISOFFmpegFilter - filter = StreamClass(fgs, *args, **kwargs) - else: - StreamClass = PipedFFmpegRunner - rates = args[0] if len(args) else kwargs.pop("input_rates_or_opts") - filter = StreamClass(fgs, in_types, *rates, **kwargs) + if "overwrite" in runner_kws and runner_kws["overwrite"] is not None: + raise TypeError("'overwrite' keyword is not supported in the filter mode.") - return filter + single = single_input and single_output + + kwargs["filter_complex"] = fgs + + return ( + SISOFFmpegFilter.create_and_open( + input_options[0], + 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( @@ -1029,84 +1339,88 @@ def _open_decoder( kwargs: dict, runner_kws: dict, ) -> PipedFFmpegRunner: - # decoder - # urls_fgs: Literal["-"], - # mode: DecoderModeLiteral, # r"e+-\>[av]+", - # /, - # *, - # output_options: list[MapString, FFmpegOptionDict] | None = None, - # extra_outputs: Sequence[str | tuple[str, FFmpegOptionDict]] | None = None, - # squeeze: bool = False, - # overwrite: bool = False, - # show_log: bool | None = None, - # progress: ProgressCallable | None = None, - # blocksize: int | None = None, - # queuesize: int | None = None, - # timeout: float | None = None, - # sp_kwargs: dict | None = None, - # **options: Unpack[FFmpegOptionDict], - - if urls is not None: - raise TypeError("urls_fgs argument for a filter must be None.") - nargs = len(args) - if nargs not in (0, 2) or (nargs == 3 and "output_options" in kwargs): + if urls != "-": + raise TypeError("urls_fgs argument for a decoder must be '-'.") + + if len(args): raise TypeError( - f"ffmpegio.open() takes two or four arguments ({2 + len(args)} given) to open a filter." + "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) - return PipedFFmpegRunner.open_media_decoder(*args, **kwargs) + 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: Litera["-"], + urls: Literal["-"], args: tuple, kwargs: dict, runner_kws: dict, ) -> PipedFFmpegRunner: - # input_rates: list[int|Fraction]|None = None, - # input_options: list[FFmpegOptionDict]|None=None, - # output_options: list[FFmpegOptionDict]|None=None, - # input_dtypes: list[DTypeString] | None = None, - # input_shapes: list[ShapeTuple] | None = None, - # extra_inputs: list[FFmpegInputOptionTuple] | None = None, - # extra_outputs: list[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, - # **options: FFmpegOptionDict, - nargs = len(args) - if nargs == 1: - input_rates = args[0] - else: - input_rates = kwargs.pop("input_rates", None) - if nargs != 0: - raise TypeError( - "ffmpegio.open() takes only three positional arguments for encoder mode." - ) - # check kwargs for unsupported keyword arguments - input_options = kwargs.pop("input_options", None) or [] + if urls != "-": + raise TypeError("urls_fgs argument for an encoder must be '-'.") - output_options = kwargs.pop("output_options", None) or [] - if len(output_options) == 0: + 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 len(output_options) != nb_out: + elif nb_out > 0 and len(output_options) != nb_out: raise ValueError( - f"output_options argument must have {nb_out} elements to match the specified transcoder mode." + "the length of 'input_options' must match the number of encoded inputs" ) + extra_outputs = kwargs.pop("extra_outputs", None) - # input_stream_types: list[Literal["a", "v"]], - # input_stream_opts: list[FFmpegOptionDict], - return PipedFFmpegRunner.open_media_encoder(*args, **kwargs) + 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( @@ -1117,38 +1431,43 @@ def _open_transcoder( kwargs: dict, runner_kws: dict, ) -> PipedFFmpegRunner: - # 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, - # overwrite: bool = False, - # show_log: bool | None = None, - # progress: ProgressCallable | None = None, - # blocksize: int | None = None, - # queuesize: int | None = None, - # timeout: float | None = None, - # sp_kwargs: dict | None = None, - # **options: Unpack[FFmpegOptionDict], + + 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.") + 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 len(input_options) != 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_options = kwargs.pop("output_options", None) or [] - if len(output_options) == 0: - output_options = [{} for i in range(nb_out)] - elif len(output_options) != nb_out: + 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_options argument must have {nb_out} elements to match the specified transcoder mode." + 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_options, **kwargs + input_options, output_streams, kwargs, extra_inputs, extra_outputs, **runner_kws ) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 3cddee4b..80c86d8e 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -1131,63 +1131,6 @@ def find_filter_complex_option( return next((o for o in optnames if o in options), None) -def format_raw_output_stream_defs( - streams: Sequence[str | FFmpegOptionDict] - | dict[str, str | FFmpegOptionDict] - | None, - options: FFmpegOptionDict | None, -) -> tuple[list[FFmpegOptionDict], dict[int, str]]: - """convert user-supplied streams arguments to the standard form - - :param streams: 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 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. - - None to select all available streams - :param options: default output options - :return stream_options: list of stream options - :return stream_alias: list of pairs of stream map options and user-supplied stream labels - """ - - # depending on user's streams input, label output streams differently - # to converge the conventions: convert streams input argument to stream_aliases and streams_ lists - streams_: list[FFmpegOptionDict] - stream_names: dict[ - int, str - ] = {} # dict of user-specified stream name (only via dict streams input) - - if isinstance(streams, dict): # dict[str,FFmpegOptionDict] - # dict key is used as both stream names (labels) and map option. - # * If FFmpegOptionDict in the dict value contains 'map' option, the key - # would only be used as the stream name - # * Note that if the map option is not unique the stream name will - # be renamed with an appended index. - streams_ = [] - for i, (k, v) in enumerate(streams.items()): - if isinstance(v, str): - v = {"map": v} - if "map" in v: # user provided non-map stream name - stream_names[i] = k - streams_.append({**options, "map": k, **v}) - elif "map" in options: - streams_ = [options] - else: # isinstance(stream,list[str|FFmpegOptionDict]) - # if an item is a str, it is the map option value - # if FFmpegOptionDict, it must contain a 'map' option - - streams_ = [ - {**options, **({"map": v} if isinstance(v, str) else v)} for v in streams - ] - - return streams_, stream_names - - def are_output_streams_unique( output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, ) -> bool: @@ -1210,19 +1153,43 @@ def are_output_streams_unique( return True -def input_file_stream_specs(url: str, stream_spec: str | None = None) -> dict[int, str]: +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. """ - streams = [ - st - for st in analyze_input_file( - ["index", "codec_type"], url, {}, {"src_type": "url"}, stream=stream_spec - ) - if st["codec_type"] in ("audio", "video") - ] + + 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) @@ -1306,7 +1273,7 @@ def expand_raw_output_streams( 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": "{file_id}:{st_map}"}) + new_streams.append(opts | {"map": f"{file_id}:{st_map}"}) new_names.append(name) return ( diff --git a/tests/test_media.py b/tests/test_media.py index 4accc3f2..46dc8f2d 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -68,16 +68,16 @@ def test_media_filter(): outrates, outdata = ff.media.filter( ["[0:V:0][1:V:0]vstack,split[out0]", "[2:a:0][3:a:0]amerge[out2]"], - "vvaa", + "vvaa", # 4 inputs (fps, F), (fps, F), (fs, x), (fs, x), - output_args={"[out0]": {}, "out1": {}, "audio": {"map": "[out2]"}}, + output_streams=["[out0]", "out1", {"map": "[out2]"}], show_log=True, shortest=ff.FLAG, ) - assert all(k in ("[out0]", "out1", "audio") for k in outrates) + assert all(k in ("out0", "out1", "out2") for k in outrates) print(outrates) diff --git a/tests/test_open.py b/tests/test_open.py index 47c475f7..cae334dc 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -1,8 +1,17 @@ +from os import path +from tempfile import TemporaryDirectory + import pytest +from ffmpegio.streams.BaseFFmpegRunner import ( + PipedFFmpegRunner, + SISOFFmpegFilter, + StdFFmpegRunner, +) + # import ffmpegio as ff # import ffmpegio.streams as ff_streams -from ffmpegio.streams.open import _parse_mode +from ffmpegio.streams.open import _parse_mode, open @pytest.mark.parametrize( @@ -35,33 +44,81 @@ def test_mode_parser(mode, ret): assert _parse_mode(mode) == ret -# def test_fg(): -# with ff.open("color=c=red:d=1:r=10", "rv", f_in="lavfi", pix_fmt="rgb24") as f: -# I = f.read(-1) -# assert I["shape"][0] == 10 - - -# url = "tests/assets/testmulti-1m.mp4" - - -# @pytest.mark.parametrize( -# "src,mode,Cls", -# [ -# (url, "rv", ff_streams.StdFFmpegRunner), -# (url, "ra", ff_streams.StdFFmpegRunner), -# (url, "e->v", ff_streams.PipedFFmpegRunner), -# (url, "e->a", ff_streams.PipedFFmpegRunner), -# ], -# ) -# def test_readers(src, mode, Cls): - -# assert isinstance(ff.open(url, mode), Cls) - +url = "tests/assets/testmulti-1m.mp4" -# def test_writers(): ... - -# def test_filters(): ... +@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 -# def test_transcoders(): ... +@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 + + SISOFFmpegFilter + # siso filter + # mimo filter + # decoder + # encoder + # transcoder diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py index bfabe70d..7655b782 100644 --- a/tests/test_streams_piped.py +++ b/tests/test_streams_piped.py @@ -153,7 +153,7 @@ def test_MediaFilter(): with ff.streams.PipedFFmpegRunner.open_media_filter( [{"r": fps}, {"r": fps}, {"ar": fs}, {"ar": fs}], - output_streams={"[out0]": {}, "audio": {"map": "[out1]"}}, + 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", @@ -164,7 +164,7 @@ def test_MediaFilter(): f.write(frame, i, last=True) # sleep(1) - assert ["[out0]", "audio"] == f.output_labels + assert ["out0", "out1"] == f.output_labels assert f.num_output_streams == 2 frames_per_read = f.output_frames() diff --git a/tests/test_streams_simple.py b/tests/test_streams_simple.py index 020d9600..cd260307 100644 --- a/tests/test_streams_simple.py +++ b/tests/test_streams_simple.py @@ -45,7 +45,7 @@ def test_read_write_video(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) - with StdFFmpegRunner.open_simple_writer({"r": fs}, [(out_url, {})]) as f: + with StdFFmpegRunner.open_simple_writer([(out_url, {})], {"r": fs}) as f: f.write(F0) f.write(F1) f.wait() @@ -109,7 +109,7 @@ def test_read_write_audio(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) with StdFFmpegRunner.open_simple_writer( - {"ar": fs}, [(out_url, {})], show_log=True + [(out_url, {})], {"ar": fs}, show_log=True ) as f: f.write({**out, "buffer": F[: 100 * bps]}) f.write({**out, "buffer": F[100 * bps :]}) @@ -131,8 +131,8 @@ def test_write_extra_inputs(): with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) with StdFFmpegRunner.open_simple_writer( - {"r": fs}, [(out_url, {})], + {"r": fs}, extra_inputs=[(url_aud, {})], show_log=True, options={"map": ["0:v", "1:a"], "loglevel": "debug"}, @@ -145,8 +145,8 @@ def test_write_extra_inputs(): assert len(info) == 2 with StdFFmpegRunner.open_simple_writer( - {"r": fs}, [(out_url, {})], + {"r": fs}, extra_inputs=[("anoisesrc", {"f": "lavfi"})], show_log=True, overwrite=True, From 3ae2c66efbdfcf51edc8f9d0246a052ec3a78ee9 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 9 Feb 2026 08:26:12 -0600 Subject: [PATCH 335/344] wip30 - done test_open_filter() --- src/ffmpegio/streams/BaseFFmpegRunner.py | 34 ++++++++----- src/ffmpegio/streams/open.py | 15 +++--- tests/test_open.py | 61 +++++++++++++++++++++++- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index e8ac3eea..ed6e12a1 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -875,22 +875,26 @@ def write_encoded(self, data: bytes, stream: int = 0, *, last: bool = False): ########################################################## @cached_property - def readable(self) -> bool: + 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``.""" - return self.num_output_streams > 0 + nout = self.num_output_streams + return nout and nout > 0 @cached_property - def num_output_streams(self) -> int: + 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: - return len(self._init_kws["output_streams"]) - except KeyError: - return 0 + output_info = self._output_info + except AttributeError: + ostreams = self._init_kws["output_streams"] + 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)""" @@ -1377,6 +1381,7 @@ def open_simple_reader( extra_outputs: ( Sequence[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None ) = None, + *, blocksize: int | None = None, progress: ProgressCallable | None = None, show_log: bool | None = None, @@ -1442,6 +1447,7 @@ def open_simple_writer( 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, @@ -1609,6 +1615,7 @@ def open_media_reader( extra_outputs: ( list[FFmpegOutputOptionTuple] | dict[str, FFmpegOptionDict] | None ) = None, + *, primary_output: int | None = None, blocksize: int | None = None, enc_blocksize: int | None = None, @@ -1655,6 +1662,7 @@ def open_media_writer( 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, @@ -1695,6 +1703,7 @@ def open_media_filter( 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, @@ -1740,6 +1749,7 @@ def open_media_encoder( 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, @@ -1790,6 +1800,7 @@ def open_media_decoder( 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, @@ -1836,6 +1847,7 @@ def open_media_transcoder( 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, @@ -1883,15 +1895,15 @@ class SISOFFmpegFilter(SISOMixin, PipedFFmpegRunner): 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, - squeeze: bool = True, + *, input_dtype: DTypeString | None = None, input_shape: ShapeTuple | None = None, primary_output: int | None = None, @@ -1907,9 +1919,9 @@ def create_and_open( runner = SISOFFmpegFilter( input_options, output_stream, + squeeze=squeeze, extra_inputs=extra_inputs, extra_outputs=extra_outputs, - squeeze=squeeze, input_dtype=input_dtype, input_shape=input_shape, primary_output=primary_output, @@ -1930,7 +1942,6 @@ def __init__( self, input_options: FFmpegOptionDict, output_stream: str | FFmpegOptionDict | None = None, - *, options: FFmpegOptionDict | None = None, squeeze: bool = True, extra_inputs: ( @@ -1939,6 +1950,7 @@ def __init__( extra_outputs: ( list[FFmpegOutputUrlComposite | FFmpegOutputOptionTuple] | None ) = None, + *, input_dtype: DTypeString | None = None, input_shape: ShapeTuple | None = None, primary_output: int | None = None, @@ -1954,7 +1966,7 @@ def __init__( init_func = configure.init_media_filter init_kws: MediaFilterKwsDict = { "input_options": [input_options], - "output_streams": output_stream, + "output_streams": output_stream and [output_stream], "options": options, "extra_inputs": extra_inputs, "extra_outputs": extra_outputs, diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py index d4bc80eb..876d0009 100644 --- a/src/ffmpegio/streams/open.py +++ b/src/ffmpegio/streams/open.py @@ -119,6 +119,7 @@ import logging import re from fractions import Fraction +from typing import overload from .. import utils from .._typing import ( @@ -130,7 +131,6 @@ Sequence, ShapeTuple, Unpack, - overload, ) from ..configure import ( FFmpegInputOptionTuple, @@ -1012,7 +1012,7 @@ def _parse_mode(mode: str) -> tuple[Literal["r", "w", "f", "d", "e", "t"], str, outputs = m[4] or "" if op_mode == "t": inputs = outputs = "e" - elif op_mode in "ew": + elif op_mode in "efw": # writer & (single-output) decoder -> output media types inputs = inputs + outputs outputs = "e" if op_mode == "e" else "" @@ -1112,7 +1112,7 @@ def _process_raw_input_args( 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 output streams (mode='w').") + raise ValueError("Cannot resolve the input streams.") elif input_options is None: input_options = [ {"ar" if mtype == "a" else "r": r} @@ -1295,7 +1295,7 @@ def _open_filter( used_kws, single_output, output_streams, extra_outputs, squeeze = ( _process_raw_output_args(out_types, kwargs, len(in_types)) ) - open_kws -= -used_kws + open_kws -= used_kws for k in open_kws: if k in kwargs: @@ -1304,14 +1304,15 @@ def _open_filter( 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 + single = single_input and (single_output or output_streams is None) - kwargs["filter_complex"] = fgs + if fgs is not None and fgs != "-": + kwargs["filter_complex"] = fgs return ( SISOFFmpegFilter.create_and_open( input_options[0], - output_streams[0], + output_streams and output_streams[0], kwargs, squeeze, extra_inputs, diff --git a/tests/test_open.py b/tests/test_open.py index cae334dc..35c5862e 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -1,15 +1,16 @@ from os import path from tempfile import TemporaryDirectory +import numpy as np import pytest +import ffmpegio as ff from ffmpegio.streams.BaseFFmpegRunner import ( PipedFFmpegRunner, SISOFFmpegFilter, StdFFmpegRunner, ) -# import ffmpegio as ff # import ffmpegio.streams as ff_streams from ffmpegio.streams.open import _parse_mode, open @@ -28,6 +29,7 @@ ("avra", ("r", "", "ava")), ("wva", ("w", "va", "")), ("awv", ("w", "av", "")), + ("fa", ("f", "a", "")), ("dav", ("d", "e", "av")), ("eav", ("e", "av", "e")), ("ea->ev", None), @@ -116,7 +118,62 @@ def test_open_writer(mode, input_rates, cls): assert not runner.decodable assert not runner.encodable - SISOFFmpegFilter + +@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 + # siso filter # mimo filter # decoder From f5d6da81e4d6fc7488c9749d64313262bd22d89d Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 9 Feb 2026 19:05:08 -0600 Subject: [PATCH 336/344] wip31 - completed testing open() --- src/ffmpegio/streams/BaseFFmpegRunner.py | 5 +- src/ffmpegio/streams/open.py | 12 +++- tests/test_open.py | 91 ++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/ffmpegio/streams/BaseFFmpegRunner.py b/src/ffmpegio/streams/BaseFFmpegRunner.py index ed6e12a1..eaeb26e1 100644 --- a/src/ffmpegio/streams/BaseFFmpegRunner.py +++ b/src/ffmpegio/streams/BaseFFmpegRunner.py @@ -891,7 +891,7 @@ def num_output_streams(self) -> int | None: try: output_info = self._output_info except AttributeError: - ostreams = self._init_kws["output_streams"] + 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) @@ -1555,6 +1555,9 @@ def read_encoded_nowait(self, n: int, stream: int = 0) -> bytes: f"Specified {stream=} is not a valid output encoded stream." ) + if self.status == FFmpegStatus.BUFFERING: + return b"" + st = stream + self.num_output_streams try: diff --git a/src/ffmpegio/streams/open.py b/src/ffmpegio/streams/open.py index 876d0009..8c16e497 100644 --- a/src/ffmpegio/streams/open.py +++ b/src/ffmpegio/streams/open.py @@ -983,11 +983,17 @@ def open( 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, kwargs, runner_kws) + 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), args, kwargs, runner_kws) + runner = _open_encoder( + in_types, len(out_types), urls_fgs, args, kwargs, runner_kws + ) else: - runner = _open_transcoder(len(in_types), len(out_types), kwargs, runner_kws) + runner = _open_transcoder( + len(in_types), len(out_types), urls_fgs, args, kwargs, runner_kws + ) return runner diff --git a/tests/test_open.py b/tests/test_open.py index 35c5862e..35275efc 100644 --- a/tests/test_open.py +++ b/tests/test_open.py @@ -1,3 +1,4 @@ +import builtins from os import path from tempfile import TemporaryDirectory @@ -12,7 +13,10 @@ ) # import ffmpegio.streams as ff_streams -from ffmpegio.streams.open import _parse_mode, open +from ffmpegio.streams.open import ( + _parse_mode, + open, +) @pytest.mark.parametrize( @@ -174,8 +178,83 @@ def test_open_filter(mode, input_rates, data, cls): assert not runner.decodable assert not runner.encodable - # siso filter - # mimo filter - # decoder - # encoder - # transcoder + +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 From 446f419ad08cb69890e68cf09fb94ce4c01c4735 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 9 Feb 2026 19:05:34 -0600 Subject: [PATCH 337/344] wip32 - invalid caps() call flags FFmpegError --- src/ffmpegio/caps.py | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/ffmpegio/caps.py b/src/ffmpegio/caps.py index c7278ef7..a96d515f 100644 --- a/src/ffmpegio/caps.py +++ b/src/ffmpegio/caps.py @@ -190,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", @@ -423,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 @@ -474,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" @@ -679,6 +683,9 @@ def demuxer_info(name): stdout, ) + if m is None: + raise FFmpegError(stdout) + data = dict( names=m[1].split(","), long_name=m[2], @@ -686,7 +693,7 @@ def demuxer_info(name): options=m[4], ) - if not "demuxer" in _cache: + if "demuxer" not in _cache: _cache["demuxer"] = {} _cache["demuxer"][name] = data return data @@ -725,6 +732,9 @@ def muxer_info(name): stdout, ) + if m is None: + raise FFmpegError(stdout) + data = { "names": m[1].split(","), "long_name": m[2], @@ -735,7 +745,7 @@ def muxer_info(name): "subtitle_codecs": m[7].split(",") if m[7] else [], "options": m[8], } - if not "muxer" in _cache: + if "muxer" not in _cache: _cache["muxer"] = {} _cache["muxer"][name] = data return data @@ -817,6 +827,9 @@ def _getCodecInfo(name, encoder): stdout, ) + if m is None: + raise FFmpegError(stdout) + def resolveFs(s): m = re.match(r"(\d+)\/(\d+)", s) return fractions.Fraction(int(m[1]), int(m[2])) @@ -840,7 +853,7 @@ def resolveFs(s): "options": m[11], } - if not "muxer" in _cache: + if "muxer" not in _cache: _cache["muxer"] = {} _cache["muxer"][name] = data return data @@ -934,7 +947,9 @@ def _get_filter_option(str, name): else ( partial(_conv_func, float) if otype in ("float", "double") - else partial(_conv_func, Fraction) if otype == "rational" else (lambda s: s) + else partial(_conv_func, Fraction) + if otype == "rational" + else (lambda s: s) ) ) @@ -1084,6 +1099,10 @@ def filter_info(name): r"([\s\S]*)", blocks[0], ) + + if m is None: + raise FFmpegError(blocks[0]) + name = m[1] desc = m[2] threading = ["slice"] if m[3] else [] @@ -1135,7 +1154,7 @@ def filter_info(name): timeline, ) - if not "filter" in _cache: + if "filter" not in _cache: _cache["filter"] = {} _cache["filter"][name] = data return data @@ -1172,14 +1191,14 @@ def bsfilter_info(name): ) if stdout.startswith("Unknown"): - raise Exception(stdout) + raise FFmpegError(stdout) data = { "name": m[1], "supported_codecs": m[2].split(" ") if m[2] else [], "options": m[3], } - if not "filter" in _cache: + if "filter" not in _cache: _cache["filter"] = {} _cache["filter"][name] = data return data From 5fc30e4fb06a9ec522c9112ba2b5e1e31dbb7333 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 9 Feb 2026 19:20:05 -0600 Subject: [PATCH 338/344] wip33 - purged unused functions in the configure module --- src/ffmpegio/configure.py | 417 ------------------------------------- tests/test_configure.py | 113 +--------- tests/test_utils_concat.py | 26 ++- 3 files changed, 20 insertions(+), 536 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 95f031d2..ec94fdd7 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -32,9 +32,7 @@ from collections import Counter from collections.abc import Sequence from contextlib import ExitStack -from fractions import Fraction from functools import cache -from io import IOBase from itertools import count from namedpipe import NPopen @@ -68,10 +66,8 @@ ShapeTuple, ToBytesCallable, TypedDict, - Unpack, cast, ) -from ._utils import as_multi_option, is_non_str_sequence from .errors import ( FFmpegError, FFmpegioError, @@ -574,53 +570,6 @@ def init_media_transcode( ############################################################### -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}, - ) - - -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 - - :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.") - - return ( - pipe_id or "-", - {**utils.array_to_audio_options(data)[0], "ar": rate, **opts}, - ) - - def empty(global_options: FFmpegOptionDict | None = None) -> FFmpegArgs: """create empty ffmpeg arg dict @@ -630,62 +579,6 @@ def empty(global_options: FFmpegOptionDict | None = None) -> FFmpegArgs: return {"inputs": [], "outputs": [], "global_options": global_options or {}} -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 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 - - Custom Pipe Class - ----------------- - - `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. - - """ - - def hasmethod(o, name): - return hasattr(o, name) and callable(getattr(o, name)) - - 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 - - def add_url( args: FFmpegArgs, type: Literal["input", "output"], @@ -920,19 +813,6 @@ def gather_video_read_opts( return raw_info, outopts -def check_alpha_change(args, dir=None, ifile=0, ofile=0): - # check removal of alpha channel - inopts = args["inputs"][ifile][1] - outopts = args["outputs"][ofile][1] - if inopts is None or outopts is None: - return None if dir is None else False # indeterminable - return utils.alpha_change(inopts.get("pix_fmt", None), outopts.get("pix_fmt", None)) - - -def get_audio_key_opts(opts) -> RawStreamInfoTuple: - return [opts.get(o, None) for o in ("sample_fmt", "ac", "ar")] - - def gather_audio_read_opts( options: FFmpegOptionDict, skip_rate: bool = False, @@ -1066,97 +946,6 @@ def gather_audio_read_opts( ################################################################################ -def get_option(ffmpeg_args, type, name, file_id=0, stream_type=None, stream_id=None): - """get ffmpeg option value from ffmpeg args dict - - :param ffmpeg_args: ffmpeg args dict - :type ffmpeg_args: dict - :param type: option type: 'video', 'audio', or 'global' - :type type: str - :param name: option name w/out stream specifier - :type name: str - :param file_id: index of target file, defaults to 0 - :type file_id: int, optional - :param stream_type: target stream type: 'v' or 'a', defaults to None - :type stream_type: str, optional - :param stream_id: target stream index (within specified stream type), defaults to None - :type stream_id: int, optional - :return: option value - :rtype: various - - If stream is specified, several option names are looked up till one is defined. For example, - 3 entries are checked for `name`='c', `stream_type`='v', and `stream_id`=0 in this order: - "c:v:0", "c:v", then "c". Function returns the first hit. - - """ - if 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} - else: - ffmpeg_args[type] = {**opts, **user_options} - 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}, - ) - - return ffmpeg_args - - -def get_video_array_format(ffmpeg_args, type, file_id=0): - try: - opts = ffmpeg_args[f"{type}s"][file_id][1] - except: - raise ValueError(f"{type} file #{file_id} is not specified") - try: - dtype, ncomp = utils.get_pixel_format(opts["pix_fmt"]) - shape = [*opts["s"][::-1], ncomp] - except: - raise ValueError(f"{type} options must specify both `s` and `pix_fmt` options") - - return shape, dtype - - def move_global_options(args: FFmpegArgs) -> FFmpegArgs: """move global options from the output options dicts @@ -1185,69 +974,6 @@ 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: str | FilterGraphObject, args: tuple, kwargs: dict ) -> tuple[str | fgb.Filter, float | None, dict]: @@ -1312,117 +1038,6 @@ def config_input_fg( 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 - - :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 - ) - ) - - ret = process_one(urls) - return [process_one(url) for url in urls] if ret is None else [ret] - - -def add_filtergraph( - 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 - - :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 = 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 - - class RawInputCallablesDict(TypedDict): data2bytes: ToBytesCallable data_count: CountDataCallable @@ -2083,38 +1698,6 @@ def get_callables(media_type: MediaType) -> RawInputCallablesDict: return input_info -def update_raw_input( - args: FFmpegArgs, - input_info: list[RawInputInfoDict], - 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[RawInputInfoDict | EncodedInputInfoDict], diff --git a/tests/test_configure.py b/tests/test_configure.py index 11fd4c08..b96101d6 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 @@ -11,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": " Date: Mon, 9 Feb 2026 19:20:27 -0600 Subject: [PATCH 339/344] wip34 - fixed plugins test --- tests/test_plugins.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 24598353..c2cd4795 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,8 +1,11 @@ -from ffmpegio.utils import prod from ffmpegio import plugins +from ffmpegio.utils import prod def test_rawdata_bytes(): + + plugins.use("read_bytes") + hook = plugins.get_hook() dtype = "|u1" @@ -28,6 +31,7 @@ def test_rawdata_bytes(): assert hook.audio_info(obj=data) == (shape, dtype) assert hook.audio_bytes(obj=data) == b + def test_use(): import numpy as np From 88c1af2f8d659d984b8547cd75b3ad4a03f723a3 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 9 Feb 2026 19:23:25 -0600 Subject: [PATCH 340/344] wip35 - purged utils.avi --- src/ffmpegio/threading.py | 3 +- src/ffmpegio/utils/avi.py | 646 -------------------------------------- 2 files changed, 1 insertion(+), 648 deletions(-) delete mode 100644 src/ffmpegio/utils/avi.py diff --git a/src/ffmpegio/threading.py b/src/ffmpegio/threading.py index 0b63d9bc..5f76171e 100644 --- a/src/ffmpegio/threading.py +++ b/src/ffmpegio/threading.py @@ -16,14 +16,13 @@ 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', +__all__ = ['FFmpegError', 'ThreadNotActive', 'ProgressMonitorThread', 'LoggerThread', 'ReaderThread', 'WriterThread', 'Empty', 'Full'] # fmt:on diff --git a/src/ffmpegio/utils/avi.py b/src/ffmpegio/utils/avi.py deleted file mode 100644 index e69d227e..00000000 --- a/src/ffmpegio/utils/avi.py +++ /dev/null @@ -1,646 +0,0 @@ -import fractions -import re -from collections import namedtuple -from io import SEEK_CUR -from itertools import accumulate -from struct import Struct - -from .. import plugins -from ..utils import (get_audio_format, get_samplesize, get_video_format, - stream_spec) - -# https://docs.microsoft.com/en-us/previous-versions//dd183376(v=vs.85)?redirectedfrom=MSDN - - -class FlagProcessor: - def __init__(self, name, flags, masks, defaults): - self.template = namedtuple( - name, - flags, - defaults=defaults, - ) - self.masks = self.template._make(masks) - - def default(self): - return self.template() - - def unpack(self, flags): - return self.template._make((bool(flags & mask) for mask in self.masks)) - - def pack(self, flags): - return sum((mask if flag else 0 for flag, mask in zip(flags, self.masks))) - - -class StructProcessor: - def __init__(self, name, format, fields, defaults=None, **flags): - if "S" in format or "C" in format: - # expand the format - m = re.match(r"([<>!=])?(.+)", format) - fmt_items = [ - (int(m[1]) if m[1] else 1, m[2]) - for m in re.finditer(r"(\d*)([xcCbB?hHiIlLqQnNefdsSpP])", m[2]) - ] - fmt_counts = [1 if f in "sSp" else count for count, f in fmt_items] - fmt_offsets = list((0, *accumulate(fmt_counts))) - is_str = [False] * fmt_offsets[-1] - for itm, offset in zip(fmt_items, fmt_offsets[:-1]): - is_str[offset] = itm[1] in "SC" - self.is_str = [fields[i] for i, tf in enumerate(is_str) if tf] - format = format.replace("C", "c").replace("S", "s") - else: - self.is_str = () - - self.struct = Struct(format) - self.template = namedtuple(name, fields, defaults=defaults) - self.flags = ((k, FlagProcessor(*v)) for k, v in flags.items()) - - def default(self): - data = self.template() - return data._replace(**{k: proc.default() for k, proc in self.flags}) - - def _unpack(self, data): - data = self.template._make(data) - return data._replace( - **{field: getattr(data, field).decode("utf-8") for field in self.is_str}, - **{k: proc.unpack(getattr(data, k)) for k, proc in self.flags}, - ) - - def unpack(self, buffer): - return self._unpack(self.struct.unpack(buffer)) - - def unpack_from(self, buffer, offset=0): - return self._unpack(self.struct.unpack_from(buffer, offset)) - - def _pack(self, ntuple): - return ntuple._replace( - **{k: proc.pack(getattr(ntuple, k)) for k, proc in self.flags}, - **{field: ntuple[field].encode("utf-8") for field in self.is_str}, - ) - - def pack(self, ntuple): - return self.struct.pack(*self._pack(ntuple)) - - def pack_into(self, buffer, offset, ntuple): - self.struct.pack_into(buffer, offset, *self._pack(ntuple)) - - @property - def size(self): - return self.struct.size - - -AVIMainHeader = StructProcessor( - "Avih", - "<10I", - ( - "micro_sec_per_frame", - "max_bytes_per_sec", - "padding_granularity", - "flags", - "total_frames", - "initial_frames", - "streams", - "suggested_buffer_size", - "width", - "height", - ), - (0,) * 10, - flags=( - "AvihFlags", - ( - "copyrighted", - "has_index", - "is_interleaved", - "must_use_index", - "was_capture_file", - ), - ( - int("0x00020000", 0), - int("0x00000010", 0), - int("0x00000100", 0), - int("0x00000020", 0), - int("0x00010000", 0), - ), - (False,) * 5, - ), -) - - -AVIStreamHeader = StructProcessor( - "AVISTREAMHEADER", - "<4S4SI2H8I4h", - ( - "fcc_type", # 'auds','mids','txts','vids' - "fcc_handler", - "flags", - "priority", - "language", - "initial_frame", - "scale", - "rate", - "start", - "length", - "suggested_buffer_size", - "quality", - "sample_size", - "frame_left", - "frame_top", - "frame_right", - "frame_bottom", - ), - (b"\0" * 4, b"\0" * 4, *((0,) * 15)), - flags=( - "StrhFlags", - ( - "video_pal_changes", - "disabled", - ), - ( - int("0x00000001", 0), - int("0x00010000", 0), - ), - (False,) * 2, - ), -) - -# PCM audio -WAVE_FORMAT_PCM = 1 -# IEEE floating-point audio -WAVE_FORMAT_IEEE_FLOAT = 3 -WAVE_FORMAT_EXTENSIBLE = int("FFFE", 16) # /* Microsoft, 65534 */ - -BitmapInfoHeader = StructProcessor( - "BITMAPINFOHEADER", - "IiiHH4sIiiII", - ( - "size", - "width", - "height", - "planes", - "bit_count", - "compression", # convert to str if 1st byte is >=4 - "size_image", - "x_pels_per_meter", - "y_pels_per_meter", - "clr_used", - "clr_important", - ), - (0,) * 11, -) - -WaveFormatEx = StructProcessor( - "WAVEFORMATEX", - "HHIIHH", - ( - "format_tag", - "channels", - "samples_per_sec", - "avg_bytes_per_sec", - "block_align", - "bits_per_sample", - ), - (0,) * 6, -) - -WaveFormatExtensible = StructProcessor( - "WAVEFORMATEXTENSIBLE", - "HHIH14s", - ( - "size", - "samples", - "channel_mask", - "sub_format_wave", - "sub_format_rest", - ), - (*((0,) * 3), 0, "\0" * 14), -) - - -VideoPropHeader = StructProcessor( - "VPRP", - "5IHH3I", - ( - "video_format_token", - "video_standard", - "vertical_refresh_rate", - "h_total_in_t", - "v_total_in_lines", - "frame_aspect_ratio_y", - "frame_aspect_ratio_x", - "frame_width_in_pixels", - "frame_height_in_lines", - "field_per_frame", - ), - ((0,) * 10), -) - -VPRP_VideoField = StructProcessor( - "VPRP_VIDEO_FIELD_DESC", - "8I", - ( - "compressed_bm_height", - "compressed_bm_width", - "valid_bm_height", - "valid_bm_width", - "valid_bm_x_offset", - "valid_bm_y_offset", - "video_x_offset_in_t", - "video_y_valid_start_line", - ), - ((0,) * 8), -) - - -ChunkHeader = StructProcessor("CHDR", "<4SI", ("id", "datasize")) - - -fcc_types = dict(vids="v", auds="a", txts="s") # , mids="midi") - - -def read_chunk_header(f): - b = f.read(ChunkHeader.size) - id, datasize = ChunkHeader.unpack(b) - list_type = None - if id in ("RIFF", "LIST"): - list_type = f.read(4).decode("utf-8") - datasize -= 4 - chunksize = datasize + 1 if datasize % 2 else datasize - return id, datasize, chunksize, list_type - - -def get_chunk_header(b, offset=0): - id, datasize = ChunkHeader.unpack_from(b, offset) - offset += ChunkHeader.size - list_type = None - if id in ("RIFF", "LIST"): - list_type = b[offset : offset + 4].decode("utf-8") - offset += 4 - datasize -= 4 - chunksize = datasize + 1 if datasize % 2 else datasize - return offset, chunksize, id, list_type - - -def get_stream_header(b, offset, end): - data = {} - - offset, chunksize, id, _ = get_chunk_header(b, offset) - data[id] = strh = AVIStreamHeader.unpack_from(b, offset) - offset += chunksize - - offset, chunksize, id, _ = get_chunk_header(b, offset) - if strh.fcc_type == "vids": - data[id] = BitmapInfoHeader.unpack_from(b, offset) - - # if 1st byte is a readable ascii char - compression = data[id].compression - comp_val = compression[0] - data[id] = data[id]._replace( - compression=comp_val if comp_val < 32 else compression.decode("utf-8") - ) - - # offset += chunksize - # while offset < end: - # offset, chunksize, id, _ = get_chunk_header(b, offset) - # if id == "vprp": - # vprp = VideoPropHeader.unpack_from(b, offset) - # offset += VideoPropHeader.size - # ninfo = VPRP_VideoField.size - # field_info = [ - # VPRP_VideoField.unpack_from(b, i) - # for i in range(offset, offset + ninfo * vprp.field_per_frame, ninfo) - # ] - # data[id] = namedtuple( - # type(vprp).__name__, (*vprp._fields, "field_info") - # )(*vprp, field_info) - # break - # else: - # offset += chunksize - - elif strh.fcc_type == "auds": - strf = WaveFormatEx.unpack_from(b, offset) - if strf.format_tag == WAVE_FORMAT_EXTENSIBLE: - strfext = WaveFormatExtensible.unpack_from(b, offset + WaveFormatEx.size) - strf = namedtuple( - type(strfext).__name__, (*strf._fields, *strfext._fields) - )(strfext.sub_format_wave, *strf[1:], *strfext) - data[id] = strf - else: - raise RuntimeError(f"Unsupported stream type: {strh.fcc_type}") - - return data - - -def _seek(f, n): - try: - f.seek(n, SEEK_CUR) - except: - f.read(n) - - -def read_header(f, pix_fmt=None): - - # read the RIFF header - id, datasize, chunksize, list_type = read_chunk_header(f) - if id != "RIFF" or list_type != "AVI ": - raise RuntimeError(f"File stream is not AVI") - - # read the hdrl chunk - id, datasize, chunksize, list_type = read_chunk_header(f) - if id != "LIST" and list_type != "hdrl": - raise RuntimeError(f"AVI is missing header chunk") - b = f.read(datasize) - if chunksize > datasize: - _seek(f, 1) - - # read until encountering the movi list - while True: - id, _, chunksize, list_type = read_chunk_header(f) - if list_type == "movi": - break - _seek(f, chunksize) - - # parse hdrl LIST chunk - offset, chunksize, id, list_type = get_chunk_header(b) - if id != "avih": - raise RuntimeError("missing avi chunk") - avih = AVIMainHeader.unpack_from(b, offset) - offset += chunksize - streams = [] - while True: - try: - offset, chunksize, id, list_type = get_chunk_header(b, offset) - except: - break - if list_type != "strl": - break - - streams.append(get_stream_header(b, offset, offset + chunksize)) - offset += chunksize - - def get_stream_info(i, strl, use_ya): - strh = strl["strh"] - strf = strl["strf"] - type = fcc_types[strh.fcc_type] # raises if not valid type - info = dict(index=i, type=type) - if type == fcc_types["vids"]: - info["frame_rate"] = fractions.Fraction(strh.rate, strh.scale) - info["width"] = strf.width - info["height"] = abs(strf.height) - bpp = strf.bit_count - compression = strf.compression - # force unsupported pixel formats - info["pix_fmt"] = ( - {"Y800": "gray", "RGBA": "rgba"}.get(compression, None) - if isinstance(compression, str) - else (compression, bpp) - if compression - else "rgba64le" - if bpp == 64 - else "rgb48le" - if bpp == 48 - else ("ya16le" if use_ya else "grayf32le") - if bpp == 32 - else "rgb24" - if bpp == 24 - else ("ya8" if use_ya else "gray16le") - if bpp == 16 - else None - ) - # vprp = strl.get("vprp", None) - # info["dar"] = ( - # fractions.Fraction(vprp.frame_aspect_ratio_x, vprp.frame_aspect_ratio_y) - # if vprp - # else None - # ) - info["dtype"], info["shape"] = get_video_format( - info["pix_fmt"], (info["width"], info["height"]) - ) - elif type == fcc_types["auds"]: #'audio' - info["sample_rate"] = strf.samples_per_sec - info["channels"] = strf.channels - - strf_format = ( - strf.format_tag, - strf.bits_per_sample, - ) - - info["sample_fmt"] = { - (WAVE_FORMAT_PCM, 8): "u8", - (WAVE_FORMAT_PCM, 16): "s16", - (WAVE_FORMAT_PCM, 32): "s32", - (WAVE_FORMAT_PCM, 64): "s64", - (WAVE_FORMAT_IEEE_FLOAT, 32): "flt", - (WAVE_FORMAT_IEEE_FLOAT, 64): "dbl", - }.get(strf_format, strf_format) - # TODO: if need arises, resolve more formats, need to include codec names though - info["dtype"], info["shape"] = get_audio_format( - info["sample_fmt"], info["channels"] - ) - return info - - return [get_stream_info(i, strl, pix_fmt) for i, strl in enumerate(streams)], ( - avih, - streams, - ) - - -re_movi = re.compile(r"\d{2}(?:wb|db|dc|tx)") - - -def read_frame(f): - while True: - id, datasize, chunksize, list_type = read_chunk_header(f) - if not list_type: - m = re_movi.match(id) - if m: # data chunk found - b = f.read(datasize) - if chunksize > datasize: - _seek(f, chunksize - datasize) - return int(id[:2]), b - else: - _seek(f, chunksize) - - id, datasize, chunksize, list_type = read_chunk_header(f) - - -####################################################################################################### - - -class AviReader: - def __init__(self): - self._f = None - self.ready = False #:bool: True if AVI headers has been processed - self.streams = None #:dict: Stream headers keyed by stream id (int key) - self.itemsizes = None #:dict: sample size of each stream in bytes - - hook = plugins.get_hook() - self.converters = {"v": hook.bytes_to_video, "a": hook.bytes_to_audio} - #:dict : bytes to media data object conversion functions keyed by stream type - - def start(self, f, pix_fmt=None): - self._f = f - hdr = read_header(self._f, pix_fmt)[0] - - cnt = {"v": 0, "a": 0, "s": 0} - - def set_stream_info(hdr): - st_type = hdr["type"] - id = cnt[st_type] - cnt[st_type] += 1 - return { - "spec": stream_spec(id, st_type), - **hdr, - } - - self.streams = {v["index"]: set_stream_info(v) for v in hdr} - self.itemsizes = { - v["index"]: get_samplesize(v["shape"], v["dtype"]) for v in hdr - } - self.ready = True - - def __next__(self): - i = d = None - while i is None: # None if unknown frame format, skip - try: - i, d = read_frame(self._f) - except: - raise StopIteration - return i, d - - def __iter__(self): - return self - - def from_bytes(self, id, b): - info = self.streams[id] - return self.converters[info["type"]]( - b=b, dtype=info["dtype"], shape=info["shape"], squeeze=False - ) - - -# ( -# "hdrl", -# [ -# ( -# "avih", -# { -# "micro_sec_per_frame": 66733, -# "max_bytes_per_sec": 3974198, -# "padding_granularity": 0, -# "flags": 0, -# "total_frames": 0, -# "initial_frames": 0, -# "streams": 2, -# "suggested_buffer_size": 1048576, -# "width": 352, -# "height": 240, -# }, -# ), -# ( -# "strl", -# [ -# ( -# "strh", -# { -# "fcc_type": "vids", -# "fcc_handler": "\x00\x00\x00\x00", -# "flags": 0, -# "priority": 0, -# "language": 0, -# "initial_frames": 0, -# "scale": 200, -# "rate": 2997, -# "start": 0, -# "length": 1073741824, -# "suggested_buffer_size": 1048576, -# "quality": 4294967295, -# "sample_size": 0, -# "frame_left": 0, -# "frame_top": 0, -# "frame_right": 352, -# "frame_bottom": 240, -# }, -# ), -# ( -# "strf", -# { -# "size": 40, -# "width": 352, -# "height": -240, -# "planes": 1, -# "bit_count": 24, -# "compression": "rgb24", -# "size_image": 253440, -# "x_pels_per_meter": 0, -# "y_pels_per_meter": 0, -# "clr_used": 0, -# "clr_important": 0, -# }, -# ), -# ( -# "vprp", -# { -# "video_format_token": 0, -# "video_standard": 0, -# "vertical_refresh_rate": 15, -# "h_total_in_t": 352, -# "v_total_in_lines": 240, -# "frame_aspect_ratio": Fraction(15, 22), -# "frame_width_in_pixels": 352, -# "frame_height_in_lines": 240, -# "field_per_frame": 1, -# "field_info": ( -# { -# "compressed_bm_height": 240, -# "compressed_bm_width": 352, -# "valid_bm_height": 240, -# "valid_bm_width": 352, -# "valid_bmx_offset": 0, -# "valid_bmy_offset": 0, -# "video_x_offset_in_t": 0, -# "video_y_valid_start_line": 0, -# }, -# ), -# }, -# ), -# ], -# ), -# ( -# "strl", -# [ -# ( -# "strh", -# { -# "fcc_type": "auds", -# "fcc_handler": "\x01\x00\x00\x00", -# "flags": 0, -# "priority": 0, -# "language": 0, -# "initial_frames": 0, -# "scale": 1, -# "rate": 44100, -# "start": 0, -# "length": 1073741824, -# "suggested_buffer_size": 12288, -# "quality": 4294967295, -# "sample_size": 4, -# "frame_left": 0, -# "frame_top": 0, -# "frame_right": 0, -# "frame_bottom": 0, -# }, -# ), -# ( -# "strf", -# { -# "format_tag": 1, -# "channels": 2, -# "samples_per_sec": 44100, -# "avg_bytes_per_sec": 176400, -# "block_align": 4, -# "bits_per_sample": 16, -# }, -# ), -# ], -# ), -# ], -# 368, -# ) From cf09ae23dc5275fa2915a24f075921c1d90d5fc8 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Mon, 9 Feb 2026 19:29:34 -0600 Subject: [PATCH 341/344] wip36 - purged unused utils functions --- src/ffmpegio/utils/__init__.py | 172 --------------------------------- tests/test_utils.py | 66 +------------ 2 files changed, 3 insertions(+), 235 deletions(-) diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 80c86d8e..13771500 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -248,22 +248,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"), @@ -355,61 +339,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() @@ -462,18 +391,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 @@ -962,73 +879,6 @@ def analyze_output_audio_filter( return (*stream.values(),) -def are_input_pipes_ready( - inputs: list[tuple[FFmpegUrlType, FFmpegOptionDict]], - input_info: list[InputInfoDict], - 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 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 - -------------- - - * 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 must_probe - and all(o in opts for o in required_options[info["media_type"]]) - ) - ) - for (_, opts), info in zip(inputs, input_info) - ] - - -def get_output_stream_id(output_info: list[OutputInfoDict], 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 - - 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)) @@ -1131,28 +981,6 @@ def find_filter_complex_option( return next((o for o in optnames if o in options), None) -def are_output_streams_unique( - output_streams: list[FFmpegOptionDict] | dict[str, FFmpegOptionDict] | None, -) -> bool: - """True if output raw stream specification uniquely defines all streams - - :param output_streams: a list of FFmpeg output stream options, or the options - dict keyed by user-specified stream name, or ``None`` - to autodetect all streams in input sources - """ - - if output_streams is None: - return False - - for opts in ( - output_streams.values() if isinstance(output_streams, dict) else output_streams - ): - map_opt = parse_map_option(opts["map"], input_file_id=0, parse_stream=True) - if "linklabel" in map_opt or not is_unique_stream(map_opt["stream_specifier"]): - return False - return True - - def input_file_stream_specs( url: FFmpegUrlType | FilterGraphObject | None, stream_spec: str | None = None, diff --git a/tests/test_utils.py b/tests/test_utils.py index da6b1097..4ae10cec 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,7 @@ -import math -from ffmpegio import utils, FFmpegioError import pytest +from ffmpegio import utils + def test_string_escaping(): raw = "Crime d'Amour" @@ -71,15 +71,6 @@ def test_alpha_change(): assert utils.alpha_change(input_pix_fmt, output_pix_fmt, -1) is False -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) - - def test_get_audio_codec(): cfg = utils.get_audio_codec("s16") assert cfg[0] == "pcm_s16le" and cfg[1] == "s16le" @@ -90,57 +81,6 @@ def test_get_audio_format(): assert cfg[0] == " Date: Tue, 10 Feb 2026 21:48:08 -0600 Subject: [PATCH 342/344] wip37 - gather_video_read_opts - removed alpha check (also purged supporting utils.alpha_change) --- src/ffmpegio/configure.py | 29 +++++++++-------------------- src/ffmpegio/utils/__init__.py | 20 -------------------- tests/test_image.py | 4 ---- tests/test_utils.py | 16 ---------------- 4 files changed, 9 insertions(+), 60 deletions(-) diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index ec94fdd7..c4ffa742 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -762,7 +762,6 @@ def gather_video_read_opts( ) # pixel format must be specified - remove_alpha = False if pix_fmt is None: # use the analyzed value, falling back to 'rgb24' if pix_fmt_in == "unknown": @@ -771,27 +770,17 @@ def gather_video_read_opts( ) # deduce output pixel format from the input pixel format - pix_fmt, ncomp, dtype, remove_alpha = utils.get_pixel_config(pix_fmt_in) + pix_fmt, ncomp, dtype, _ = utils.get_pixel_config(pix_fmt_in) outopts["pix_fmt"] = pix_fmt - else: - # make sure assigned pix_fmt is valid - if pix_fmt_in is None: - # 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 - else: - remove_alpha = utils.alpha_change(pix_fmt_in, pix_fmt, -1) - - if remove_alpha: - raise FFmpegioError( - "The output pix_fmt does not have a transparency while its input does. " - "Additional filtering is necessary to remove the alpha channel properly. See ffmpegio.filtergraph.presets.remove_alpha()." - ) + 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 if s is None: s = s_in diff --git a/src/ffmpegio/utils/__init__.py b/src/ffmpegio/utils/__init__.py index 13771500..060272af 100644 --- a/src/ffmpegio/utils/__init__.py +++ b/src/ffmpegio/utils/__init__.py @@ -130,26 +130,6 @@ def get_pixel_config(input_pix_fmt: str) -> tuple[str, int, DTypeString, bool]: ) -def alpha_change( - input_pix_fmt: str, output_pix_fmt: str | None, dir: int | None = None -) -> bool | int | None: - """get best pixel configuration to read video data in specified pixel format - - :param input_pix_fmt: input pixel format - :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[DTypeString, int]: """get data format and number of components associated with video pixel format diff --git a/tests/test_image.py b/tests/test_image.py index 52e947c2..4aa4ecaf 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -49,10 +49,6 @@ def test_read_write(): A = image.read(url) print(A["dtype"] == "|u1") B = image.read(url, pix_fmt="ya8") - with pytest.raises(FFmpegioError): - image.read(url, pix_fmt="rgb24") - with pytest.raises(FFmpegioError): - image.read(url, pix_fmt="gray") with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, re.sub(r"\..*?$", outext, path.basename(url))) image.write(out_url, B) diff --git a/tests/test_utils.py b/tests/test_utils.py index 4ae10cec..6fec56cb 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -55,22 +55,6 @@ def test_get_pixel_format(): assert cfg[0] == "|u1" and cfg[1] == 3 -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_audio_codec(): cfg = utils.get_audio_codec("s16") assert cfg[0] == "pcm_s16le" and cfg[1] == "s16le" From ed57fcf5df0cadb38c82b74ddd5c00a706ebebf6 Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Tue, 10 Feb 2026 21:50:08 -0600 Subject: [PATCH 343/344] wip28 - working on docs --- Makefile | 2 +- docsrc/filtergraph.rst | 6 +- docsrc/options.rst | 118 +++------------------------ docsrc/quick.rst | 2 +- src/ffmpegio/__init__.py | 2 +- src/ffmpegio/audio.py | 80 +++++++++--------- src/ffmpegio/filtergraph/__init__.py | 25 ++++-- src/ffmpegio/filtergraph/presets.py | 2 - src/ffmpegio/filtergraph/utils.py | 10 +-- src/ffmpegio/image.py | 10 +-- src/ffmpegio/media.py | 6 +- src/ffmpegio/transcode.py | 76 ++++++++--------- src/ffmpegio/video.py | 10 +-- tests/test_image.py | 29 ++++--- tests/test_streams_piped.py | 7 -- 15 files changed, 148 insertions(+), 237 deletions(-) diff --git a/Makefile b/Makefile index dde022fb..4cd0e2e3 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 12 SPHINXBUILD ?= sphinx-build SOURCEDIR = docsrc BUILDDIR = build diff --git a/docsrc/filtergraph.rst b/docsrc/filtergraph.rst index 4982211c..29599b57 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. diff --git a/docsrc/options.rst b/docsrc/options.rst index cd21c18e..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: - -.. digraph:: video_manipulation - :caption: Video Manipulation Order - - rankdir=LR - node [margin=0.1 width=1.5 shape=box]; - - "square_pixels" -> "crop" -> "flip" -> "transpose"; - -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/src/ffmpegio/__init__.py b/src/ffmpegio/__init__.py index 0a3d85be..f75e91bf 100644 --- a/src/ffmpegio/__init__.py +++ b/src/ffmpegio/__init__.py @@ -81,7 +81,7 @@ def __getattr__(name): __all__ = ["ffmpeg_info", "get_path", "set_path", "is_ready", "ffmpeg", "ffprobe", "transcode", "caps", "probe", "audio", "image", "video", "media", "devices", "open", "streams", "ffmpegprocess", "FFmpegError", "FFmpegioError", "FilterGraph", - "FFConcat", "use", "FLAG"] + "FFConcat", "use", "FLAG", "stream_spec"] # fmt:on __version__ = "0.11.1" diff --git a/src/ffmpegio/audio.py b/src/ffmpegio/audio.py index 6b81db23..64017d79 100644 --- a/src/ffmpegio/audio.py +++ b/src/ffmpegio/audio.py @@ -37,31 +37,31 @@ def create( """Create audio data using an audio source filter :param expr: name of the source filter or full filter expression - :param \\*args: sequential filter option arguments. Only valid for - a single-filter expr, and they will overwrite the - options set by expr. + :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. + channels, defaults to True to reduce monaural data to 1D, + eliminating the singular audio channel dimension. :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. + 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 - :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. + `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 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 + :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 @@ -116,29 +116,29 @@ def read( """Read audio samples. :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. + 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. + 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. + channels, defaults to True to reduce monaural data to 1D, eliminating + the singular audio channel dimension. :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 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 - :param \\**options: FFmpeg options, append '_in' for input option names (see :doc:`options`) + `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 if - data is monaural. To match the shape + :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. @@ -211,7 +211,7 @@ def write( :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`) + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ # if filter_complex is not defined use '0:a:0' as default mapping @@ -277,7 +277,7 @@ def filter( :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`) + :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 diff --git a/src/ffmpegio/filtergraph/__init__.py b/src/ffmpegio/filtergraph/__init__.py index 0501aee1..c4763c4b 100644 --- a/src/ffmpegio/filtergraph/__init__.py +++ b/src/ffmpegio/filtergraph/__init__.py @@ -9,7 +9,7 @@ :widths: 15 10 30 :header-rows: 1 - --------------------------------- ------------------------------------------------------------ + ------------------------------ ------------------------------------------------------------ Operation Description Related Methods ------------------------------ ------------------------------------------------------------ `+` operator Chaining/join operator, supports scalar expansion @@ -63,16 +63,17 @@ Filter Pad Labeling =================== -`str >> Filter/Chain/Graph` and `Filter/Chain/Graph >> str` operations can be used to set input -and output labels, respectively. The labels must be specified in square brackets as in the same -manner as FFmpeg filtergraph specification. +`str >> Filter/Chain/Graph` and `Filter/Chain/Graph >> str` operations can be +used to set input and output labels, respectively. The labels must be specified +in square brackets as in the same manner as FFmpeg filtergraph specification. .. code-block::python fg = '[in]' >> Filter('scale',0.5,-1) >> '[out]' -The brackets are required to distinguish labels from str expressions of filter, chain, and graph. -For example, the following expression chains `scale` and `setsar` filters: +The brackets are required to distinguish labels from str expressions of filter, +chain, and graph. For example, the following expression chains `scale` and +`setsar` filters: .. code-block::python @@ -104,9 +105,14 @@ from . import abc from .build import attach, concatenate, connect, join, stack from .Chain import Chain -from .convert import (as_filter, as_filterchain, as_filtergraph, - as_filtergraph_object, as_filtergraph_object_like, - atleast_filterchain) +from .convert import ( + as_filter, + as_filterchain, + as_filtergraph, + as_filtergraph_object, + as_filtergraph_object_like, + atleast_filterchain, +) from .exceptions import FiltergraphInvalidIndex, FiltergraphPadNotFoundError from .Filter import Filter from .Graph import Graph @@ -165,6 +171,7 @@ def func(*args, filter_id=None, **kwargs): return func + # TODO # def validate_input_filtergraph(fg): diff --git a/src/ffmpegio/filtergraph/presets.py b/src/ffmpegio/filtergraph/presets.py index 61fff130..0e4dba4e 100644 --- a/src/ffmpegio/filtergraph/presets.py +++ b/src/ffmpegio/filtergraph/presets.py @@ -3,13 +3,11 @@ from __future__ import annotations from fractions import Fraction -from functools import reduce from .. import filtergraph as fgb from .._typing import TYPE_CHECKING, Any, Literal, Sequence from ..path import check_version from ..stream_spec import StreamSpecDict -from .abc import FilterGraphObject if TYPE_CHECKING: from .Chain import Chain diff --git a/src/ffmpegio/filtergraph/utils.py b/src/ffmpegio/filtergraph/utils.py index ccb79742..cbd24a12 100644 --- a/src/ffmpegio/filtergraph/utils.py +++ b/src/ffmpegio/filtergraph/utils.py @@ -88,7 +88,7 @@ def get_kw(arg): def compose_filter_args(*args): """compose once-escaped filter argument string - :param *args: list of argument strings; last element may be a dict of key-value pairs + :param args: list of argument strings; last element may be a dict of key-value pairs :type *args: list of str + dict :return: filter argument string :rtype: str @@ -229,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] @@ -238,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): @@ -301,7 +303,6 @@ def parse_labels(expr, i, output, *cidfid): i = j else: - # add new filter to the chain fc.append(parse_filter(fs)) @@ -469,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 41ad3157..07271821 100644 --- a/src/ffmpegio/image.py +++ b/src/ffmpegio/image.py @@ -31,7 +31,7 @@ def create( """Create an image using a source video filter :param name: name of the source filter - :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. :param progress: progress callback function, defaults to None @@ -41,7 +41,7 @@ def create( :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :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 @@ -97,7 +97,7 @@ def read( :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`) + :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. @@ -170,7 +170,7 @@ def write( to None :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`) + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ # if filter_complex is not defined use '0:V:0' as default mapping @@ -232,7 +232,7 @@ def filter( :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`) + :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. diff --git a/src/ffmpegio/media.py b/src/ffmpegio/media.py index 2e3340b6..bb203630 100644 --- a/src/ffmpegio/media.py +++ b/src/ffmpegio/media.py @@ -150,7 +150,7 @@ def read( :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 + :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 @@ -219,7 +219,7 @@ def write( :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 @@ -300,7 +300,7 @@ def filter( :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 diff --git a/src/ffmpegio/transcode.py b/src/ffmpegio/transcode.py index b7003425..e3e429d2 100644 --- a/src/ffmpegio/transcode.py +++ b/src/ffmpegio/transcode.py @@ -2,16 +2,19 @@ import logging -logger = logging.getLogger("ffmpegio") - -from . import FFmpegError, configure +from . import FFmpegError, configure, utils from . import ffmpegprocess as fp -from . import utils from ._typing import FFmpegOptionDict, ProgressCallable, Sequence, Unpack -from .configure import (FFmpegInputOptionTuple, FFmpegInputUrlComposite, - FFmpegOutputOptionTuple, FFmpegOutputUrlComposite) +from .configure import ( + FFmpegInputOptionTuple, + FFmpegInputUrlComposite, + FFmpegOutputOptionTuple, + FFmpegOutputUrlComposite, +) from .path import check_version +logger = logging.getLogger("ffmpegio") + __all__ = ["transcode"] @@ -43,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 """ @@ -118,8 +120,8 @@ def transcode( } ) if two_pass: - if len(output_info)>1: - raise ValueError('transcode() only supports two_pass mode for one output.') + 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/video.py b/src/ffmpegio/video.py index 802637a1..b141f46b 100644 --- a/src/ffmpegio/video.py +++ b/src/ffmpegio/video.py @@ -40,7 +40,7 @@ def create( """Create a video using a source video filter :param expr: source filter graph - :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. :param squeeze: False to return 2D data with the 2nd dimension as the audio @@ -53,7 +53,7 @@ def create( :param sp_kwargs: dictionary with keywords passed to `subprocess.run()` or `subprocess.Popen()` call used to run the FFmpeg, defaults to None - :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 @@ -127,7 +127,7 @@ def read( :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`) + :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 """ @@ -202,7 +202,7 @@ def write( :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`) + :param options: FFmpeg options, append '_in' for input option names (see :doc:`options`) """ # if filter_complex is not defined use '0:V:0' as default mapping @@ -268,7 +268,7 @@ def filter( :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`) + :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 """ diff --git a/tests/test_image.py b/tests/test_image.py index 4aa4ecaf..1c4cd0ee 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,9 +1,11 @@ -import pytest -from ffmpegio import image, probe, transcode, FFmpegError, FFmpegioError -import tempfile, re +import re +import tempfile from os import path +import pytest + from ffmpegio import filtergraph as fgb +from ffmpegio import image, transcode outext = ".png" @@ -80,10 +82,11 @@ def test_read_basic_filter(): ) image.read(url, show_log=True, vf=vf) + def test_filter(): url = "tests/assets/ffmpeg-logo.png" - I = image.read(url,vf=fgb.presets.remove_alpha('red','rgb24')) + I = image.read(url, vf=fgb.presets.remove_alpha("red", "rgb24")) vf = fgb.presets.filter_video_basic( crop=(10, 50), flip="horizontal", @@ -91,6 +94,7 @@ def test_filter(): ) J = image.filter(vf, I, show_log=True) + @pytest.mark.parametrize( "fill_color,pix_fmt,ncomp", [("red", "rgb24", 3), ("red", None, 4), ("red", "gray", 0)], @@ -103,26 +107,27 @@ def test_remove_alpha_filter(fill_color, pix_fmt, ncomp): I = image.read(url, show_log=True, vf=vf) assert I["shape"] == ((100, 396, ncomp) if ncomp else (100, 396)) -@pytest.fixture(scope='module') + +@pytest.fixture(scope="module") def nonsquarepix_url(): url = "tests/assets/testvideo-1m.mp4" with tempfile.TemporaryDirectory() as tmpdirname: out_url = path.join(tmpdirname, path.basename(url)) - transcode(url, out_url, show_log=True, vf="setsar=11/13", t=0.5, pix_fmt='gray') + transcode(url, out_url, show_log=True, vf="setsar=11/13", t=0.5, pix_fmt="gray") yield out_url -@pytest.mark.parametrize('mode',['upscale','downscale','upscale_even','downscale_even']) + +@pytest.mark.parametrize( + "mode", ["upscale", "downscale", "upscale_even", "downscale_even"] +) def test_square_pixels(nonsquarepix_url, mode): - vf = fgb.presets.square_pixels(mode) - image.read(nonsquarepix_url, vf=vf,show_log=None) + vf = fgb.presets.square_pixels(mode) + image.read(nonsquarepix_url, vf=vf, show_log=None) if __name__ == "__main__": from matplotlib import pyplot as plt - import logging - from ffmpegio import utils, ffmpegprocess - from ffmpegio.utils import log as log_utils # logging.basicConfig(level=logging.DEBUG) diff --git a/tests/test_streams_piped.py b/tests/test_streams_piped.py index 7655b782..d170a53b 100644 --- a/tests/test_streams_piped.py +++ b/tests/test_streams_piped.py @@ -1,7 +1,6 @@ import logging import numpy as np -import pytest import ffmpegio as ff from ffmpegio import streams @@ -14,7 +13,6 @@ outext = ".mp4" -@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaReader(): with streams.PipedFFmpegRunner.open_media_reader( [(mult_url, {})], None, options={"t_in": 1}, squeeze=False @@ -26,7 +24,6 @@ def test_MediaReader(): assert nframes == [30, 44100, 25, 44100] -@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaWriter_audio(): ff.use("read_numpy") @@ -49,7 +46,6 @@ def test_MediaWriter_audio(): b = writer.read_encoded(0) -@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaWriter(): ff.use("read_numpy") @@ -96,7 +92,6 @@ def test_MediaWriter(): assert isinstance(b, bytes) and len(b) > 0 -@pytest.mark.xdist_group(name="group_named_pipe") def test_SimpleMediaFilter(): ff.use("read_numpy") @@ -141,7 +136,6 @@ def test_SimpleMediaFilter(): assert nread == ntotal -@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaFilter(): ff.use("read_bytes") @@ -182,7 +176,6 @@ def test_MediaFilter(): f.wait(1) -@pytest.mark.xdist_group(name="group_named_pipe") def test_MediaTranscoder(): url = "tests/assets/sample.mp4" From ac0d0be8010b589fbcfa79d7d478081215183b3b Mon Sep 17 00:00:00 2001 From: "Takeshi Ikuma (LSUHSC)" Date: Thu, 12 Feb 2026 20:41:23 -0600 Subject: [PATCH 344/344] wip29 - fixing filtergraph --- Makefile | 2 +- docsrc/conf.py | 14 +-- docsrc/filtergraph.rst | 2 +- src/ffmpegio/filtergraph/Graph.py | 26 +++--- src/ffmpegio/filtergraph/GraphLinks.py | 113 +++++++++++++++++++++---- src/ffmpegio/filtergraph/abc.py | 13 ++- src/ffmpegio/filtergraph/build.py | 17 ++-- 7 files changed, 140 insertions(+), 47 deletions(-) diff --git a/Makefile b/Makefile index 4cd0e2e3..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 ?= -j 12 +SPHINXOPTS ?= -j auto -n -v -W -T SPHINXBUILD ?= sphinx-build SOURCEDIR = docsrc BUILDDIR = build diff --git a/docsrc/conf.py b/docsrc/conf.py index 2288c479..a5809871 100644 --- a/docsrc/conf.py +++ b/docsrc/conf.py @@ -20,7 +20,7 @@ project = "python-ffmpegio" copyright = ( - "2021-2025, 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,8 +36,8 @@ "sphinx.ext.intersphinx", "sphinx.ext.autosummary", "sphinx.ext.todo", - "sphinx.ext.graphviz", - "sphinxcontrib.repl", + # "sphinx.ext.graphviz", + # "sphinxcontrib.repl", "matplotlib.sphinxext.plot_directive", ] # Looks for objects in external projects @@ -48,7 +48,7 @@ autodoc_type_aliases = { "ArrayLike": "~numpy.typing.ArrayLike", "NDArray": "~numpy.typing.NDArray", - "ff": "ffmpegio" + "ff": "ffmpegio", } autodoc_mock_imports = ["builtins"] autodoc_typehints_format = "short" @@ -56,7 +56,7 @@ autodoc_default_options = {"exclude-members": "__new__", "class-doc-from": "init"} autodoc_typehints = "description" -overloads_location = 'signature' +overloads_location = "signature" # Intersphinx configuration intersphinx_mapping = { @@ -67,7 +67,7 @@ "python": ("https://docs.python.org/3/", None), } -autodoc_typehints = 'description' +autodoc_typehints = "description" # autodoc_type_aliases = {'AgentAssignment': 'AgentAssignment'} # Add any paths that contain templates here, relative to this directory. @@ -83,7 +83,7 @@ copybutton_selector = "div:not(.output_area) > div.highlight > pre" -graphviz_output_format = 'svg' +graphviz_output_format = "svg" # -- Options for HTML output ------------------------------------------------- diff --git a/docsrc/filtergraph.rst b/docsrc/filtergraph.rst index 29599b57..2634fcd9 100644 --- a/docsrc/filtergraph.rst +++ b/docsrc/filtergraph.rst @@ -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/src/ffmpegio/filtergraph/Graph.py b/src/ffmpegio/filtergraph/Graph.py index bf49b26a..e8e2bec8 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) @@ -273,7 +278,7 @@ def compose( for j, (index, _, _) in enumerate( self.iter_output_pads(unlabeled_only=True) ): - unc_pads[f"{label}{i+j+1}"] = (None, index) + unc_pads[f"{label}{i + j + 1}"] = (None, index) links = {**fg._links, **unc_pads} if i >= 0 or j >= 0 else fg._links @@ -304,11 +309,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) @@ -317,8 +325,8 @@ def __repr__(self): FFmpeg expression: \"{str(self)}\" Number of chains: {len(self)} {chain_list} - Available input pads ({self.get_num_inputs()}): {', '.join((str(id[0]) for id in self.iter_input_pads()))} - Available output pads: ({self.get_num_outputs()}): {', '.join((str(id[0]) for id in self.iter_output_pads()))} + Available input pads ({self.get_num_inputs()}): {", ".join((str(id[0]) for id in self.iter_input_pads()))} + Available output pads: ({self.get_num_outputs()}): {", ".join((str(id[0]) for id in self.iter_output_pads()))} """ def __setitem__(self, key, value): @@ -337,7 +345,7 @@ def __getitem__(self, key): return UserList.__getitem__(self, key) except (IndexError, StopIteration) as e: raise e - except Exception as e: + except Exception: try: assert len(key) == 2 and all((isinstance(k, int) for k in key)) return UserList.__getitem__(self, key[0])[key[1]] @@ -460,7 +468,6 @@ def _iter_pads( ioff = chain for i, c in enumerate(chains): - j = (len(c) + filter) if filter is not None and filter < 0 else filter for pidx, f, other_pidx in iter_filter_pad( @@ -942,14 +949,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: @@ -1016,7 +1022,6 @@ def _connect( 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), @@ -1064,7 +1069,6 @@ def _connect( # 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] diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index 671b1d29..d1c7f534 100644 --- a/src/ffmpegio/filtergraph/GraphLinks.py +++ b/src/ffmpegio/filtergraph/GraphLinks.py @@ -26,11 +26,7 @@ """ -class GraphLinks: ... - - class GraphLinks(UserDict): - class Error(FFmpegioError): pass @@ -83,7 +79,7 @@ def validate_pad_idx(id: PAD_INDEX | None, none_ok: bool = True): if id is None: if none_ok: return - raise GraphLinks.Error(f"pad index cannot be None") + raise GraphLinks.Error("pad index cannot be None") if not ( isinstance(id, (tuple)) @@ -101,7 +97,7 @@ def validate_pad_idx_pair(ids: PAD_PAIR): assert len(ids) == 2 except: raise GraphLinks.Error( - f"Link value must be a 2-element tuple with inpad and outpad pad ids" + "Link value must be a 2-element tuple with inpad and outpad pad ids" ) (inpad, outpad) = ids @@ -109,12 +105,12 @@ def validate_pad_idx_pair(ids: PAD_PAIR): inpad_is_none = inpad is None if inpad_is_none and outpad is None: - raise GraphLinks.Error(f"Both input and output pads cannot be None.") + raise GraphLinks.Error("Both input and output pads cannot be None.") i = -1 for i, d in enumerate(GraphLinks.iter_inpad_ids(inpad, True)): if d is None and not inpad_is_none: - raise GraphLinks.Error(f"multi-id input label item cannot be None.") + raise GraphLinks.Error("multi-id input label item cannot be None.") GraphLinks.validate_pad_idx(d) @staticmethod @@ -136,7 +132,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 @@ -343,7 +338,7 @@ def resolve_label( if not auto_index: raise GraphLinks.Error(f"{label=} is already in use.") i = 0 - label_ = f'{label}{auto_index_sep}' + label_ = f"{label}{auto_index_sep}" while label in self: i += 1 label = f"{label_}{i}" @@ -352,6 +347,94 @@ 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 + """ + + # 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 @@ -604,7 +687,7 @@ def are_linked( ) else: if inpad is None and outpad is None: - raise ValueError(f"At least one of inpad or outpad must be specified.") + raise ValueError("At least one of inpad or outpad must be specified.") # check internal links first it_links = self.iter_links() @@ -810,8 +893,8 @@ def update( if not isinstance(other, GraphLinks) and validate: try: assert isinstance(other, Mapping) - except Exception as e: - raise GraphLinks.Error(f"Other must be a dict-like mapping object") + except Exception: + raise GraphLinks.Error("Other must be a dict-like mapping object") self.validate(other) # set aside labels @@ -887,8 +970,8 @@ def adjust_filters(self, chain_id: int, pos: int, len: int): :param len: number of chains to be inserted (if positive) or removed (if negative) """ - select = ( - lambda pid: pid[0] == chain_id and pid[1] >= pos + select = lambda pid: ( + pid[0] == chain_id and pid[1] >= pos ) # select all chains at or above pos adjust = lambda pid: (pid[0], pid[1] + len, pid[2]) self._modify_pad_ids(select, adjust) diff --git a/src/ffmpegio/filtergraph/abc.py b/src/ffmpegio/filtergraph/abc.py index 3022925b..65c707ae 100644 --- a/src/ffmpegio/filtergraph/abc.py +++ b/src/ffmpegio/filtergraph/abc.py @@ -6,12 +6,22 @@ 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"] 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 @@ -759,7 +769,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.") @@ -813,7 +822,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.") @@ -994,7 +1002,6 @@ def resolve_pad_indices( ] if resolve_omitted: - # assign unknown pad indices in the order of the following ranking: # indices ranking # - int, int, int = 3*6 = 18 diff --git a/src/ffmpegio/filtergraph/build.py b/src/ffmpegio/filtergraph/build.py index f0ae395f..68ce005b 100644 --- a/src/ffmpegio/filtergraph/build.py +++ b/src/ffmpegio/filtergraph/build.py @@ -1,11 +1,11 @@ from __future__ import annotations from copy import copy -from itertools import islice 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"] @@ -204,12 +204,8 @@ def join( 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) - ) + 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": @@ -218,7 +214,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)] @@ -301,7 +296,6 @@ def analyze_fgobj(obj): right_objs_labels, attach_right = analyze_fgobj(right) if not (attach_left or attach_right): - if not len(right_objs_labels): return left_objs_labels if not len(left_objs_labels): @@ -416,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: