From fc697b1155489792e51e45aad8e3ed7ca6a2c883 Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Sat, 29 Mar 2025 09:45:33 -0400 Subject: [PATCH] Support writing files in Cython's pure python mode --- .github/workflows/smoke.yml | 3 +- .github/workflows/tests.yml | 2 +- Makefile | 2 +- av/filter/loudnorm.pxd | 15 ++++++++ av/filter/loudnorm.py | 48 +++++++++++++++++++++++++ av/filter/loudnorm.pyx | 69 ------------------------------------ av/{packet.pyx => packet.py} | 64 +++++++++++++++++---------------- pyproject.toml | 2 +- scripts/build | 2 +- setup.py | 22 +++++++----- 10 files changed, 117 insertions(+), 112 deletions(-) create mode 100644 av/filter/loudnorm.py delete mode 100644 av/filter/loudnorm.pyx rename av/{packet.pyx => packet.py} (79%) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 40dcb3fe6..5882bf1cf 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -121,8 +121,9 @@ jobs: . $CONDA/etc/profile.d/conda.sh conda config --set always_yes true conda config --add channels conda-forge + conda config --add channels scientific-python-nightly-wheels conda create -q -n pyav \ - cython \ + cython==3.1.0b0 \ numpy \ pillow \ pytest \ diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a6c5232b3..bf30096d5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,7 +13,7 @@ jobs: python-version: "3.13" - name: Build source package run: | - pip install setuptools cython + pip install -U --pre cython setuptools python scripts/fetch-vendor.py --config-file scripts/ffmpeg-7.1.json /tmp/vendor PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig python setup.py sdist - name: Upload source package diff --git a/Makefile b/Makefile index 667ff3116..84a36f2a8 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ default: build build: - $(PIP) install -U cython setuptools + $(PIP) install -U --pre cython setuptools CFLAGS=$(CFLAGS) LDFLAGS=$(LDFLAGS) $(PYTHON) setup.py build_ext --inplace --debug clean: diff --git a/av/filter/loudnorm.pxd b/av/filter/loudnorm.pxd index b08d3502f..2729dd8be 100644 --- a/av/filter/loudnorm.pxd +++ b/av/filter/loudnorm.pxd @@ -1,4 +1,19 @@ from av.audio.stream cimport AudioStream +cdef extern from "libavcodec/avcodec.h": + ctypedef struct AVCodecContext: + pass + +cdef extern from "libavformat/avformat.h": + ctypedef struct AVFormatContext: + pass + +cdef extern from "loudnorm_impl.h": + char* loudnorm_get_stats( + AVFormatContext* fmt_ctx, + int audio_stream_index, + const char* loudnorm_args + ) nogil + cpdef bytes stats(str loudnorm_args, AudioStream stream) diff --git a/av/filter/loudnorm.py b/av/filter/loudnorm.py new file mode 100644 index 000000000..0be991ab6 --- /dev/null +++ b/av/filter/loudnorm.py @@ -0,0 +1,48 @@ +import cython +from cython.cimports.av.audio.stream import AudioStream +from cython.cimports.av.container.core import Container +from cython.cimports.libc.stdlib import free + +from av.logging import get_level, set_level + + +@cython.ccall +def stats(loudnorm_args: str, stream: AudioStream) -> bytes: + """ + Get loudnorm statistics for an audio stream. + + Args: + loudnorm_args (str): Arguments for the loudnorm filter (e.g. "i=-24.0:lra=7.0:tp=-2.0") + stream (AudioStream): Input audio stream to analyze + + Returns: + bytes: JSON string containing the loudnorm statistics + """ + + if "print_format=json" not in loudnorm_args: + loudnorm_args = loudnorm_args + ":print_format=json" + + container: Container = stream.container + format_ptr: cython.pointer[AVFormatContext] = container.ptr + container.ptr = cython.NULL # Prevent double-free + + stream_index: cython.int = stream.index + py_args: bytes = loudnorm_args.encode("utf-8") + c_args: cython.p_const_char = py_args + result: cython.p_char + + # Save log level since C function overwrite it. + level = get_level() + + with cython.nogil: + result = loudnorm_get_stats(format_ptr, stream_index, c_args) + + if result == cython.NULL: + raise RuntimeError("Failed to get loudnorm stats") + + py_result = result[:] # Make a copy of the string + free(result) # Free the C string + + set_level(level) + + return py_result diff --git a/av/filter/loudnorm.pyx b/av/filter/loudnorm.pyx deleted file mode 100644 index 78f320a9e..000000000 --- a/av/filter/loudnorm.pyx +++ /dev/null @@ -1,69 +0,0 @@ -# av/filter/loudnorm.pyx - -cimport libav as lib -from cpython.bytes cimport PyBytes_FromString -from libc.stdlib cimport free - -from av.audio.codeccontext cimport AudioCodecContext -from av.audio.stream cimport AudioStream -from av.container.core cimport Container -from av.stream cimport Stream -from av.logging import get_level, set_level - - -cdef extern from "libavcodec/avcodec.h": - ctypedef struct AVCodecContext: - pass - -cdef extern from "libavformat/avformat.h": - ctypedef struct AVFormatContext: - pass - -cdef extern from "loudnorm_impl.h": - char* loudnorm_get_stats( - AVFormatContext* fmt_ctx, - int audio_stream_index, - const char* loudnorm_args - ) nogil - - -cpdef bytes stats(str loudnorm_args, AudioStream stream): - """ - Get loudnorm statistics for an audio stream. - - Args: - loudnorm_args (str): Arguments for the loudnorm filter (e.g. "i=-24.0:lra=7.0:tp=-2.0") - stream (AudioStream): Input audio stream to analyze - - Returns: - bytes: JSON string containing the loudnorm statistics - """ - - if "print_format=json" not in loudnorm_args: - loudnorm_args = loudnorm_args + ":print_format=json" - - cdef Container container = stream.container - cdef AVFormatContext* format_ptr = container.ptr - - container.ptr = NULL # Prevent double-free - - cdef int stream_index = stream.index - cdef bytes py_args = loudnorm_args.encode("utf-8") - cdef const char* c_args = py_args - cdef char* result - - # Save log level since C function overwrite it. - level = get_level() - - with nogil: - result = loudnorm_get_stats(format_ptr, stream_index, c_args) - - if result == NULL: - raise RuntimeError("Failed to get loudnorm stats") - - py_result = result[:] # Make a copy of the string - free(result) # Free the C string - - set_level(level) - - return py_result diff --git a/av/packet.pyx b/av/packet.py similarity index 79% rename from av/packet.pyx rename to av/packet.py index b5c9251eb..c49085c57 100644 --- a/av/packet.pyx +++ b/av/packet.py @@ -1,27 +1,30 @@ -cimport libav as lib +import cython +from cython.cimports import libav as lib +from cython.cimports.av.bytesource import bytesource +from cython.cimports.av.error import err_check +from cython.cimports.av.opaque import opaque_container +from cython.cimports.av.utils import avrational_to_fraction, to_avrational -from av.bytesource cimport bytesource -from av.error cimport err_check -from av.opaque cimport opaque_container -from av.utils cimport avrational_to_fraction, to_avrational - - -cdef class Packet(Buffer): +@cython.cclass +class Packet(Buffer): """A packet of encoded data within a :class:`~av.format.Stream`. This may, or may not include a complete object within a stream. :meth:`decode` must be called to extract encoded data. - """ def __cinit__(self, input=None): - with nogil: + with cython.nogil: self.ptr = lib.av_packet_alloc() + def __dealloc__(self): + with cython.nogil: + lib.av_packet_free(cython.address(self.ptr)) + def __init__(self, input=None): - cdef size_t size = 0 - cdef ByteSource source = None + size: cython.size_t = 0 + source: ByteSource = None if input is None: return @@ -41,24 +44,24 @@ def __init__(self, input=None): # instead of its data. # self.source = source - def __dealloc__(self): - with nogil: - lib.av_packet_free(&self.ptr) - def __repr__(self): stream = self._stream.index if self._stream else 0 return ( - f"" ) # Buffer protocol. - cdef size_t _buffer_size(self): + @cython.cfunc + def _buffer_size(self) -> cython.size_t: return self.ptr.size - cdef void* _buffer_ptr(self): + + @cython.cfunc + def _buffer_ptr(self) -> cython.p_void: return self.ptr.data - cdef _rebase_time(self, lib.AVRational dst): + @cython.cfunc + def _rebase_time(self, dst: lib.AVRational): if not dst.num: raise ValueError("Cannot rebase to zero time.") @@ -92,7 +95,7 @@ def stream(self): return self._stream @stream.setter - def stream(self, Stream stream): + def stream(self, stream: Stream): self._stream = stream self.ptr.stream_index = stream.ptr.index @@ -103,11 +106,11 @@ def time_base(self): :type: fractions.Fraction """ - return avrational_to_fraction(&self._time_base) + return avrational_to_fraction(cython.address(self._time_base)) @time_base.setter def time_base(self, value): - to_avrational(value, &self._time_base) + to_avrational(value, cython.address(self._time_base)) @property def pts(self): @@ -116,7 +119,7 @@ def pts(self): This is the time at which the packet should be shown to the user. - :type: int + :type: int | None """ if self.ptr.pts != lib.AV_NOPTS_VALUE: return self.ptr.pts @@ -133,7 +136,7 @@ def dts(self): """ The decoding timestamp in :attr:`time_base` units for this packet. - :type: int + :type: int | None """ if self.ptr.dts != lib.AV_NOPTS_VALUE: return self.ptr.dts @@ -152,7 +155,7 @@ def pos(self): Returns `None` if it is not known. - :type: int + :type: int | None """ if self.ptr.pos != -1: return self.ptr.pos @@ -221,14 +224,15 @@ def is_disposable(self): @property def opaque(self): - if self.ptr.opaque_ref is not NULL: - return opaque_container.get( self.ptr.opaque_ref.data) + if self.ptr.opaque_ref is not cython.NULL: + return opaque_container.get( + cython.cast(cython.p_char, self.ptr.opaque_ref.data) + ) @opaque.setter def opaque(self, v): - lib.av_buffer_unref(&self.ptr.opaque_ref) + lib.av_buffer_unref(cython.address(self.ptr.opaque_ref)) if v is None: return self.ptr.opaque_ref = opaque_container.add(v) - diff --git a/pyproject.toml b/pyproject.toml index 15ed77023..0bfb6cce9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>61", "cython>=3,<4"] +requires = ["setuptools>61", "cython>=3.1.0a1,<4"] [project] name = "av" diff --git a/scripts/build b/scripts/build index 7e27d7f74..3b9346d60 100755 --- a/scripts/build +++ b/scripts/build @@ -21,6 +21,6 @@ which ffmpeg || exit 2 ffmpeg -version || exit 3 echo -$PYAV_PIP install -U cython setuptools 2> /dev/null +$PYAV_PIP install -U --pre cython setuptools 2> /dev/null "$PYAV_PYTHON" scripts/comptime.py "$PYAV_PYTHON" setup.py config build_ext --inplace || exit 1 diff --git a/setup.py b/setup.py index f1ae841dd..d5ad4fb31 100644 --- a/setup.py +++ b/setup.py @@ -177,13 +177,15 @@ def parse_cflags(raw_flags): "library_dirs": [], } +IMPORT_NAME = "av" + loudnorm_extension = Extension( - "av.filter.loudnorm", + f"{IMPORT_NAME}.filter.loudnorm", sources=[ - "av/filter/loudnorm.pyx", - "av/filter/loudnorm_impl.c", + f"{IMPORT_NAME}/filter/loudnorm.py", + f"{IMPORT_NAME}/filter/loudnorm_impl.c", ], - include_dirs=["av/filter"] + extension_extra["include_dirs"], + include_dirs=[f"{IMPORT_NAME}/filter"] + extension_extra["include_dirs"], libraries=extension_extra["libraries"], library_dirs=extension_extra["library_dirs"], ) @@ -204,10 +206,14 @@ def parse_cflags(raw_flags): include_path=["include"], ) -for dirname, dirnames, filenames in os.walk("av"): +for dirname, dirnames, filenames in os.walk(IMPORT_NAME): for filename in filenames: # We are looking for Cython sources. - if filename.startswith(".") or os.path.splitext(filename)[1] != ".pyx": + if filename.startswith("."): + continue + if filename in {"__init__.py", "__main__.py", "about.py", "datasets.py"}: + continue + if os.path.splitext(filename)[1] not in {".pyx", ".py"}: continue pyx_path = os.path.join(dirname, filename) @@ -236,13 +242,13 @@ def parse_cflags(raw_flags): insert_enum_in_generated_files(cfile) -package_folders = pathlib.Path("av").glob("**/") +package_folders = pathlib.Path(IMPORT_NAME).glob("**/") package_data = { ".".join(pckg.parts): ["*.pxd", "*.pyi", "*.typed"] for pckg in package_folders } setup( - packages=find_packages(include=["av*"]), + packages=find_packages(include=[f"{IMPORT_NAME}*"]), package_data=package_data, ext_modules=ext_modules, )