[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