diff --git a/src/ffmpegio/configure.py b/src/ffmpegio/configure.py index 14c291c6..b49d2a7d 100644 --- a/src/ffmpegio/configure.py +++ b/src/ffmpegio/configure.py @@ -8,7 +8,8 @@ logger = logging.getLogger("ffmpegio") from . import utils, plugins -from .filtergraph import Graph, Filter, Chain +from .utils.typing import FFmpeg_Arguments +from .filtergraph import Graph, Filter, Chain, as_filtergraph_object from .filtergraph.abc import FilterGraphObject from .errors import FFmpegioError @@ -184,48 +185,83 @@ def add_url(args, type, url, opts=None, update=False): return id, filelist[id] -def has_filtergraph(args, type): +def has_filtergraph( + args: FFmpeg_Arguments, + media_type: Literal["video", "audio"], + file_id: int = 0, + stream_spec: str | None = None, +) -> list[FilterGraphObject] | dict[str, FilterGraphObject] | FilterGraphObject | None: """True if FFmpeg arguments specify a filter graph :param args: FFmpeg argument dict - :type args: dict - :param type: filter type - :type type: 'video' or 'audio' - :param file_id: specify output file id (ignored if type=='complex'), defaults to None (or 0) - :type file_id: int, optional - :param stream_id: stream, defaults to None - :type stream_id: int, optional - :return: True if filter graph is specified - :rtype: bool + :param media_type: stream media type + :param file_id: specify output file id, defaults to None (or 0) + :param stream_id: stream, defaults to None (first mapped) + :return: None if no filter is defined for the specified output file for the + specified media type, a list of filtergraphs if filter_complex global + option is specified, or a dict of filtergraphs keyed by the simple + filter options. If stream_spec is not None and finds a matching simple + filter option, it returns the best matched filter. + """ try: - if ( - "filter_complex" in args["global_options"] - or "lavfi" in args["global_options"] - ): - return True - except: - pass # no global_options defined + graph = args["global_options"]["filter_complex"] + except KeyError: + try: + graph = args["global_options"]["lavfi"] + except KeyError: + graph = None # no global filtergraph options defined + + if isinstance(graph, (str, FilterGraphObject)): + return [as_filtergraph_object(graph)] + elif isinstance(graph, Sequence): + return [as_filtergraph_object(fg) for fg in graph] # input filter - if any( - ( - opts is not None and opts.get("f", None) == "lavfi" - for _, opts in args["inputs"] - ) - ): - return True + # if any( + # ( + # opts is not None and opts.get("f", None) == "lavfi" + # for _, opts in args["inputs"] + # ) + # ): + # return True + + # find output filter options + + out_args = args["outputs"][file_id] + if isinstance(out_args, str): + # only output url given + return None + + out_opts = out_args[1] + + # if int stream_spec given, it's the stream_id within the specified media type + if isinstance(stream_spec, int): + # specific stream number of the specified media type + stream_spec = f"{media_type[0]}:{stream_spec}" + + # output filter options + fkey = f"filter:{media_type[0]}" + keys = ({"video": "vf", "audio": "af"}[media_type], "filter", fkey) + if stream_spec is None: + fkey += ":" + fcn = lambda k: k.startswith(fkey) + else: + keys = (*keys, f"filter:{stream_spec}") + fcn = lambda k: False + + found = [k for k in out_opts if k in keys or fcn(k)] - # 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 + if not len(found): + return None + if stream_spec is None: + return {k: as_filtergraph_object(out_opts[k]) for k in found} - return False # no output options defined + # return only the filtergraph to be applied to the specified stream + key = max(found, key=len) + if keys[0] in found and key == "filter": + key = keys[0] + return as_filtergraph_object(out_opts[key]) def finalize_video_read_opts( @@ -266,7 +302,7 @@ 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: + if has_filtergraph(args, "video") is None and ncomp is not None: r = outopts.get("r", inopts.get("r", r_in)) s = outopts.get("s", inopts.get("s", s_in)) @@ -423,11 +459,16 @@ 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 -): + args: FFmpeg_Arguments, + sample_fmt_in: str | None = None, + ac_in: int | None = None, + ar_in: int | None = None, + ofile: int = 0, + ifile: int = 0, + ostream: int = 0, +) -> tuple[str, int, int]: inopts = args["inputs"][ifile][1] or {} outopts = args["outputs"][ofile][1] - has_filter = has_filtergraph(args, "audio") if outopts is None: outopts = {} @@ -447,10 +488,29 @@ def finalize_audio_read_opts( # 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", inopts.get("ac", ac_in)) - ar = outopts.get("ar", inopts.get("ar", ar_in)) + ac = outopts.get("ac", None) + ar = outopts.get("ar", None) + + if ac is not None or ar is not None: + has_filter = has_filtergraph(args, "audio", ofile, 0, ostream) + if isinstance(has_filter, Sequence): + # complex filter + ... + elif has_filter is not None: + # simple filter + # find last ar changing filter (aresample, asetrate, aformat, aselect) + # loudnorm -> 192000 if dynamic mode + # measured_I, measured_LRA, measured_TP, and measured_thresh must all be specified + # Target LRA shouldn’t be lower than source LRA and the change in integrated loudness shouldn’t result in a true peak which exceeds the target TP. + # If any of these conditions aren’t met, normalization mode will revert to dynamic. + # Options are true or false. Default is true. + + if ac is not None or ar is not None: + # no filter + if ac is None: + ac = inopts.get("ac", ac_in) + if ar is None: + ar = inopts.get("ar", ar_in) # sample_fmt must be given dtype, shape = utils.get_audio_format(sample_fmt, ac) diff --git a/src/ffmpegio/ffmpegprocess.py b/src/ffmpegio/ffmpegprocess.py index e7bde8f5..e38833bc 100644 --- a/src/ffmpegio/ffmpegprocess.py +++ b/src/ffmpegio/ffmpegprocess.py @@ -19,6 +19,10 @@ """ +from __future__ import annotations + +from typing import TYPE_CHECKING + from collections import abc from os import path, name as os_name from threading import Thread @@ -30,6 +34,19 @@ logger = logging.getLogger("ffmpegio") +from .utils.typing import ( + FFmpeg_Arguments, + Any, + Sequence, + Callable, +) + +if TYPE_CHECKING: + from .utils.typing import ( + SupportsRead, + SupportsWrite, + ) + from .utils.parser import parse, compose, FLAG from .threading import ProgressMonitorThread from .configure import move_global_options @@ -39,39 +56,30 @@ def exec( - ffmpeg_args, - hide_banner=True, - progress=None, - overwrite=None, - capture_log=None, - stdin=None, - stdout=None, - stderr=None, - sp_run=sp.run, + ffmpeg_args: FFmpeg_Arguments | Sequence[str] | str, + hide_banner: bool = True, + progress: ProgressMonitorThread | None = None, + overwrite: bool | None = None, + capture_log: bool | None = None, + stdin: SupportsRead | None = None, + stdout: SupportsWrite | None = None, + stderr: SupportsWrite | None = None, + sp_run: Callable = sp.run, **sp_kwargs, -): +)->Any: """run ffmpeg command :param ffmpeg_args: FFmpeg argument options - :type ffmpeg_args: dict, seq(str), or str :param hide_banner: False to output ffmpeg banner in stderr, defaults to True - :type hide_banner: bool, optional :param progress: progress monitor object, defaults to None - :type progress: ProgressMonitorThread, optional :param overwrite: True to overwrite if output url exists, defaults to None (auto-select) - :type overwrite: bool, optional :param capture_log: True to capture log messages on stderr, False to suppress console log messages, defaults to None (show on console) - :type capture_log: bool or None, optional :param stdin: source file object, defaults to None - :type stdin: readable file-like object, optional :param stdout: sink file object, defaults to None - :type stdout: writable file-like object, optional :param stderr: file to log ffmpeg messages, defaults to None - :type stderr: writable file-like object, optional :param sp_run: function to run FFmpeg as a subprocess, defaults to subprocess.run - :type sp_run: Callable, optional :param **sp_kwargs: additional keyword arguments for sp_run, optional :type **sp_kwargs: dict :return: depends on sp_run @@ -374,7 +382,7 @@ def send_signal(self, sig: int = None, kill_monitor: bool = False): Without any argument, `send_signal()` will perform control-C to initiate soft-terminate FFmpeg. FFmpeg may output additional frames before exits. - Note: Setting `kill_monitor=True` will block the caller thread until the + Note: Setting `kill_monitor=True` will block the caller thread until the FFmpeg terminates. """ diff --git a/src/ffmpegio/filtergraph/GraphLinks.py b/src/ffmpegio/filtergraph/GraphLinks.py index bfdb7f2f..77b2bc01 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_stream_spec as _is_stream_spec from ..errors import FFmpegioError from .typing import PAD_INDEX, PAD_PAIR, Literal @@ -26,6 +26,9 @@ """ +def is_stream_spec(label:str)->bool: + # also allow loopback stream + return _is_stream_spec(label) or re.match(r'dec:\d+',label) class GraphLinks: ... diff --git a/src/ffmpegio/utils/parser.py b/src/ffmpegio/utils/parser.py index c07487a0..0f29d48a 100644 --- a/src/ffmpegio/utils/parser.py +++ b/src/ffmpegio/utils/parser.py @@ -2,6 +2,7 @@ from collections import abc from ..filtergraph import Graph, Chain, Filter +from ..filtergraph.abc import FilterGraphObject from .. import devices __all__ = ["parse", "compose", "FLAG"] @@ -84,7 +85,11 @@ def parse(cmdline): elif all_gopts[k] is FLAG: gopts[k] = FLAG else: - gopts[k] = args[i + 1] + if k == "filter_complex" and isinstance(gopts.get(k, None), str): + # if more than 1 filter_complex, return a list instead + gopts[k] = [gopts[k], args[i + 1]] + else: + gopts[k] = args[i + 1] is_lopt[i + 1] = False args = [s for s, tf in zip(args, is_lopt) if tf] @@ -137,20 +142,20 @@ def compose(args, command="", shell_command=False): If global_options is None and only 1 output file given, FFmpeg global options may be specified as additional output options. + """ def finalize_global(key, val): + if key == "filter_complex" and not isinstance(val, (str, FilterGraphObject, list)): + val = list(val) return 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 - ] + elif key == "map" and not isinstance(val, str, int, list): + # multiple stream mapping, must be a list + val = list(val) return key, val def finalize_input(key, val): @@ -166,6 +171,8 @@ def opts2args(opts, finalize): opts_parsed = {} for itm in opts.items(): k, v = finalize(*itm) + # if isinstance(v,list): + # # multiple option values oname, *sspec = k.split(":", 1) o = opts_parsed.get(oname, None) if o is None: @@ -194,7 +201,7 @@ def set_arg(karg, val): return args - def inputs2args(inputs): + def inputs2args(inputs)->list[str]: args = [] for url, opts in inputs: # resolve url enumeration if it's a device @@ -210,7 +217,7 @@ def inputs2args(inputs): ) return args - def outputs2args(outputs): + def outputs2args(outputs)->list[str]: args = [] for url, opts in outputs: # resolve url enumeration if it's a device diff --git a/src/ffmpegio/utils/typing.py b/src/ffmpegio/utils/typing.py index 3f4bf871..3bec985b 100644 --- a/src/ffmpegio/utils/typing.py +++ b/src/ffmpegio/utils/typing.py @@ -3,6 +3,10 @@ from typing import * from typing_extensions import * + +if TYPE_CHECKING: + from _typeshed import SupportsRead, SupportsWrite + # from typing_extensions import * MediaType = Literal["v", "a", "s", "d", "t", "V"] @@ -31,3 +35,9 @@ class StreamSpec_Usable(StreamSpec_Options): StreamSpec = Union[StreamSpec_Index, StreamSpec_Tag, StreamSpec_Usable] + + +class FFmpeg_Arguments(TypedDict): + inputs: list[str | tuple[str, dict[str, Any]]] + outputs: list[str | tuple[str, dict[str, Any]]] + global_options: dict[str, Any] # py3.11 NotRequired[int] diff --git a/tests/test_configure.py b/tests/test_configure.py index 13562ca9..3f36d0f4 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -1,4 +1,7 @@ +import pytest + from ffmpegio import configure +from ffmpegio.filtergraph import as_filtergraph_object, as_filtergraph_object_like vid_url = "tests/assets/testvideo-1m.mp4" img_url = "tests/assets/ffmpeg-logo.png" @@ -120,3 +123,59 @@ def test_video_basic_filter(): # transpose="clock", ) ) + + +@pytest.mark.parametrize( + "args,media_type,file_id,stream_spec,ret", + [ + ( + {"global_options": {"filter_complex": "[0:v][dec:0]hstack[stack]"}}, + "video", + 0, + None, + ["[0:v][dec:0]hstack[stack]"], + ), + ( + {"outputs": [('-', {"filter": "boxblur",'vf':'avgblur','filter:v:0':'crop'})]}, + "video", + 0, + None, + {"filter": "boxblur",'vf':'avgblur','filter:v:0':'crop'}, + ), + ( + {"outputs": [('-', {"filter": "boxblur",'vf':'avgblur','filter:v:0':'crop'})]}, + "audio", + 0, + None, + {"filter": "boxblur"}, + ), + ( + {"outputs": [('-', {"filter": "boxblur",'vf':'avgblur','filter:v:0':'crop'})]}, + "video", + 0, + 1, + {'vf':'avgblur'}, + ), + ( + {"outputs": [('-', {"filter": "boxblur",'vf':'avgblur','filter:v:0':'crop'})]}, + "video", + 0, + 0, + {'filter:v:0':'crop'}, + ), + ], +) +def test_has_filtergraph(args, media_type, file_id, stream_spec, ret): + val = configure.has_filtergraph(args, media_type, file_id, stream_spec) + if ret is None: + assert val is None + else: + assert len(ret)==len(val) + if isinstance(ret, list): + ret = [as_filtergraph_object(r) for r in ret] + for r, v in zip(ret, val): + assert as_filtergraph_object_like(v, r) == r + elif isinstance(ret, dict): + ret = {k: as_filtergraph_object(r) for k,r in ret.items()} + for k, v in val.items(): + assert as_filtergraph_object_like(v, ret[k]) == ret[k] diff --git a/tests/test_utils_parser.py b/tests/test_utils_parser.py index 0000195f..53646f03 100644 --- a/tests/test_utils_parser.py +++ b/tests/test_utils_parser.py @@ -100,3 +100,9 @@ def test_compose(): ) == "ffmpeg -i /tmp/a.wav -map 0:a -b:a 64k '/tmp/a test.mp2' -map 0:a -b:a 128k /tmp/b.mp2" ) + +def test_ffmpeg7(): + ffmpeg7_example = "ffmpeg -i input.mkv -filter_complex '[0:v]scale=size=hd1080,split=outputs=2[for_enc][orig_scaled]' -filter_complex '[dec:0][orig_scaled]hstack[stacked]' -c:v libx264 -map '[for_enc]' output.mkv -dec 0:0 -map '[stacked]' -c:v ffv1 comparison.mkv" + + ffargs = parser.parse(ffmpeg7_example) + assert len(ffargs['global_options']['filter_complex'])==2 \ No newline at end of file