From 4ebeb4af54d544feaaf6f1db1b85cfd3605cfc63 Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Wed, 22 Jul 2026 18:59:02 -0400 Subject: [PATCH 1/2] Prevent codec reconfiguration after opening (fixes #2232) --- CHANGELOG.rst | 1 + av/audio/codeccontext.py | 3 +++ av/codec/context.pxd | 1 + av/codec/context.py | 5 +++++ av/container/output.py | 6 ++++++ av/video/codeccontext.py | 6 ++++++ tests/test_encode.py | 25 +++++++++++++++++++++++++ 7 files changed, 47 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 2ad54e184..7255af52f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -40,6 +40,7 @@ Features: Fixes: +- Prevent crashes and corrupted output when structural codec properties are changed after an output stream has been opened by :gh-user:`WyattBlue`, reported by :gh-user:`oakaigh` (:issue:`2232`). - 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/codeccontext.py b/av/audio/codeccontext.py index 4e7118d34..86ac107cf 100644 --- a/av/audio/codeccontext.py +++ b/av/audio/codeccontext.py @@ -62,6 +62,7 @@ def sample_rate(self): @sample_rate.setter def sample_rate(self, value: cython.int): + self._assert_not_open("sample_rate") self.ptr.sample_rate = value @property @@ -88,6 +89,7 @@ def layout(self): @layout.setter def layout(self, value): + self._assert_not_open("layout") layout: AudioLayout = AudioLayout(value) self.ptr.ch_layout = layout.layout @@ -102,5 +104,6 @@ def format(self): @format.setter def format(self, value): + self._assert_not_open("format") format: AudioFormat = AudioFormat(value) self.ptr.sample_fmt = format.sample_fmt diff --git a/av/codec/context.pxd b/av/codec/context.pxd index 48d0bb9bd..a9cb702a7 100644 --- a/av/codec/context.pxd +++ b/av/codec/context.pxd @@ -17,6 +17,7 @@ cdef class CodecContext: cdef lib.AVCodecParserContext *parser cdef _init(self, lib.AVCodecContext *ptr, const lib.AVCodec *codec, HWAccel hwaccel) + cdef _assert_not_open(self, name) # Public API. cdef readonly bint is_open diff --git a/av/codec/context.py b/av/codec/context.py index 65f130dc5..7b3fb7cca 100644 --- a/av/codec/context.py +++ b/av/codec/context.py @@ -287,6 +287,11 @@ def _init( self.ptr.thread_count = 0 # use as many threads as there are CPUs. self.ptr.thread_type = 0x02 # thread within a frame. Does not change the API. + @cython.cfunc + def _assert_not_open(self, name): + if self.is_open: + raise RuntimeError(f"Cannot change {name} after codec is open.") + @property def flags(self): """ diff --git a/av/container/output.py b/av/container/output.py index ebf9269e8..a33dadd61 100644 --- a/av/container/output.py +++ b/av/container/output.py @@ -103,6 +103,12 @@ def add_stream( :param \\**kwargs: Set attributes for the stream. :rtype: The new :class:`~av.stream.Stream`. + .. warning:: + + Configure every output stream before muxing the first packet. Writing + the file header opens all stream codec contexts, after which structural + properties such as format, dimensions, layout, and rate cannot change. + """ codec_obj: Codec = Codec(codec_name, "w") diff --git a/av/video/codeccontext.py b/av/video/codeccontext.py index 2e39a7feb..27dbaacb9 100644 --- a/av/video/codeccontext.py +++ b/av/video/codeccontext.py @@ -193,6 +193,7 @@ def format(self): @format.setter def format(self, format: VideoFormat): + self._assert_not_open("format") self.ptr.pix_fmt = format.pix_fmt self.ptr.width = format.width self.ptr.height = format.height @@ -205,6 +206,7 @@ def width(self): @width.setter def width(self, value: cython.uint): + self._assert_not_open("width") self.ptr.width = value @property @@ -215,6 +217,7 @@ def height(self): @height.setter def height(self, value: cython.uint): + self._assert_not_open("height") self.ptr.height = value @property @@ -251,6 +254,7 @@ def pix_fmt(self): @pix_fmt.setter def pix_fmt(self, value): + self._assert_not_open("pix_fmt") self.ptr.pix_fmt = get_pix_fmt(value) @property @@ -277,6 +281,7 @@ def sw_format(self): @sw_format.setter def sw_format(self, value): + self._assert_not_open("sw_format") self.ptr.sw_pix_fmt = get_pix_fmt(value) @property @@ -290,6 +295,7 @@ def framerate(self): @framerate.setter def framerate(self, value): + self._assert_not_open("framerate") to_avrational(value, cython.address(self.ptr.framerate)) @property diff --git a/tests/test_encode.py b/tests/test_encode.py index e6015ae1b..71a337c19 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -4,6 +4,7 @@ import math import os from fractions import Fraction +from typing import cast import numpy as np import pytest @@ -268,6 +269,30 @@ def test_subtitle_muxing(self) -> None: class TestEncodeStreamSemantics(TestCase): + def test_reconfigure_stream_after_mux(self) -> None: + output_bytes = io.BytesIO() + with av.open(output_bytes, "w", format="mp4") as output: + first = cast(VideoStream, output.add_stream("ffv1", rate=30)) + second = cast(VideoStream, output.add_stream("ffv1", rate=30)) + + first.format = av.VideoFormat("bgr0", width=16, height=16) + frame = VideoFrame(16, 16, "bgr0") + frame.pts = 0 + frame.time_base = Fraction(1, 30) + output.mux(first.encode(frame)) + + # Muxing the first packet writes the header and opens every stream. + # Changing the second encoder now used to corrupt FFV1 state and crash. + assert second.codec_context.is_open + with pytest.raises(RuntimeError, match="Cannot change format"): + second.format = av.VideoFormat("bgr0", width=16, height=16) + with pytest.raises(RuntimeError, match="Cannot change width"): + second.width = 16 + with pytest.raises(RuntimeError, match="Cannot change height"): + second.height = 16 + with pytest.raises(RuntimeError, match="Cannot change pix_fmt"): + second.pix_fmt = "bgr0" + def test_stream_index(self) -> None: with av.open(self.sandboxed("output.mov"), "w") as output: vstream = output.add_stream("mpeg4", 24) From ab089f402939bd457069ca6b84a50bf4958e26e3 Mon Sep 17 00:00:00 2001 From: WyattBlue Date: Wed, 22 Jul 2026 19:13:43 -0400 Subject: [PATCH 2/2] Type all FFmpeg encoder names --- CHANGELOG.rst | 1 + av/audio/__init__.pyi | 106 ++++++++++++++++++++++ av/codec/context.pyi | 9 ++ av/container/output.pyi | 9 ++ av/subtitles/__init__.pyi | 18 ++++ av/subtitles/stream.pyi | 3 + av/video/__init__.pyi | 180 ++++++++++++++++++++++++++++++++++++++ tests/test_dlpack.py | 4 +- tests/test_encode.py | 5 +- tests/test_subtitles.py | 9 +- 10 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 av/subtitles/__init__.pyi diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 7255af52f..45efcb643 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,6 +33,7 @@ v18.1.0 (Unreleased) Features: +- Infer the specific stream and codec context types for all FFmpeg encoder names in type-checked code by :gh-user:`WyattBlue`. - Add ``CodecContext.supported_options`` to discover generic and codec-specific options by :gh-user:`WyattBlue` (:issue:`2032`). - Add ``Packet.rescale_ts()`` to rescale packet PTS, DTS, and duration to a new ``AVRational`` time base by :gh-user:`WyattBlue`. - 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`). diff --git a/av/audio/__init__.pyi b/av/audio/__init__.pyi index daefab6c9..b6a5c78e0 100644 --- a/av/audio/__init__.pyi +++ b/av/audio/__init__.pyi @@ -3,14 +3,120 @@ from typing import Literal from .frame import AudioFrame from .stream import AudioStream +# FFmpeg 8.1 encoders and the codec descriptor aliases that resolve to them. _AudioCodecName = Literal[ "aac", + "aac_at", + "aac_mf", + "ac3", + "ac3_fixed", + "ac3_mf", + "adpcm_adx", + "adpcm_argo", + "adpcm_g722", + "adpcm_g726", + "adpcm_g726le", + "adpcm_ima_alp", + "adpcm_ima_amv", + "adpcm_ima_apm", + "adpcm_ima_qt", + "adpcm_ima_ssi", + "adpcm_ima_wav", + "adpcm_ima_ws", + "adpcm_ms", + "adpcm_swf", + "adpcm_yamaha", + "alac", + "alac_at", + "amr_nb", + "amr_wb", + "anull", + "aptx", + "aptx_hd", + "codec2", + "comfortnoise", + "dca", + "dfpwm", + "dts", + "eac3", + "flac", + "g722", + "g723_1", + "g726", + "g726le", + "gsm", + "gsm_ms", + "ilbc", + "ilbc_at", + "lc3", + "libcodec2", + "libfdk_aac", + "libgsm", + "libgsm_ms", + "libilbc", + "liblc3", + "libmp3lame", + "libopencore_amrnb", "libopus", + "libshine", + "libspeex", + "libtwolame", + "libvo_amrwbenc", + "libvorbis", + "mlp", "mp2", + "mp2fixed", "mp3", + "mp3_mf", + "nellymoser", + "opus", "pcm_alaw", + "pcm_alaw_at", + "pcm_bluray", + "pcm_dvd", + "pcm_f32be", + "pcm_f32le", + "pcm_f64be", + "pcm_f64le", "pcm_mulaw", + "pcm_mulaw_at", + "pcm_s16be", + "pcm_s16be_planar", "pcm_s16le", + "pcm_s16le_planar", + "pcm_s24be", + "pcm_s24daud", + "pcm_s24le", + "pcm_s24le_planar", + "pcm_s32be", + "pcm_s32le", + "pcm_s32le_planar", + "pcm_s64be", + "pcm_s64le", + "pcm_s8", + "pcm_s8_planar", + "pcm_u16be", + "pcm_u16le", + "pcm_u24be", + "pcm_u24le", + "pcm_u32be", + "pcm_u32le", + "pcm_u8", + "pcm_vidc", + "ra_144", + "real_144", + "roq_dpcm", + "s302m", + "sbc", + "sonic", + "sonicls", + "speex", + "truehd", + "tta", + "vorbis", + "wavpack", + "wmav1", + "wmav2", ] __all__ = ("AudioFrame", "AudioStream") diff --git a/av/codec/context.pyi b/av/codec/context.pyi index d500b1761..3dfb52285 100644 --- a/av/codec/context.pyi +++ b/av/codec/context.pyi @@ -6,6 +6,8 @@ from typing import ClassVar, Literal, cast, overload from av.audio import _AudioCodecName from av.audio.codeccontext import AudioCodecContext from av.packet import Packet +from av.subtitles import _SubtitleCodecName +from av.subtitles.codeccontext import SubtitleCodecContext from av.video import _VideoCodecName from av.video.codeccontext import VideoCodecContext @@ -169,6 +171,13 @@ class CodecContext: ) -> VideoCodecContext: ... @overload @staticmethod + def create( + codec: _SubtitleCodecName, + mode: Literal["r", "w"] | None = None, + hwaccel: HWAccel | None = None, + ) -> SubtitleCodecContext: ... + @overload + @staticmethod def create( codec: str | Codec, mode: Literal["r", "w"] | None = None, diff --git a/av/container/output.pyi b/av/container/output.pyi index 5ed78a2b2..961e7ad2d 100644 --- a/av/container/output.pyi +++ b/av/container/output.pyi @@ -7,6 +7,7 @@ from av.audio.stream import AudioStream from av.codec.hwaccel import HWAccel from av.packet import Packet from av.stream import AttachmentStream, DataStream, Stream +from av.subtitles import _SubtitleCodecName from av.subtitles.stream import SubtitleStream from av.video import _VideoCodecName from av.video.stream import VideoStream @@ -34,6 +35,14 @@ class OutputContainer(Container): **kwargs, ) -> VideoStream: ... @overload + def add_stream( + self, + codec_name: _SubtitleCodecName, + rate: Fraction | int | None = None, + options: dict[str, str] | None = None, + **kwargs, + ) -> SubtitleStream: ... + @overload def add_stream( self, codec_name: str, diff --git a/av/subtitles/__init__.pyi b/av/subtitles/__init__.pyi new file mode 100644 index 000000000..c02a53551 --- /dev/null +++ b/av/subtitles/__init__.pyi @@ -0,0 +1,18 @@ +from typing import Literal + +# FFmpeg 8.1 encoders and the codec descriptor aliases that resolve to them. +_SubtitleCodecName = Literal[ + "ass", + "dvb_subtitle", + "dvbsub", + "dvd_subtitle", + "dvdsub", + "mov_text", + "srt", + "ssa", + "subrip", + "text", + "ttml", + "webvtt", + "xsub", +] diff --git a/av/subtitles/stream.pyi b/av/subtitles/stream.pyi index ac8083802..4c3326f79 100644 --- a/av/subtitles/stream.pyi +++ b/av/subtitles/stream.pyi @@ -2,7 +2,10 @@ from av.packet import Packet from av.stream import Stream from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle, SubtitleSet +from .codeccontext import SubtitleCodecContext + class SubtitleStream(Stream): + codec_context: SubtitleCodecContext def decode( self, packet: Packet | None = None ) -> list[AssSubtitle] | list[BitmapSubtitle]: ... diff --git a/av/video/__init__.pyi b/av/video/__init__.pyi index 58a19a63f..63b5e0501 100644 --- a/av/video/__init__.pyi +++ b/av/video/__init__.pyi @@ -3,15 +3,195 @@ from typing import Literal from .frame import VideoFrame from .stream import VideoStream +# FFmpeg 8.1 encoders and the codec descriptor aliases that resolve to them. _VideoCodecName = Literal[ + "a64_multi", + "a64_multi5", + "a64multi", + "a64multi5", + "alias_pix", + "amv", + "apng", + "apv", + "asv1", + "asv2", + "av1", + "av1_amf", + "av1_d3d12va", + "av1_mediacodec", + "av1_mf", + "av1_nvenc", + "av1_qsv", + "av1_vaapi", + "av1_vulkan", + "avrp", + "avs2", + "avui", + "bitpacked", + "bmp", + "cavs", + "cfhd", + "cinepak", + "cljr", + "dirac", + "dnxhd", + "dpx", + "dvvideo", + "dxv", + "evc", + "exr", + "ffv1", + "ffv1_vulkan", + "ffvhuff", + "fits", + "flashsv", + "flashsv2", + "flv", + "flv1", "gif", + "h261", + "h263", + "h263_v4l2m2m", + "h263p", "h264", + "h264_amf", + "h264_d3d12va", + "h264_mediacodec", + "h264_mf", + "h264_nvenc", + "h264_oh", + "h264_omx", + "h264_qsv", + "h264_rkmpp", + "h264_v4l2m2m", + "h264_vaapi", + "h264_videotoolbox", + "h264_vulkan", + "hap", + "hdr", "hevc", + "hevc_amf", + "hevc_d3d12va", + "hevc_mediacodec", + "hevc_mf", + "hevc_nvenc", + "hevc_oh", + "hevc_qsv", + "hevc_rkmpp", + "hevc_v4l2m2m", + "hevc_vaapi", + "hevc_videotoolbox", + "hevc_vulkan", + "huffyuv", + "jpeg2000", + "jpegls", + "jpegxl", + "jpegxl_anim", + "jpegxs", + "libaom-av1", + "libjxl", + "libjxl_anim", + "libkvazaar", + "liboapv", + "libopenh264", + "libopenjpeg", + "librav1e", + "libsvtav1", + "libsvtjpegxs", + "libtheora", "libvpx", + "libvpx-vp9", + "libvvenc", + "libwebp", + "libwebp_anim", + "libx262", "libx264", + "libx264rgb", + "libx265", + "libxavs", + "libxavs2", + "libxeve", + "libxvid", + "ljpeg", + "magicyuv", + "mjpeg", + "mjpeg_qsv", + "mjpeg_vaapi", + "mpeg1video", + "mpeg2_qsv", + "mpeg2_vaapi", + "mpeg2video", "mpeg4", + "mpeg4_mediacodec", + "mpeg4_omx", + "mpeg4_v4l2m2m", + "msmpeg4", + "msmpeg4v2", + "msmpeg4v3", + "msrle", + "msvideo1", + "pam", + "pbm", + "pcx", + "pfm", + "pgm", + "pgmyuv", + "phm", "png", + "ppm", + "prores", + "prores_aw", + "prores_ks", + "prores_ks_vulkan", + "prores_videotoolbox", + "qoi", "qtrle", + "r10k", + "r210", + "rawvideo", + "roq", + "roqvideo", + "rpza", + "rv10", + "rv20", + "sgi", + "smc", + "snow", + "speedhq", + "sunrast", + "svq1", + "targa", + "theora", + "tiff", + "utvideo", + "v210", + "v308", + "v408", + "v410", + "vbn", + "vc2", + "vnull", + "vp8", + "vp8_mediacodec", + "vp8_v4l2m2m", + "vp8_vaapi", + "vp9", + "vp9_mediacodec", + "vp9_qsv", + "vp9_vaapi", + "vvc", + "wbmp", + "webp", + "wmv1", + "wmv2", + "wrapped_avframe", + "xbm", + "xface", + "xwd", + "y41p", + "yuv4", + "zlib", + "zmbv", ] __all__ = ("VideoFrame", "VideoStream") diff --git a/tests/test_dlpack.py b/tests/test_dlpack.py index 033316138..15a513e6d 100644 --- a/tests/test_dlpack.py +++ b/tests/test_dlpack.py @@ -1,6 +1,5 @@ import gc from fractions import Fraction -from typing import cast import numpy import pytest @@ -8,7 +7,6 @@ import av from av import VideoFrame from av.codec.hwaccel import HWAccel -from av.video.codeccontext import VideoCodecContext from .common import assertNdarraysEqual, fate_png @@ -669,7 +667,7 @@ def test_encode_cuda_frame_with_nvenc_if_available(use_current_ctx: bool) -> Non assert frame.format.name == "cuda" assert frame.sw_format is not None and frame.sw_format.name == "nv12" - cc = cast(VideoCodecContext, av.CodecContext.create("h264_nvenc", "w")) + cc = av.CodecContext.create("h264_nvenc", "w") cc.width = width cc.height = height cc.time_base = Fraction(1, 24) diff --git a/tests/test_encode.py b/tests/test_encode.py index 71a337c19..95942b4b7 100644 --- a/tests/test_encode.py +++ b/tests/test_encode.py @@ -4,7 +4,6 @@ import math import os from fractions import Fraction -from typing import cast import numpy as np import pytest @@ -272,8 +271,8 @@ class TestEncodeStreamSemantics(TestCase): def test_reconfigure_stream_after_mux(self) -> None: output_bytes = io.BytesIO() with av.open(output_bytes, "w", format="mp4") as output: - first = cast(VideoStream, output.add_stream("ffv1", rate=30)) - second = cast(VideoStream, output.add_stream("ffv1", rate=30)) + first = output.add_stream("ffv1", rate=30) + second = output.add_stream("ffv1", rate=30) first.format = av.VideoFormat("bgr0", width=16, height=16) frame = VideoFrame(16, 16, "bgr0") diff --git a/tests/test_subtitles.py b/tests/test_subtitles.py index c3d462b59..ff3f58027 100644 --- a/tests/test_subtitles.py +++ b/tests/test_subtitles.py @@ -3,7 +3,6 @@ import av from av.codec.context import CodecContext -from av.subtitles.codeccontext import SubtitleCodecContext from av.subtitles.subtitle import AssSubtitle, BitmapSubtitle, Subtitle, SubtitleSet from .common import TestCase, fate_suite @@ -83,13 +82,13 @@ def test_subtitle_header_read(self) -> None: with av.open(path) as container: stream = container.streams.subtitles[0] - ctx = cast(SubtitleCodecContext, stream.codec_context) + ctx = stream.codec_context header = ctx.subtitle_header assert header is None or isinstance(header, bytes) def test_subtitle_header_write(self) -> None: """Test setting subtitle_header on encoder context.""" - ctx = cast(SubtitleCodecContext, CodecContext.create("mov_text", "w")) + ctx = CodecContext.create("mov_text", "w") assert ctx.subtitle_header is None ass_header = b"[Script Info]\nScriptType: v4.00+\n" @@ -155,7 +154,7 @@ def test_subtitle_encode_mp4(self) -> None: video_stream.pix_fmt = "yuv420p" sub_stream = container.add_stream("mov_text") - sub_ctx = cast(SubtitleCodecContext, sub_stream.codec_context) + sub_ctx = sub_stream.codec_context sub_ctx.subtitle_header = ass_header container.start_encoding() @@ -193,7 +192,7 @@ def test_subtitle_encode_mkv_srt(self) -> None: output = io.BytesIO() with av.open(output, "w", format="matroska") as container: sub_stream = container.add_stream("srt") - sub_ctx = cast(SubtitleCodecContext, sub_stream.codec_context) + sub_ctx = sub_stream.codec_context sub_ctx.subtitle_header = minimal_header container.start_encoding()