From df18834dbb95dc039e90f65bf7e56613124414c2 Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Tue, 21 Jul 2026 00:44:41 -0400 Subject: [PATCH] Fix segfaults on streams without a codec context `Stream.codec_context` is cdef-typed, so with Cython's default nonecheck=False, calling into it while None causes a segfault. A stream has no codec context when avcodec_find_decoder comes up empty on demux, or when it was created by `add_mux_stream`. Decoding and encoding such a stream now raise DecoderNotFoundError and EncoderNotFoundError through a shared guard. `BitStreamFilterContext` instead skips the dereference entirely: it only mirrored codecpar into the context, and avcodec_parameters_copy has already given the muxer what it needs, so `add_mux_stream` with h264_mp4toannexb now works rather than crashing. Closes #2344. --- CHANGELOG.rst | 4 ++++ av/audio/stream.py | 5 +++-- av/bitstream.py | 9 ++++++--- av/stream.pxd | 1 + av/stream.py | 8 ++++++++ av/subtitles/stream.py | 1 + av/video/stream.py | 3 ++- tests/test_decode.py | 29 +++++++++++++++++++++++++++++ 8 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 8dabf3fcb..48fe78d4d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -36,6 +36,10 @@ Features: - Support reusing the thread's current CUDA context via a ``current_ctx`` flag on ``CudaContext`` and ``VideoFrame.from_dlpack``, for interop with libraries like PyTorch that initialize CUDA first by :gh-user:`Yozer` (:pr:`2339`). - ``VideoFrame.from_dlpack`` no longer requires restating ``primary_ctx``/``current_ctx`` when passing an explicit ``cuda_context``; the flags are only validated when explicitly given by :gh-user:`WyattBlue`. +Fixes: + +- Fix a crash when using a stream that has no ``CodecContext`` (a demuxed stream with no available decoder, such as one from a truncated file, or a stream created by ``add_mux_stream``); decoding now raises ``DecoderNotFoundError``, encoding now raises ``EncoderNotFoundError``, and ``BitStreamFilterContext`` accepts such a stream as ``out_stream`` by :gh-user:`WyattBlue`, reported by :gh-user:`justinrmiller` (:issue:`2344`). + v18.0.0 ------- diff --git a/av/audio/stream.py b/av/audio/stream.py index 38009db19..e7fc63560 100644 --- a/av/audio/stream.py +++ b/av/audio/stream.py @@ -1,4 +1,5 @@ import cython +from cython.cimports import libav as lib from cython.cimports.av.audio.frame import AudioFrame from cython.cimports.av.packet import Packet @@ -31,7 +32,7 @@ def encode(self, frame: AudioFrame | None = None): .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.encode`. """ - + self._assert_has_codec_context(lib.AVERROR_ENCODER_NOT_FOUND) packets = self.codec_context.encode(frame) packet: Packet for packet in packets: @@ -49,5 +50,5 @@ def decode(self, packet: Packet | None = None): .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. """ - + self._assert_has_codec_context() return self.codec_context.decode(packet) diff --git a/av/bitstream.py b/av/bitstream.py index 822e710a5..a3bc0088b 100644 --- a/av/bitstream.py +++ b/av/bitstream.py @@ -61,9 +61,12 @@ def __cinit__( out_stream.ptr.codecpar, self.ptr.par_out ) err_check(res) - lib.avcodec_parameters_to_context( - out_stream.codec_context.ptr, out_stream.ptr.codecpar - ) + # codecpar carries everything the muxer needs; a mux-only stream + # (add_mux_stream) has no context to keep in sync. + if out_stream.codec_context is not None: + lib.avcodec_parameters_to_context( + out_stream.codec_context.ptr, out_stream.ptr.codecpar + ) def __dealloc__(self): if self.ptr: diff --git a/av/stream.pxd b/av/stream.pxd index 89d41e559..727e420ea 100644 --- a/av/stream.pxd +++ b/av/stream.pxd @@ -21,6 +21,7 @@ cdef class Stream: # Private API. cdef _init(self, Container, lib.AVStream*, CodecContext) + cdef _assert_has_codec_context(self, int err=*) cdef _finalize_for_output(self) cdef _set_id(self, value) diff --git a/av/stream.py b/av/stream.py index f96030e26..2d087c8ad 100644 --- a/av/stream.py +++ b/av/stream.py @@ -128,6 +128,14 @@ def _init( errors=self.container.metadata_errors, ) + @cython.cfunc + def _assert_has_codec_context( + self, err: cython.int = lib.AVERROR_DECODER_NOT_FOUND + ): + # Calling into a NULL codec_context is a segfault, not an AttributeError. + if self.codec_context is None: + err_check(err) + def __repr__(self): name = getattr(self, "name", None) return ( diff --git a/av/subtitles/stream.py b/av/subtitles/stream.py index 484eff5be..a39f8b0a3 100644 --- a/av/subtitles/stream.py +++ b/av/subtitles/stream.py @@ -18,6 +18,7 @@ def decode(self, packet: Packet | None = None): .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. """ + self._assert_has_codec_context() if not packet: packet = Packet() diff --git a/av/video/stream.py b/av/video/stream.py index bb8b06fc0..be1fcaf9d 100644 --- a/av/video/stream.py +++ b/av/video/stream.py @@ -40,7 +40,7 @@ def encode(self, frame: VideoFrame | None = None): .. seealso:: This is mostly a passthrough to :meth:`.CodecContext.encode`. """ - + self._assert_has_codec_context(lib.AVERROR_ENCODER_NOT_FOUND) packets = self.codec_context.encode(frame) packet: Packet for packet in packets: @@ -57,6 +57,7 @@ def decode(self, packet: Packet | None = None): .. seealso:: This is a passthrough to :meth:`.CodecContext.decode`. """ + self._assert_has_codec_context() return self.codec_context.decode(packet) @cython.cfunc diff --git a/tests/test_decode.py b/tests/test_decode.py index 814e0fc0e..e0b55e952 100644 --- a/tests/test_decode.py +++ b/tests/test_decode.py @@ -1,4 +1,5 @@ import functools +import io import os import pathlib from fractions import Fraction @@ -52,6 +53,34 @@ def make_h264_test_video(path: str) -> None: class TestDecode(TestCase): + def test_decode_stream_without_codec_context(self) -> None: + buffer = io.BytesIO() + with av.open(buffer, "w", format="mp4") as output: + stream = output.add_mux_stream("h264", width=16, height=16) + packet = av.Packet(b"invalid") + packet.stream = stream + packet.pts = packet.dts = 0 + packet.time_base = Fraction(1, 1000) + output.mux(packet) + + # Keep the MP4 video stream while making its codec unknown to FFmpeg. + data = buffer.getvalue().replace(b"avc1", b"zzzz") + with av.open(io.BytesIO(data)) as container: + stream = container.streams.video[0] + assert stream.codec_context is None + with pytest.raises(av.DecoderNotFoundError): + list(container.decode(stream)) + + def test_mux_stream_without_codec_context(self) -> None: + with av.open(io.BytesIO(), "w", format="mp4") as output: + stream = output.add_mux_stream("h264", width=16, height=16) + assert stream.codec_context is None + with pytest.raises(av.EncoderNotFoundError): + stream.encode(None) + + # A bitstream filter only needs to update codecpar for a mux stream. + av.BitStreamFilterContext("h264_mp4toannexb", "h264", out_stream=stream) + def test_decoded_video_frame_count(self) -> None: container = av.open(fate_suite("h264/interlaced_crop.mp4")) video_stream = next(s for s in container.streams if s.type == "video")