diff --git a/av/audio/codeccontext.py b/av/audio/codeccontext.py index 76234a20f..079adc802 100644 --- a/av/audio/codeccontext.py +++ b/av/audio/codeccontext.py @@ -17,7 +17,7 @@ def _prepare_frames_for_encode( ) -> list[Frame | None]: frame: AudioFrame | None = input_frame allow_var_frame_size: cython.bint = ( - self.ptr.codec.capabilities & lib.AV_CODEC_CAP_VARIABLE_FRAME_SIZE + self._codec.capabilities & lib.AV_CODEC_CAP_VARIABLE_FRAME_SIZE ) # Note that the resampler will simply return an input frame if there is diff --git a/av/codec/context.pxd b/av/codec/context.pxd index da5e79923..6db100398 100644 --- a/av/codec/context.pxd +++ b/av/codec/context.pxd @@ -12,6 +12,8 @@ cdef class CodecContext: # Fields are laid out in declaration order: pointers first, then the small # scalars, so there are no padding holes between them. cdef lib.AVCodecContext *ptr + # avcodec_open2() may clear AVCodecContext.codec when opening fails. + cdef const lib.AVCodec *_codec cdef lib.AVCodecParserContext *parser cdef public dict options cdef HWAccel hwaccel_ctx diff --git a/av/codec/context.py b/av/codec/context.py index 8a27d979f..365d290ae 100644 --- a/av/codec/context.py +++ b/av/codec/context.py @@ -264,7 +264,7 @@ def supported_options(self): descriptors only; set options through :attr:`options`. """ ctx: cython.pointer[lib.AVCodecContext] = lib.avcodec_alloc_context3( - self.ptr.codec + self._codec ) child: cython.p_void private: list[CodecOption] = [] @@ -290,6 +290,9 @@ def _init( self.ptr = ptr if self.ptr.codec and codec and self.ptr.codec != codec: raise RuntimeError("Wrapping CodecContext with mismatched codec.") + # Keep the selected implementation independently of FFmpeg's mutable + # context: a failed avcodec_open2() clears ptr.codec during cleanup. + self._codec = codec if codec != cython.NULL else self.ptr.codec # Set reasonable threading defaults. self.ptr.thread_count = 0 # use as many threads as there are CPUs. @@ -390,19 +393,19 @@ def extradata_size(self): @property def is_encoder(self): - if self.ptr is cython.NULL: + if self.ptr is cython.NULL or self._codec is cython.NULL: return False - return bool(lib.av_codec_is_encoder(self.ptr.codec)) + return bool(lib.av_codec_is_encoder(self._codec)) @property def is_decoder(self): - if self.ptr is cython.NULL: + if self.ptr is cython.NULL or self._codec is cython.NULL: return False - return bool(lib.av_codec_is_decoder(self.ptr.codec)) + return bool(lib.av_codec_is_decoder(self._codec)) @property def codec(self): - return wrap_codec(self.ptr.codec) + return wrap_codec(self._codec) @property def is_open(self): @@ -412,6 +415,8 @@ def is_open(self): @cython.ccall def open(self, strict: cython.bint = True): + if self._codec is cython.NULL: + raise ValueError("Cannot open an unknown codec.") if lib.avcodec_is_open(self.ptr): if strict: raise ValueError("CodecContext is already open.") @@ -434,8 +439,8 @@ def open(self, strict: cython.bint = True): self._setup_encode_hwframes() err_check( - lib.avcodec_open2(self.ptr, self.ptr.codec, cython.address(options.ptr)), - f'avcodec_open2("{self.ptr.codec.name}", {self.options})', + lib.avcodec_open2(self.ptr, self._codec, cython.address(options.ptr)), + f'avcodec_open2("{self._codec.name}", {self.options})', ) self.options = dict(options) @@ -476,9 +481,11 @@ def parse(self, raw_input=None): """ if not self.parser: - self.parser = lib.av_parser_init(self.ptr.codec.id) + if self._codec is cython.NULL: + raise ValueError("Cannot parse with an unknown codec.") + self.parser = lib.av_parser_init(self._codec.id) if not self.parser: - raise ValueError(f"No parser for {self.ptr.codec.name}") + raise ValueError(f"No parser for {self._codec.name}") source: ByteSource = bytesource(raw_input, allow_none=True) @@ -746,7 +753,7 @@ def decode(self, packet: Packet | None = None): @cython.cfunc def _decode(self, packet: Packet | None): - if not self.ptr.codec: + if not self._codec: raise ValueError("cannot decode unknown codec") self.open(strict=False) @@ -798,7 +805,7 @@ def _setup_decoded_frame(self, frame: Frame, packet: Packet | None) -> cython.vo @property def name(self): - return self.ptr.codec.name or "" + return (self._codec.name or "") if self._codec != cython.NULL else "" @property def type(self): @@ -815,7 +822,7 @@ def profiles(self): desc: cython.pointer[cython.const[lib.AVCodecDescriptor]] = ( lib.avcodec_descriptor_get(self.ptr.codec_id) ) - if not self.ptr.codec or desc == cython.NULL or not desc.profiles: + if not self._codec or desc == cython.NULL or not desc.profiles: return ret i: cython.int = 0 @@ -830,7 +837,7 @@ def profile(self): desc: cython.pointer[cython.const[lib.AVCodecDescriptor]] = ( lib.avcodec_descriptor_get(self.ptr.codec_id) ) - if not self.ptr.codec or desc == cython.NULL or not desc.profiles: + if not self._codec or desc == cython.NULL or not desc.profiles: return i: cython.int = 0 @@ -844,7 +851,7 @@ def profile(self, value): desc: cython.pointer[cython.const[lib.AVCodecDescriptor]] = ( lib.avcodec_descriptor_get(self.ptr.codec_id) ) - if not self.ptr.codec or desc == cython.NULL or not desc.profiles: + if not self._codec or desc == cython.NULL or not desc.profiles: return i: cython.int = 0 diff --git a/av/subtitles/codeccontext.py b/av/subtitles/codeccontext.py index bb9efc5cd..e4f14a506 100644 --- a/av/subtitles/codeccontext.py +++ b/av/subtitles/codeccontext.py @@ -61,7 +61,7 @@ def encode_subtitle(self, subtitle: SubtitleSet) -> Packet: Returns: A Packet containing the encoded subtitle data """ - if not self.ptr.codec: + if not self._codec: raise ValueError("Cannot encode with unknown codec") self.open(strict=False) @@ -96,7 +96,7 @@ def encode_subtitle(self, subtitle: SubtitleSet) -> Packet: def _decode(self, packet: Packet | None): """Decode a subtitle packet, returning a list of :class:`.Subtitle` objects if a subtitle was decoded, or an empty list otherwise.""" - if not self.ptr.codec: + if not self._codec: raise ValueError("cannot decode unknown codec") self.open(strict=False) @@ -136,7 +136,7 @@ def decode2(self, packet: Packet): """ Returns SubtitleSet if you really need it. """ - if not self.ptr.codec: + if not self._codec: raise ValueError("cannot decode unknown codec") self.open(strict=False) diff --git a/tests/codec_context_retry.py b/tests/codec_context_retry.py new file mode 100644 index 000000000..dd03007ac --- /dev/null +++ b/tests/codec_context_retry.py @@ -0,0 +1,71 @@ +"""Subprocess regression for codec identity after a real FFmpeg open failure.""" + +from __future__ import annotations + +import io +import sys +from fractions import Fraction + +# Exercise the exact compiled package imported by the parent, including in-place builds. +sys.path.insert(0, sys.argv[1]) + + +def main() -> None: + import av + + buffer = io.BytesIO() + with av.open(buffer, mode="w", format="adts") as output: + stream = output.add_stream("aac", rate=48000) + assert isinstance(stream, av.AudioStream) + context = stream.codec_context + assert context is not None + context.layout = "stereo" + context.format = "s16" + + for attempt in range(3): + print(f"invalid-open-{attempt}", flush=True) + try: + context.open() + except av.error.ArgumentError: + pass + else: + raise AssertionError( + "AAC must reject the unsupported s16 configuration" + ) + assert not context.is_open + assert context.name == "aac" + assert context.codec.name == "aac" + assert context.is_encoder and not context.is_decoder + assert context.supported_options.generic + + # Correcting the configuration must retain the selected implementation + # and permit real encoding, rather than permanently poisoning the context. + # FFmpeg also clears ch_layout during failed-open cleanup, so restore + # the complete valid audio configuration before retrying. + context.layout = "stereo" + context.format = "fltp" + frame = av.AudioFrame(format="s16", layout="stereo", samples=1024) + frame.sample_rate = 48000 + frame.time_base = Fraction(1, 48000) + frame.pts = 0 + for plane in frame.planes: + plane.update(bytes(plane.buffer_size)) + packets = list(stream.encode(frame)) + packets.extend(stream.encode(None)) + assert packets + for packet in packets: + output.mux(packet) + assert buffer.getvalue() + buffer.seek(0) + with av.open(buffer, mode="r") as source: + decoded = list(source.decode(audio=0)) + assert decoded + assert all( + frame.sample_rate == 48000 and frame.layout.name == "stereo" + for frame in decoded + ) + print("real-codec-retry-and-recovery-pass", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tests/test_codec_context.py b/tests/test_codec_context.py index 01384d1fd..39a9c6695 100644 --- a/tests/test_codec_context.py +++ b/tests/test_codec_context.py @@ -1,8 +1,11 @@ from __future__ import annotations import os +import subprocess +import sys from collections.abc import Iterator from fractions import Fraction +from pathlib import Path from typing import Any, TypedDict, overload import pytest @@ -71,6 +74,19 @@ def iter_raw_frames( class TestCodecContext(TestCase): + def test_failed_open_preserves_selected_codec(self) -> None: + child = Path(__file__).with_name("codec_context_retry.py") + package_root = Path(av.__file__).resolve().parent.parent + result = subprocess.run( + [sys.executable, "-I", "-B", str(child), str(package_root)], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + assert result.returncode == 0, (result.returncode, result.stdout, result.stderr) + assert "real-codec-retry-and-recovery-pass" in result.stdout + def test_supported_options(self) -> None: ctx = Codec("flac", "w").create() supported = ctx.supported_options