diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e68481872..bfb570ae4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -65,6 +65,7 @@ Fixes: - Fix crashes from indexes that were turned into C pointer arithmetic without being range checked. ``MotionVectors[i]`` only checked the upper bound, so a negative index read off the front of the buffer (``mvs[-1]`` now returns the last vector, as with any sequence); ``VideoFormatComponent`` and ``AudioPlane`` accepted any index at all; and ``BitmapSubtitlePlane`` and ``VideoBlockParams`` were missing their lower bounds. - Frames returned by flushing a codec context directly (``CodecContext.decode()`` with no packet) now carry the stream's ``time_base`` instead of ``None``. - ``VideoFrame.reformat()`` (and so ``to_ndarray(format=...)``, ``to_rgb()``, ``to_image()``) now shares one ``SwsContext`` per thread instead of allocating one per frame. FFmpeg 8's swscale retains megabytes of graph state per context, which showed up as large RSS growth when many frames were alive at once. +- Building from source against a shared FFmpeg configured with ``--enable-rpath`` no longer fails on ``-Wl,-rpath`` pkg-config flags, and the compiled extensions now embed that rpath. 18.X and Below diff --git a/MANIFEST.in b/MANIFEST.in index b87a010d3..f1e792c35 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,5 @@ include *.txt *.md +include setup_cflags.py recursive-include av *.pyx *.pxd recursive-include docs *.rst *.py recursive-include examples *.py diff --git a/Makefile b/Makefile index 4fb974eb2..d3a52aa95 100644 --- a/Makefile +++ b/Makefile @@ -30,7 +30,7 @@ fate-suite: lint: $(PIP) install -U ruff isort pillow numpy mypy==2.1.0 pytest - ruff format --check av examples tests setup.py + ruff format --check av examples tests setup.py setup_cflags.py isort --check-only --diff av examples tests mypy av tests diff --git a/setup.py b/setup.py index 61a77447b..1271db8fc 100644 --- a/setup.py +++ b/setup.py @@ -1,9 +1,7 @@ -import argparse import os import pathlib import platform import re -import shlex import subprocess import sys @@ -11,6 +9,8 @@ from Cython.Compiler.AutoDocTransforms import EmbedSignature from setuptools import Extension, find_packages, setup +from setup_cflags import parse_cflags + FFMPEG_LIBRARIES = [ "avformat", "avcodec", @@ -68,11 +68,14 @@ def get_config_from_directory(ffmpeg_dir): if not os.path.exists(library_dir): library_dir = FFMPEG_DIR - return { + config = { "include_dirs": [include_dir], "libraries": FFMPEG_LIBRARIES, "library_dirs": [library_dir], } + if platform.system() != "Windows": + config["runtime_library_dirs"] = [library_dir] + return config def get_config_from_pkg_config(): @@ -102,24 +105,6 @@ def get_config_from_pkg_config(): return known -def parse_cflags(raw_flags): - parser = argparse.ArgumentParser(add_help=False) - parser.add_argument("-I", dest="include_dirs", action="append") - parser.add_argument("-L", dest="library_dirs", action="append") - parser.add_argument("-l", dest="libraries", action="append") - parser.add_argument("-D", dest="define_macros", action="append") - parser.add_argument("-R", dest="runtime_library_dirs", action="append") - - raw_args = shlex.split(raw_flags.strip()) - args, unknown = parser.parse_known_args(raw_args) - config = {k: v or [] for k, v in args.__dict__.items()} - for i, x in enumerate(config["define_macros"]): - parts = x.split("=", 1) - value = x[1] or None if len(x) == 2 else None - config["define_macros"][i] = (parts[0], value) - return config, " ".join(shlex.quote(x) for x in unknown) - - # Parse command-line arguments. FFMPEG_DIR = None for i, arg in enumerate(sys.argv): @@ -141,6 +126,14 @@ def parse_cflags(raw_flags): IMPORT_NAME = "av" +# MSVC cannot embed rpath. An empty list is fine; a path raises +# "don't know how to set runtime library search path for MSVC". +runtime_library_dirs = ( + [] + if platform.system() == "Windows" + else extension_extra.get("runtime_library_dirs", []) +) + loudnorm_extension = Extension( f"{IMPORT_NAME}.filter.loudnorm", sources=[ @@ -150,6 +143,7 @@ def parse_cflags(raw_flags): include_dirs=[f"{IMPORT_NAME}/filter"] + extension_extra["include_dirs"], libraries=extension_extra["libraries"], library_dirs=extension_extra["library_dirs"], + runtime_library_dirs=runtime_library_dirs, define_macros=define_macros, py_limited_api=py_limited_api, ) @@ -195,6 +189,7 @@ def parse_cflags(raw_flags): include_dirs=extension_extra["include_dirs"], libraries=extension_extra["libraries"], library_dirs=extension_extra["library_dirs"], + runtime_library_dirs=runtime_library_dirs, sources=[pyx_path], define_macros=define_macros, py_limited_api=py_limited_api, diff --git a/setup_cflags.py b/setup_cflags.py new file mode 100644 index 000000000..a96663e1c --- /dev/null +++ b/setup_cflags.py @@ -0,0 +1,54 @@ +import argparse +import shlex + +# GNU ld spellings FFmpeg writes into .pc Libs: when configured with --enable-rpath. +_WL_RPATH_PREFIXES = ("-Wl,-rpath,", "-Wl,-rpath=") + + +def _rpath_from_wl_flag(flag): + """Return a path from a GNU rpath linker flag, or None.""" + for prefix in _WL_RPATH_PREFIXES: + if flag.startswith(prefix): + path = flag[len(prefix) :] + if path: + return path + return None + + +def _unique(items): + seen = set() + unique = [] + for item in items: + if item not in seen: + seen.add(item) + unique.append(item) + return unique + + +def parse_cflags(raw_flags): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("-I", dest="include_dirs", action="append") + parser.add_argument("-L", dest="library_dirs", action="append") + parser.add_argument("-l", dest="libraries", action="append") + parser.add_argument("-D", dest="define_macros", action="append") + parser.add_argument("-R", dest="runtime_library_dirs", action="append") + + raw_args = shlex.split(raw_flags.strip()) + args, unknown = parser.parse_known_args(raw_args) + config = {k: v or [] for k, v in args.__dict__.items()} + for i, x in enumerate(config["define_macros"]): + parts = x.split("=", 1) + value = x[1] or None if len(x) == 2 else None + config["define_macros"][i] = (parts[0], value) + + remaining_unknown = [] + rpaths = list(config["runtime_library_dirs"]) + for flag in unknown: + path = _rpath_from_wl_flag(flag) + if path is not None: + rpaths.append(path) + else: + remaining_unknown.append(flag) + + config["runtime_library_dirs"] = _unique(rpaths) + return config, " ".join(shlex.quote(x) for x in remaining_unknown) diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 000000000..829f725e8 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,73 @@ +from setup_cflags import parse_cflags + +BASE_FLAGS = "-I/opt/ffmpeg/include -L/opt/ffmpeg/lib -lavcodec" + + +def test_parse_cflags_known_flags() -> None: + config, unknown = parse_cflags(BASE_FLAGS) + + assert unknown == "" + assert config["include_dirs"] == ["/opt/ffmpeg/include"] + assert config["library_dirs"] == ["/opt/ffmpeg/lib"] + assert config["libraries"] == ["avcodec"] + assert config["runtime_library_dirs"] == [] + + +def test_parse_cflags_dash_r() -> None: + config, unknown = parse_cflags(f"{BASE_FLAGS} -R/opt/ffmpeg/lib") + + assert unknown == "" + assert config["runtime_library_dirs"] == ["/opt/ffmpeg/lib"] + + +def test_parse_cflags_wl_rpath_comma() -> None: + config, unknown = parse_cflags(f"{BASE_FLAGS} -Wl,-rpath,/opt/ffmpeg/lib") + + assert unknown == "" + assert config["runtime_library_dirs"] == ["/opt/ffmpeg/lib"] + + +def test_parse_cflags_wl_rpath_equals() -> None: + config, unknown = parse_cflags(f"{BASE_FLAGS} -Wl,-rpath=/opt/ffmpeg/lib") + + assert unknown == "" + assert config["runtime_library_dirs"] == ["/opt/ffmpeg/lib"] + + +def test_parse_cflags_unknown_flag() -> None: + config, unknown = parse_cflags(f"{BASE_FLAGS} -Wl,-z,defs") + + assert "-Wl,-z,defs" in unknown + assert config["runtime_library_dirs"] == [] + + +def test_parse_cflags_ffmpeg_enable_rpath_pkg_config() -> None: + raw = ( + "-I/opt/ffmpeg/include -L/opt/ffmpeg/lib -lavformat -lavcodec " + "-lavdevice -lavutil -lavfilter -lswscale -lswresample " + "-Wl,-rpath,/opt/ffmpeg/lib" + ) + config, unknown = parse_cflags(raw) + + assert unknown == "" + assert config["runtime_library_dirs"] == ["/opt/ffmpeg/lib"] + assert config["include_dirs"] == ["/opt/ffmpeg/include"] + assert config["library_dirs"] == ["/opt/ffmpeg/lib"] + assert config["libraries"] == [ + "avformat", + "avcodec", + "avdevice", + "avutil", + "avfilter", + "swscale", + "swresample", + ] + + +def test_parse_cflags_deduplicates_rpath() -> None: + config, unknown = parse_cflags( + f"{BASE_FLAGS} -R/opt/ffmpeg/lib -Wl,-rpath,/opt/ffmpeg/lib" + ) + + assert unknown == "" + assert config["runtime_library_dirs"] == ["/opt/ffmpeg/lib"]