Skip to content

Commit 91b4bc6

Browse files
authored
Add stream_index parameter to Demuxer (#1659)
1 parent 0478c26 commit 91b4bc6

4 files changed

Lines changed: 88 additions & 12 deletions

File tree

src/torchcodec/_core/Demuxer.cpp

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,13 +91,41 @@ Demuxer::Demuxer(
9191
select_stream(stream_index);
9292
}
9393

94+
void Demuxer::validate_requested_stream(int stream_index) {
95+
int num_streams = static_cast<int>(format_context_->nb_streams);
96+
STD_TORCH_CHECK(
97+
stream_index >= 0 && stream_index < num_streams,
98+
"The stream index ",
99+
stream_index,
100+
" is not a valid stream. The file has ",
101+
num_streams,
102+
" streams, so the index must be in [0, ",
103+
num_streams - 1,
104+
"].");
105+
106+
AVMediaType media_type =
107+
format_context_->streams[stream_index]->codecpar->codec_type;
108+
const char* media_type_name = av_get_media_type_string(media_type);
109+
STD_TORCH_CHECK(
110+
media_type == AVMEDIA_TYPE_VIDEO,
111+
"The stream at index ",
112+
stream_index,
113+
" is not a video stream, it is of type '",
114+
media_type_name == nullptr ? "unknown" : media_type_name,
115+
"'. Only video streams can be demuxed.");
116+
}
117+
94118
void Demuxer::select_stream(std::optional<int> stream_index) {
95119
int status = avformat_find_stream_info(format_context_.get(), nullptr);
96120
STD_TORCH_CHECK(
97121
status >= 0,
98122
"Failed to find stream info: ",
99123
get_ffmpeg_error_string_from_error_code(status));
100124

125+
if (stream_index.has_value()) {
126+
validate_requested_stream(*stream_index);
127+
}
128+
101129
active_stream_index_ = av_find_best_stream(
102130
format_context_.get(),
103131
AVMEDIA_TYPE_VIDEO,
@@ -107,9 +135,8 @@ void Demuxer::select_stream(std::optional<int> stream_index) {
107135
/*flags=*/0);
108136
STD_TORCH_CHECK(
109137
active_stream_index_ >= 0,
110-
"No valid video stream found in input file (requested index ",
111-
stream_index.value_or(-1),
112-
").");
138+
"No valid video stream found in input file. Only video streams are "
139+
"supported: audio streams cannot be demuxed.");
113140
stream_ = format_context_->streams[active_stream_index_];
114141

115142
// We only need packets from the active stream, so tell FFmpeg to discard the

src/torchcodec/_core/Demuxer.h

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,6 @@ class FORCE_PUBLIC_VISIBILITY Demuxer {
3838
const std::string& file_path,
3939
std::optional<int> stream_index = std::nullopt);
4040

41-
// Demuxes from the AVIOContext inside the AVIOContextHolder, which wraps an
42-
// IOInterface specializing how reads and seeks work (in-memory tensor,
43-
// Python file-like, ...).
4441
explicit Demuxer(
4542
std::unique_ptr<AVIOContextHolder> avio_context_holder,
4643
std::optional<int> stream_index = std::nullopt);
@@ -64,6 +61,7 @@ class FORCE_PUBLIC_VISIBILITY Demuxer {
6461
}
6562

6663
private:
64+
void validate_requested_stream(int stream_index);
6765
void select_stream(std::optional<int> stream_index);
6866

6967
// Declared before format_context_ so that it outlives it: the format context

src/torchcodec/decoders/_blocks/_demuxer.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ class Demuxer:
4747
4848
stream_index (int, optional): Specifies which stream in the video to
4949
demux packets from. Note that this index is absolute across all
50-
media types. If left unspecified, then the :term:`best stream` is
51-
used.
50+
media types. It must refer to a video stream: audio streams aren't
51+
supported, and requesting one raises an error. If left unspecified,
52+
then the :term:`best stream` is used.
5253
"""
5354

5455
def __init__(

test/test_decoders.py

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4255,17 +4255,26 @@ def test_cpu_fallback_is_on_cuda(self, video, expected_pix_fmt):
42554255
assert frame.pix_fmt == expected_pix_fmt
42564256
assert all(plane.device.type == "cuda" for plane in frame.planes)
42574257

4258-
@pytest.mark.parametrize("pix_fmt", ("pal8", "gbrpf32le"))
4259-
def test_planes_of_non_viewable_format(self, tmp_path, pix_fmt):
4258+
@pytest.mark.parametrize(
4259+
"pix_fmt, codec, container",
4260+
(
4261+
("pal8", "rawvideo", "nut"),
4262+
# Before FFmpeg 8 the nut muxer has no rawvideo tag for the float
4263+
# formats and silently writes a bogus one, so the file reads back as
4264+
# rgb555le. EXR in mkv stores gbrpf32le properly on all versions.
4265+
("gbrpf32le", "exr", "mkv"),
4266+
),
4267+
)
4268+
def test_planes_of_non_viewable_format(self, tmp_path, pix_fmt, codec, container):
42604269
# Palettised and float formats can't be handed out as views. Everything
42614270
# *but* the planes still works, which is what lets a caller check
42624271
# pix_fmt before reaching for them.
4263-
path = tmp_path / f"{pix_fmt}.nut"
4272+
path = tmp_path / f"{pix_fmt}.{container}"
42644273
subprocess.run(
42654274
[
42664275
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
42674276
"-f", "lavfi", "-i", "testsrc2=size=64x48:rate=10:duration=1",
4268-
"-c:v", "rawvideo", "-pix_fmt", pix_fmt, str(path),
4277+
"-c:v", codec, "-pix_fmt", pix_fmt, str(path),
42694278
],
42704279
check=True,
42714280
) # fmt: skip
@@ -4759,6 +4768,47 @@ def test_seek_on_every_source_kind(self, make_source):
47594768
assert got.pts_seconds == expected.pts_seconds == seconds
47604769
assert_frames_equal(got.data, expected.data)
47614770

4771+
# ===== stream_index =====
4772+
4773+
@pytest.mark.parametrize("stream_index", (None, 0, 3))
4774+
def test_stream_index(self, stream_index):
4775+
# nasa_13013.mp4 has two video streams, 0 and 3, of different sizes,
4776+
# and 3 is the best one, i.e. the one used when nothing is requested.
4777+
demuxer = Demuxer(NASA_VIDEO.path, stream_index=stream_index)
4778+
decoder = PacketDecoder(demuxer)
4779+
converter = ColorConverter()
4780+
got = [
4781+
converter.convert(raw_frame)
4782+
for raw_frame in itertools.islice(
4783+
self._decode(decoder, self._demux(demuxer)), 10
4784+
)
4785+
]
4786+
4787+
expected = VideoDecoder(NASA_VIDEO.path, stream_index=stream_index)[:10]
4788+
4789+
assert len(got) == len(expected) == 10
4790+
for got_frame, expected_data in zip(got, expected):
4791+
assert_frames_equal(got_frame.data, expected_data)
4792+
4793+
@pytest.mark.parametrize("stream_index", (1, 4)) # the mp4's aac streams
4794+
def test_audio_stream_index_raises(self, stream_index):
4795+
with pytest.raises(RuntimeError, match="is not a video stream.*'audio'"):
4796+
Demuxer(NASA_VIDEO.path, stream_index=stream_index)
4797+
4798+
def test_audio_only_file_raises(self):
4799+
with pytest.raises(RuntimeError, match="No valid video stream found"):
4800+
Demuxer(NASA_AUDIO_MP3.path)
4801+
4802+
def test_non_video_stream_index_raises(self):
4803+
# Stream 2 of the mp4 is a subtitle stream.
4804+
with pytest.raises(RuntimeError, match="is not a video stream.*'subtitle'"):
4805+
Demuxer(NASA_VIDEO.path, stream_index=2)
4806+
4807+
@pytest.mark.parametrize("stream_index", (-1, 6, 1000))
4808+
def test_invalid_stream_index_raises(self, stream_index):
4809+
with pytest.raises(RuntimeError, match="is not a valid stream"):
4810+
Demuxer(NASA_VIDEO.path, stream_index=stream_index)
4811+
47624812
def test_bad_source_type_raises(self):
47634813
with pytest.raises(TypeError, match="Unknown source type"):
47644814
Demuxer(123)

0 commit comments

Comments
 (0)