Skip to content

Commit 327cd62

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 327cd62

File tree

7 files changed

+161
-20
lines changed

7 files changed

+161
-20
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Major:
3737

3838
Features:
3939

40+
- Add ``OutputContainer.add_mux_stream()`` for creating codec-context-free streams, enabling muxing of pre-encoded packets without an encoder, addressing :issue:`1970` by :gh-user:`WyattBlue`.
4041
- Use zero-copy for Packet init from buffer data by :gh-user:`WyattBlue` in (:pr:`2199`).
4142
- Expose AVIndexEntry by :gh-user:`Queuecumber` in (:pr:`2136`).
4243
- Preserving hardware memory during cuvid decoding, exporting/importing via dlpack by :gh-user:`WyattBlue` in (:pr:`2155`).

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: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,83 @@ def add_stream(self, codec_name, rate=None, options: dict | None = None, **kwarg
137137

138138
return py_stream
139139

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

293370
if codec_name is not None:
294-
codec = lib.avcodec_find_encoder_by_name(codec_name.encode())
371+
codec_name_bytes: bytes = codec_name.encode()
372+
codec = lib.avcodec_find_encoder_by_name(codec_name_bytes)
295373
if codec == cython.NULL:
296-
codec = lib.avcodec_find_decoder_by_name(codec_name.encode())
374+
codec = lib.avcodec_find_decoder_by_name(codec_name_bytes)
297375
if codec == cython.NULL:
298-
codec_descriptor = lib.avcodec_descriptor_get_by_name(
299-
codec_name.encode()
300-
)
376+
codec_descriptor = lib.avcodec_descriptor_get_by_name(codec_name_bytes)
301377
if codec_descriptor == cython.NULL:
302378
raise ValueError(f"Unknown data codec: {codec_name}")
303379

@@ -361,22 +437,17 @@ def start_encoding(self):
361437
# Finalize and open all streams.
362438
for stream in self.streams:
363439
ctx = stream.codec_context
364-
# Skip codec context handling for streams without codecs (e.g. data/attachments).
365-
if ctx is None:
366-
if stream.type not in {"data", "attachment"}:
367-
raise ValueError(f"Stream {stream.index} has no codec context")
368-
else:
369-
if not ctx.is_open:
370-
for k, v in self.options.items():
371-
ctx.options.setdefault(k, v)
440+
if ctx is not None and not ctx.is_open:
441+
for k, v in self.options.items():
442+
ctx.options.setdefault(k, v)
372443

373-
if not ctx._template_initialized:
374-
ctx.open()
444+
if not ctx._template_initialized:
445+
ctx.open()
375446

376-
# Track option consumption.
377-
for k in self.options:
378-
if k not in ctx.options:
379-
used_options.add(k)
447+
# Track option consumption.
448+
for k in self.options:
449+
if k not in ctx.options:
450+
used_options.add(k)
380451

381452
stream._finalize_for_output()
382453

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)