diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5857d1a66..1a69e9273 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,7 +61,9 @@ jobs: CIBW_BEFORE_BUILD: python scripts/fetch-vendor.py --config-file scripts/ffmpeg-latest.json /tmp/vendor CIBW_BEFORE_BUILD_MACOS: python scripts/fetch-vendor.py --config-file scripts/ffmpeg-latest.json /tmp/vendor CIBW_BEFORE_BUILD_WINDOWS: python scripts\fetch-vendor.py --config-file scripts\ffmpeg-latest.json C:\cibw\vendor - CIBW_ENVIRONMENT_LINUX: LD_LIBRARY_PATH=/tmp/vendor/lib:$LD_LIBRARY_PATH PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig + # Linux is the only platform that embeds debug info in the extensions; + # macOS and Windows keep it out of the shipped binaries. + CIBW_ENVIRONMENT_LINUX: LD_LIBRARY_PATH=/tmp/vendor/lib:$LD_LIBRARY_PATH PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig LDFLAGS=-Wl,--strip-debug CIBW_ENVIRONMENT_MACOS: PKG_CONFIG_PATH=/tmp/vendor/lib/pkgconfig LDFLAGS=-headerpad_max_install_names CIBW_ENVIRONMENT_WINDOWS: INCLUDE=C:\\cibw\\vendor\\include LIB=C:\\cibw\\vendor\\lib PYAV_SKIP_TESTS=unicode_filename CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: delvewheel repair --add-path C:\cibw\vendor\bin -w {dest_dir} {wheel} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ef9e71bb6..80513c758 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -46,6 +46,7 @@ Fixes: - Type stubs now pass ``mypy --strict`` and have been corrected throughout to match runtime behavior, including attributes on the wrong class, values that may be ``None``, missing return types, and mismatched parameter names. - ``FilterContext.link_to`` raises ``ValueError`` when linking filters from different graphs instead of aborting the process. - Filters added or linked after ``Graph.configure()`` are now configured on the next push or pull instead of segfaulting. +- ``SubtitleCodecContext.decode(None)`` flushes the decoder instead of raising ``RuntimeError``, so delayed decoders such as EIA-608 closed captions return their final subtitle. v19.0.2 ------- diff --git a/av/audio/codeccontext.pyi b/av/audio/codeccontext.pyi index 46e4bf853..8af27b26c 100644 --- a/av/audio/codeccontext.pyi +++ b/av/audio/codeccontext.pyi @@ -1,5 +1,5 @@ from collections.abc import Iterator -from typing import Any, Literal, final +from typing import Any, Literal, final, override from av.codec.context import CodecContext from av.packet import Packet @@ -27,8 +27,11 @@ class AudioCodecContext(CodecContext): def channels(self) -> int: ... @property def block_align(self) -> int: ... + @override def encode(self, frame: AudioFrame | None = None) -> list[Packet[Stream]]: ... # type: ignore[override] + @override def encode_lazy( # type: ignore[override] self, frame: AudioFrame | None = None ) -> Iterator[Packet[Stream]]: ... + @override def decode(self, packet: Packet[Any] | None = None) -> list[AudioFrame]: ... diff --git a/av/codec/context.pyi b/av/codec/context.pyi index 1ff6409cd..e5b81d351 100644 --- a/av/codec/context.pyi +++ b/av/codec/context.pyi @@ -13,7 +13,7 @@ from av.packet import Packet, PktSideDataT from av.rational import AVRational from av.sidedata.sidedata import Type as FrameSideDataType from av.stream import Stream -from av.subtitles import _SubtitleCodecName +from av.subtitles import _SubtitleCodecName, _SubtitleDecoderName from av.subtitles.codeccontext import SubtitleCodecContext from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle from av.video import _VideoCodecName @@ -222,6 +222,13 @@ class CodecContext: ) -> SubtitleCodecContext: ... @overload @staticmethod + def create( + codec: _SubtitleDecoderName, + mode: Literal["r"] | None = None, + hwaccel: HWAccel | None = None, + ) -> SubtitleCodecContext: ... + @overload + @staticmethod def create( codec: str | Codec, mode: Literal["r", "w"] | None = None, diff --git a/av/subtitles/__init__.pyi b/av/subtitles/__init__.pyi index 004efc264..71d02168e 100644 --- a/av/subtitles/__init__.pyi +++ b/av/subtitles/__init__.pyi @@ -16,3 +16,38 @@ type _SubtitleCodecName = Literal[ "webvtt", "xsub", ] + +# FFmpeg 9.0 decoders and the codec descriptor aliases that resolve to them. +type _SubtitleDecoderName = Literal[ + "arib_caption", + "ass", + "cc_dec", + "dvb_subtitle", + "dvb_teletext", + "dvbsub", + "dvd_subtitle", + "dvdsub", + "eia_608", + "hdmv_pgs_subtitle", + "jacosub", + "libaribb24", + "libaribcaption", + "libzvbi_teletextdec", + "microdvd", + "mov_text", + "mpl2", + "pgssub", + "pjs", + "realtext", + "sami", + "srt", + "ssa", + "stl", + "subrip", + "subviewer", + "subviewer1", + "text", + "vplayer", + "webvtt", + "xsub", +] diff --git a/av/subtitles/codeccontext.pxd b/av/subtitles/codeccontext.pxd index fb5748294..d14ac2083 100644 --- a/av/subtitles/codeccontext.pxd +++ b/av/subtitles/codeccontext.pxd @@ -5,4 +5,5 @@ from av.packet cimport Packet cdef class SubtitleCodecContext(CodecContext): cdef bint subtitle_header_set cdef _decode(self, Packet packet) + cdef _decode_one(self, Packet packet) cpdef decode2(self, Packet packet) diff --git a/av/subtitles/codeccontext.py b/av/subtitles/codeccontext.py index 301bfd33e..bb9efc5cd 100644 --- a/av/subtitles/codeccontext.py +++ b/av/subtitles/codeccontext.py @@ -99,10 +99,22 @@ def _decode(self, packet: Packet | None): if not self.ptr.codec: raise ValueError("cannot decode unknown codec") - if packet is None: - raise RuntimeError("packet cannot be None") - self.open(strict=False) + + if packet is not None: + subtitle: SubtitleSet | None = self._decode_one(packet) + return list(subtitle) if subtitle is not None else [] + + # Flush: send empty packets until the decoder stops returning subtitles, + # as avcodec_decode_subtitle2() documents. + flush_packet: Packet = Packet() + subtitles: list = [] + while (subtitle := self._decode_one(flush_packet)) is not None: + subtitles.extend(subtitle) + return subtitles + + @cython.cfunc + def _decode_one(self, packet: Packet): proxy: SubtitleProxy = SubtitleProxy() got_frame: cython.int = 0 @@ -116,8 +128,8 @@ def _decode(self, packet: Packet | None): ) if got_frame: - return list(SubtitleSet(proxy)) - return [] + return SubtitleSet(proxy) + return None @cython.ccall def decode2(self, packet: Packet): diff --git a/av/subtitles/codeccontext.pyi b/av/subtitles/codeccontext.pyi index 57f667672..cc18057b6 100644 --- a/av/subtitles/codeccontext.pyi +++ b/av/subtitles/codeccontext.pyi @@ -1,4 +1,4 @@ -from typing import Any, Literal, final +from typing import Any, Literal, final, override from av.codec.context import CodecContext from av.packet import Packet @@ -9,8 +9,9 @@ from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle, SubtitleSet class SubtitleCodecContext(CodecContext): type: Literal["subtitle"] subtitle_header: bytes | None - def decode( # type: ignore[override] - self, packet: Packet[Any] + @override + def decode( + self, packet: Packet[Any] | None = None ) -> list[AssSubtitle] | list[BitmapSubtitle]: ... def decode2(self, packet: Packet[Any]) -> SubtitleSet | None: ... def encode_subtitle(self, subtitle: SubtitleSet) -> Packet[Stream]: ... diff --git a/av/video/codeccontext.pyi b/av/video/codeccontext.pyi index 1cac5d3f8..a846bb59d 100644 --- a/av/video/codeccontext.pyi +++ b/av/video/codeccontext.pyi @@ -1,6 +1,6 @@ from collections.abc import Iterator from fractions import Fraction -from typing import Any, Literal, final +from typing import Any, Literal, final, override from av.codec.context import CodecContext from av.packet import Packet @@ -54,8 +54,11 @@ class VideoCodecContext(CodecContext): qmax: int type: Literal["video"] + @override def encode(self, frame: VideoFrame | None = None) -> list[Packet[Stream]]: ... # type: ignore[override] + @override def encode_lazy( # type: ignore[override] self, frame: VideoFrame | None = None ) -> Iterator[Packet[Stream]]: ... + @override def decode(self, packet: Packet[Any] | None = None) -> list[VideoFrame]: ... diff --git a/scripts/build-armv7l-cross.sh b/scripts/build-armv7l-cross.sh index f7928cc1f..632f48347 100755 --- a/scripts/build-armv7l-cross.sh +++ b/scripts/build-armv7l-cross.sh @@ -104,12 +104,14 @@ build_one() { # The _PYTHON_* vars make sysconfig report armv7l; zig does the compiling. # max-page-size=4096 aligns our extensions to the armv7l (4K) page size; # lld's default 64K alignment uses 4K file offsets, which the loader rejects. + # zig cc emits debug info by default and links its own debug-built runtime; + # --strip-debug drops it, which otherwise makes this the largest wheel. # Setting CFLAGS replaces the target's default OPT flags, so -O2 -DNDEBUG # must be passed explicitly (without NDEBUG the free-threaded build trips a # Py_SET_REFCNT assertion that release wheels compile out). env \ CC="$BIN_DIR/zig-cc" CXX="$BIN_DIR/zig-cxx" AR="$BIN_DIR/zig-ar" \ - LDSHARED="$BIN_DIR/zig-cc -shared -Wl,-z,max-page-size=4096" \ + LDSHARED="$BIN_DIR/zig-cc -shared -Wl,-z,max-page-size=4096 -Wl,--strip-debug" \ _PYTHON_HOST_PLATFORM=linux-armv7l \ _PYTHON_SYSCONFIGDATA_NAME="$(basename "${scd[0]}" .py)" \ PYTHONPATH="$(dirname "${scd[0]}")" \ diff --git a/tests/test_subtitles.py b/tests/test_subtitles.py index f6dfe5708..b5b70746a 100644 --- a/tests/test_subtitles.py +++ b/tests/test_subtitles.py @@ -1,4 +1,5 @@ import io +from fractions import Fraction from typing import cast import av @@ -76,6 +77,31 @@ def test_subtitle_flush(self) -> None: assert len(subs) == 3 + def test_codec_context_flush_returns_delayed_subtitles(self) -> None: + def cc_pair(b1: int, b2: int) -> bytes: + # EIA-608 bytes carry odd parity in the high bit. + def odd(b: int) -> int: + return b if b.bit_count() % 2 else b | 0x80 + + return bytes([0xFC, odd(b1), odd(b2)]) + + ctx = CodecContext.create("eia_608", "r") + ctx.pkt_timebase = Fraction(1, 1000) + + # A pop-on caption: resume caption loading, "HI", end of caption. + subs: list[AssSubtitle | BitmapSubtitle] = [] + for i, pair in enumerate([(0x14, 0x20), (ord("H"), ord("I")), (0x14, 0x2F)]): + packet = av.Packet(cc_pair(*pair)) + packet.pts = i * 33 + subs.extend(ctx.decode(packet)) + assert subs == [] + + # cc_dec holds the displayed caption until flushed. + flushed = ctx.decode(None) + assert len(flushed) == 1 + assert isinstance(flushed[0], AssSubtitle) + assert flushed[0].dialogue.endswith(b"HI") + def test_subtitle_header_read(self) -> None: """Test reading subtitle_header from a decoded subtitle stream.""" path = fate_suite("sub/MovText_capability_tester.mp4")