[Multimodal] Reorganize video decoder backends (#49155)

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

Isotr0py committed Aug 18, 2026 at 23:06 UTC 3bb9c18f0c845170679498f6aa39c6a92f2f68cb
12 files changed +1416 -1097
tests/multimodal/media/test_video.py
+7 -3
@@ -21,8 +21,10 @@ from vllm.multimodal.media import ImageMediaIO, VideoMediaIO
21 from vllm.multimodal.video import (
22 PYNVVIDEOCODEC_VIDEO_BACKEND,
23 VIDEO_LOADER_REGISTRY,
24 - PyNvVideoCodecVideoBackend,
24 VideoLoader,
25 +)
26 +from vllm.multimodal.video_decoders.pynvvideocodec import (
27 + PyNvVideoCodecVideoBackendMixin,
28 _pynvvc_frames_to_nhwc,
29 )
30
@@ -381,13 +383,15 @@ def test_pynvvideocodec_unrelated_error_propagates(
383 raise original_error
384
385 monkeypatch.setattr(
384 - PyNvVideoCodecVideoBackend,
386 + PyNvVideoCodecVideoBackendMixin,
387 "_read_source_metadata",
388 classmethod(raise_unrelated_error),
389 )
390
391 with pytest.raises(RuntimeError) as exc_info:
390 - PyNvVideoCodecVideoBackend.decode_frames_pynvvideocodec(b"video", None)
392 + PyNvVideoCodecVideoBackendMixin.decode_frames_pynvvideocodec(
393 + None, b"video", None
394 + )
395
396 assert exc_info.value is original_error
397
tests/multimodal/test_gpu_ipc_memory.py
+7 -28
@@ -6,7 +6,6 @@ import time
6
7 import pytest
8
9 -import vllm.config.multimodal as multimodal_config_module
9 from vllm.config.multimodal import MultiModalConfig
10 from vllm.multimodal.gpu_ipc_memory import (
11 MultiModalGPUMemoryPool,
@@ -15,10 +14,10 @@ from vllm.multimodal.gpu_ipc_memory import (
14 reserve_mm_ipc_gpu_memory,
15 set_mm_gpu_ipc_pool,
16 )
18 -from vllm.multimodal.video import (
17 +from vllm.multimodal.video_decoders import PYNVVIDEOCODEC_VIDEO_BACKEND
18 +from vllm.multimodal.video_decoders.pynvvideocodec import (
19 PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
20 PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
21 - PYNVVIDEOCODEC_VIDEO_BACKEND,
21 )
22 from vllm.utils.mem_constants import GiB_bytes
23
@@ -186,11 +185,7 @@ def test_reserve_mm_ipc_gpu_memory_raw_frame_budget_only(
185 monkeypatch: pytest.MonkeyPatch,
186 video_backend: str | None,
187 ):
189 - monkeypatch.setattr(
190 - multimodal_config_module.envs,
191 - "VLLM_VIDEO_LOADER_BACKEND",
192 - "opencv",
193 - )
188 + monkeypatch.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv")
189 mm_config = _mm_config(
190 mm_ipc_gpu_memory_gb=0.25,
191 video_backend=video_backend,
@@ -202,11 +197,7 @@ def test_reserve_mm_ipc_gpu_memory_raw_frame_budget_only(
197 def test_reserve_mm_ipc_gpu_memory_includes_pynvvideocodec_decoder_budget(
198 monkeypatch: pytest.MonkeyPatch,
199 ):
205 - monkeypatch.setattr(
206 - multimodal_config_module.envs,
207 - "VLLM_VIDEO_LOADER_BACKEND",
208 - "opencv",
209 - )
200 + monkeypatch.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv")
201 mm_config = _mm_config(
202 mm_ipc_gpu_memory_gb=0.25,
203 video_backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
@@ -221,11 +212,7 @@ def test_reserve_mm_ipc_gpu_memory_includes_pynvvideocodec_decoder_budget(
212 def test_reserve_mm_ipc_gpu_memory_uses_env_video_backend(
213 monkeypatch: pytest.MonkeyPatch,
214 ):
224 - monkeypatch.setattr(
225 - multimodal_config_module.envs,
226 - "VLLM_VIDEO_LOADER_BACKEND",
227 - PYNVVIDEOCODEC_VIDEO_BACKEND,
228 - )
215 + monkeypatch.setenv("VLLM_VIDEO_LOADER_BACKEND", PYNVVIDEOCODEC_VIDEO_BACKEND)
216 available_bytes = 4 * GiB_bytes
217
218 assert reserve_mm_ipc_gpu_memory(available_bytes, _mm_config()) == (
@@ -236,11 +223,7 @@ def test_reserve_mm_ipc_gpu_memory_uses_env_video_backend(
223 def test_reserve_mm_ipc_gpu_memory_scales_decoder_budget_by_api_servers(
224 monkeypatch: pytest.MonkeyPatch,
225 ):
239 - monkeypatch.setattr(
240 - multimodal_config_module.envs,
241 - "VLLM_VIDEO_LOADER_BACKEND",
242 - PYNVVIDEOCODEC_VIDEO_BACKEND,
243 - )
226 + monkeypatch.setenv("VLLM_VIDEO_LOADER_BACKEND", PYNVVIDEOCODEC_VIDEO_BACKEND)
227 available_bytes = 8 * GiB_bytes
228
229 assert reserve_mm_ipc_gpu_memory(
@@ -253,11 +236,7 @@ def test_reserve_mm_ipc_gpu_memory_scales_decoder_budget_by_api_servers(
236 def test_reserve_mm_ipc_gpu_memory_uses_configured_hw_decoders(
237 monkeypatch: pytest.MonkeyPatch,
238 ):
256 - monkeypatch.setattr(
257 - multimodal_config_module.envs,
258 - "VLLM_VIDEO_LOADER_BACKEND",
259 - "opencv",
260 - )
239 + monkeypatch.setenv("VLLM_VIDEO_LOADER_BACKEND", "opencv")
240 available_bytes = 4 * GiB_bytes
241 mm_config = _mm_config(
242 video_backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
tests/multimodal/test_video.py
+155 -18
@@ -2,6 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import itertools
5 +import subprocess
6 import sys
7 import threading
8 from contextlib import ExitStack, contextmanager
@@ -16,24 +17,26 @@ from transformers.video_utils import VideoMetadata
17 from vllm.assets.base import get_vllm_public_assets
18 from vllm.models.minimax_m3.common.mm_preprocess import MiniMaxM3VideoBackend
19 from vllm.multimodal.video import (
19 - PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
20 PYNVVIDEOCODEC_VIDEO_BACKEND,
21 VIDEO_LOADER_REGISTRY,
22 DynamicVideoBackend,
23 GLM46VVideoBackend,
24 Molmo2VideoBackend,
25 - PyNvVideoCodecDecoderSlot,
26 - PyNvVideoCodecVideoBackend,
27 - PyNvVideoCodecVideoBackendMixin,
25 Qwen2VLVideoBackend,
26 Qwen3VLVideoBackend,
27 VideoBackend,
28 VideoLoader,
29 VideoSourceMetadata,
30 VideoTargetMetadata,
34 - _pynv_decoder_pool,
31 get_video_loader_backend_for_processor,
32 )
33 +from vllm.multimodal.video_decoders import decode_video, resolve_video_backend_kwargs
34 +from vllm.multimodal.video_decoders.pynvvideocodec import (
35 + PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
36 + PyNvVideoCodecDecoderSlot,
37 + PyNvVideoCodecVideoBackendMixin,
38 + _pynv_decoder_pool,
39 +)
40 from vllm.platforms import current_platform
41 from vllm.transformers_utils.processor import get_video_processor_cls_name_from_config
42
@@ -99,6 +102,126 @@ def test_video_loader_type_doesnt_exist():
102 VIDEO_LOADER_REGISTRY.load("non_existing_video_loader")
103
104
105 +def test_video_decoder_backends_are_lazy_imported():
106 + code = """
107 +import sys
108 +import vllm.multimodal.video # noqa: F401
109 +
110 +backend_modules = {
111 + f"vllm.multimodal.video_decoders.{backend}"
112 + for backend in ("opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream")
113 +}
114 +loaded = sorted(backend_modules & sys.modules.keys())
115 +assert not loaded, loaded
116 +"""
117 + result = subprocess.run(
118 + [sys.executable, "-c", code],
119 + capture_output=True,
120 + text=True,
121 + )
122 + assert result.returncode == 0, result.stderr
123 +
124 +
125 +@pytest.mark.parametrize(
126 + "backend",
127 + ["opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"],
128 +)
129 +def test_decode_video_imports_only_selected_backend(
130 + backend: str,
131 + monkeypatch: pytest.MonkeyPatch,
132 +):
133 + imports = []
134 + decoded = object()
135 +
136 + def fake_decoder(*args, **kwargs):
137 + return decoded
138 +
139 + class FakeBackendModule:
140 + pass
141 +
142 + setattr(FakeBackendModule, f"decode_{backend}", fake_decoder)
143 +
144 + def fake_import_module(name: str, package: str):
145 + imports.append((name, package))
146 + return FakeBackendModule
147 +
148 + monkeypatch.setattr(
149 + "vllm.multimodal.video_decoders.import_module", fake_import_module
150 + )
151 + result = decode_video(
152 + backend,
153 + loader_cls=None,
154 + data=b"",
155 + target=VideoTargetMetadata(-1, -1, 300),
156 + sampling_kwargs={},
157 + backend_kwargs={},
158 + frame_recovery=False,
159 + )
160 +
161 + assert result is decoded
162 + assert imports == [(f".{backend}", "vllm.multimodal.video_decoders")]
163 +
164 +
165 +@pytest.mark.parametrize(
166 + ("backend", "kwargs", "expected_sampling", "expected_backend"),
167 + [
168 + (
169 + "torchcodec",
170 + {"min_frames": 4, "num_ffmpeg_threads": 2, "seek_mode": "approximate"},
171 + {"min_frames": 4},
172 + {"num_ffmpeg_threads": 2, "seek_mode": "approximate"},
173 + ),
174 + (
175 + "deepstream",
176 + {"max_frames": 16, "pool_size": 3, "timeout_sec": 10.0},
177 + {"max_frames": 16},
178 + {"pool_size": 3, "timeout_sec": 10.0},
179 + ),
180 + ],
181 +)
182 +def test_video_backend_kwargs_are_separated_from_sampling_kwargs(
183 + backend: str,
184 + kwargs: dict,
185 + expected_sampling: dict,
186 + expected_backend: dict,
187 +):
188 + original_kwargs = dict(kwargs)
189 + sampling_kwargs, backend_kwargs = resolve_video_backend_kwargs(backend, kwargs)
190 +
191 + assert sampling_kwargs == expected_sampling
192 + assert backend_kwargs == expected_backend
193 + assert kwargs == original_kwargs
194 +
195 +
196 +def test_video_backend_rejects_options_for_another_decoder():
197 + with pytest.raises(
198 + ValueError, match="num_ffmpeg_threads is not supported by the 'pyav' backend"
199 + ):
200 + resolve_video_backend_kwargs("pyav", {"num_ffmpeg_threads": 2})
201 +
202 +
203 +@pytest.mark.parametrize(
204 + ("backend", "error"),
205 + [
206 + ("pyav", AssertionError),
207 + (PYNVVIDEOCODEC_VIDEO_BACKEND, ValueError),
208 + ],
209 +)
210 +def test_video_decoder_spec_validates_frame_recovery(
211 + backend: str, error: type[Exception]
212 +):
213 + with pytest.raises(error, match="frame_recovery is not supported"):
214 + decode_video(
215 + backend,
216 + loader_cls=None,
217 + data=b"",
218 + target=VideoTargetMetadata(-1, -1, 300),
219 + sampling_kwargs={},
220 + backend_kwargs={},
221 + frame_recovery=True,
222 + )
223 +
224 +
225 @pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
226 def test_pynvvideocodec_backend_accounts_raw_decoded_frames(
227 monkeypatch: pytest.MonkeyPatch,
@@ -145,7 +268,9 @@ def test_pynvvideocodec_backend_accounts_raw_decoded_frames(
268 "vllm.multimodal.gpu_ipc_memory.get_mm_gpu_ipc_pool", lambda: pool
269 )
270 monkeypatch.setattr(
148 - PyNvVideoCodecVideoBackend, "_decode_to_pinned_host", classmethod(fake_decode)
271 + PyNvVideoCodecVideoBackendMixin,
272 + "_decode_to_pinned_host",
273 + classmethod(fake_decode),
274 )
275
276 loader = VIDEO_LOADER_REGISTRY.load(PYNVVIDEOCODEC_VIDEO_BACKEND)
@@ -205,7 +330,9 @@ def test_pynvvideocodec_codec_uses_dynamic_sampling_strategy(
330 "vllm.multimodal.gpu_ipc_memory.get_mm_gpu_ipc_pool", lambda: pool
331 )
332 monkeypatch.setattr(
208 - DynamicVideoBackend, "_decode_to_pinned_host", classmethod(fake_decode)
333 + PyNvVideoCodecVideoBackendMixin,
334 + "_decode_to_pinned_host",
335 + classmethod(fake_decode),
336 )
337
338 loader = VIDEO_LOADER_REGISTRY.load("opencv_dynamic")
@@ -273,7 +400,7 @@ def test_pynvvideocodec_decoder_slots_are_bounded(
400
401 create_count = 0
402 with _fresh_decoder_pool():
276 - PyNvVideoCodecVideoBackend._configure_decoder_slots(hw_decoders)
403 + PyNvVideoCodecVideoBackendMixin._configure_decoder_slots(hw_decoders)
404
405 def fake_create_slot(cls):
406 nonlocal create_count
@@ -281,7 +408,7 @@ def test_pynvvideocodec_decoder_slots_are_bounded(
408 return FakeSlot()
409
410 monkeypatch.setattr(
284 - PyNvVideoCodecVideoBackend,
411 + PyNvVideoCodecVideoBackendMixin,
412 "_create_decoder_slot",
413 classmethod(fake_create_slot),
414 )
@@ -291,12 +418,16 @@ def test_pynvvideocodec_decoder_slots_are_bounded(
418
419 with ExitStack() as stack:
420 retained_slots = [
294 - stack.enter_context(PyNvVideoCodecVideoBackend._borrow_decoder_slot())
421 + stack.enter_context(
422 + PyNvVideoCodecVideoBackendMixin._borrow_decoder_slot()
423 + )
424 for _ in range(hw_decoders)
425 ]
426
427 def borrow_extra_slot():
299 - with PyNvVideoCodecVideoBackend._borrow_decoder_slot() as extra_slot:
428 + with (
429 + PyNvVideoCodecVideoBackendMixin._borrow_decoder_slot()
430 + ) as extra_slot:
431 seen_slots.append(extra_slot)
432 borrowed.set()
433
@@ -317,11 +448,11 @@ def test_pynvvideocodec_decoder_slots_are_configured_once(
448 ):
449 monkeypatch.setattr(_pynv_decoder_pool, "max_slots", None)
450
320 - PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
321 - PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
451 + PyNvVideoCodecVideoBackendMixin._configure_decoder_slots(2)
452 + PyNvVideoCodecVideoBackendMixin._configure_decoder_slots(2)
453
454 with pytest.raises(RuntimeError, match="already configured as 2, got 3"):
324 - PyNvVideoCodecVideoBackend._configure_decoder_slots(3)
455 + PyNvVideoCodecVideoBackendMixin._configure_decoder_slots(3)
456
457
458 def test_pynvvideocodec_failed_rebuild_invalidates_decoder_slot():
@@ -367,7 +498,7 @@ def test_pynvvideocodec_failed_rebuild_invalidates_decoder_slot():
498
499 with (
500 pytest.raises(RuntimeError, match="construct failed"),
370 - PyNvVideoCodecVideoBackend._borrow_decoder_slot() as borrowed,
501 + PyNvVideoCodecVideoBackendMixin._borrow_decoder_slot() as borrowed,
502 ):
503 assert borrowed is slot
504 borrowed.get_decoder(
@@ -456,6 +587,12 @@ def test_pynvvideocodec_cross_subclass_shares_single_pool():
587 counters via ClassVar shadowing.
588 """
589
590 + class MixinSubclassA(PyNvVideoCodecVideoBackendMixin):
591 + pass
592 +
593 + class MixinSubclassB(PyNvVideoCodecVideoBackendMixin):
594 + pass
595 +
596 class FakeSlot:
597 pass
598
@@ -475,8 +612,8 @@ def test_pynvvideocodec_cross_subclass_shares_single_pool():
612 )
613 try:
614 with ExitStack() as stack:
478 - stack.enter_context(VideoBackend._borrow_decoder_slot())
479 - stack.enter_context(Qwen3VLVideoBackend._borrow_decoder_slot())
615 + stack.enter_context(MixinSubclassA._borrow_decoder_slot())
616 + stack.enter_context(MixinSubclassB._borrow_decoder_slot())
617 assert pool.active == 2
618
619 blocked = threading.Event()
@@ -484,7 +621,7 @@ def test_pynvvideocodec_cross_subclass_shares_single_pool():
621
622 def try_borrow():
623 blocked.set()
487 - with Qwen2VLVideoBackend._borrow_decoder_slot():
624 + with MixinSubclassB._borrow_decoder_slot():
625 acquired.set()
626
627 t = threading.Thread(target=try_borrow)
vllm/multimodal/gpu_ipc_memory.py
+5 -3
@@ -188,11 +188,13 @@ def reserve_mm_ipc_gpu_memory(
188 return available_kv_cache_memory_bytes
189
190 from vllm import envs
191 - from vllm.multimodal.video import (
192 - PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
193 - PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
191 + from vllm.multimodal.video_decoders import (
192 PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
193 PYNVVIDEOCODEC_VIDEO_BACKEND,
194 + )
195 + from vllm.multimodal.video_decoders.pynvvideocodec import (
196 + PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES,
197 + PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES,
198 validate_pynvvideocodec_hw_decoders,
199 )
200
vllm/multimodal/video.py
+66 -1045
@@ -1,42 +1,29 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import math
4 -import os
5 -import tempfile
6 -import threading
4 from abc import abstractmethod
8 -from contextlib import contextmanager, suppress
9 -from io import BytesIO
10 -from typing import Any, ClassVar, Literal, NamedTuple, cast
5 +from typing import Any, ClassVar, Literal, cast
6
7 import numpy as np
8 import numpy.typing as npt
9 import torch
10
16 -from vllm import envs
11 from vllm.logger import init_logger
18 -from vllm.utils.import_utils import PlaceholderModule, check_torchcodec_available
19 -from vllm.utils.mem_constants import MiB_bytes
12 +from vllm.multimodal.video_decoders import (
13 + PYNVVIDEOCODEC_VIDEO_BACKEND,
14 + VideoDecoderBackend,
15 + VideoSourceMetadata,
16 + VideoTargetMetadata,
17 + decode_video,
18 + resolve_video_backend_kwargs,
19 +)
20 +from vllm.utils.import_utils import PlaceholderModule
21 from vllm.utils.registry import ExtensionManager
22
23 try:
24 import cv2
24 - import cv2.videoio_registry as vr
25 except ImportError:
26 cv2 = PlaceholderModule("cv2")
27 - vr = PlaceholderModule("cv2").placeholder_attr("videoio_registry")
28 -
29 -try:
30 - import av
31 -except ImportError:
32 - av = PlaceholderModule("av") # type: ignore[assignment]
33 -
34 -try:
35 - from torchcodec.decoders import VideoDecoder
36 -except (ImportError, RuntimeError):
37 - VideoDecoder = PlaceholderModule("torchcodec").placeholder_attr( # type: ignore[assignment]
38 - "decoders.VideoDecoder"
39 - )
27
28
29 logger = init_logger(__name__)
@@ -106,18 +93,6 @@ def get_video_loader_backend_for_processor(
93 return VIDEO_LOADER_REGISTRY.get_backend_for_video_processor(video_processor)
94
95
109 -def _check_frame_pixel_limit(width: int, height: int) -> None:
110 - """Reject video frames exceeding VLLM_MAX_IMAGE_PIXELS before decoding."""
111 - max_pixels = envs.VLLM_MAX_IMAGE_PIXELS
112 - if max_pixels > 0 and width * height > max_pixels:
113 - raise ValueError(
114 - f"Video frame dimensions {width}x{height} "
115 - f"({width * height} pixels) exceed the maximum of "
116 - f"{max_pixels} pixels. Set VLLM_MAX_IMAGE_PIXELS to "
117 - f"increase this limit."
118 - )
119 -
120 -
96 def resize_video(frames: npt.NDArray, size: tuple[int, int]) -> npt.NDArray:
97 num_frames, _, _, channels = frames.shape
98 new_height, new_width = size
@@ -149,30 +124,6 @@ def sample_frames_from_video(frames: npt.NDArray, num_frames: int) -> npt.NDArra
124 return sampled_frames
125
126
152 -class VideoTargetMetadata(NamedTuple):
153 - """Metadata represents target video."""
154 -
155 - num_frames: int
156 - fps: float
157 - max_duration: float
158 -
159 -
160 -class VideoSourceMetadata(NamedTuple):
161 - """Metadata represents source video."""
162 -
163 - total_frames_num: int
164 - original_fps: float
165 - duration: float
166 -
167 -
168 -class PyNvVideoCodecSourceMetadata(NamedTuple):
169 - """Metadata needed before GPU video decode."""
170 -
171 - source: VideoSourceMetadata
172 - width: int
173 - height: int
174 -
175 -
127 class VideoLoader:
128 @classmethod
129 def compute_frames_index_to_sample(
@@ -184,6 +135,11 @@ class VideoLoader:
135 """Return the list of frame indices to sample from the video."""
136 raise NotImplementedError
137
138 + @classmethod
139 + def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
140 + """Sampling-algorithm-specific metadata adjustment hook."""
141 + return source
142 +
143 @classmethod
144 @abstractmethod
145 def load_bytes(
@@ -214,835 +170,9 @@ class VideoLoader:
170 VIDEO_LOADER_REGISTRY = VideoLoaderRegistry()
171 VIDEO_LOADER_REGISTRY.register_gpu_codec("deepstream")
172
217 -PYNVVIDEOCODEC_VIDEO_BACKEND: Literal["pynvvideocodec"] = "pynvvideocodec"
218 -# Per-decoder upper bound reserved for persistent PyNvVideoCodec surfaces.
219 -PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes
220 -PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
221 -PYNVVIDEOCODEC_DEFAULT_HW_DECODERS = 2
222 -# Per-API-server CUDA context and driver allocation, measured with
223 -# PyNvVideoCodec 2.0.4 on H100.
224 -PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes)
225 -
226 -
227 -def validate_pynvvideocodec_hw_decoders(hw_decoders: object) -> int:
228 - if (
229 - isinstance(hw_decoders, bool)
230 - or not isinstance(hw_decoders, int)
231 - or hw_decoders < 1
232 - ):
233 - raise ValueError("hw_decoders must be a positive integer")
234 - return hw_decoders
235 -
236 -
237 -def _pynvvideocodec_exception_types(nvc) -> tuple[type[Exception], ...]:
238 - return tuple(
239 - exception_type
240 - for name in dir(nvc)
241 - if name.startswith("PyNvVCException")
242 - and isinstance((exception_type := getattr(nvc, name)), type)
243 - and issubclass(exception_type, Exception)
244 - )
245 -
246 -
247 -class PyNvVideoCodecDecoderSlot:
248 - """A retained PyNv decoder slot and its CUDA stream.
249 -
250 - The decoder is reused across requests: ``reconfigure_decoder`` repoints the
251 - existing decoder at each new source instead of paying a fresh
252 - ``SimpleDecoder`` construction per request. Construction (CUVID parser +
253 - decoder + surface-pool allocation) is the dominant per-request cost, so
254 - reconfiguring is far cheaper. A single decoder serves both metadata
255 - (``len``/``get_stream_metadata``) and frame decode -- no separate
256 - metadata decoder.
257 - """
258 -
259 - def __init__(self, stream) -> None:
260 - self.stream = stream
261 - self.decoder = None
262 - self.source_path: str | None = None
263 -
264 - def invalidate(self) -> None:
265 - self.decoder = None
266 - self.source_path = None
267 -
268 - def _construct(self, file_path: str, nvc, device_index: int) -> None:
269 - self.invalidate()
270 - decoder = nvc.SimpleDecoder(
271 - file_path,
272 - output_color_type=nvc.OutputColorType.RGB,
273 - use_device_memory=True,
274 - need_scanned_stream_metadata=True,
275 - gpu_id=device_index,
276 - cuda_stream=self.stream.cuda_stream,
277 - decoder_cache_size=PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
278 - )
279 - self.decoder = decoder
280 - self.source_path = file_path
281 -
282 - def get_decoder(self, file_path: str, nvc, device_index: int):
283 - if self.decoder is None:
284 - self._construct(file_path, nvc, device_index)
285 - elif self.source_path != file_path:
286 - try:
287 - self.decoder.reconfigure_decoder(file_path)
288 - self.source_path = file_path
289 - except Exception:
290 - # reconfigure unsupported/unsafe for this source -> rebuild.
291 - self._construct(file_path, nvc, device_index)
292 - return self.decoder
293 -
294 -
295 -class OpenCVVideoBackendMixin:
296 - @staticmethod
297 - def get_cv2_video_api():
298 - api_pref = None
299 - for backend in vr.getStreamBufferedBackends():
300 - if not vr.hasBackend(backend):
301 - continue
302 - if not vr.isBackendBuiltIn(backend):
303 - _, abi, api = vr.getStreamBufferedBackendPluginVersion(backend)
304 - if abi < 1 or (abi == 1 and api < 2):
305 - continue
306 - api_pref = backend
307 - break
308 - return api_pref
309 -
310 - @classmethod
311 - def open_video_capture(cls, data: bytes) -> "cv2.VideoCapture":
312 - backend = cls.get_cv2_video_api()
313 - cap = cv2.VideoCapture(BytesIO(data), backend, [])
314 - if not cap.isOpened():
315 - raise ValueError("Could not open video stream")
316 - return cap
317 -
318 - @staticmethod
319 - def get_video_metadata(cap: "cv2.VideoCapture") -> VideoSourceMetadata:
320 - total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
321 - original_fps = cap.get(cv2.CAP_PROP_FPS)
322 - duration = total_frames_num / original_fps if original_fps > 0 else 0
323 - return VideoSourceMetadata(
324 - total_frames_num=total_frames_num,
325 - original_fps=original_fps,
326 - duration=duration,
327 - )
328 -
329 - @classmethod
330 - def _can_use_for_recovery(
331 - cls,
332 - idx: int,
333 - failed_frames: list[int],
334 - next_target_map: dict[int, int],
335 - total_frames: int,
336 - ) -> bool:
337 - """Check if current frame can recover the oldest failed frame."""
338 - if not failed_frames:
339 - return False
340 - oldest_failed = failed_frames[0]
341 - limit = next_target_map.get(oldest_failed, total_frames)
342 - return idx < limit
343 -
344 - @classmethod
345 - def _read_frames_with_recovery(
346 - cls,
347 - cap: "cv2.VideoCapture",
348 - frame_indices: list[int],
349 - total_frames: int,
350 - ) -> tuple[npt.NDArray, list[int], dict[int, int]]:
351 - """
352 - Read frames with dynamic window forward-scan recovery.
353 -
354 - When a target frame fails to load, the next successfully grabbed
355 - frame (before the next target frame) will be used to recover it.
356 -
357 - Args:
358 - cap: OpenCV VideoCapture object
359 - frame_indices: Sorted list of target frame indices to load
360 - total_frames: Total number of frames in the video
361 -
362 - Returns:
363 - Tuple of (frames_array, valid_frame_indices, recovered_map)
364 - - frames_array: Array of loaded frames
365 - - valid_frame_indices: List of frame indices that were loaded
366 - - recovered_map: Dict mapping recovered_idx -> source_idx
367 - """
368 - width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
369 - height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
370 -
371 - assert width > 0 and height > 0, (
372 - f"Invalid video frame size: width={width}, height={height}"
373 - )
374 -
375 - frame_idx_set = set(frame_indices)
376 - max_frame_idx = frame_indices[-1] if frame_indices else 0
377 -
378 - # Build map: target_idx -> next_target_idx (for recovery window)
379 - next_target_map: dict[int, int] = {}
380 - for k in range(len(frame_indices) - 1):
381 - next_target_map[frame_indices[k]] = frame_indices[k + 1]
382 - next_target_map[frame_indices[-1]] = total_frames
383 -
384 - frames_list: list[npt.NDArray] = []
385 - valid_frame_indices: list[int] = []
386 - failed_frames_idx: list[int] = []
387 - recovered_map: dict[int, int] = {}
388 -
389 - i = 0
390 - for idx in range(max_frame_idx + 1):
391 - is_target_frame = idx in frame_idx_set
392 -
393 - # Attempt to grab the current frame
394 - ok = cap.grab()
395 -
396 - if not ok:
397 - if is_target_frame:
398 - logger.debug(
399 - "Failed to grab frame %d during video loading.",
400 - idx,
401 - )
402 - failed_frames_idx.append(idx)
403 - continue
404 -
405 - # Check if we should retrieve: target frame OR can recover a failed one
406 - can_recover = cls._can_use_for_recovery(
407 - idx, failed_frames_idx, next_target_map, total_frames
408 - )
409 -
410 - if is_target_frame or can_recover:
411 - ret, frame = cap.retrieve()
412 -
413 - if ret and frame is not None and frame.size > 0:
414 - rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
415 - frames_list.append(rgb_frame)
416 - valid_frame_indices.append(idx)
417 - i += 1
418 -
419 - if can_recover:
420 - recovered_idx = failed_frames_idx.pop(0)
421 - recovered_map[recovered_idx] = idx
422 - logger.info(
423 - "Recovered frame %d using frame %d (delay: %d)",
424 - recovered_idx,
425 - idx,
426 - idx - recovered_idx,
427 - )
428 - elif is_target_frame:
429 - logger.debug(
430 - "Failed to retrieve frame %d during video loading.",
431 - idx,
432 - )
433 - failed_frames_idx.append(idx)
434 -
435 - # Log any remaining failed frames
436 - for failed_idx in failed_frames_idx:
437 - logger.debug(
438 - "Frame %d could not be recovered (end of video).",
439 - failed_idx,
440 - )
441 -
442 - # Stack frames
443 - if frames_list:
444 - frames = np.stack(frames_list)
445 - else:
446 - frames = np.empty((0, height, width, 3), dtype=np.uint8)
447 -
448 - return frames, valid_frame_indices, recovered_map
449 -
450 - @classmethod
451 - def _read_frames_no_recovery(
452 - cls,
453 - cap,
454 - frame_indices: set[int],
455 - max_frame_idx: int,
456 - ) -> tuple[npt.NDArray, list[int]]:
457 - num_expected_frames = len(frame_indices)
458 - width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
459 - height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
460 - frames = np.empty((num_expected_frames, height, width, 3), dtype=np.uint8)
461 -
462 - i = 0
463 - valid_frame_indices = []
464 - for idx in range(max_frame_idx + 1):
465 - ok = cap.grab()
466 - if not ok:
467 - # Frame is broken/unreadable, skip it
468 - if idx in frame_indices:
469 - logger.debug(
470 - "Failed to grab frame %d during video loading. "
471 - "This frame will be skipped.",
472 - idx,
473 - )
474 - continue
475 - if idx in frame_indices:
476 - ret, frame = cap.retrieve()
477 - if ret:
478 - frames[i] = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
479 - valid_frame_indices.append(idx)
480 - i += 1
481 - else:
482 - # retrieve() failed even though grab() succeeded
483 - logger.debug(
484 - "Failed to retrieve frame %d during video loading. "
485 - "This frame will be skipped.",
486 - idx,
487 - )
488 -
489 - valid_num_frames = len(valid_frame_indices)
490 - if valid_num_frames < num_expected_frames:
491 - logger.warning(
492 - "Video loading completed with %d broken/unreadable frames. "
493 - "Expected %d frames but only loaded %d frames.",
494 - num_expected_frames - valid_num_frames,
495 - num_expected_frames,
496 - valid_num_frames,
497 - )
498 -
499 - return frames[:valid_num_frames], valid_frame_indices
500 -
501 - @classmethod
502 - def read_frames(
503 - cls,
504 - cap: "cv2.VideoCapture",
505 - frame_idx: list[int],
506 - total_frames_num: int,
507 - *,
508 - frame_recovery: bool = False,
509 - ) -> tuple[npt.NDArray, list[int]]:
510 - if frame_recovery:
511 - num_frames_to_sample = len(frame_idx)
512 - frames, valid_frame_indices, recovered_map = cls._read_frames_with_recovery(
513 - cap, frame_idx, total_frames_num
514 - )
515 -
516 - if recovered_map:
517 - logger.info(
518 - "Frame recovery: %d frames recovered using forward scan.",
519 - len(recovered_map),
520 - )
521 - else:
522 - frame_idx_set = set(frame_idx)
523 - num_frames_to_sample = len(frame_idx_set)
524 - frames, valid_frame_indices = cls._read_frames_no_recovery(
525 - cap, frame_idx_set, max(frame_idx)
526 - )
527 - valid_num_frames = len(valid_frame_indices)
528 - if valid_num_frames < num_frames_to_sample:
529 - logger.warning(
530 - "Video loading completed with %d broken/unreadable frames. "
531 - "Expected to sample %d frames but only loaded %d frames.",
532 - num_frames_to_sample - valid_num_frames,
533 - num_frames_to_sample,
534 - valid_num_frames,
535 - )
536 - return frames, valid_frame_indices
537 -
538 -
539 -class PyAVVideoBackendMixin:
540 - """PyAV (in-process FFmpeg bindings) codec utilities.
541 -
542 - Reads stream metadata and decodes target frames via per-frame
543 - ``container.seek()``. The seek releases the GIL between frames and
544 - scales with the number of sampled frames rather than the video
545 - length, enabling concurrent decoding under serving load.
546 - """
547 -
548 - @staticmethod
549 - def get_metadata(
550 - container: "av.container.InputContainer",
551 - ) -> VideoSourceMetadata:
552 - if not container.streams.video:
553 - raise ValueError("No video streams found in container")
554 - stream = container.streams.video[0]
555 - total_frames = stream.frames or 0
556 - fps = float(stream.average_rate) if stream.average_rate else 0.0
557 - duration = float(stream.duration * stream.time_base) if stream.duration else 0.0
558 - if total_frames == 0 and duration > 0 and fps > 0:
559 - total_frames = int(duration * fps)
560 - return VideoSourceMetadata(total_frames, fps, duration)
561 -
562 - @staticmethod
563 - def decode_frames(
564 - container: "av.container.InputContainer",
565 - frame_indices: list[int],
566 - fps: float,
567 - duration: float,
568 - ) -> tuple[npt.NDArray, list[int]]:
569 - """Decode target frames via per-frame seek + forward decode to PTS."""
570 - stream = container.streams.video[0]
571 - # SLICE parallelizes within a single frame without the
572 - # one-frame-per-thread latency penalty of FRAME threading.
573 - stream.thread_type = "SLICE"
574 - time_base = stream.time_base
575 -
576 - frames_list: list[npt.NDArray] = []
577 - valid_indices: list[int] = []
578 - frame_interval = 1.0 / fps if fps > 0 else 0.1
579 - max_ts = max(0.0, duration - frame_interval) if duration > 0 else float("inf")
580 -
581 - decoder = None
582 - last_pts = None
583 - for idx in frame_indices:
584 - ts = min(idx / fps, max_ts) if fps > 0 else 0.0
585 - pts = int(ts / time_base)
586 - # seek() snaps backward to a keyframe; reuse the running decoder
587 - # while targets advance monotonically to avoid re-decoding the
588 - # GOP prefix once per requested frame.
589 - if decoder is None or last_pts is None or pts <= last_pts:
590 - container.seek(pts, stream=stream)
591 - decoder = container.decode(video=0)
592 - chosen = None
593 - for frame in decoder:
594 - if frame.pts is not None and frame.pts >= pts:
595 - chosen = frame
596 - last_pts = frame.pts
597 - break
598 - if chosen is not None:
599 - frames_list.append(chosen.to_ndarray(format="rgb24"))
600 - valid_indices.append(idx)
601 - else:
602 - decoder = None
603 -
604 - if not frames_list:
605 - return np.empty((0,), dtype=np.uint8), valid_indices
606 - return np.stack(frames_list), valid_indices
607 -
608 -
609 -class TorchCodecVideoBackendMixin:
610 - """TorchCodec (FFmpeg-backed, PyTorch-native) codec utilities.
611 -
612 - Builds a :class:`~torchcodec.decoders.VideoDecoder` over the in-memory
613 - bytes and extracts the sampled indices with a single batched
614 - ``get_frames_at`` call, while releasing the GIL during decode.
615 - """
616 -
617 - @staticmethod
618 - def make_torchcodec_decoder(
619 - data: bytes,
620 - *,
621 - num_ffmpeg_threads: int = 0,
622 - seek_mode: Literal["exact", "approximate"] = "exact",
623 - ) -> "VideoDecoder":
624 - # NHWC matches the (num_frames, H, W, 3) uint8 RGB layout the rest
625 - # of the pipeline expects, avoiding a transpose.
626 - return VideoDecoder(
627 - data,
628 - dimension_order="NHWC",
629 - num_ffmpeg_threads=num_ffmpeg_threads,
630 - seek_mode=seek_mode,
631 - )
632 -
633 - @staticmethod
634 - def get_torchcodec_metadata(decoder: "VideoDecoder") -> VideoSourceMetadata:
635 - md = decoder.metadata
636 - total_frames = md.num_frames or 0
637 - fps = float(md.average_fps) if md.average_fps else 0.0
638 - duration = float(md.duration_seconds) if md.duration_seconds else 0.0
639 - if total_frames == 0 and duration > 0 and fps > 0:
640 - total_frames = int(duration * fps)
641 - return VideoSourceMetadata(total_frames, fps, duration)
642 -
643 - @staticmethod
644 - def decode_torchcodec_frames(
645 - decoder: "VideoDecoder",
646 - frame_indices: list[int],
647 - ) -> tuple[npt.NDArray, list[int]]:
648 - """Decode the requested indices in one batched, index-exact call."""
649 - if not frame_indices:
650 - return np.empty((0,), dtype=np.uint8), []
651 - # Note: torchcodec releases the GIL for the entire call
652 - batch = decoder.get_frames_at(frame_indices)
653 - return batch.data.numpy(), list(frame_indices)
654 -
655 -
656 -def _pynvvc_frames_to_nhwc(frames: torch.Tensor) -> torch.Tensor:
657 - """Return a stacked PyNvVideoCodec frame batch as contiguous NHWC.
658 -
659 - PyNvVideoCodec's per-frame layout has varied across versions (HWC vs CHW),
660 - so detect the channel axis rather than assuming a fixed order. NHWC is the
661 - layout the other video backends return and the HF video processors expect.
662 -
663 - Args:
664 - frames: A ``(N, ?, ?, ?)`` uint8 tensor in either NHWC or NCHW order.
665 -
666 - Returns:
667 - The same frames as a contiguous ``(N, H, W, C)`` tensor.
668 - """
669 - if frames.shape[-1] != 3 and frames.shape[-3] == 3:
670 - frames = frames.permute(0, 2, 3, 1) # NCHW -> NHWC
671 - return frames.contiguous()
672 -
673 -
674 -class _PyNvDecoderPool:
675 - """Process-wide singleton managing PyNvVideoCodec decoder slot state.
676 -
677 - Prevents subclass counter shadowing (GHSA-j682-9xp5-rrf3) by storing
678 - all mutable pool state in a single module-level instance rather than
679 - in ClassVar attributes that get shadowed by Python's augmented
680 - assignment semantics on subclasses.
681 - """
682 -
683 - def __init__(self) -> None:
684 - self.slots: list[PyNvVideoCodecDecoderSlot] = []
685 - self.active: int = 0
686 - self.cond: threading.Condition = threading.Condition()
687 - self.max_slots: int | None = None
688 -
689 - def configure(self, hw_decoders: int) -> None:
690 - with self.cond:
691 - if self.max_slots is None:
692 - self.max_slots = hw_decoders
693 - elif self.max_slots != hw_decoders:
694 - raise RuntimeError(
695 - "PyNvVideoCodec decoder count is already configured as "
696 - f"{self.max_slots}, got {hw_decoders}"
697 - )
698 -
699 -
700 -_pynv_decoder_pool = _PyNvDecoderPool()
701 -
702 -
703 -class PyNvVideoCodecVideoBackendMixin:
704 - """PyNvVideoCodec utilities for GPU-backed frame decode."""
705 -
706 - _DEVICE_INDEX: ClassVar[int] = 0
707 -
708 - @classmethod
709 - @abstractmethod
710 - def compute_frames_index_to_sample(
711 - cls,
712 - source: VideoSourceMetadata,
713 - target: VideoTargetMetadata,
714 - **kwargs,
715 - ) -> list[int]:
716 - raise NotImplementedError
717 -
718 - @classmethod
719 - @abstractmethod
720 - def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
721 - raise NotImplementedError
722 -
723 - @classmethod
724 - def _create_decoder_slot(cls) -> PyNvVideoCodecDecoderSlot:
725 - import torch
726 -
727 - return PyNvVideoCodecDecoderSlot(torch.cuda.Stream(device=cls._DEVICE_INDEX))
728 -
729 - @classmethod
730 - def _configure_decoder_slots(cls, hw_decoders: object) -> None:
731 - hw_decoders = validate_pynvvideocodec_hw_decoders(hw_decoders)
732 - _pynv_decoder_pool.configure(hw_decoders)
733 -
734 - @staticmethod
735 - @contextmanager
736 - def _torch_stream_context(stream):
737 - import torch
738 -
739 - torch.accelerator.set_device_index(stream.device.index)
740 - previous_stream = torch.accelerator.current_stream()
741 - torch.accelerator.set_stream(stream)
742 - try:
743 - yield
744 - finally:
745 - torch.accelerator.set_stream(previous_stream)
746 -
747 - @classmethod
748 - @contextmanager
749 - def _borrow_decoder_slot(cls):
750 - pool = _pynv_decoder_pool
751 - create_slot = False
752 - with pool.cond:
753 - if pool.max_slots is None:
754 - raise RuntimeError("PyNvVideoCodec decoder slots are not configured")
755 - while True:
756 - if pool.slots:
757 - slot = pool.slots.pop()
758 - break
759 - if pool.active < pool.max_slots:
760 - pool.active += 1
761 - create_slot = True
762 - break
763 - pool.cond.wait()
764 -
765 - if create_slot:
766 - try:
767 - slot = cls._create_decoder_slot()
768 - except Exception:
769 - with pool.cond:
770 - pool.active -= 1
771 - pool.cond.notify()
772 - raise
773 -
774 - borrow_succeeded = False
775 - try:
776 - yield slot
777 - borrow_succeeded = True
778 - finally:
779 - if not borrow_succeeded:
780 - slot.invalidate()
781 - with pool.cond:
782 - pool.slots.append(slot)
783 - pool.cond.notify()
784 -
785 - @staticmethod
786 - def _metadata_value(metadata, *names: str, default=None):
787 - for name in names:
788 - value = getattr(metadata, name, None)
789 - if value is not None:
790 - return value
791 - return default
792 -
793 - @classmethod
794 - def _read_source_metadata(
795 - cls,
796 - file_path: str,
797 - nvc,
798 - ) -> PyNvVideoCodecSourceMetadata:
799 - with cls._borrow_decoder_slot() as decoder_slot:
800 - with cls._torch_stream_context(decoder_slot.stream):
801 - decoder = decoder_slot.get_decoder(
802 - file_path, nvc, device_index=cls._DEVICE_INDEX
803 - )
804 - metadata = decoder.get_stream_metadata()
805 - total_frames_num = len(decoder)
806 - width = int(cls._metadata_value(metadata, "width", default=0))
807 - height = int(cls._metadata_value(metadata, "height", default=0))
808 - original_fps = float(
809 - cls._metadata_value(
810 - metadata,
811 - "average_fps",
812 - "avg_frame_rate",
813 - "frame_rate",
814 - "frameRate",
815 - default=0.0,
816 - )
817 - )
818 - duration = float(
819 - cls._metadata_value(metadata, "duration", default=0.0)
820 - or (total_frames_num / original_fps if original_fps > 0 else 0.0)
821 - )
822 - if total_frames_num <= 0:
823 - raise ValueError("Could not determine video frame count")
824 - if width <= 0 or height <= 0:
825 - raise ValueError("Could not determine video dimensions")
826 - return PyNvVideoCodecSourceMetadata(
827 - source=VideoSourceMetadata(total_frames_num, original_fps, duration),
828 - width=width,
829 - height=height,
830 - )
831 -
832 - @classmethod
833 - def _decode_to_pinned_host(
834 - cls,
835 - file_path: str,
836 - frame_idx: list[int],
837 - nvc,
838 - ) -> npt.NDArray:
839 - import torch
840 -
841 - if not frame_idx:
842 - return np.empty((0,), dtype=np.uint8)
843 -
844 - with cls._borrow_decoder_slot() as decoder_slot:
845 - stream = decoder_slot.stream
846 - with cls._torch_stream_context(stream):
847 - try:
848 - decoder = decoder_slot.get_decoder(
849 - file_path, nvc, device_index=cls._DEVICE_INDEX
850 - )
851 - decoded_frames = decoder.get_batch_frames_by_index(frame_idx)
852 - except Exception as exc:
853 - if not isinstance(
854 - exc,
855 - _pynvvideocodec_exception_types(nvc) + (IndexError,),
856 - ):
857 - raise
858 - raise ValueError("Invalid or unsupported video file.") from exc
859 - if len(decoded_frames) < len(frame_idx):
860 - logger.warning(
861 - "pynvvideocodec video loading: expected %d frames but got %d.",
862 - len(frame_idx),
863 - len(decoded_frames),
864 - )
865 - torch_frames = [torch.from_dlpack(frame) for frame in decoded_frames]
866 - if not torch_frames:
867 - return np.empty((0,), dtype=np.uint8)
868 - device_frames = torch.stack(torch_frames)
869 - if device_frames.ndim != 4:
870 - raise ValueError(
871 - "PyNvVideoCodec returned frames with unexpected shape "
872 - f"{tuple(device_frames.shape)}"
873 - )
874 - device_frames = _pynvvc_frames_to_nhwc(device_frames)
875 - host_frames = torch.empty(
876 - device_frames.shape,
877 - dtype=device_frames.dtype,
878 - device="cpu",
879 - pin_memory=True,
880 - )
881 - host_frames.copy_(device_frames, non_blocking=True)
882 - stream.synchronize()
883 - host_array = host_frames.numpy()
884 - del decoded_frames, torch_frames, device_frames
885 - return host_array
886 -
887 - @classmethod
888 - def decode_frames_pynvvideocodec(
889 - cls,
890 - data: bytes,
891 - target: VideoTargetMetadata,
892 - **kwargs,
893 - ) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
894 - import PyNvVideoCodec as nvc
895 -
896 - from vllm.multimodal.gpu_ipc_memory import get_mm_gpu_ipc_pool
897 -
898 - temp_fd, temp_path = tempfile.mkstemp(suffix=".mp4")
899 - try:
900 - with os.fdopen(temp_fd, "wb") as temp_file:
901 - temp_file.write(data)
902 -
903 - try:
904 - gpu_source = cls._read_source_metadata(temp_path, nvc)
905 - except Exception as exc:
906 - if not isinstance(exc, _pynvvideocodec_exception_types(nvc)):
907 - raise
908 - raise ValueError("Invalid or unsupported video file.") from exc
909 - _check_frame_pixel_limit(gpu_source.width, gpu_source.height)
910 - source = cls._prepare_source(gpu_source.source)
911 - frame_idx = cls.compute_frames_index_to_sample(
912 - source=source, target=target, **kwargs
913 - )
914 - raw_frame_bytes = len(frame_idx) * gpu_source.height * gpu_source.width * 3
915 - pool = get_mm_gpu_ipc_pool()
916 - if pool is None or raw_frame_bytes == 0:
917 - frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
918 - else:
919 - with pool.acquire(raw_frame_bytes):
920 - frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
921 - finally:
922 - with suppress(FileNotFoundError):
923 - os.unlink(temp_path)
924 -
925 - valid_frame_indices = frame_idx[: int(frames.shape[0])]
926 - return frames, source, frame_idx, valid_frame_indices
927 -
928 -
929 -class DeepStreamVideoBackendMixin:
930 - """NVIDIA DeepStream (NVDEC) GPU-decode codec utilities.
931 -
932 - Decoding runs on a shared pool of daemon threads inside one CUDA
933 - context (see the ``nvidia-deepstream-videodecode-cu13`` package). The
934 - container bytes are pushed into an ``appsrc`` GStreamer pipeline, so no
935 - local file path is required — HTTP and base64 sources decode identically
936 - to local files.
937 -
938 - Like the OpenCV/PyAV mixins, this provides only the codec layer.
939 - Frame *selection* lives in the loader's
940 - ``compute_frames_index_to_sample`` and arrives here as an explicit
941 - list of frame indices.
942 - """
943 -
944 - # Process-wide lazy decode pool, shared across all DeepStream backends.
945 - _pool: ClassVar[Any] = None
946 - _pool_lock: ClassVar[Any] = None
947 -
948 - @classmethod
949 - def _get_pool(cls, pool_size: int | None = None):
950 - """Lazy-initialize the shared decode pool on first use.
951 -
952 - ``pool_size`` (number of decode worker threads) comes from
953 - ``--media-io-kwargs`` (``{"video": {"pool_size": N}}``); when unset it
954 - defaults to the existing ``VLLM_MEDIA_LOADING_THREAD_COUNT`` so no
955 - DeepStream-specific env var is needed. The pool is a process-wide
956 - singleton, so the first decode's value wins.
957 - """
958 - if cls._pool is not None:
959 - return cls._pool
960 - if cls._pool_lock is None:
961 - cls._pool_lock = threading.Lock()
962 - with cls._pool_lock:
963 - if cls._pool is not None:
964 - return cls._pool
965 - import os
966 -
967 - from nvidia.deepstream_videodecode import DecodePool
968 -
969 - if pool_size is None:
970 - pool_size = int(os.environ.get("VLLM_MEDIA_LOADING_THREAD_COUNT", 8))
971 - pool_size = max(1, min(int(pool_size), 16))
972 - logger.info(
973 - "[DeepStream] initializing decode pool with %d workers",
974 - pool_size,
975 - )
976 - cls._pool = DecodePool(num_workers=pool_size)
977 - return cls._pool
978 -
979 - @classmethod
980 - def decode_indices(
981 - cls,
982 - data: bytes,
983 - frame_indices: list[int],
984 - source: VideoSourceMetadata,
985 - codec: str = "",
986 - pool_size: int | None = None,
987 - timeout_sec: float = 120.0,
988 - ) -> tuple[npt.NDArray, list[int]]:
989 - """Decode the requested frame indices from raw container bytes.
990 -
991 - The whole stream is decoded; the pool keeps exactly the frames whose
992 - decode-order index is in ``frame_indices`` (1:1, frame-exact) and
993 - sends EOS once the last one is matched.
994 -
995 - ``codec`` (e.g. ``"h264"``/``"hevc"``) lets the pool keep its NVDEC
996 - session warm across same-codec streams and rebuild only on a codec
997 - change. Frames are returned as a CPU NHWC uint8 array so the
998 - upstream multimodal parser sees the same shape as the other
999 - backends.
1000 - """
1001 - if not frame_indices:
1002 - raise ValueError("DeepStream backend received no frame indices")
1003 -
1004 - result = cls._get_pool(pool_size).decode(
1005 - data,
1006 - target_indices=frame_indices,
1007 - codec=codec,
1008 - max_frames=len(frame_indices),
1009 - timeout_sec=timeout_sec,
1010 - )
1011 - if result.error:
1012 - raise ValueError(f"DeepStream decode failed: {result.error}")
1013 - if result.frames is None or result.n_kept == 0:
1014 - raise ValueError("DeepStream decode produced no frames")
1015 -
1016 - valid = frame_indices[: result.n_kept]
1017 - # GPU -> CPU NHWC uint8 at the codec boundary (one PCIe copy); keeps
1018 - # the array shape identical to the OpenCV/PyAV backends. Copy into
1019 - # PINNED host memory (reused across calls by PyTorch's pinned caching
1020 - # allocator) so the D2H runs at full PCIe bandwidth (~13 GB/s) rather
1021 - # than the ~1 GB/s pageable path that plain ``.cpu()`` takes — ~12x
1022 - # faster for a 1080p x8 frame batch (~46ms -> ~4ms). ``numpy()`` keeps
1023 - # the pinned tensor alive via the array's base.
1024 - import torch
1025 -
1026 - gpu = result.frames
1027 - if gpu.is_cuda:
1028 - host = torch.empty(gpu.shape, dtype=gpu.dtype, pin_memory=True)
1029 - host.copy_(gpu, non_blocking=True)
1030 - torch.cuda.current_stream().synchronize()
1031 - arr = host.numpy()
1032 - else:
1033 - arr = gpu.numpy()
1034 - return arr, valid
1035 -
173
174 @VIDEO_LOADER_REGISTRY.register("opencv")
1038 -class VideoBackend(
1039 - VideoLoader,
1040 - OpenCVVideoBackendMixin,
1041 - PyAVVideoBackendMixin,
1042 - TorchCodecVideoBackendMixin,
1043 - PyNvVideoCodecVideoBackendMixin,
1044 - DeepStreamVideoBackendMixin,
1045 -):
175 +class VideoBackend(VideoLoader):
176 """Uniform-sampling video backend.
177
178 Samples ``num_frames`` uniformly across the video (or one frame every
@@ -1080,11 +210,6 @@ class VideoBackend(
210 0, total_frames_num - 1, num_frames_to_sample, dtype=int
211 ).tolist()
212
1083 - @classmethod
1084 - def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
1085 - """Sampling-algorithm-specific metadata adjustment hook."""
1086 - return source
1087 -
213 @classmethod
214 def load_bytes(
215 cls,
@@ -1094,12 +219,7 @@ class VideoBackend(
219 max_duration: int = 300,
220 frame_recovery: bool = False,
221 *,
1097 - backend: Literal[
1098 - "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
1099 - ] = "opencv",
1100 - num_ffmpeg_threads: int = 0,
1101 - seek_mode: Literal["exact", "approximate"] = "exact",
1102 - hw_decoders: int = PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
222 + backend: VideoDecoderBackend = "opencv",
223 **kwargs,
224 ) -> tuple[npt.NDArray, dict[str, Any]]:
225 """Load sampled frames from raw video bytes.
@@ -1114,20 +234,25 @@ class VideoBackend(
234 Only honored by the OpenCV codec.
235 backend: Decoding codec — ``"opencv"``, ``"pyav"``,
236 ``"torchcodec"``, ``"pynvvideocodec"`` or ``"deepstream"``.
1117 - num_ffmpeg_threads: Number of FFmpeg decoding threads, only used by
1118 - TorchCodec: ``0`` (default) relies on the FFmpeg default value
1119 - which is ``min(cpu_count + 1, 16)``.
1120 - OpenCV will always use ``min(cpu_count, 16)`` while pyav will
1121 - always use ``min(cpu_count, (height + 15) / 16)``.
1122 - seek_mode: Seek mode for the TorchCodec decoder, only used by
1123 - TorchCodec: ``"exact"`` (default) guarantees frame-accurate
1124 - sampling by scanning the file on creation, while
1125 - ``"approximate"`` skips that scan for faster decoder creation
1126 - at the cost of relying on the file's metadata. See
1127 - https://meta-pytorch.org/torchcodec/stable/generated_examples/decoding/approximate_mode.html
1128 - for details.
1129 - hw_decoders: Maximum number of concurrent PyNvVideoCodec decoder
1130 - slots. Defaults to 2 and must be a positive integer.
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)``.
245 + - ``seek_mode`` (TorchCodec): ``"exact"`` (default) guarantees
246 + frame-accurate sampling by scanning the file on creation,
247 + while ``"approximate"`` skips that scan for faster decoder
248 + creation at the cost of relying on the file's metadata. See
249 + https://meta-pytorch.org/torchcodec/stable/generated_examples/decoding/approximate_mode.html
250 + for details.
251 + - ``hw_decoders`` (PyNvVideoCodec): maximum number of
252 + concurrent decoder slots. Defaults to 2 and must be a
253 + positive integer.
254 + - ``pool_size`` / ``timeout_sec`` (DeepStream): decoder pool
255 + size and pool acquisition timeout in seconds.
256
257 Returns:
258 Tuple of ``(frames_array, metadata_dict)``.
@@ -1135,101 +260,16 @@ class VideoBackend(
260 target = VideoTargetMetadata(
261 num_frames=num_frames, fps=fps, max_duration=max_duration
262 )
1138 -
1139 - if backend == "opencv":
1140 - cap = cls.open_video_capture(data)
1141 - _check_frame_pixel_limit(
1142 - int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
1143 - int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
1144 - )
1145 - source = cls._prepare_source(cls.get_video_metadata(cap))
1146 - frame_idx = cls.compute_frames_index_to_sample(
1147 - source=source, target=target, **kwargs
1148 - )
1149 - frames, valid = cls.read_frames(
1150 - cap,
1151 - frame_idx,
1152 - total_frames_num=source.total_frames_num,
1153 - frame_recovery=frame_recovery,
1154 - )
1155 - elif backend == "pyav":
1156 - assert not frame_recovery, (
1157 - "frame_recovery is only available for `opencv` backend"
1158 - )
1159 - with av.open(BytesIO(data)) as container:
1160 - stream = container.streams.video[0]
1161 - _check_frame_pixel_limit(stream.width, stream.height)
1162 - source = cls._prepare_source(cls.get_metadata(container))
1163 - frame_idx = cls.compute_frames_index_to_sample(
1164 - source=source, target=target, **kwargs
1165 - )
1166 - frames, valid = cls.decode_frames(
1167 - container, frame_idx, source.original_fps, source.duration
1168 - )
1169 - elif backend == "torchcodec":
1170 - assert not frame_recovery, (
1171 - "frame_recovery is only available for `opencv` backend"
1172 - )
1173 - check_torchcodec_available()
1174 - decoder = cls.make_torchcodec_decoder(
1175 - data,
1176 - num_ffmpeg_threads=num_ffmpeg_threads,
1177 - seek_mode=seek_mode,
1178 - )
1179 - _check_frame_pixel_limit(
1180 - decoder.metadata.width or 0,
1181 - decoder.metadata.height or 0,
1182 - )
1183 - source = cls._prepare_source(cls.get_torchcodec_metadata(decoder))
1184 - frame_idx = cls.compute_frames_index_to_sample(
1185 - source=source, target=target, **kwargs
1186 - )
1187 - frames, valid = cls.decode_torchcodec_frames(decoder, frame_idx)
1188 - elif backend == PYNVVIDEOCODEC_VIDEO_BACKEND:
1189 - if frame_recovery:
1190 - raise ValueError(
1191 - "frame_recovery is not supported for "
1192 - f"`{PYNVVIDEOCODEC_VIDEO_BACKEND}` backend"
1193 - )
1194 - cls._configure_decoder_slots(hw_decoders)
1195 - frames, source, frame_idx, valid = cls.decode_frames_pynvvideocodec(
1196 - data,
1197 - target,
1198 - **kwargs,
1199 - )
1200 - elif backend == "deepstream":
1201 - assert not frame_recovery, (
1202 - "frame_recovery is only available for `opencv` backend"
1203 - )
1204 - # Decode-pool size comes from media-io-kwargs (no env var); the
1205 - # pool is a process-wide singleton so the first decode's value
1206 - # wins. Pop it so it isn't forwarded to the frame sampler.
1207 - pool_size = kwargs.pop("pool_size", None)
1208 - # Probe container metadata from the bytes via GStreamer (in
1209 - # the deepstream video-decode wheel) — no PyAV/pymediainfo, no path.
1210 - from nvidia.deepstream_videodecode import probe_metadata
1211 -
1212 - total_frames, original_fps, duration, _w, _h, codec = probe_metadata(data)
1213 - _check_frame_pixel_limit(_w, _h)
1214 - source = cls._prepare_source(
1215 - VideoSourceMetadata(
1216 - total_frames_num=total_frames,
1217 - original_fps=original_fps,
1218 - duration=duration,
1219 - )
1220 - )
1221 - frame_idx = cls.compute_frames_index_to_sample(
1222 - source=source, target=target, **kwargs
1223 - )
1224 - frames, valid = cls.decode_indices(
1225 - data, frame_idx, source, codec=codec, pool_size=pool_size
1226 - )
1227 - else:
1228 - raise ValueError(
1229 - f"Unknown video codec backend {backend!r}; "
1230 - "valid options: 'opencv', 'pyav', 'torchcodec', "
1231 - "'pynvvideocodec' and 'deepstream'."
1232 - )
263 + sampling_kwargs, backend_kwargs = resolve_video_backend_kwargs(backend, kwargs)
264 + frames, source, frame_idx, valid = decode_video(
265 + backend,
266 + cls,
267 + data,
268 + target,
269 + sampling_kwargs,
270 + backend_kwargs,
271 + frame_recovery=frame_recovery,
272 + )
273
274 if len(valid) < len(frame_idx):
275 logger.warning(
@@ -1742,7 +782,7 @@ class GLMGAVideoBackend(VideoBackend):
782 "molmo2",
783 video_processor="Molmo2VideoProcessor",
784 )
1745 -class Molmo2VideoBackend(VideoLoader, OpenCVVideoBackendMixin):
785 +class Molmo2VideoBackend(VideoLoader):
786 @classmethod
787 def get_candidate_target_fps(
788 cls,
@@ -1975,30 +1015,21 @@ class Molmo2VideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1015 frame_recovery: bool = False,
1016 **kwargs,
1017 ) -> tuple[npt.NDArray, dict[str, Any]]:
1978 - cap = cls.open_video_capture(data)
1979 - _check_frame_pixel_limit(
1980 - int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
1981 - int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
1982 - )
1983 -
1984 - source = OpenCVVideoBackendMixin.get_video_metadata(cap)
1018 target = VideoTargetMetadata(
1019 num_frames=num_frames,
1020 fps=sampling_fps,
1988 - max_duration=source.duration,
1021 + max_duration=-1,
1022 )
1990 -
1991 - frame_idx = cls.compute_frames_index_to_sample(
1992 - source=source,
1993 - target=target,
1994 - frame_sample_mode=frame_sample_mode,
1995 - max_fps=max_fps,
1996 - )
1997 -
1998 - frames, valid_frame_indices = cls.read_frames(
1999 - cap,
2000 - frame_idx,
2001 - total_frames_num=source.total_frames_num,
1023 + frames, source, _, valid_frame_indices = decode_video(
1024 + "opencv",
1025 + cls,
1026 + data,
1027 + target,
1028 + {
1029 + "frame_sample_mode": frame_sample_mode,
1030 + "max_fps": max_fps,
1031 + },
1032 + {},
1033 frame_recovery=frame_recovery,
1034 )
1035
@@ -2064,7 +1095,7 @@ class NemotronVLVideoBackend(VideoBackend):
1095
1096
1097 @VIDEO_LOADER_REGISTRY.register("openpangu")
2067 -class OpenCVDynamicOpenPanguVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1098 +class OpenCVDynamicOpenPanguVideoBackend(VideoLoader):
1099 @classmethod
1100 def compute_frames_index_to_sample(
1101 cls,
@@ -2130,14 +1161,6 @@ class OpenCVDynamicOpenPanguVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1161 Returns:
1162 Tuple of (frames_array, metadata_dict)
1163 """
2133 - cap = cls.open_video_capture(data)
2134 - _check_frame_pixel_limit(
2135 - int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
2136 - int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
2137 - )
2138 -
2139 - source = OpenCVVideoBackendMixin.get_video_metadata(cap)
2140 -
1164 # recompute source metadata with adjusted duration to ensure correct
1165 # sampling indices computation
1166 target = VideoTargetMetadata(
@@ -2146,15 +1169,13 @@ class OpenCVDynamicOpenPanguVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
1169 max_duration=max_duration,
1170 )
1171
2149 - frame_indices_list = cls.compute_frames_index_to_sample(
2150 - source=source,
2151 - target=target,
2152 - )
2153 -
2154 - frames, valid_frame_indices = cls.read_frames(
2155 - cap,
2156 - frame_indices_list,
2157 - total_frames_num=source.total_frames_num,
1172 + frames, source, _, valid_frame_indices = decode_video(
1173 + "opencv",
1174 + cls,
1175 + data,
1176 + target,
1177 + {},
1178 + {},
1179 frame_recovery=frame_recovery,
1180 )
1181
vllm/multimodal/video_decoders/__init__.py new
+110
@@ -0,0 +1,110 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from importlib import import_module
5 +from typing import Any, Literal
6 +
7 +from .base import (
8 + PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
9 + PYNVVIDEOCODEC_VIDEO_BACKEND,
10 + VideoSourceMetadata,
11 + VideoTargetMetadata,
12 + check_frame_pixel_limit,
13 +)
14 +
15 +VideoDecoderBackend = Literal[
16 + "opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
17 +]
18 +
19 +_BACKEND_OPTION_DEFAULTS: dict[str, dict[str, Any]] = {
20 + "opencv": {},
21 + "pyav": {},
22 + "torchcodec": {
23 + "num_ffmpeg_threads": 0,
24 + "seek_mode": "exact",
25 + },
26 + PYNVVIDEOCODEC_VIDEO_BACKEND: {
27 + "hw_decoders": PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
28 + },
29 + "deepstream": {
30 + "pool_size": None,
31 + "timeout_sec": 120.0,
32 + },
33 +}
34 +
35 +
36 +def _get_backend_option_defaults(backend: str) -> dict[str, Any]:
37 + try:
38 + return _BACKEND_OPTION_DEFAULTS[backend]
39 + except KeyError:
40 + valid = ", ".join(repr(name) for name in _BACKEND_OPTION_DEFAULTS)
41 + raise ValueError(
42 + f"Unknown video codec backend {backend!r}; valid options: {valid}."
43 + ) from None
44 +
45 +
46 +def resolve_video_backend_kwargs(
47 + backend: str,
48 + kwargs: dict[str, Any],
49 +) -> tuple[dict[str, Any], dict[str, Any]]:
50 + """Split frame-sampling kwargs from options owned by a decoder backend."""
51 + defaults = _get_backend_option_defaults(backend)
52 + sampling_kwargs = dict(kwargs)
53 + backend_kwargs = dict(defaults)
54 + backend_option_names = {
55 + name for options in _BACKEND_OPTION_DEFAULTS.values() for name in options
56 + }
57 + misplaced = (sampling_kwargs.keys() & backend_option_names) - defaults.keys()
58 + if misplaced:
59 + names = ", ".join(sorted(misplaced))
60 + raise ValueError(f"{names} is not supported by the {backend!r} backend")
61 +
62 + for name in defaults:
63 + if name in sampling_kwargs:
64 + backend_kwargs[name] = sampling_kwargs.pop(name)
65 +
66 + return sampling_kwargs, backend_kwargs
67 +
68 +
69 +def decode_video(
70 + backend: str,
71 + loader_cls,
72 + data: bytes,
73 + target: VideoTargetMetadata,
74 + sampling_kwargs: dict[str, Any],
75 + backend_kwargs: dict[str, Any],
76 + *,
77 + frame_recovery: bool,
78 +):
79 + """Decode a sampled video, importing only the selected backend."""
80 + _get_backend_option_defaults(backend)
81 + if frame_recovery and backend != "opencv":
82 + error = (
83 + ValueError if backend == PYNVVIDEOCODEC_VIDEO_BACKEND else AssertionError
84 + )
85 + raise error(f"frame_recovery is not supported by the {backend!r} backend")
86 +
87 + decoder_kwargs = dict(backend_kwargs)
88 + if backend == "opencv":
89 + decoder_kwargs["frame_recovery"] = frame_recovery
90 + module = import_module(f".{backend}", __name__)
91 + decoder = getattr(module, f"decode_{backend}")
92 + return decoder(
93 + loader_cls,
94 + data,
95 + target,
96 + sampling_kwargs,
97 + **decoder_kwargs,
98 + )
99 +
100 +
101 +__all__ = [
102 + "PYNVVIDEOCODEC_DEFAULT_HW_DECODERS",
103 + "PYNVVIDEOCODEC_VIDEO_BACKEND",
104 + "VideoDecoderBackend",
105 + "VideoSourceMetadata",
106 + "VideoTargetMetadata",
107 + "check_frame_pixel_limit",
108 + "decode_video",
109 + "resolve_video_backend_kwargs",
110 +]
vllm/multimodal/video_decoders/base.py new
+37
@@ -0,0 +1,37 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from typing import Literal, NamedTuple
5 +
6 +from vllm import envs
7 +
8 +PYNVVIDEOCODEC_VIDEO_BACKEND: Literal["pynvvideocodec"] = "pynvvideocodec"
9 +PYNVVIDEOCODEC_DEFAULT_HW_DECODERS = 2
10 +
11 +
12 +class VideoTargetMetadata(NamedTuple):
13 + """Metadata describing the requested video sample."""
14 +
15 + num_frames: int
16 + fps: float
17 + max_duration: float
18 +
19 +
20 +class VideoSourceMetadata(NamedTuple):
21 + """Metadata describing the encoded video source."""
22 +
23 + total_frames_num: int
24 + original_fps: float
25 + duration: float
26 +
27 +
28 +def check_frame_pixel_limit(width: int, height: int) -> None:
29 + """Reject video frames exceeding ``VLLM_MAX_IMAGE_PIXELS``."""
30 + max_pixels = envs.VLLM_MAX_IMAGE_PIXELS
31 + if max_pixels > 0 and width * height > max_pixels:
32 + raise ValueError(
33 + f"Video frame dimensions {width}x{height} "
34 + f"({width * height} pixels) exceed the maximum of "
35 + f"{max_pixels} pixels. Set VLLM_MAX_IMAGE_PIXELS to "
36 + f"increase this limit."
37 + )
vllm/multimodal/video_decoders/deepstream.py new
+151
@@ -0,0 +1,151 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import threading
5 +from typing import Any, ClassVar
6 +
7 +import numpy.typing as npt
8 +
9 +from vllm.logger import init_logger
10 +
11 +from .base import VideoSourceMetadata, VideoTargetMetadata, check_frame_pixel_limit
12 +
13 +logger = init_logger(__name__)
14 +
15 +
16 +def decode_deepstream(
17 + loader_cls,
18 + data: bytes,
19 + target: VideoTargetMetadata,
20 + sampling_kwargs: dict,
21 + *,
22 + pool_size: int | None = None,
23 + timeout_sec: float = 120.0,
24 +) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
25 + from nvidia.deepstream_videodecode import probe_metadata
26 +
27 + total_frames, original_fps, duration, _width, _height, codec = probe_metadata(data)
28 + check_frame_pixel_limit(_width, _height)
29 + source = loader_cls._prepare_source(
30 + VideoSourceMetadata(total_frames, original_fps, duration)
31 + )
32 + frame_idx = loader_cls.compute_frames_index_to_sample(
33 + source=source, target=target, **sampling_kwargs
34 + )
35 + frames, valid = DeepStreamVideoBackendMixin.decode_indices(
36 + data,
37 + frame_idx,
38 + source,
39 + codec=codec,
40 + pool_size=pool_size,
41 + timeout_sec=timeout_sec,
42 + )
43 + return frames, source, frame_idx, valid
44 +
45 +
46 +class DeepStreamVideoBackendMixin:
47 + """NVIDIA DeepStream (NVDEC) GPU-decode codec utilities.
48 +
49 + Decoding runs on a shared pool of daemon threads inside one CUDA
50 + context (see the ``nvidia-deepstream-videodecode-cu13`` package). The
51 + container bytes are pushed into an ``appsrc`` GStreamer pipeline, so no
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.
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.
59 + """
60 +
61 + # Process-wide lazy decode pool, shared across all DeepStream backends.
62 + _pool: ClassVar[Any] = None
63 + _pool_lock: ClassVar[Any] = None
64 +
65 + @classmethod
66 + def _get_pool(cls, pool_size: int | None = None):
67 + """Lazy-initialize the shared decode pool on first use.
68 +
69 + ``pool_size`` (number of decode worker threads) comes from
70 + ``--media-io-kwargs`` (``{"video": {"pool_size": N}}``); when unset it
71 + defaults to the existing ``VLLM_MEDIA_LOADING_THREAD_COUNT`` so no
72 + DeepStream-specific env var is needed. The pool is a process-wide
73 + singleton, so the first decode's value wins.
74 + """
75 + if cls._pool is not None:
76 + return cls._pool
77 + if cls._pool_lock is None:
78 + cls._pool_lock = threading.Lock()
79 + with cls._pool_lock:
80 + if cls._pool is not None:
81 + return cls._pool
82 + import os
83 +
84 + from nvidia.deepstream_videodecode import DecodePool
85 +
86 + if pool_size is None:
87 + pool_size = int(os.environ.get("VLLM_MEDIA_LOADING_THREAD_COUNT", 8))
88 + pool_size = max(1, min(int(pool_size), 16))
89 + logger.info(
90 + "[DeepStream] initializing decode pool with %d workers",
91 + pool_size,
92 + )
93 + cls._pool = DecodePool(num_workers=pool_size)
94 + return cls._pool
95 +
96 + @classmethod
97 + def decode_indices(
98 + cls,
99 + data: bytes,
100 + frame_indices: list[int],
101 + source: VideoSourceMetadata,
102 + codec: str = "",
103 + pool_size: int | None = None,
104 + timeout_sec: float = 120.0,
105 + ) -> tuple[npt.NDArray, list[int]]:
106 + """Decode the requested frame indices from raw container bytes.
107 +
108 + The whole stream is decoded; the pool keeps exactly the frames whose
109 + decode-order index is in ``frame_indices`` (1:1, frame-exact) and
110 + sends EOS once the last one is matched.
111 +
112 + ``codec`` (e.g. ``"h264"``/``"hevc"``) lets the pool keep its NVDEC
113 + session warm across same-codec streams and rebuild only on a codec
114 + change. Frames are returned as a CPU NHWC uint8 array so the
115 + upstream multimodal parser sees the same shape as the other
116 + backends.
117 + """
118 + if not frame_indices:
119 + raise ValueError("DeepStream backend received no frame indices")
120 +
121 + result = cls._get_pool(pool_size).decode(
122 + data,
123 + target_indices=frame_indices,
124 + codec=codec,
125 + max_frames=len(frame_indices),
126 + timeout_sec=timeout_sec,
127 + )
128 + if result.error:
129 + raise ValueError(f"DeepStream decode failed: {result.error}")
130 + if result.frames is None or result.n_kept == 0:
131 + raise ValueError("DeepStream decode produced no frames")
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
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
139 + # faster for a 1080p x8 frame batch (~46ms -> ~4ms). ``numpy()`` keeps
140 + # the pinned tensor alive via the array's base.
141 + import torch
142 +
143 + gpu = result.frames
144 + if gpu.is_cuda:
145 + host = torch.empty(gpu.shape, dtype=gpu.dtype, pin_memory=True)
146 + host.copy_(gpu, non_blocking=True)
147 + torch.cuda.current_stream().synchronize()
148 + arr = host.numpy()
149 + else:
150 + arr = gpu.numpy()
151 + return arr, valid
vllm/multimodal/video_decoders/opencv.py new
+295
@@ -0,0 +1,295 @@
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.logger import init_logger
10 +from vllm.utils.import_utils import PlaceholderModule
11 +
12 +from .base import (
13 + VideoSourceMetadata,
14 + VideoTargetMetadata,
15 + check_frame_pixel_limit,
16 +)
17 +
18 +try:
19 + import cv2
20 + import cv2.videoio_registry as vr
21 +except ImportError:
22 + cv2 = PlaceholderModule("cv2")
23 + vr = PlaceholderModule("cv2").placeholder_attr("videoio_registry")
24 +
25 +logger = init_logger(__name__)
26 +
27 +
28 +def decode_opencv(
29 + loader_cls,
30 + data: bytes,
31 + target: VideoTargetMetadata,
32 + sampling_kwargs: dict,
33 + *,
34 + frame_recovery: bool = False,
35 +) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
36 + cap = OpenCVVideoBackendMixin.open_video_capture(data)
37 + check_frame_pixel_limit(
38 + int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
39 + int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
40 + )
41 + source = loader_cls._prepare_source(OpenCVVideoBackendMixin.get_video_metadata(cap))
42 + frame_idx = loader_cls.compute_frames_index_to_sample(
43 + source=source, target=target, **sampling_kwargs
44 + )
45 + frames, valid = OpenCVVideoBackendMixin.read_frames(
46 + cap,
47 + frame_idx,
48 + total_frames_num=source.total_frames_num,
49 + frame_recovery=frame_recovery,
50 + )
51 + return frames, source, frame_idx, valid
52 +
53 +
54 +class OpenCVVideoBackendMixin:
55 + @staticmethod
56 + def get_cv2_video_api():
57 + api_pref = None
58 + for backend in vr.getStreamBufferedBackends():
59 + if not vr.hasBackend(backend):
60 + continue
61 + if not vr.isBackendBuiltIn(backend):
62 + _, abi, api = vr.getStreamBufferedBackendPluginVersion(backend)
63 + if abi < 1 or (abi == 1 and api < 2):
64 + continue
65 + api_pref = backend
66 + break
67 + return api_pref
68 +
69 + @classmethod
70 + def open_video_capture(cls, data: bytes) -> "cv2.VideoCapture":
71 + backend = cls.get_cv2_video_api()
72 + cap = cv2.VideoCapture(BytesIO(data), backend, [])
73 + if not cap.isOpened():
74 + raise ValueError("Could not open video stream")
75 + return cap
76 +
77 + @staticmethod
78 + def get_video_metadata(cap: "cv2.VideoCapture") -> VideoSourceMetadata:
79 + total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
80 + original_fps = cap.get(cv2.CAP_PROP_FPS)
81 + duration = total_frames_num / original_fps if original_fps > 0 else 0
82 + return VideoSourceMetadata(
83 + total_frames_num=total_frames_num,
84 + original_fps=original_fps,
85 + duration=duration,
86 + )
87 +
88 + @classmethod
89 + def _can_use_for_recovery(
90 + cls,
91 + idx: int,
92 + failed_frames: list[int],
93 + next_target_map: dict[int, int],
94 + total_frames: int,
95 + ) -> bool:
96 + """Check if current frame can recover the oldest failed frame."""
97 + if not failed_frames:
98 + return False
99 + oldest_failed = failed_frames[0]
100 + limit = next_target_map.get(oldest_failed, total_frames)
101 + return idx < limit
102 +
103 + @classmethod
104 + def _read_frames_with_recovery(
105 + cls,
106 + cap: "cv2.VideoCapture",
107 + frame_indices: list[int],
108 + total_frames: int,
109 + ) -> tuple[npt.NDArray, list[int], dict[int, int]]:
110 + """
111 + Read frames with dynamic window forward-scan recovery.
112 +
113 + When a target frame fails to load, the next successfully grabbed
114 + frame (before the next target frame) will be used to recover it.
115 +
116 + Args:
117 + cap: OpenCV VideoCapture object
118 + frame_indices: Sorted list of target frame indices to load
119 + total_frames: Total number of frames in the video
120 +
121 + Returns:
122 + Tuple of (frames_array, valid_frame_indices, recovered_map)
123 + - frames_array: Array of loaded frames
124 + - valid_frame_indices: List of frame indices that were loaded
125 + - recovered_map: Dict mapping recovered_idx -> source_idx
126 + """
127 + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
128 + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
129 +
130 + assert width > 0 and height > 0, (
131 + f"Invalid video frame size: width={width}, height={height}"
132 + )
133 +
134 + frame_idx_set = set(frame_indices)
135 + max_frame_idx = frame_indices[-1] if frame_indices else 0
136 +
137 + # Build map: target_idx -> next_target_idx (for recovery window)
138 + next_target_map: dict[int, int] = {}
139 + for k in range(len(frame_indices) - 1):
140 + next_target_map[frame_indices[k]] = frame_indices[k + 1]
141 + next_target_map[frame_indices[-1]] = total_frames
142 +
143 + frames_list: list[npt.NDArray] = []
144 + valid_frame_indices: list[int] = []
145 + failed_frames_idx: list[int] = []
146 + recovered_map: dict[int, int] = {}
147 +
148 + i = 0
149 + for idx in range(max_frame_idx + 1):
150 + is_target_frame = idx in frame_idx_set
151 +
152 + # Attempt to grab the current frame
153 + ok = cap.grab()
154 +
155 + if not ok:
156 + if is_target_frame:
157 + logger.debug(
158 + "Failed to grab frame %d during video loading.",
159 + idx,
160 + )
161 + failed_frames_idx.append(idx)
162 + continue
163 +
164 + # Check if we should retrieve: target frame OR can recover a failed one
165 + can_recover = cls._can_use_for_recovery(
166 + idx, failed_frames_idx, next_target_map, total_frames
167 + )
168 +
169 + if is_target_frame or can_recover:
170 + ret, frame = cap.retrieve()
171 +
172 + if ret and frame is not None and frame.size > 0:
173 + rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
174 + frames_list.append(rgb_frame)
175 + valid_frame_indices.append(idx)
176 + i += 1
177 +
178 + if can_recover:
179 + recovered_idx = failed_frames_idx.pop(0)
180 + recovered_map[recovered_idx] = idx
181 + logger.info(
182 + "Recovered frame %d using frame %d (delay: %d)",
183 + recovered_idx,
184 + idx,
185 + idx - recovered_idx,
186 + )
187 + elif is_target_frame:
188 + logger.debug(
189 + "Failed to retrieve frame %d during video loading.",
190 + idx,
191 + )
192 + failed_frames_idx.append(idx)
193 +
194 + # Log any remaining failed frames
195 + for failed_idx in failed_frames_idx:
196 + logger.debug(
197 + "Frame %d could not be recovered (end of video).",
198 + failed_idx,
199 + )
200 +
201 + # Stack frames
202 + if frames_list:
203 + frames = np.stack(frames_list)
204 + else:
205 + frames = np.empty((0, height, width, 3), dtype=np.uint8)
206 +
207 + return frames, valid_frame_indices, recovered_map
208 +
209 + @classmethod
210 + def _read_frames_no_recovery(
211 + cls,
212 + cap,
213 + frame_indices: set[int],
214 + max_frame_idx: int,
215 + ) -> tuple[npt.NDArray, list[int]]:
216 + num_expected_frames = len(frame_indices)
217 + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
218 + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
219 + frames = np.empty((num_expected_frames, height, width, 3), dtype=np.uint8)
220 +
221 + i = 0
222 + valid_frame_indices = []
223 + for idx in range(max_frame_idx + 1):
224 + ok = cap.grab()
225 + if not ok:
226 + # Frame is broken/unreadable, skip it
227 + if idx in frame_indices:
228 + logger.debug(
229 + "Failed to grab frame %d during video loading. "
230 + "This frame will be skipped.",
231 + idx,
232 + )
233 + continue
234 + if idx in frame_indices:
235 + ret, frame = cap.retrieve()
236 + if ret:
237 + frames[i] = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
238 + valid_frame_indices.append(idx)
239 + i += 1
240 + else:
241 + # retrieve() failed even though grab() succeeded
242 + logger.debug(
243 + "Failed to retrieve frame %d during video loading. "
244 + "This frame will be skipped.",
245 + idx,
246 + )
247 +
248 + valid_num_frames = len(valid_frame_indices)
249 + if valid_num_frames < num_expected_frames:
250 + logger.warning(
251 + "Video loading completed with %d broken/unreadable frames. "
252 + "Expected %d frames but only loaded %d frames.",
253 + num_expected_frames - valid_num_frames,
254 + num_expected_frames,
255 + valid_num_frames,
256 + )
257 +
258 + return frames[:valid_num_frames], valid_frame_indices
259 +
260 + @classmethod
261 + def read_frames(
262 + cls,
263 + cap: "cv2.VideoCapture",
264 + frame_idx: list[int],
265 + total_frames_num: int,
266 + *,
267 + frame_recovery: bool = False,
268 + ) -> tuple[npt.NDArray, list[int]]:
269 + if frame_recovery:
270 + num_frames_to_sample = len(frame_idx)
271 + frames, valid_frame_indices, recovered_map = cls._read_frames_with_recovery(
272 + cap, frame_idx, total_frames_num
273 + )
274 +
275 + if recovered_map:
276 + logger.info(
277 + "Frame recovery: %d frames recovered using forward scan.",
278 + len(recovered_map),
279 + )
280 + else:
281 + frame_idx_set = set(frame_idx)
282 + num_frames_to_sample = len(frame_idx_set)
283 + frames, valid_frame_indices = cls._read_frames_no_recovery(
284 + cap, frame_idx_set, max(frame_idx)
285 + )
286 + valid_num_frames = len(valid_frame_indices)
287 + if valid_num_frames < num_frames_to_sample:
288 + logger.warning(
289 + "Video loading completed with %d broken/unreadable frames. "
290 + "Expected to sample %d frames but only loaded %d frames.",
291 + num_frames_to_sample - valid_num_frames,
292 + num_frames_to_sample,
293 + valid_num_frames,
294 + )
295 + return frames, valid_frame_indices
vllm/multimodal/video_decoders/pyav.py new
+111
@@ -0,0 +1,111 @@
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 + return VideoSourceMetadata(total_frames, fps, duration)
66 +
67 + @staticmethod
68 + def decode_frames(
69 + container: "av.container.InputContainer",
70 + frame_indices: list[int],
71 + fps: float,
72 + duration: float,
73 + ) -> tuple[npt.NDArray, list[int]]:
74 + """Decode target frames via per-frame seek + forward decode to PTS."""
75 + stream = container.streams.video[0]
76 + # SLICE parallelizes within a single frame without the
77 + # one-frame-per-thread latency penalty of FRAME threading.
78 + stream.thread_type = "SLICE"
79 + time_base = stream.time_base
80 +
81 + frames_list: list[npt.NDArray] = []
82 + valid_indices: list[int] = []
83 + frame_interval = 1.0 / fps if fps > 0 else 0.1
84 + max_ts = max(0.0, duration - frame_interval) if duration > 0 else float("inf")
85 +
86 + decoder = None
87 + last_pts = None
88 + for idx in frame_indices:
89 + ts = min(idx / fps, max_ts) if fps > 0 else 0.0
90 + pts = int(ts / time_base)
91 + # seek() snaps backward to a keyframe; reuse the running decoder
92 + # while targets advance monotonically to avoid re-decoding the
93 + # GOP prefix once per requested frame.
94 + if decoder is None or last_pts is None or pts <= last_pts:
95 + container.seek(pts, stream=stream)
96 + decoder = container.decode(video=0)
97 + chosen = None
98 + for frame in decoder:
99 + if frame.pts is not None and frame.pts >= pts:
100 + chosen = frame
101 + last_pts = frame.pts
102 + break
103 + if chosen is not None:
104 + frames_list.append(chosen.to_ndarray(format="rgb24"))
105 + valid_indices.append(idx)
106 + else:
107 + decoder = None
108 +
109 + if not frames_list:
110 + return np.empty((0,), dtype=np.uint8), valid_indices
111 + return np.stack(frames_list), valid_indices
vllm/multimodal/video_decoders/pynvvideocodec.py new
+372
@@ -0,0 +1,372 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import os
5 +import tempfile
6 +import threading
7 +from contextlib import contextmanager, suppress
8 +from typing import ClassVar, NamedTuple
9 +
10 +import numpy as np
11 +import numpy.typing as npt
12 +
13 +from vllm.logger import init_logger
14 +from vllm.utils.mem_constants import MiB_bytes
15 +
16 +from .base import (
17 + PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
18 + VideoSourceMetadata,
19 + VideoTargetMetadata,
20 + check_frame_pixel_limit,
21 +)
22 +
23 +logger = init_logger(__name__)
24 +
25 +
26 +def decode_pynvvideocodec(
27 + loader_cls,
28 + data: bytes,
29 + target: VideoTargetMetadata,
30 + sampling_kwargs: dict,
31 + *,
32 + hw_decoders: int = PYNVVIDEOCODEC_DEFAULT_HW_DECODERS,
33 +) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
34 + PyNvVideoCodecVideoBackendMixin._configure_decoder_slots(hw_decoders)
35 + return PyNvVideoCodecVideoBackendMixin.decode_frames_pynvvideocodec(
36 + loader_cls,
37 + data,
38 + target,
39 + **sampling_kwargs,
40 + )
41 +
42 +
43 +class PyNvVideoCodecSourceMetadata(NamedTuple):
44 + """Metadata needed before GPU video decode."""
45 +
46 + source: VideoSourceMetadata
47 + width: int
48 + height: int
49 +
50 +
51 +# Per-decoder upper bound reserved for persistent PyNvVideoCodec surfaces.
52 +PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes
53 +PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
54 +# Per-API-server CUDA context and driver allocation, measured with
55 +# PyNvVideoCodec 2.0.4 on H100.
56 +PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes)
57 +
58 +
59 +def validate_pynvvideocodec_hw_decoders(hw_decoders: object) -> int:
60 + if (
61 + isinstance(hw_decoders, bool)
62 + or not isinstance(hw_decoders, int)
63 + or hw_decoders < 1
64 + ):
65 + raise ValueError("hw_decoders must be a positive integer")
66 + return hw_decoders
67 +
68 +
69 +def _pynvvideocodec_exception_types(nvc) -> tuple[type[Exception], ...]:
70 + return tuple(
71 + exception_type
72 + for name in dir(nvc)
73 + if name.startswith("PyNvVCException")
74 + and isinstance((exception_type := getattr(nvc, name)), type)
75 + and issubclass(exception_type, Exception)
76 + )
77 +
78 +
79 +def _pynvvc_frames_to_nhwc(frames):
80 + """Return a stacked PyNvVideoCodec frame batch as contiguous NHWC."""
81 + if frames.shape[-1] != 3 and frames.shape[-3] == 3:
82 + frames = frames.permute(0, 2, 3, 1)
83 + return frames.contiguous()
84 +
85 +
86 +class PyNvVideoCodecDecoderSlot:
87 + """A retained PyNv decoder slot and its CUDA stream.
88 +
89 + The decoder is reused across requests: ``reconfigure_decoder`` repoints the
90 + existing decoder at each new source instead of paying a fresh
91 + ``SimpleDecoder`` construction per request. Construction (CUVID parser +
92 + decoder + surface-pool allocation) is the dominant per-request cost, so
93 + reconfiguring is far cheaper. A single decoder serves both metadata
94 + (``len``/``get_stream_metadata``) and frame decode -- no separate
95 + metadata decoder.
96 + """
97 +
98 + def __init__(self, stream) -> None:
99 + self.stream = stream
100 + self.decoder = None
101 + self.source_path: str | None = None
102 +
103 + def invalidate(self) -> None:
104 + self.decoder = None
105 + self.source_path = None
106 +
107 + def _construct(self, file_path: str, nvc, device_index: int) -> None:
108 + self.invalidate()
109 + decoder = nvc.SimpleDecoder(
110 + file_path,
111 + output_color_type=nvc.OutputColorType.RGB,
112 + use_device_memory=True,
113 + need_scanned_stream_metadata=True,
114 + gpu_id=device_index,
115 + cuda_stream=self.stream.cuda_stream,
116 + decoder_cache_size=PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
117 + )
118 + self.decoder = decoder
119 + self.source_path = file_path
120 +
121 + def get_decoder(self, file_path: str, nvc, device_index: int):
122 + if self.decoder is None:
123 + self._construct(file_path, nvc, device_index)
124 + elif self.source_path != file_path:
125 + try:
126 + self.decoder.reconfigure_decoder(file_path)
127 + self.source_path = file_path
128 + except Exception:
129 + # reconfigure unsupported/unsafe for this source -> rebuild.
130 + self._construct(file_path, nvc, device_index)
131 + return self.decoder
132 +
133 +
134 +class _PyNvDecoderPool:
135 + """Process-wide singleton managing PyNvVideoCodec decoder slot state.
136 +
137 + Prevents subclass counter shadowing (GHSA-j682-9xp5-rrf3) by storing
138 + all mutable pool state in a single module-level instance rather than
139 + in ClassVar attributes that get shadowed by Python's augmented
140 + assignment semantics on subclasses.
141 + """
142 +
143 + def __init__(self) -> None:
144 + self.slots: list[PyNvVideoCodecDecoderSlot] = []
145 + self.active: int = 0
146 + self.cond: threading.Condition = threading.Condition()
147 + self.max_slots: int | None = None
148 +
149 + def configure(self, hw_decoders: int) -> None:
150 + with self.cond:
151 + if self.max_slots is None:
152 + self.max_slots = hw_decoders
153 + elif self.max_slots != hw_decoders:
154 + raise RuntimeError(
155 + "PyNvVideoCodec decoder count is already configured as "
156 + f"{self.max_slots}, got {hw_decoders}"
157 + )
158 +
159 +
160 +_pynv_decoder_pool = _PyNvDecoderPool()
161 +
162 +
163 +class PyNvVideoCodecVideoBackendMixin:
164 + """PyNvVideoCodec utilities for GPU-backed frame decode."""
165 +
166 + _DEVICE_INDEX: ClassVar[int] = 0
167 +
168 + @classmethod
169 + def _create_decoder_slot(cls) -> PyNvVideoCodecDecoderSlot:
170 + import torch
171 +
172 + return PyNvVideoCodecDecoderSlot(torch.cuda.Stream(device=cls._DEVICE_INDEX))
173 +
174 + @classmethod
175 + def _configure_decoder_slots(cls, hw_decoders: object) -> None:
176 + hw_decoders = validate_pynvvideocodec_hw_decoders(hw_decoders)
177 + _pynv_decoder_pool.configure(hw_decoders)
178 +
179 + @staticmethod
180 + @contextmanager
181 + def _torch_stream_context(stream):
182 + import torch
183 +
184 + torch.accelerator.set_device_index(stream.device.index)
185 + previous_stream = torch.accelerator.current_stream()
186 + torch.accelerator.set_stream(stream)
187 + try:
188 + yield
189 + finally:
190 + torch.accelerator.set_stream(previous_stream)
191 +
192 + @classmethod
193 + @contextmanager
194 + def _borrow_decoder_slot(cls):
195 + pool = _pynv_decoder_pool
196 + create_slot = False
197 + with pool.cond:
198 + if pool.max_slots is None:
199 + raise RuntimeError("PyNvVideoCodec decoder slots are not configured")
200 + while True:
201 + if pool.slots:
202 + slot = pool.slots.pop()
203 + break
204 + if pool.active < pool.max_slots:
205 + pool.active += 1
206 + create_slot = True
207 + break
208 + pool.cond.wait()
209 +
210 + if create_slot:
211 + try:
212 + slot = cls._create_decoder_slot()
213 + except Exception:
214 + with pool.cond:
215 + pool.active -= 1
216 + pool.cond.notify()
217 + raise
218 +
219 + borrow_succeeded = False
220 + try:
221 + yield slot
222 + borrow_succeeded = True
223 + finally:
224 + if not borrow_succeeded:
225 + slot.invalidate()
226 + with pool.cond:
227 + pool.slots.append(slot)
228 + pool.cond.notify()
229 +
230 + @staticmethod
231 + def _metadata_value(metadata, *names: str, default=None):
232 + for name in names:
233 + value = getattr(metadata, name, None)
234 + if value is not None:
235 + return value
236 + return default
237 +
238 + @classmethod
239 + def _read_source_metadata(
240 + cls,
241 + file_path: str,
242 + nvc,
243 + ) -> PyNvVideoCodecSourceMetadata:
244 + with cls._borrow_decoder_slot() as decoder_slot:
245 + with cls._torch_stream_context(decoder_slot.stream):
246 + decoder = decoder_slot.get_decoder(
247 + file_path, nvc, device_index=cls._DEVICE_INDEX
248 + )
249 + metadata = decoder.get_stream_metadata()
250 + total_frames_num = len(decoder)
251 + width = int(cls._metadata_value(metadata, "width", default=0))
252 + height = int(cls._metadata_value(metadata, "height", default=0))
253 + original_fps = float(
254 + cls._metadata_value(
255 + metadata,
256 + "average_fps",
257 + "avg_frame_rate",
258 + "frame_rate",
259 + "frameRate",
260 + default=0.0,
261 + )
262 + )
263 + duration = float(
264 + cls._metadata_value(metadata, "duration", default=0.0)
265 + or (total_frames_num / original_fps if original_fps > 0 else 0.0)
266 + )
267 + if total_frames_num <= 0:
268 + raise ValueError("Could not determine video frame count")
269 + if width <= 0 or height <= 0:
270 + raise ValueError("Could not determine video dimensions")
271 + return PyNvVideoCodecSourceMetadata(
272 + source=VideoSourceMetadata(total_frames_num, original_fps, duration),
273 + width=width,
274 + height=height,
275 + )
276 +
277 + @classmethod
278 + def _decode_to_pinned_host(
279 + cls,
280 + file_path: str,
281 + frame_idx: list[int],
282 + nvc,
283 + ) -> npt.NDArray:
284 + import torch
285 +
286 + if not frame_idx:
287 + return np.empty((0,), dtype=np.uint8)
288 +
289 + with cls._borrow_decoder_slot() as decoder_slot:
290 + stream = decoder_slot.stream
291 + with cls._torch_stream_context(stream):
292 + try:
293 + decoder = decoder_slot.get_decoder(
294 + file_path, nvc, device_index=cls._DEVICE_INDEX
295 + )
296 + decoded_frames = decoder.get_batch_frames_by_index(frame_idx)
297 + except Exception as exc:
298 + if not isinstance(
299 + exc,
300 + _pynvvideocodec_exception_types(nvc) + (IndexError,),
301 + ):
302 + raise
303 + raise ValueError("Invalid or unsupported video file.") from exc
304 + if len(decoded_frames) < len(frame_idx):
305 + logger.warning(
306 + "pynvvideocodec video loading: expected %d frames but got %d.",
307 + len(frame_idx),
308 + len(decoded_frames),
309 + )
310 + torch_frames = [torch.from_dlpack(frame) for frame in decoded_frames]
311 + if not torch_frames:
312 + return np.empty((0,), dtype=np.uint8)
313 + device_frames = torch.stack(torch_frames)
314 + if device_frames.ndim != 4:
315 + raise ValueError(
316 + "PyNvVideoCodec returned frames with unexpected shape "
317 + f"{tuple(device_frames.shape)}"
318 + )
319 + device_frames = _pynvvc_frames_to_nhwc(device_frames)
320 + host_frames = torch.empty(
321 + device_frames.shape,
322 + dtype=device_frames.dtype,
323 + device="cpu",
324 + pin_memory=True,
325 + )
326 + host_frames.copy_(device_frames, non_blocking=True)
327 + stream.synchronize()
328 + host_array = host_frames.numpy()
329 + del decoded_frames, torch_frames, device_frames
330 + return host_array
331 +
332 + @classmethod
333 + def decode_frames_pynvvideocodec(
334 + cls,
335 + loader_cls,
336 + data: bytes,
337 + target: VideoTargetMetadata,
338 + **kwargs,
339 + ) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
340 + import PyNvVideoCodec as nvc
341 +
342 + from vllm.multimodal.gpu_ipc_memory import get_mm_gpu_ipc_pool
343 +
344 + temp_fd, temp_path = tempfile.mkstemp(suffix=".mp4")
345 + try:
346 + with os.fdopen(temp_fd, "wb") as temp_file:
347 + temp_file.write(data)
348 +
349 + try:
350 + gpu_source = cls._read_source_metadata(temp_path, nvc)
351 + except Exception as exc:
352 + if not isinstance(exc, _pynvvideocodec_exception_types(nvc)):
353 + raise
354 + raise ValueError("Invalid or unsupported video file.") from exc
355 + check_frame_pixel_limit(gpu_source.width, gpu_source.height)
356 + source = loader_cls._prepare_source(gpu_source.source)
357 + frame_idx = loader_cls.compute_frames_index_to_sample(
358 + source=source, target=target, **kwargs
359 + )
360 + raw_frame_bytes = len(frame_idx) * gpu_source.height * gpu_source.width * 3
361 + pool = get_mm_gpu_ipc_pool()
362 + if pool is None or raw_frame_bytes == 0:
363 + frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
364 + else:
365 + with pool.acquire(raw_frame_bytes):
366 + frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
367 + finally:
368 + with suppress(FileNotFoundError):
369 + os.unlink(temp_path)
370 +
371 + valid_frame_indices = frame_idx[: int(frames.shape[0])]
372 + return frames, source, frame_idx, valid_frame_indices
vllm/multimodal/video_decoders/torchcodec.py new
+100
@@ -0,0 +1,100 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from typing import Literal
5 +
6 +import numpy as np
7 +import numpy.typing as npt
8 +
9 +from vllm.utils.import_utils import PlaceholderModule, check_torchcodec_available
10 +
11 +from .base import (
12 + VideoSourceMetadata,
13 + VideoTargetMetadata,
14 + check_frame_pixel_limit,
15 +)
16 +
17 +try:
18 + from torchcodec.decoders import VideoDecoder
19 +except (ImportError, RuntimeError):
20 + VideoDecoder = PlaceholderModule("torchcodec").placeholder_attr( # type: ignore[assignment]
21 + "decoders.VideoDecoder"
22 + )
23 +
24 +
25 +def decode_torchcodec(
26 + loader_cls,
27 + data: bytes,
28 + target: VideoTargetMetadata,
29 + sampling_kwargs: dict,
30 + *,
31 + num_ffmpeg_threads: int = 0,
32 + seek_mode: Literal["exact", "approximate"] = "exact",
33 +) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
34 + check_torchcodec_available()
35 + decoder = TorchCodecVideoBackendMixin.make_torchcodec_decoder(
36 + data,
37 + num_ffmpeg_threads=num_ffmpeg_threads,
38 + seek_mode=seek_mode,
39 + )
40 + check_frame_pixel_limit(
41 + decoder.metadata.width or 0,
42 + decoder.metadata.height or 0,
43 + )
44 + source = loader_cls._prepare_source(
45 + TorchCodecVideoBackendMixin.get_torchcodec_metadata(decoder)
46 + )
47 + frame_idx = loader_cls.compute_frames_index_to_sample(
48 + source=source, target=target, **sampling_kwargs
49 + )
50 + frames, valid = TorchCodecVideoBackendMixin.decode_torchcodec_frames(
51 + decoder, frame_idx
52 + )
53 + return frames, source, frame_idx, valid
54 +
55 +
56 +class TorchCodecVideoBackendMixin:
57 + """TorchCodec (FFmpeg-backed, PyTorch-native) codec utilities.
58 +
59 + Builds a :class:`~torchcodec.decoders.VideoDecoder` over the in-memory
60 + bytes and extracts the sampled indices with a single batched
61 + ``get_frames_at`` call, while releasing the GIL during decode.
62 + """
63 +
64 + @staticmethod
65 + def make_torchcodec_decoder(
66 + data: bytes,
67 + *,
68 + num_ffmpeg_threads: int = 0,
69 + seek_mode: Literal["exact", "approximate"] = "exact",
70 + ) -> "VideoDecoder":
71 + # NHWC matches the (num_frames, H, W, 3) uint8 RGB layout the rest
72 + # of the pipeline expects, avoiding a transpose.
73 + return VideoDecoder(
74 + data,
75 + dimension_order="NHWC",
76 + num_ffmpeg_threads=num_ffmpeg_threads,
77 + seek_mode=seek_mode,
78 + )
79 +
80 + @staticmethod
81 + def get_torchcodec_metadata(decoder: "VideoDecoder") -> VideoSourceMetadata:
82 + md = decoder.metadata
83 + total_frames = md.num_frames or 0
84 + fps = float(md.average_fps) if md.average_fps else 0.0
85 + duration = float(md.duration_seconds) if md.duration_seconds else 0.0
86 + if total_frames == 0 and duration > 0 and fps > 0:
87 + total_frames = int(duration * fps)
88 + return VideoSourceMetadata(total_frames, fps, duration)
89 +
90 + @staticmethod
91 + def decode_torchcodec_frames(
92 + decoder: "VideoDecoder",
93 + frame_indices: list[int],
94 + ) -> tuple[npt.NDArray, list[int]]:
95 + """Decode the requested indices in one batched, index-exact call."""
96 + if not frame_indices:
97 + return np.empty((0,), dtype=np.uint8), []
98 + # Note: torchcodec releases the GIL for the entire call
99 + batch = decoder.get_frames_at(frame_indices)
100 + return batch.data.numpy(), list(frame_indices)