Skip to content

Commit bf9d2b5

Browse files
WyattBlueclaude
andcommitted
Add OutputContainer.add_mux_stream() for codec-context-free streams
Adds `add_mux_stream(codec_name, rate=None, **kwargs)` to `OutputContainer`, allowing users to create a stream with only `codecpar` set (codec id, type, width, height, sample_rate) and no `CodecContext`. This is useful when muxing pre-encoded packets from an external source where no encoding or decoding is needed, separating the muxer role from the encoder role. Also relaxes `start_encoding()` to allow any stream type without a codec context (previously only data/attachment streams were permitted), and guards `VideoStream`/`AudioStream` repr and `__getattr__` against `codec_context=None`. Two missing fields (`AVMediaType type` on `AVCodecDescriptor`, and `width`, `height`, `sample_rate` on `AVCodecParameters`) are added to the pxd declarations so they can be accessed from Cython. Closes #1970 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0f462e2 commit bf9d2b5

File tree

6 files changed

+151
-4
lines changed

6 files changed

+151
-4
lines changed

av/audio/stream.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,19 @@
66
@cython.cclass
77
class AudioStream(Stream):
88
def __repr__(self):
9+
if self.codec_context is None:
10+
return f"<av.AudioStream #{self.index} audio/<nocodec> at 0x{id(self):x}>"
911
form = self.format.name if self.format else None
1012
return (
1113
f"<av.AudioStream #{self.index} {self.name} at {self.rate}Hz,"
1214
f" {self.layout.name}, {form} at 0x{id(self):x}>"
1315
)
1416

1517
def __getattr__(self, name):
18+
if self.codec_context is None:
19+
raise AttributeError(
20+
f"'{type(self).__name__}' object has no attribute '{name}'"
21+
)
1622
return getattr(self.codec_context, name)
1723

1824
@cython.ccall

av/container/output.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,84 @@ def add_stream(self, codec_name, rate=None, options: dict | None = None, **kwarg
137137

138138
return py_stream
139139

140+
def add_mux_stream(
141+
self, codec_name: str, rate=None, **kwargs
142+
) -> Stream:
143+
"""add_mux_stream(codec_name, rate=None)
144+
145+
Creates a new stream for muxing pre-encoded data without creating a
146+
:class:`.CodecContext`. Use this when you want to mux packets that were
147+
already encoded externally and no encoding/decoding is needed.
148+
149+
:param codec_name: The name of a codec.
150+
:type codec_name: str
151+
:param \\**kwargs: Set attributes for the stream (e.g. ``width``, ``height``,
152+
``time_base``).
153+
:rtype: The new :class:`~av.stream.Stream`.
154+
155+
"""
156+
# Find the codec to get its id and type (try encoder first, then decoder).
157+
codec: cython.pointer[cython.const[lib.AVCodec]] = (
158+
lib.avcodec_find_encoder_by_name(codec_name.encode())
159+
)
160+
codec_descriptor: cython.pointer[cython.const[lib.AVCodecDescriptor]] = (
161+
cython.NULL
162+
)
163+
if codec == cython.NULL:
164+
codec = lib.avcodec_find_decoder_by_name(codec_name.encode())
165+
if codec == cython.NULL:
166+
codec_descriptor = lib.avcodec_descriptor_get_by_name(codec_name.encode())
167+
if codec_descriptor == cython.NULL:
168+
raise ValueError(f"Unknown codec: {codec_name!r}")
169+
170+
codec_id: lib.AVCodecID
171+
codec_type: lib.AVMediaType
172+
if codec != cython.NULL:
173+
codec_id = codec.id
174+
codec_type = codec.type
175+
else:
176+
codec_id = codec_descriptor.id
177+
codec_type = codec_descriptor.type
178+
179+
# Assert that this format supports the requested codec.
180+
if not lib.avformat_query_codec(
181+
self.ptr.oformat, codec_id, lib.FF_COMPLIANCE_NORMAL
182+
):
183+
raise ValueError(
184+
f"{self.format.name!r} format does not support {codec_name!r} codec"
185+
)
186+
187+
# Create stream with no codec context.
188+
stream: cython.pointer[lib.AVStream] = lib.avformat_new_stream(
189+
self.ptr, cython.NULL
190+
)
191+
if stream == cython.NULL:
192+
raise MemoryError("Could not allocate stream")
193+
194+
stream.codecpar.codec_id = codec_id
195+
stream.codecpar.codec_type = codec_type
196+
197+
if codec_type == lib.AVMEDIA_TYPE_VIDEO:
198+
stream.codecpar.width = kwargs.pop("width", 0)
199+
stream.codecpar.height = kwargs.pop("height", 0)
200+
if rate is not None:
201+
to_avrational(rate, cython.address(stream.avg_frame_rate))
202+
elif codec_type == lib.AVMEDIA_TYPE_AUDIO:
203+
if rate is not None:
204+
if type(rate) is int:
205+
stream.codecpar.sample_rate = rate
206+
else:
207+
raise TypeError("audio stream `rate` must be: int | None")
208+
209+
# Construct the user-land stream (no codec context).
210+
py_stream: Stream = wrap_stream(self, stream, None)
211+
self.streams.add_stream(py_stream)
212+
213+
for k, v in kwargs.items():
214+
setattr(py_stream, k, v)
215+
216+
return py_stream
217+
140218
def add_stream_from_template(
141219
self, template: Stream, opaque: bool | None = None, **kwargs
142220
):
@@ -361,10 +439,10 @@ def start_encoding(self):
361439
# Finalize and open all streams.
362440
for stream in self.streams:
363441
ctx = stream.codec_context
364-
# Skip codec context handling for streams without codecs (e.g. data/attachments).
442+
# Skip codec context handling for streams without codecs
443+
# (e.g. data, attachments, and mux-only streams).
365444
if ctx is None:
366-
if stream.type not in {"data", "attachment"}:
367-
raise ValueError(f"Stream {stream.index} has no codec context")
445+
pass
368446
else:
369447
if not ctx.is_open:
370448
for k, v in self.options.items():

av/container/output.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ class OutputContainer(Container):
3939
options: dict[str, str] | None = None,
4040
**kwargs,
4141
) -> VideoStream | AudioStream | SubtitleStream: ...
42+
def add_mux_stream(
43+
self,
44+
codec_name: str,
45+
rate: Fraction | int | None = None,
46+
**kwargs,
47+
) -> Stream: ...
4248
def add_stream_from_template(
4349
self, template: _StreamT, opaque: bool | None = None, **kwargs
4450
) -> _StreamT: ...

av/video/stream.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
@cython.cclass
99
class VideoStream(Stream):
1010
def __repr__(self):
11+
if self.codec_context is None:
12+
return f"<av.VideoStream #{self.index} video/<nocodec> at 0x{id(self):x}>"
1113
return (
1214
f"<av.VideoStream #{self.index} {self.name}, "
1315
f"{self.format.name if self.format else None} {self.codec_context.width}x"
@@ -19,7 +21,10 @@ def __getattr__(self, name):
1921
raise AttributeError(
2022
f"'{type(self).__name__}' object has no attribute '{name}'"
2123
)
22-
24+
if self.codec_context is None:
25+
raise AttributeError(
26+
f"'{type(self).__name__}' object has no attribute '{name}'"
27+
)
2328
return getattr(self.codec_context, name)
2429

2530
@cython.ccall

include/avcodec.pxd

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ cdef extern from "libavcodec/avcodec.h" nogil:
206206

207207
cdef struct AVCodecDescriptor:
208208
AVCodecID id
209+
AVMediaType type
209210
char *name
210211
char *long_name
211212
int props
@@ -470,6 +471,9 @@ cdef extern from "libavcodec/avcodec.h" nogil:
470471
AVCodecID codec_id
471472
uint8_t *extradata
472473
int extradata_size
474+
int width
475+
int height
476+
int sample_rate
473477

474478
cdef int avcodec_parameters_copy(
475479
AVCodecParameters *dst, const AVCodecParameters *src

tests/test_remux.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import io
2+
13
import av
24
import av.datasets
35

@@ -31,3 +33,49 @@ def test_video_remux() -> None:
3133
packet_count += 1
3234

3335
assert packet_count > 50
36+
37+
38+
def test_add_mux_stream_video() -> None:
39+
"""add_mux_stream creates a video stream without a CodecContext."""
40+
input_path = av.datasets.curated("pexels/time-lapse-video-of-night-sky-857195.mp4")
41+
42+
buf = io.BytesIO()
43+
with av.open(input_path) as input_:
44+
in_stream = input_.streams.video[0]
45+
width = in_stream.codec_context.width
46+
height = in_stream.codec_context.height
47+
48+
with av.open(buf, "w", format="mp4") as output:
49+
out_stream = output.add_mux_stream(
50+
in_stream.codec_context.name, width=width, height=height
51+
)
52+
assert out_stream.codec_context is None
53+
assert out_stream.type == "video"
54+
55+
out_stream.time_base = in_stream.time_base
56+
57+
for packet in input_.demux(in_stream):
58+
if packet.dts is None:
59+
continue
60+
packet.stream = out_stream
61+
output.mux(packet)
62+
63+
buf.seek(0)
64+
with av.open(buf) as result:
65+
assert len(result.streams.video) == 1
66+
assert result.streams.video[0].codec_context.width == width
67+
assert result.streams.video[0].codec_context.height == height
68+
69+
70+
def test_add_mux_stream_no_codec_context() -> None:
71+
"""add_mux_stream streams have no codec context and repr does not crash."""
72+
buf = io.BytesIO()
73+
with av.open(buf, "w", format="mp4") as output:
74+
video_stream = output.add_mux_stream("h264", width=1920, height=1080)
75+
audio_stream = output.add_mux_stream("aac", rate=44100)
76+
77+
assert video_stream.codec_context is None
78+
assert audio_stream.codec_context is None
79+
# repr should not crash
80+
assert "video/<nocodec>" in repr(video_stream)
81+
assert "audio/<nocodec>" in repr(audio_stream)

0 commit comments

Comments
 (0)