[Multimodal] Deprecate PyAV video decoder backend (#54231)

Signed-off-by: Isotr0py <Isotr0py@outlook.com>

Isotr0py committed Aug 29, 2026 at 23:13 UTC 4fc943b8676e07cfb6be1b7c54cb4f9fba99033e
9 files changed +35 -301
docs/features/multimodal_inputs.md
+4 -5
@@ -844,20 +844,19 @@ Full example: [examples/generate/multimodal/openai_chat_completion_client_for_mu
844
845 #### Video Decoding Backend
846
847 -vLLM decodes video bytes into frames using a selectable decoding backend. Five
847 +vLLM decodes video bytes into frames using a selectable decoding backend. Four
848 backends are supported:
849
850 | Backend | Device | Description |
851 | --- | --- | --- |
852 | `opencv` (default) | CPU | OpenCV-based decoder |
853 -| `pyav` | CPU | PyAV decoder |
853 | `torchcodec` | CPU | TorchCodec (PyTorch-native) decoder |
854 | `pynvvideocodec` | GPU | NVIDIA PyNvVideoCodec decoder |
855 | `deepstream` | GPU | NVIDIA DeepStream decoder |
856
858 -The three CPU backends are ultimately backed by FFmpeg. `torchcodec` lets you
859 -choose which FFmpeg version is used while `opencv` and `pyav` rely on whichever
860 -FFmpeg build they were linked against.
857 +The two CPU backends are ultimately backed by FFmpeg. `torchcodec` lets you
858 +choose which FFmpeg version is used while `opencv` relies on whichever
859 +FFmpeg build it was linked against.
860
861 Select the backend by passing the `backend` parameter via `--media-io-kwargs`:
862
tests/models/multimodal/processing/test_glm4_1v.py
+1 -1
@@ -146,7 +146,7 @@ def test_processor_override(
146
147 @pytest.mark.parametrize("model_id", ["zai-org/GLM-4.1V-9B-Thinking"])
148 @pytest.mark.parametrize("fps", [2])
149 -@pytest.mark.parametrize("backend", ["opencv", "pyav"])
149 +@pytest.mark.parametrize("backend", ["opencv"])
150 def test_video_loader_consistency(
151 model_id: str,
152 fps: int,
tests/multimodal/media/test_video.py
+2 -2
@@ -454,7 +454,7 @@ class TestMergeKwargsGpuBackendPolicy:
454 )
455 assert result["hw_decoders"] == 2
456
457 - @pytest.mark.parametrize("backend", ["opencv", "pyav", "torchcodec"])
457 + @pytest.mark.parametrize("backend", ["opencv", "torchcodec"])
458 def test_software_video_backend_passes_through(self, backend: str):
459 result = VideoMediaIO.merge_kwargs(
460 default_kwargs=None,
@@ -462,7 +462,7 @@ class TestMergeKwargsGpuBackendPolicy:
462 )
463 assert result["video_backend"] == backend
464
465 - @pytest.mark.parametrize("backend", ["opencv", "pyav"])
465 + @pytest.mark.parametrize("backend", ["opencv"])
466 def test_software_codec_backend_passes_through(self, backend: str):
467 result = VideoMediaIO.merge_kwargs(
468 default_kwargs=None,
tests/multimodal/test_video.py
+10 -135
@@ -113,7 +113,7 @@ import vllm.multimodal.video # noqa: F401
113
114 backend_modules = {
115 f"vllm.multimodal.video_decoders.{backend}"
116 - for backend in ("opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream")
116 + for backend in ("opencv", "torchcodec", "pynvvideocodec", "deepstream")
117 }
118 loaded = sorted(backend_modules & sys.modules.keys())
119 assert not loaded, loaded
@@ -128,7 +128,7 @@ assert not loaded, loaded
128
129 @pytest.mark.parametrize(
130 "backend",
131 - ["opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"],
131 + ["opencv", "torchcodec", "pynvvideocodec", "deepstream"],
132 )
133 def test_decode_video_imports_only_selected_backend(
134 backend: str,
@@ -199,15 +199,15 @@ def test_video_backend_kwargs_are_separated_from_sampling_kwargs(
199
200 def test_video_backend_rejects_options_for_another_decoder():
201 with pytest.raises(
202 - ValueError, match="num_ffmpeg_threads is not supported by the 'pyav' backend"
202 + ValueError, match="num_ffmpeg_threads is not supported by the 'opencv' backend"
203 ):
204 - resolve_video_backend_kwargs("pyav", {"num_ffmpeg_threads": 2})
204 + resolve_video_backend_kwargs("opencv", {"num_ffmpeg_threads": 2})
205
206
207 @pytest.mark.parametrize(
208 ("backend", "error"),
209 [
210 - ("pyav", AssertionError),
210 + ("torchcodec", AssertionError),
211 (PYNVVIDEOCODEC_VIDEO_BACKEND, ValueError),
212 ],
213 )
@@ -885,7 +885,7 @@ def test_video_backend_handles_edit_list_trimmed_video(
885 )
886
887 loader = VIDEO_LOADER_REGISTRY.load("opencv")
888 - for backend in ["opencv", "pyav"]:
888 + for backend in ["opencv"]:
889 frames, metadata = loader.load_bytes(
890 video_data, num_frames=-1, backend=backend
891 )
@@ -1136,97 +1136,6 @@ def dummy_video_path(tmp_path):
1136 return video_path
1137
1138
1139 -# ============================================================================
1140 -# PyAV Backend Tests
1141 -# ============================================================================
1142 -
1143 -
1144 -def test_pyav_backend_loads_frames(dummy_video_path, monkeypatch: pytest.MonkeyPatch):
1145 - """Test that the pyav codec backend can load frames from a valid video."""
1146 - with monkeypatch.context() as m:
1147 - m.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv")
1148 -
1149 - with open(dummy_video_path, "rb") as f:
1150 - video_data = f.read()
1151 -
1152 - loader = VIDEO_LOADER_REGISTRY.load("opencv")
1153 - frames, metadata = loader.load_bytes(video_data, num_frames=8, backend="pyav")
1154 -
1155 - assert frames.ndim == 4
1156 - assert frames.shape[3] == 3 # RGB
1157 - assert frames.shape[0] == 8
1158 - assert frames.shape[0] == len(metadata["frames_indices"])
1159 - assert metadata["video_backend"] == "pyav"
1160 - assert "total_num_frames" in metadata
1161 - assert "fps" in metadata
1162 - assert "duration" in metadata
1163 -
1164 -
1165 -def test_pyav_dynamic_backend_loads_frames(
1166 - dummy_video_path, monkeypatch: pytest.MonkeyPatch
1167 -):
1168 - """Test that the pyav codec with dynamic sampling can load frames."""
1169 - with monkeypatch.context() as m:
1170 - m.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv_dynamic")
1171 -
1172 - with open(dummy_video_path, "rb") as f:
1173 - video_data = f.read()
1174 -
1175 - loader = VIDEO_LOADER_REGISTRY.load("opencv_dynamic")
1176 - frames, metadata = loader.load_bytes(
1177 - video_data, fps=2, max_duration=10, backend="pyav"
1178 - )
1179 -
1180 - assert frames.ndim == 4
1181 - assert frames.shape[3] == 3 # RGB
1182 - assert frames.shape[0] > 0
1183 - assert frames.shape[0] == len(metadata["frames_indices"])
1184 - assert metadata["video_backend"] == "pyav_dynamic"
1185 -
1186 -
1187 -def test_pyav_backend_returns_target_frames_not_keyframes():
1188 - """Regression test: PyAV must decode forward past the seek keyframe.
1189 -
1190 - container.seek() snaps backward to the nearest keyframe. With a long GOP
1191 - (here: one keyframe at frame 0), a decoder that does not advance forward
1192 - to the target PTS collapses every sampled slot onto the keyframe. This
1193 - test encodes a per-frame marker on the green channel and verifies the
1194 - returned frames are distinct, ordered, and match the requested indices.
1195 - """
1196 - num_frames = 50
1197 - num_sampled = 4
1198 - height, width = 64, 64
1199 -
1200 - video_bytes = create_long_gop_video(
1201 - num_frames=num_frames, width=width, height=height
1202 - )
1203 -
1204 - loader = VIDEO_LOADER_REGISTRY.load("opencv")
1205 - frames, metadata = loader.load_bytes(
1206 - video_bytes, num_frames=num_sampled, backend="pyav"
1207 - )
1208 - assert frames.shape == (num_sampled, height, width, 3)
1209 -
1210 - requested = list(metadata["frames_indices"])
1211 - assert len(requested) == num_sampled
1212 -
1213 - actual = [int(f[height // 2, width // 2, 1]) for f in frames]
1214 -
1215 - assert len(set(actual)) == num_sampled, (
1216 - f"PyAV returned only {len(set(actual))} distinct frames for "
1217 - f"{num_sampled} requested indices: markers={actual}, "
1218 - f"requested={requested}. Keyframe-snap regression."
1219 - )
1220 -
1221 - assert actual == sorted(actual), f"Returned frames out of order: markers={actual}"
1222 -
1223 - for marker, want_idx in zip(actual, requested):
1224 - assert abs(marker - want_idx) <= 10, (
1225 - f"Frame mismatch: requested index {want_idx}, "
1226 - f"got marker {marker} (tolerance ±10)"
1227 - )
1228 -
1229 -
1139 # ============================================================================
1140 # TorchCodec Backend Tests
1141 # ============================================================================
@@ -1298,11 +1207,10 @@ def test_torchcodec_backend_returns_target_frames_not_keyframes():
1207 """Regression test: torchcodec must return the requested frames, not the
1208 GOP keyframe they seek back to.
1209
1301 - Mirrors ``test_pyav_backend_returns_target_frames_not_keyframes``: a long
1302 - GOP (single keyframe at frame 0) with a per-frame green-channel marker.
1303 - With ``seek_mode="exact"`` torchcodec resolves each index to the exact
1304 - frame, so the returned markers must be distinct, ordered, and match the
1305 - requested indices.
1210 + A long GOP (single keyframe at frame 0) with a per-frame green-channel
1211 + marker. With ``seek_mode="exact"`` torchcodec resolves each index to the
1212 + exact frame, so the returned markers must be distinct, ordered, and match
1213 + the requested indices.
1214 """
1215 pytest.importorskip("torchcodec")
1216 num_frames = 50
@@ -1384,33 +1292,6 @@ def test_torchcodec_backend_returns_target_frames_not_keyframes():
1292 119,
1293 id="molmo2-fps",
1294 ),
1387 - # uniform sampling + pyav codec (same frame counts as opencv)
1388 - pytest.param(
1389 - "opencv",
1390 - {"num_frames": 32, "backend": "pyav"},
1391 - 32,
1392 - id="pyav-num_frames",
1393 - ),
1394 - pytest.param("opencv", {"fps": 2, "backend": "pyav"}, 120, id="pyav-fps"),
1395 - pytest.param(
1396 - "opencv",
1397 - {"num_frames": 500, "fps": 2, "backend": "pyav"},
1398 - 120,
1399 - id="pyav-num_frames_wins_fps",
1400 - ),
1401 - # dynamic sampling + pyav codec
1402 - pytest.param(
1403 - "opencv_dynamic",
1404 - {"fps": 1, "max_duration": 60, "backend": "pyav"},
1405 - 60,
1406 - id="pyav_dynamic-within_max_duration",
1407 - ),
1408 - pytest.param(
1409 - "opencv_dynamic",
1410 - {"fps": 2, "max_duration": 30, "backend": "pyav"},
1411 - 60,
1412 - id="pyav_dynamic-exceeds_max_duration",
1413 - ),
1295 # glm46v dynamic FPS (1800 frames @ 30fps = 60s)
1296 # 60s falls in (30, 300] → target_fps=1.0, extract_t = 60*1.0*2 = 120
1297 pytest.param(
@@ -1419,12 +1300,6 @@ def test_torchcodec_backend_returns_target_frames_not_keyframes():
1300 120,
1301 id="glm46v-60s",
1302 ),
1422 - pytest.param(
1423 - "glm46v",
1424 - {"backend": "pyav"},
1425 - 120,
1426 - id="glm46v-pyav-60s",
1427 - ),
1303 # uniform sampling + torchcodec codec (same frame counts as opencv)
1304 pytest.param(
1305 "opencv",
vllm/models/minimax_m3/common/mm_preprocess.py
+3 -8
@@ -3,7 +3,7 @@
3
4 import math
5 from collections.abc import Mapping, Sequence
6 -from typing import Any, Literal, cast
6 +from typing import Any, cast
7
8 import numpy.typing as npt
9 import torch
@@ -35,6 +35,7 @@ from vllm.multimodal.processing import (
35 from vllm.multimodal.video import (
36 VIDEO_LOADER_REGISTRY,
37 VideoBackend,
38 + VideoDecoderBackend,
39 VideoSourceMetadata,
40 VideoTargetMetadata,
41 )
@@ -485,13 +486,7 @@ class MiniMaxM3VideoBackend(VideoBackend):
486 max_duration: int = 300,
487 frame_recovery: bool = False,
488 *,
488 - backend: Literal[
489 - "opencv",
490 - "pyav",
491 - "torchcodec",
492 - "pynvvideocodec",
493 - "deepstream",
494 - ] = "opencv",
489 + backend: VideoDecoderBackend = "opencv",
490 **kwargs,
491 ) -> tuple[npt.NDArray, dict[str, Any]]:
492 return super().load_bytes(
vllm/multimodal/video.py
+12 -25
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import math
4 from abc import abstractmethod
5 -from typing import Any, ClassVar, Literal, cast
5 +from typing import Any, ClassVar, cast
6
7 import numpy as np
8 import numpy.typing as npt
@@ -177,8 +177,8 @@ class VideoBackend(VideoLoader):
177
178 Samples ``num_frames`` uniformly across the video (or one frame every
179 ``1/fps`` seconds, whichever produces fewer frames). The decoding codec
180 - is selected via the ``backend`` kwarg (``"opencv"``, ``"pyav"``,
181 - ``"torchcodec"``, ``"pynvvideocodec"``, or ``"deepstream"``),
180 + is selected via the ``backend`` kwarg (``"opencv"``, ``"torchcodec"``,
181 + ``"pynvvideocodec"``, or ``"deepstream"``),
182 which can be passed through ``--media-io-kwargs``. Defaults to ``"opencv"``.
183 """
184
@@ -232,16 +232,15 @@ class VideoBackend(VideoLoader):
232 dynamic subclass; ignored here.
233 frame_recovery: Enable forward-scan recovery for failed frames.
234 Only honored by the OpenCV codec.
235 - backend: Decoding codec — ``"opencv"``, ``"pyav"``,
236 - ``"torchcodec"``, ``"pynvvideocodec"`` or ``"deepstream"``.
235 + backend: Decoding codec — ``"opencv"``, ``"torchcodec"``,
236 + ``"pynvvideocodec"`` or ``"deepstream"``.
237 kwargs: Codec-specific options, validated against and forwarded to
238 ``backend``:
239
240 - ``num_ffmpeg_threads`` (TorchCodec): number of FFmpeg
241 decoding threads; ``0`` (default) relies on the FFmpeg
242 default value which is ``min(cpu_count + 1, 16)``.
243 - OpenCV will always use ``min(cpu_count, 16)`` while pyav
244 - will always use ``min(cpu_count, (height + 15) / 16)``.
243 + OpenCV will always use ``min(cpu_count, 16)``.
244 - ``seek_mode`` (TorchCodec): ``"exact"`` (default) guarantees
245 frame-accurate sampling by scanning the file on creation,
246 while ``"approximate"`` skips that scan for faster decoder
@@ -355,9 +354,7 @@ class Qwen3VLVideoBackend(VideoBackend):
354 max_duration: int = 300,
355 frame_recovery: bool = False,
356 *,
358 - backend: Literal[
359 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
360 - ] = "opencv",
357 + backend: VideoDecoderBackend = "opencv",
358 **kwargs,
359 ) -> tuple[npt.NDArray, dict[str, Any]]:
360 return super().load_bytes(
@@ -436,9 +433,7 @@ class Qwen2VLVideoBackend(VideoBackend):
433 max_duration: int = 300,
434 frame_recovery: bool = False,
435 *,
439 - backend: Literal[
440 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
441 - ] = "opencv",
436 + backend: VideoDecoderBackend = "opencv",
437 **kwargs,
438 ) -> tuple[npt.NDArray, dict[str, Any]]:
439 return super().load_bytes(
@@ -530,9 +525,7 @@ class DynamicVideoBackend(VideoBackend):
525 max_duration: int = 300,
526 frame_recovery: bool = False,
527 *,
533 - backend: Literal[
534 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
535 - ] = "opencv",
528 + backend: VideoDecoderBackend = "opencv",
529 **kwargs,
530 ) -> tuple[npt.NDArray, dict[str, Any]]:
531 return super().load_bytes(
@@ -657,9 +650,7 @@ class GLM46VVideoBackend(VideoBackend):
650 max_duration: int = 300,
651 frame_recovery: bool = False,
652 *,
660 - backend: Literal[
661 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
662 - ] = "opencv",
653 + backend: VideoDecoderBackend = "opencv",
654 **kwargs,
655 ) -> tuple[npt.NDArray, dict[str, Any]]:
656 return super().load_bytes(
@@ -757,9 +748,7 @@ class GLMGAVideoBackend(VideoBackend):
748 max_duration: int = 300,
749 frame_recovery: bool = False,
750 *,
760 - backend: Literal[
761 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
762 - ] = "opencv",
751 + backend: VideoDecoderBackend = "opencv",
752 **kwargs,
753 ) -> tuple[npt.NDArray, dict[str, Any]]:
754 frames, metadata = super().load_bytes(
@@ -1073,9 +1062,7 @@ class NemotronVLVideoBackend(VideoBackend):
1062 max_duration: int = 300,
1063 frame_recovery: bool = False,
1064 *,
1076 - backend: Literal[
1077 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1078 - ] = "opencv",
1065 + backend: VideoDecoderBackend = "opencv",
1066 **kwargs,
1067 ) -> tuple[npt.NDArray, dict[str, Any]]:
1068 frames, metadata = super().load_bytes(
vllm/multimodal/video_decoders/__init__.py
+1 -4
@@ -12,13 +12,10 @@ from .base import (
12 check_frame_pixel_limit,
13 )
14
15 -VideoDecoderBackend = Literal[
16 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
17 -]
15 +VideoDecoderBackend = Literal["opencv", "torchcodec", "pynvvideocodec", "deepstream"]
16
17 _BACKEND_OPTION_DEFAULTS: dict[str, dict[str, Any]] = {
18 "opencv": {},
21 - "pyav": {},
19 "torchcodec": {
20 "num_ffmpeg_threads": 0,
21 "seek_mode": "exact",
vllm/multimodal/video_decoders/deepstream.py
+2 -2
@@ -52,7 +52,7 @@ class DeepStreamVideoBackendMixin:
52 local file path is required — HTTP and base64 sources decode identically
53 to local files.
54
55 - Like the OpenCV/PyAV mixins, this provides only the codec layer.
55 + Like the OpenCV mixin, this provides only the codec layer.
56 Frame *selection* lives in the loader's
57 ``compute_frames_index_to_sample`` and arrives here as an explicit
58 list of frame indices.
@@ -132,7 +132,7 @@ class DeepStreamVideoBackendMixin:
132
133 valid = frame_indices[: result.n_kept]
134 # GPU -> CPU NHWC uint8 at the codec boundary (one PCIe copy); keeps
135 - # the array shape identical to the OpenCV/PyAV backends. Copy into
135 + # the array shape identical to the OpenCV backend. Copy into
136 # PINNED host memory (reused across calls by PyTorch's pinned caching
137 # allocator) so the D2H runs at full PCIe bandwidth (~13 GB/s) rather
138 # than the ~1 GB/s pageable path that plain ``.cpu()`` takes — ~12x
vllm/multimodal/video_decoders/pyav.py deleted
-119
@@ -1,119 +0,0 @@
1 -# SPDX-License-Identifier: Apache-2.0
2 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -
4 -from io import BytesIO
5 -
6 -import numpy as np
7 -import numpy.typing as npt
8 -
9 -from vllm.utils.import_utils import PlaceholderModule
10 -
11 -from .base import (
12 - VideoSourceMetadata,
13 - VideoTargetMetadata,
14 - check_frame_pixel_limit,
15 -)
16 -
17 -try:
18 - import av
19 -except ImportError:
20 - av = PlaceholderModule("av") # type: ignore[assignment]
21 -
22 -
23 -def decode_pyav(
24 - loader_cls,
25 - data: bytes,
26 - target: VideoTargetMetadata,
27 - sampling_kwargs: dict,
28 -) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
29 - with av.open(BytesIO(data)) as container:
30 - stream = container.streams.video[0]
31 - check_frame_pixel_limit(stream.width, stream.height)
32 - source = loader_cls._prepare_source(
33 - PyAVVideoBackendMixin.get_metadata(container)
34 - )
35 - frame_idx = loader_cls.compute_frames_index_to_sample(
36 - source=source, target=target, **sampling_kwargs
37 - )
38 - frames, valid = PyAVVideoBackendMixin.decode_frames(
39 - container, frame_idx, source.original_fps, source.duration
40 - )
41 - return frames, source, frame_idx, valid
42 -
43 -
44 -class PyAVVideoBackendMixin:
45 - """PyAV (in-process FFmpeg bindings) codec utilities.
46 -
47 - Reads stream metadata and decodes target frames via per-frame
48 - ``container.seek()``. The seek releases the GIL between frames and
49 - scales with the number of sampled frames rather than the video
50 - length, enabling concurrent decoding under serving load.
51 - """
52 -
53 - @staticmethod
54 - def get_metadata(
55 - container: "av.container.InputContainer",
56 - ) -> VideoSourceMetadata:
57 - if not container.streams.video:
58 - raise ValueError("No video streams found in container")
59 - stream = container.streams.video[0]
60 - total_frames = stream.frames or 0
61 - fps = float(stream.average_rate) if stream.average_rate else 0.0
62 - duration = float(stream.duration * stream.time_base) if stream.duration else 0.0
63 - if total_frames == 0 and duration > 0 and fps > 0:
64 - total_frames = int(duration * fps)
65 - elif duration > 0 and fps > 0 and total_frames > round(duration * fps) + 1:
66 - # The header sample count can exceed the frames the presentation
67 - # timeline actually holds — e.g. an mp4 edit list hides the decode
68 - # lead-in of a stream-copied cut, but ``stream.frames`` still
69 - # counts the hidden samples. Sampling indices past the visible
70 - # range would silently collapse onto the last visible frame, so
71 - # trust the (edit-list-aware) duration instead.
72 - total_frames = round(duration * fps)
73 - return VideoSourceMetadata(total_frames, fps, duration)
74 -
75 - @staticmethod
76 - def decode_frames(
77 - container: "av.container.InputContainer",
78 - frame_indices: list[int],
79 - fps: float,
80 - duration: float,
81 - ) -> tuple[npt.NDArray, list[int]]:
82 - """Decode target frames via per-frame seek + forward decode to PTS."""
83 - stream = container.streams.video[0]
84 - # SLICE parallelizes within a single frame without the
85 - # one-frame-per-thread latency penalty of FRAME threading.
86 - stream.thread_type = "SLICE"
87 - time_base = stream.time_base
88 -
89 - frames_list: list[npt.NDArray] = []
90 - valid_indices: list[int] = []
91 - frame_interval = 1.0 / fps if fps > 0 else 0.1
92 - max_ts = max(0.0, duration - frame_interval) if duration > 0 else float("inf")
93 -
94 - decoder = None
95 - last_pts = None
96 - for idx in frame_indices:
97 - ts = min(idx / fps, max_ts) if fps > 0 else 0.0
98 - pts = int(ts / time_base)
99 - # seek() snaps backward to a keyframe; reuse the running decoder
100 - # while targets advance monotonically to avoid re-decoding the
101 - # GOP prefix once per requested frame.
102 - if decoder is None or last_pts is None or pts <= last_pts:
103 - container.seek(pts, stream=stream)
104 - decoder = container.decode(video=0)
105 - chosen = None
106 - for frame in decoder:
107 - if frame.pts is not None and frame.pts >= pts:
108 - chosen = frame
109 - last_pts = frame.pts
110 - break
111 - if chosen is not None:
112 - frames_list.append(chosen.to_ndarray(format="rgb24"))
113 - valid_indices.append(idx)
114 - else:
115 - decoder = None
116 -
117 - if not frames_list:
118 - return np.empty((0,), dtype=np.uint8), valid_indices
119 - return np.stack(frames_list), valid_indices