Files
vllm-project--vllm/vllm/multimodal/video.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

2063 lines
73 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import math
import os
import tempfile
import threading
from abc import abstractmethod
from contextlib import contextmanager, suppress
from io import BytesIO
from typing import Any, ClassVar, Literal, NamedTuple, cast
import numpy as np
import numpy.typing as npt
import torch
from vllm import envs
from vllm.logger import init_logger
from vllm.utils.import_utils import PlaceholderModule, check_torchcodec_available
from vllm.utils.mem_constants import MiB_bytes
from vllm.utils.registry import ExtensionManager
try:
import cv2
import cv2.videoio_registry as vr
except ImportError:
cv2 = PlaceholderModule("cv2")
vr = PlaceholderModule("cv2").placeholder_attr("videoio_registry")
try:
import av
except ImportError:
av = PlaceholderModule("av") # type: ignore[assignment]
try:
from torchcodec.decoders import VideoDecoder
except (ImportError, RuntimeError):
VideoDecoder = PlaceholderModule("torchcodec").placeholder_attr( # type: ignore[assignment]
"decoders.VideoDecoder"
)
logger = init_logger(__name__)
class VideoLoaderRegistry(ExtensionManager):
def __init__(self) -> None:
super().__init__()
self.processor2backend: dict[str, str] = {}
self._requires_gpu: dict[str, bool] = {}
@staticmethod
def _normalize_registered_video_processors(
video_processor: str | tuple[str, ...] | None,
) -> tuple[str, ...]:
if video_processor is None:
return ()
if isinstance(video_processor, str):
return (video_processor,)
if all(isinstance(processor, str) for processor in video_processor):
return video_processor
raise TypeError(
"video_processor must be a class name or a tuple of class names"
)
def register(
self,
name: str,
*,
video_processor: str | tuple[str, ...] | None = None,
requires_gpu: bool = False,
):
processors = self._normalize_registered_video_processors(video_processor)
def wrap(cls_to_register):
self.name2class[name] = cls_to_register
self._requires_gpu[name] = requires_gpu
for processor_name in processors:
self.processor2backend[processor_name] = name
return cls_to_register
return wrap
def get_backend_for_video_processor(
self,
video_processor: str | None,
) -> str | None:
if video_processor is None:
return None
return self.processor2backend.get(video_processor)
def backend_requires_gpu(self, name: str) -> bool:
return self._requires_gpu.get(name, False)
def get_video_loader_backend_for_processor(
video_processor: str | None,
) -> str | None:
return VIDEO_LOADER_REGISTRY.get_backend_for_video_processor(video_processor)
def _check_frame_pixel_limit(width: int, height: int) -> None:
"""Reject video frames exceeding VLLM_MAX_IMAGE_PIXELS before decoding."""
max_pixels = envs.VLLM_MAX_IMAGE_PIXELS
if max_pixels > 0 and width * height > max_pixels:
raise ValueError(
f"Video frame dimensions {width}x{height} "
f"({width * height} pixels) exceed the maximum of "
f"{max_pixels} pixels. Set VLLM_MAX_IMAGE_PIXELS to "
f"increase this limit."
)
def resize_video(frames: npt.NDArray, size: tuple[int, int]) -> npt.NDArray:
num_frames, _, _, channels = frames.shape
new_height, new_width = size
resized_frames = np.empty(
(num_frames, new_height, new_width, channels), dtype=frames.dtype
)
for i, frame in enumerate(frames):
resized_frame = cv2.resize(frame, (new_width, new_height))
resized_frames[i] = resized_frame
return resized_frames
def rescale_video_size(frames: npt.NDArray, size_factor: float) -> npt.NDArray:
_, height, width, _ = frames.shape
new_height = int(height * size_factor)
new_width = int(width * size_factor)
return resize_video(frames, (new_height, new_width))
def sample_frames_from_video(frames: npt.NDArray, num_frames: int) -> npt.NDArray:
total_frames = frames.shape[0]
if num_frames == -1:
return frames
frame_indices = np.linspace(0, total_frames - 1, num_frames, dtype=int)
sampled_frames = frames[frame_indices, ...]
return sampled_frames
class VideoTargetMetadata(NamedTuple):
"""Metadata represents target video."""
num_frames: int
fps: float
max_duration: float
class VideoSourceMetadata(NamedTuple):
"""Metadata represents source video."""
total_frames_num: int
original_fps: float
duration: float
class PyNvVideoCodecSourceMetadata(NamedTuple):
"""Metadata needed before GPU video decode."""
source: VideoSourceMetadata
width: int
height: int
class VideoLoader:
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
"""Return the list of frame indices to sample from the video."""
raise NotImplementedError
@classmethod
@abstractmethod
def load_bytes(
cls,
data: bytes,
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
"""Load video frames from bytes and return (frames_array, metadata_dict)."""
raise NotImplementedError
@classmethod
def create_hf_metadata(
cls,
source: VideoSourceMetadata,
valid_frame_indices: list[int],
video_backend: str,
):
return {
"total_num_frames": source.total_frames_num,
"fps": source.original_fps,
"duration": source.duration,
"video_backend": video_backend,
"frames_indices": valid_frame_indices,
"do_sample_frames": len(valid_frame_indices) == source.total_frames_num,
}
VIDEO_LOADER_REGISTRY = VideoLoaderRegistry()
PYNVVIDEOCODEC_VIDEO_BACKEND: Literal["pynvvideocodec"] = "pynvvideocodec"
# Fixed upper bound reserved for persistent PyNvVideoCodec decoder surfaces.
PYNVVIDEOCODEC_DECODER_GPU_MEMORY_BYTES = 128 * MiB_bytes
PYNVVIDEOCODEC_DECODER_CACHE_SIZE = 2
PYNVVIDEOCODEC_MAX_RETAINED_DECODERS = 1
# Per-API-server CUDA context and driver allocation, measured with
# PyNvVideoCodec 2.0.4 on H100.
PYNVVIDEOCODEC_CUDA_CONTEXT_BYTES = int(1.8 * 1024 * MiB_bytes)
class PyNvVideoCodecDecoderSlot:
"""A retained PyNv decoder slot and its CUDA stream.
The decoder is reused across requests: ``reconfigure_decoder`` repoints the
existing decoder at each new source instead of paying a fresh
``SimpleDecoder`` construction per request. Construction (CUVID parser +
decoder + surface-pool allocation) is the dominant per-request cost, so
reconfiguring is far cheaper. A single decoder serves both metadata
(``len``/``get_stream_metadata``) and frame decode -- no separate
metadata decoder.
"""
def __init__(self, stream) -> None:
self.stream = stream
self.decoder = None
self.source_path: str | None = None
def _construct(self, file_path: str, nvc, device_index: int) -> None:
self.decoder = nvc.SimpleDecoder(
file_path,
output_color_type=nvc.OutputColorType.RGB,
use_device_memory=True,
need_scanned_stream_metadata=True,
gpu_id=device_index,
cuda_stream=self.stream.cuda_stream,
decoder_cache_size=PYNVVIDEOCODEC_DECODER_CACHE_SIZE,
)
self.source_path = file_path
def get_decoder(self, file_path: str, nvc, device_index: int):
if self.decoder is None:
self._construct(file_path, nvc, device_index)
elif self.source_path != file_path:
try:
self.decoder.reconfigure_decoder(file_path)
self.source_path = file_path
except Exception:
# reconfigure unsupported/unsafe for this source -> rebuild.
self._construct(file_path, nvc, device_index)
return self.decoder
class OpenCVVideoBackendMixin:
@staticmethod
def get_cv2_video_api():
api_pref = None
for backend in vr.getStreamBufferedBackends():
if not vr.hasBackend(backend):
continue
if not vr.isBackendBuiltIn(backend):
_, abi, api = vr.getStreamBufferedBackendPluginVersion(backend)
if abi < 1 or (abi == 1 and api < 2):
continue
api_pref = backend
break
return api_pref
@classmethod
def open_video_capture(cls, data: bytes) -> "cv2.VideoCapture":
backend = cls.get_cv2_video_api()
cap = cv2.VideoCapture(BytesIO(data), backend, [])
if not cap.isOpened():
raise ValueError("Could not open video stream")
return cap
@staticmethod
def get_video_metadata(cap: "cv2.VideoCapture") -> VideoSourceMetadata:
total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
original_fps = cap.get(cv2.CAP_PROP_FPS)
duration = total_frames_num / original_fps if original_fps > 0 else 0
return VideoSourceMetadata(
total_frames_num=total_frames_num,
original_fps=original_fps,
duration=duration,
)
@classmethod
def _can_use_for_recovery(
cls,
idx: int,
failed_frames: list[int],
next_target_map: dict[int, int],
total_frames: int,
) -> bool:
"""Check if current frame can recover the oldest failed frame."""
if not failed_frames:
return False
oldest_failed = failed_frames[0]
limit = next_target_map.get(oldest_failed, total_frames)
return idx < limit
@classmethod
def _read_frames_with_recovery(
cls,
cap: "cv2.VideoCapture",
frame_indices: list[int],
total_frames: int,
) -> tuple[npt.NDArray, list[int], dict[int, int]]:
"""
Read frames with dynamic window forward-scan recovery.
When a target frame fails to load, the next successfully grabbed
frame (before the next target frame) will be used to recover it.
Args:
cap: OpenCV VideoCapture object
frame_indices: Sorted list of target frame indices to load
total_frames: Total number of frames in the video
Returns:
Tuple of (frames_array, valid_frame_indices, recovered_map)
- frames_array: Array of loaded frames
- valid_frame_indices: List of frame indices that were loaded
- recovered_map: Dict mapping recovered_idx -> source_idx
"""
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
assert width > 0 and height > 0, (
f"Invalid video frame size: width={width}, height={height}"
)
frame_idx_set = set(frame_indices)
max_frame_idx = frame_indices[-1] if frame_indices else 0
# Build map: target_idx -> next_target_idx (for recovery window)
next_target_map: dict[int, int] = {}
for k in range(len(frame_indices) - 1):
next_target_map[frame_indices[k]] = frame_indices[k + 1]
next_target_map[frame_indices[-1]] = total_frames
frames_list: list[npt.NDArray] = []
valid_frame_indices: list[int] = []
failed_frames_idx: list[int] = []
recovered_map: dict[int, int] = {}
i = 0
for idx in range(max_frame_idx + 1):
is_target_frame = idx in frame_idx_set
# Attempt to grab the current frame
ok = cap.grab()
if not ok:
if is_target_frame:
logger.debug(
"Failed to grab frame %d during video loading.",
idx,
)
failed_frames_idx.append(idx)
continue
# Check if we should retrieve: target frame OR can recover a failed one
can_recover = cls._can_use_for_recovery(
idx, failed_frames_idx, next_target_map, total_frames
)
if is_target_frame or can_recover:
ret, frame = cap.retrieve()
if ret and frame is not None and frame.size > 0:
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
frames_list.append(rgb_frame)
valid_frame_indices.append(idx)
i += 1
if can_recover:
recovered_idx = failed_frames_idx.pop(0)
recovered_map[recovered_idx] = idx
logger.info(
"Recovered frame %d using frame %d (delay: %d)",
recovered_idx,
idx,
idx - recovered_idx,
)
elif is_target_frame:
logger.debug(
"Failed to retrieve frame %d during video loading.",
idx,
)
failed_frames_idx.append(idx)
# Log any remaining failed frames
for failed_idx in failed_frames_idx:
logger.debug(
"Frame %d could not be recovered (end of video).",
failed_idx,
)
# Stack frames
if frames_list:
frames = np.stack(frames_list)
else:
frames = np.empty((0, height, width, 3), dtype=np.uint8)
return frames, valid_frame_indices, recovered_map
@classmethod
def _read_frames_no_recovery(
cls,
cap,
frame_indices: set[int],
max_frame_idx: int,
) -> tuple[npt.NDArray, list[int]]:
num_expected_frames = len(frame_indices)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
frames = np.empty((num_expected_frames, height, width, 3), dtype=np.uint8)
i = 0
valid_frame_indices = []
for idx in range(max_frame_idx + 1):
ok = cap.grab()
if not ok:
# Frame is broken/unreadable, skip it
if idx in frame_indices:
logger.debug(
"Failed to grab frame %d during video loading. "
"This frame will be skipped.",
idx,
)
continue
if idx in frame_indices:
ret, frame = cap.retrieve()
if ret:
frames[i] = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
valid_frame_indices.append(idx)
i += 1
else:
# retrieve() failed even though grab() succeeded
logger.debug(
"Failed to retrieve frame %d during video loading. "
"This frame will be skipped.",
idx,
)
valid_num_frames = len(valid_frame_indices)
if valid_num_frames < num_expected_frames:
logger.warning(
"Video loading completed with %d broken/unreadable frames. "
"Expected %d frames but only loaded %d frames.",
num_expected_frames - valid_num_frames,
num_expected_frames,
valid_num_frames,
)
return frames[:valid_num_frames], valid_frame_indices
@classmethod
def read_frames(
cls,
cap: "cv2.VideoCapture",
frame_idx: list[int],
total_frames_num: int,
*,
frame_recovery: bool = False,
) -> tuple[npt.NDArray, list[int]]:
if frame_recovery:
num_frames_to_sample = len(frame_idx)
frames, valid_frame_indices, recovered_map = cls._read_frames_with_recovery(
cap, frame_idx, total_frames_num
)
if recovered_map:
logger.info(
"Frame recovery: %d frames recovered using forward scan.",
len(recovered_map),
)
else:
frame_idx_set = set(frame_idx)
num_frames_to_sample = len(frame_idx_set)
frames, valid_frame_indices = cls._read_frames_no_recovery(
cap, frame_idx_set, max(frame_idx)
)
valid_num_frames = len(valid_frame_indices)
if valid_num_frames < num_frames_to_sample:
logger.warning(
"Video loading completed with %d broken/unreadable frames. "
"Expected to sample %d frames but only loaded %d frames.",
num_frames_to_sample - valid_num_frames,
num_frames_to_sample,
valid_num_frames,
)
return frames, valid_frame_indices
class PyAVVideoBackendMixin:
"""PyAV (in-process FFmpeg bindings) codec utilities.
Reads stream metadata and decodes target frames via per-frame
``container.seek()``. The seek releases the GIL between frames and
scales with the number of sampled frames rather than the video
length, enabling concurrent decoding under serving load.
"""
@staticmethod
def get_metadata(
container: "av.container.InputContainer",
) -> VideoSourceMetadata:
if not container.streams.video:
raise ValueError("No video streams found in container")
stream = container.streams.video[0]
total_frames = stream.frames or 0
fps = float(stream.average_rate) if stream.average_rate else 0.0
duration = float(stream.duration * stream.time_base) if stream.duration else 0.0
if total_frames == 0 and duration > 0 and fps > 0:
total_frames = int(duration * fps)
return VideoSourceMetadata(total_frames, fps, duration)
@staticmethod
def decode_frames(
container: "av.container.InputContainer",
frame_indices: list[int],
fps: float,
duration: float,
) -> tuple[npt.NDArray, list[int]]:
"""Decode target frames via per-frame seek + forward decode to PTS."""
stream = container.streams.video[0]
# SLICE parallelizes within a single frame without the
# one-frame-per-thread latency penalty of FRAME threading.
stream.thread_type = "SLICE"
time_base = stream.time_base
frames_list: list[npt.NDArray] = []
valid_indices: list[int] = []
frame_interval = 1.0 / fps if fps > 0 else 0.1
max_ts = max(0.0, duration - frame_interval) if duration > 0 else float("inf")
decoder = None
last_pts = None
for idx in frame_indices:
ts = min(idx / fps, max_ts) if fps > 0 else 0.0
pts = int(ts / time_base)
# seek() snaps backward to a keyframe; reuse the running decoder
# while targets advance monotonically to avoid re-decoding the
# GOP prefix once per requested frame.
if decoder is None or last_pts is None or pts <= last_pts:
container.seek(pts, stream=stream)
decoder = container.decode(video=0)
chosen = None
for frame in decoder:
if frame.pts is not None and frame.pts >= pts:
chosen = frame
last_pts = frame.pts
break
if chosen is not None:
frames_list.append(chosen.to_ndarray(format="rgb24"))
valid_indices.append(idx)
else:
decoder = None
if not frames_list:
return np.empty((0,), dtype=np.uint8), valid_indices
return np.stack(frames_list), valid_indices
class TorchCodecVideoBackendMixin:
"""TorchCodec (FFmpeg-backed, PyTorch-native) codec utilities.
Builds a :class:`~torchcodec.decoders.VideoDecoder` over the in-memory
bytes and extracts the sampled indices with a single batched
``get_frames_at`` call, while releasing the GIL during decode.
"""
@staticmethod
def make_torchcodec_decoder(
data: bytes,
*,
num_ffmpeg_threads: int = 0,
seek_mode: Literal["exact", "approximate"] = "exact",
) -> "VideoDecoder":
# NHWC matches the (num_frames, H, W, 3) uint8 RGB layout the rest
# of the pipeline expects, avoiding a transpose.
return VideoDecoder(
data,
dimension_order="NHWC",
num_ffmpeg_threads=num_ffmpeg_threads,
seek_mode=seek_mode,
)
@staticmethod
def get_torchcodec_metadata(decoder: "VideoDecoder") -> VideoSourceMetadata:
md = decoder.metadata
total_frames = md.num_frames or 0
fps = float(md.average_fps) if md.average_fps else 0.0
duration = float(md.duration_seconds) if md.duration_seconds else 0.0
if total_frames == 0 and duration > 0 and fps > 0:
total_frames = int(duration * fps)
return VideoSourceMetadata(total_frames, fps, duration)
@staticmethod
def decode_torchcodec_frames(
decoder: "VideoDecoder",
frame_indices: list[int],
) -> tuple[npt.NDArray, list[int]]:
"""Decode the requested indices in one batched, index-exact call."""
if not frame_indices:
return np.empty((0,), dtype=np.uint8), []
# Note: torchcodec releases the GIL for the entire call
batch = decoder.get_frames_at(frame_indices)
return batch.data.numpy(), list(frame_indices)
class PyNvVideoCodecVideoBackendMixin:
"""PyNvVideoCodec utilities for GPU-backed frame decode."""
_decoder_slots: ClassVar[list[PyNvVideoCodecDecoderSlot]] = []
_active_decoder_slots: ClassVar[int] = 0
_decoder_slot_cond: ClassVar[threading.Condition] = threading.Condition()
_DEVICE_INDEX: ClassVar[int] = 0
@classmethod
@abstractmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
raise NotImplementedError
@classmethod
@abstractmethod
def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
raise NotImplementedError
@classmethod
def _create_decoder_slot(cls) -> PyNvVideoCodecDecoderSlot:
import torch
return PyNvVideoCodecDecoderSlot(torch.cuda.Stream(device=cls._DEVICE_INDEX))
@staticmethod
@contextmanager
def _torch_stream_context(stream):
import torch
torch.accelerator.set_device_index(stream.device.index)
previous_stream = torch.accelerator.current_stream()
torch.accelerator.set_stream(stream)
try:
yield
finally:
torch.accelerator.set_stream(previous_stream)
@classmethod
@contextmanager
def _borrow_decoder_slot(cls):
create_slot = False
with cls._decoder_slot_cond:
while True:
if cls._decoder_slots:
slot = cls._decoder_slots.pop()
break
if cls._active_decoder_slots < PYNVVIDEOCODEC_MAX_RETAINED_DECODERS:
cls._active_decoder_slots += 1
create_slot = True
break
cls._decoder_slot_cond.wait()
if create_slot:
try:
slot = cls._create_decoder_slot()
except Exception:
with cls._decoder_slot_cond:
cls._active_decoder_slots -= 1
cls._decoder_slot_cond.notify()
raise
try:
yield slot
finally:
with cls._decoder_slot_cond:
cls._decoder_slots.append(slot)
cls._decoder_slot_cond.notify()
@staticmethod
def _metadata_value(metadata, *names: str, default=None):
for name in names:
value = getattr(metadata, name, None)
if value is not None:
return value
return default
@classmethod
def _read_source_metadata(
cls,
file_path: str,
nvc,
) -> PyNvVideoCodecSourceMetadata:
with cls._borrow_decoder_slot() as decoder_slot:
with cls._torch_stream_context(decoder_slot.stream):
decoder = decoder_slot.get_decoder(
file_path, nvc, device_index=cls._DEVICE_INDEX
)
metadata = decoder.get_stream_metadata()
total_frames_num = len(decoder)
width = int(cls._metadata_value(metadata, "width", default=0))
height = int(cls._metadata_value(metadata, "height", default=0))
original_fps = float(
cls._metadata_value(
metadata,
"average_fps",
"avg_frame_rate",
"frame_rate",
"frameRate",
default=0.0,
)
)
duration = float(
cls._metadata_value(metadata, "duration", default=0.0)
or (total_frames_num / original_fps if original_fps > 0 else 0.0)
)
if total_frames_num <= 0:
raise ValueError("Could not determine video frame count")
if width <= 0 or height <= 0:
raise ValueError("Could not determine video dimensions")
return PyNvVideoCodecSourceMetadata(
source=VideoSourceMetadata(total_frames_num, original_fps, duration),
width=width,
height=height,
)
@classmethod
def _decode_to_pinned_host(
cls,
file_path: str,
frame_idx: list[int],
nvc,
) -> npt.NDArray:
import torch
if not frame_idx:
return np.empty((0,), dtype=np.uint8)
with cls._borrow_decoder_slot() as decoder_slot:
stream = decoder_slot.stream
with cls._torch_stream_context(stream):
decoder = decoder_slot.get_decoder(
file_path, nvc, device_index=cls._DEVICE_INDEX
)
decoded_frames = decoder.get_batch_frames_by_index(frame_idx)
if len(decoded_frames) < len(frame_idx):
logger.warning(
"pynvvideocodec video loading: expected %d frames but got %d.",
len(frame_idx),
len(decoded_frames),
)
torch_frames = [torch.from_dlpack(frame) for frame in decoded_frames]
if not torch_frames:
return np.empty((0,), dtype=np.uint8)
device_frames = torch.stack(torch_frames)
if device_frames.ndim != 4:
raise ValueError(
"PyNvVideoCodec returned frames with unexpected shape "
f"{tuple(device_frames.shape)}"
)
device_frames = device_frames.permute(0, 3, 1, 2).contiguous()
host_frames = torch.empty(
device_frames.shape,
dtype=device_frames.dtype,
device="cpu",
pin_memory=True,
)
host_frames.copy_(device_frames, non_blocking=True)
stream.synchronize()
host_array = host_frames.numpy()
del decoded_frames, torch_frames, device_frames
return host_array
@classmethod
def decode_frames_pynvvideocodec(
cls,
data: bytes,
target: VideoTargetMetadata,
**kwargs,
) -> tuple[npt.NDArray, VideoSourceMetadata, list[int], list[int]]:
import PyNvVideoCodec as nvc
from vllm.multimodal.gpu_ipc_memory import get_mm_gpu_ipc_pool
temp_fd, temp_path = tempfile.mkstemp(suffix=".mp4")
try:
with os.fdopen(temp_fd, "wb") as temp_file:
temp_file.write(data)
gpu_source = cls._read_source_metadata(temp_path, nvc)
_check_frame_pixel_limit(gpu_source.width, gpu_source.height)
source = cls._prepare_source(gpu_source.source)
frame_idx = cls.compute_frames_index_to_sample(
source=source, target=target, **kwargs
)
raw_frame_bytes = len(frame_idx) * gpu_source.height * gpu_source.width * 3
pool = get_mm_gpu_ipc_pool()
if pool is None or raw_frame_bytes == 0:
frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
else:
with pool.acquire(raw_frame_bytes):
frames = cls._decode_to_pinned_host(temp_path, frame_idx, nvc)
finally:
with suppress(FileNotFoundError):
os.unlink(temp_path)
valid_frame_indices = frame_idx[: int(frames.shape[0])]
return frames, source, frame_idx, valid_frame_indices
class DeepStreamVideoBackendMixin:
"""NVIDIA DeepStream (NVDEC) GPU-decode codec utilities.
Decoding runs on a shared pool of daemon threads inside one CUDA
context (see the ``nvidia-deepstream-videodecode-cu13`` package). The
container bytes are pushed into an ``appsrc`` GStreamer pipeline, so no
local file path is required — HTTP and base64 sources decode identically
to local files.
Like the OpenCV/PyAV mixins, this provides only the codec layer.
Frame *selection* lives in the loader's
``compute_frames_index_to_sample`` and arrives here as an explicit
list of frame indices.
"""
# Process-wide lazy decode pool, shared across all DeepStream backends.
_pool: ClassVar[Any] = None
_pool_lock: ClassVar[Any] = None
@classmethod
def _get_pool(cls, pool_size: int | None = None):
"""Lazy-initialize the shared decode pool on first use.
``pool_size`` (number of decode worker threads) comes from
``--media-io-kwargs`` (``{"video": {"pool_size": N}}``); when unset it
defaults to the existing ``VLLM_MEDIA_LOADING_THREAD_COUNT`` so no
DeepStream-specific env var is needed. The pool is a process-wide
singleton, so the first decode's value wins.
"""
if cls._pool is not None:
return cls._pool
if cls._pool_lock is None:
cls._pool_lock = threading.Lock()
with cls._pool_lock:
if cls._pool is not None:
return cls._pool
import os
from nvidia.deepstream_videodecode import DecodePool
if pool_size is None:
pool_size = int(os.environ.get("VLLM_MEDIA_LOADING_THREAD_COUNT", 8))
pool_size = max(1, min(int(pool_size), 16))
logger.info(
"[DeepStream] initializing decode pool with %d workers",
pool_size,
)
cls._pool = DecodePool(num_workers=pool_size)
return cls._pool
@classmethod
def decode_indices(
cls,
data: bytes,
frame_indices: list[int],
source: VideoSourceMetadata,
codec: str = "",
pool_size: int | None = None,
timeout_sec: float = 120.0,
) -> tuple[npt.NDArray, list[int]]:
"""Decode the requested frame indices from raw container bytes.
The whole stream is decoded; the pool keeps exactly the frames whose
decode-order index is in ``frame_indices`` (1:1, frame-exact) and
sends EOS once the last one is matched.
``codec`` (e.g. ``"h264"``/``"hevc"``) lets the pool keep its NVDEC
session warm across same-codec streams and rebuild only on a codec
change. Frames are returned as a CPU NHWC uint8 array so the
upstream multimodal parser sees the same shape as the other
backends.
"""
if not frame_indices:
raise ValueError("DeepStream backend received no frame indices")
result = cls._get_pool(pool_size).decode(
data,
target_indices=frame_indices,
codec=codec,
max_frames=len(frame_indices),
timeout_sec=timeout_sec,
)
if result.error:
raise ValueError(f"DeepStream decode failed: {result.error}")
if result.frames is None or result.n_kept == 0:
raise ValueError("DeepStream decode produced no frames")
valid = frame_indices[: result.n_kept]
# GPU -> CPU NHWC uint8 at the codec boundary (one PCIe copy); keeps
# the array shape identical to the OpenCV/PyAV backends. Copy into
# PINNED host memory (reused across calls by PyTorch's pinned caching
# allocator) so the D2H runs at full PCIe bandwidth (~13 GB/s) rather
# than the ~1 GB/s pageable path that plain ``.cpu()`` takes — ~12x
# faster for a 1080p x8 frame batch (~46ms -> ~4ms). ``numpy()`` keeps
# the pinned tensor alive via the array's base.
import torch
gpu = result.frames
if gpu.is_cuda:
host = torch.empty(gpu.shape, dtype=gpu.dtype, pin_memory=True)
host.copy_(gpu, non_blocking=True)
torch.cuda.current_stream().synchronize()
arr = host.numpy()
else:
arr = gpu.numpy()
return arr, valid
@VIDEO_LOADER_REGISTRY.register("opencv")
class VideoBackend(
VideoLoader,
OpenCVVideoBackendMixin,
PyAVVideoBackendMixin,
TorchCodecVideoBackendMixin,
PyNvVideoCodecVideoBackendMixin,
DeepStreamVideoBackendMixin,
):
"""Uniform-sampling video backend.
Samples ``num_frames`` uniformly across the video (or one frame every
``1/fps`` seconds, whichever produces fewer frames). The decoding codec
is selected via the ``backend`` kwarg (``"opencv"``, ``"pyav"``,
``"torchcodec"``, ``"pynvvideocodec"``, or ``"deepstream"``),
which can be passed through ``--media-io-kwargs``. Defaults to ``"opencv"``.
"""
_sampling_suffix: ClassVar[str] = ""
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
total_frames_num = source.total_frames_num
duration = source.duration
num_frames = target.num_frames
fps = target.fps
# resample video to target num_frames and fps
# - the minimum of the two will be used
num_frames_to_sample = total_frames_num
if num_frames > 0:
num_frames_to_sample = min(num_frames, total_frames_num)
if fps > 0:
num_frames_to_sample = min(num_frames_to_sample, math.floor(duration * fps))
num_frames_to_sample = max(1, num_frames_to_sample)
if num_frames_to_sample == total_frames_num:
return list(range(num_frames_to_sample))
return np.linspace(
0, total_frames_num - 1, num_frames_to_sample, dtype=int
).tolist()
@classmethod
def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
"""Sampling-algorithm-specific metadata adjustment hook."""
return source
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = -1,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
] = "opencv",
num_ffmpeg_threads: int = 0,
seek_mode: Literal["exact", "approximate"] = "exact",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
"""Load sampled frames from raw video bytes.
Args:
data: Raw video bytes.
num_frames: Target number of frames to sample (``-1`` for all).
fps: Target FPS for sampling (``-1`` for original).
max_duration: Maximum duration in seconds — only used by the
dynamic subclass; ignored here.
frame_recovery: Enable forward-scan recovery for failed frames.
Only honored by the OpenCV codec.
backend: Decoding codec — ``"opencv"``, ``"pyav"``,
``"torchcodec"``, ``"pynvvideocodec"`` or ``"deepstream"``.
num_ffmpeg_threads: Number of FFmpeg decoding threads, only used by
TorchCodec: ``0`` (default) relies on the FFmpeg default value
which is ``min(cpu_count + 1, 16)``.
OpenCV will always use ``min(cpu_count, 16)`` while pyav will
always use ``min(cpu_count, (height + 15) / 16)``.
seek_mode: Seek mode for the TorchCodec decoder, only used by
TorchCodec: ``"exact"`` (default) guarantees frame-accurate
sampling by scanning the file on creation, while
``"approximate"`` skips that scan for faster decoder creation
at the cost of relying on the file's metadata. See
https://meta-pytorch.org/torchcodec/stable/generated_examples/decoding/approximate_mode.html
for details.
Returns:
Tuple of ``(frames_array, metadata_dict)``.
"""
target = VideoTargetMetadata(
num_frames=num_frames, fps=fps, max_duration=max_duration
)
if backend == "opencv":
cap = cls.open_video_capture(data)
_check_frame_pixel_limit(
int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
)
source = cls._prepare_source(cls.get_video_metadata(cap))
frame_idx = cls.compute_frames_index_to_sample(
source=source, target=target, **kwargs
)
frames, valid = cls.read_frames(
cap,
frame_idx,
total_frames_num=source.total_frames_num,
frame_recovery=frame_recovery,
)
elif backend == "pyav":
assert not frame_recovery, (
"frame_recovery is only available for `opencv` backend"
)
with av.open(BytesIO(data)) as container:
stream = container.streams.video[0]
_check_frame_pixel_limit(stream.width, stream.height)
source = cls._prepare_source(cls.get_metadata(container))
frame_idx = cls.compute_frames_index_to_sample(
source=source, target=target, **kwargs
)
frames, valid = cls.decode_frames(
container, frame_idx, source.original_fps, source.duration
)
elif backend == "torchcodec":
assert not frame_recovery, (
"frame_recovery is only available for `opencv` backend"
)
check_torchcodec_available()
decoder = cls.make_torchcodec_decoder(
data,
num_ffmpeg_threads=num_ffmpeg_threads,
seek_mode=seek_mode,
)
_check_frame_pixel_limit(
decoder.metadata.width or 0,
decoder.metadata.height or 0,
)
source = cls._prepare_source(cls.get_torchcodec_metadata(decoder))
frame_idx = cls.compute_frames_index_to_sample(
source=source, target=target, **kwargs
)
frames, valid = cls.decode_torchcodec_frames(decoder, frame_idx)
elif backend == PYNVVIDEOCODEC_VIDEO_BACKEND:
if frame_recovery:
raise ValueError(
"frame_recovery is not supported for "
f"`{PYNVVIDEOCODEC_VIDEO_BACKEND}` backend"
)
frames, source, frame_idx, valid = cls.decode_frames_pynvvideocodec(
data,
target,
**kwargs,
)
elif backend == "deepstream":
assert not frame_recovery, (
"frame_recovery is only available for `opencv` backend"
)
# Decode-pool size comes from media-io-kwargs (no env var); the
# pool is a process-wide singleton so the first decode's value
# wins. Pop it so it isn't forwarded to the frame sampler.
pool_size = kwargs.pop("pool_size", None)
# Probe container metadata from the bytes via GStreamer (in
# the deepstream video-decode wheel) — no PyAV/pymediainfo, no path.
from nvidia.deepstream_videodecode import probe_metadata
total_frames, original_fps, duration, _w, _h, codec = probe_metadata(data)
source = cls._prepare_source(
VideoSourceMetadata(
total_frames_num=total_frames,
original_fps=original_fps,
duration=duration,
)
)
frame_idx = cls.compute_frames_index_to_sample(
source=source, target=target, **kwargs
)
frames, valid = cls.decode_indices(
data, frame_idx, source, codec=codec, pool_size=pool_size
)
else:
raise ValueError(
f"Unknown video codec backend {backend!r}; "
"valid options: 'opencv', 'pyav', 'torchcodec', "
"'pynvvideocodec' and 'deepstream'."
)
if len(valid) < len(frame_idx):
logger.warning(
"%s video loading: expected %d frames but got %d.",
backend,
len(frame_idx),
len(valid),
)
return frames, cls.create_hf_metadata(
source=source,
video_backend=f"{backend}{cls._sampling_suffix}",
valid_frame_indices=valid,
)
@VIDEO_LOADER_REGISTRY.register(PYNVVIDEOCODEC_VIDEO_BACKEND, requires_gpu=True)
class PyNvVideoCodecVideoBackend(VideoBackend):
"""Hardware-accelerated video backend using PyNvVideoCodec.
The backend first opens the stream only to read metadata and compute the
sampled frame indices. It then acquires the raw decoded RGB byte count from
the process-local multimodal GPU memory pool before decoding the selected
frames into VRAM. Decoded frames are copied into pinned host memory before
the lease is released, so downstream preprocessing continues to receive a
CPU ``np.ndarray`` in NHWC RGB format.
"""
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = -1,
max_duration: int = 300,
frame_recovery: bool = False,
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
kwargs.pop("backend", None)
return super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=PYNVVIDEOCODEC_VIDEO_BACKEND,
**kwargs,
)
@VIDEO_LOADER_REGISTRY.register(
"qwen3_vl",
video_processor="Qwen3VLVideoProcessor",
)
class Qwen3VLVideoBackend(VideoBackend):
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
total_frames_num = source.total_frames_num
original_fps = source.original_fps
fps = target.fps
max_frame_idx = source.total_frames_num - 1
min_frames = kwargs.get("min_frames", 4)
max_frames = kwargs.get("max_frames", 768)
# Refer to:
# https://github.com/huggingface/transformers/blob/v5.9.0/src/transformers/models/qwen3_vl/video_processing_qwen3_vl.py#L119-L125
num_frames = int(total_frames_num / original_fps * fps)
num_frames = min(max(num_frames, min_frames), max_frames, total_frames_num)
indices = np.linspace(0, max_frame_idx, num_frames).round().astype(int).tolist()
return indices
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = 2,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=backend,
**kwargs,
)
@VIDEO_LOADER_REGISTRY.register(
"qwen2_vl",
video_processor="Qwen2VLVideoProcessor",
)
class Qwen2VLVideoBackend(VideoBackend):
"""Qwen2-VL / Qwen2.5-VL fps-based video backend.
Ports transformers' ``Qwen2VLVideoProcessor.sample_frames`` (fps mode),
shared by Qwen2-VL and Qwen2.5-VL (the latter has no video processor of its
own): sample ``total / original_fps * fps`` frames, clamp to
``[min_frames, max_frames]`` (4 and 768), floor to a multiple of
``temporal_patch_size`` (2), and take indices with the exact
``torch.arange(0, total, total / n)`` call so they match HF byte-for-byte.
``num_frames`` is ignored (fps-driven, like the Qwen3-VL loader). The
float32 step can emit an out-of-range tail index (e.g. 451 for a 451-frame
clip); it is clamped to the last valid frame.
"""
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
# Refer to:
# https://github.com/huggingface/transformers/blob/v5.7.0/src/transformers/models/qwen2_vl/video_processing_qwen2_vl.py#L122-L190
total_frames_num = source.total_frames_num
original_fps = source.original_fps
temporal_patch_size = kwargs.get("temporal_patch_size", 2)
min_frames = kwargs.get("min_frames", 4)
max_frames = kwargs.get("max_frames", 768)
# vLLM reports original_fps == 0 for clips with unknown/variable fps
# (VFR, malformed, streaming); fail loudly instead of dividing by zero.
if original_fps <= 0:
raise ValueError(
"Qwen2-VL video sampling needs a known source fps, but the "
"container reported 0 (variable or unknown frame rate)."
)
max_frames = (
math.floor(min(max_frames, total_frames_num) / temporal_patch_size)
* temporal_patch_size
)
n = total_frames_num / original_fps * target.fps
n = min(max(n, min_frames), max_frames, total_frames_num)
n = math.floor(n / temporal_patch_size) * temporal_patch_size
# ``torch.arange`` matches transformers' float32 index math exactly
# (numpy's float64 diverges by a frame on some inputs); clamp the tail
# because that step can emit an index == total_frames_num.
indices = torch.arange(0, total_frames_num, total_frames_num / n).int()
return torch.clamp(indices, max=total_frames_num - 1).tolist()
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = 2,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=backend,
**kwargs,
)
@VIDEO_LOADER_REGISTRY.register(
"opencv_dynamic",
video_processor="Glm4vVideoProcessor",
)
class DynamicVideoBackend(VideoBackend):
"""Duration-aware dynamic-sampling video backend.
Samples at ``fps`` up to ``max_duration`` seconds, falling back to
uniform sampling across the full duration when the video is longer
than ``max_duration``. Codec is selectable the same way as
:class:`VideoBackend`.
"""
_sampling_suffix: ClassVar[str] = "_dynamic"
@classmethod
def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
# Estimate duration from frame count and fps when the container
# does not report it (common for WebM/streaming inputs).
if source.duration:
return source
if source.original_fps > 0:
max_frame_idx = source.total_frames_num - 1
duration = round(max_frame_idx / source.original_fps) + 1
else:
duration = 0
return VideoSourceMetadata(
source.total_frames_num, source.original_fps, duration
)
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
total_frames_num = source.total_frames_num
duration = source.duration
original_fps = source.original_fps
max_duration = target.max_duration
fps = target.fps
max_frame_idx = source.total_frames_num - 1
# Refer to:
# https://github.com/huggingface/transformers/blob/v4.55.4/src/transformers/models/glm4v/video_processing_glm4v.py#L103-L140
frame_indices_list: list[int]
if duration <= max_duration:
n = int(math.floor(duration * fps))
frame_indices_list = sorted(
{
min(max_frame_idx, int(math.ceil(i * original_fps / fps)))
for i in range(n)
}
)
else:
num_samples = int(max_duration * fps)
if num_samples >= total_frames_num:
frame_indices_list = list(range(total_frames_num))
else:
target_seconds = np.linspace(0, duration, num_samples, endpoint=True)
frame_indices_list = sorted(
{
min(max_frame_idx, int(math.ceil(t * original_fps)))
for t in target_seconds
}
)
return frame_indices_list
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = 2,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=backend,
**kwargs,
)
@VIDEO_LOADER_REGISTRY.register(
"glm46v",
video_processor="Glm46VVideoProcessor",
)
class GLM46VVideoBackend(VideoBackend):
"""GLM-4.6V dynamic FPS video backend.
Faithfully replicates the frame sampling logic from transformers'
``Glm46VVideoProcessor.sample_frames``:
- Dynamic FPS thresholds based on effective video duration:
``{≤30s: 3fps, ≤300s: 1fps, >300s: 0.5fps}``
- ``temporal_patch_size`` multiplier (default 2) applied to extract count
- Duration capped at 2400s, frame count capped at 640
- Even frame count enforced (append last frame if odd)
"""
# Match transformers defaults
_DYNAMIC_FPS_THRESHOLDS: ClassVar[dict[int, float]] = {
30: 3.0,
300: 1.0,
2400: 0.5,
}
_MAX_FRAME_COUNT_DYNAMIC: ClassVar[int] = 640
_MAX_DURATION: ClassVar[int] = 2400
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
# Refer to:
# https://github.com/huggingface/transformers/blob/v5.9.0/src/transformers/models/glm46v/video_processing_glm46v.py#L97-L102
total_frames_num = source.total_frames_num
original_fps = source.original_fps
duration = source.duration
temporal_patch_size = kwargs.get("temporal_patch_size", 2)
max_frame_idx = total_frames_num - 1
# Estimate duration from frame count and fps when not reported
if not duration and original_fps > 0:
duration = round(max_frame_idx / original_fps) + 1
effective_duration = min(duration, cls._MAX_DURATION)
# Select target_fps from dynamic thresholds
if effective_duration <= 30:
target_fps = cls._DYNAMIC_FPS_THRESHOLDS[30]
elif effective_duration <= 300:
target_fps = cls._DYNAMIC_FPS_THRESHOLDS[300]
else:
target_fps = cls._DYNAMIC_FPS_THRESHOLDS[2400]
extract_t = int(effective_duration * target_fps * temporal_patch_size)
extract_t = min(extract_t, cls._MAX_FRAME_COUNT_DYNAMIC)
duration_per_frame = 1 / original_fps if original_fps > 0 else 0
max_second = int(duration) if duration else 0
if total_frames_num < extract_t:
frame_indices = np.linspace(
0, total_frames_num - 1, extract_t, dtype=int
).tolist()
else:
frame_indices = []
current_second = 0.0
inv_fps = 1 / (temporal_patch_size * target_fps)
for frame_index in range(total_frames_num):
if frame_index * duration_per_frame >= current_second:
current_second += inv_fps
frame_indices.append(frame_index)
if current_second >= max_second:
break
if len(frame_indices) < extract_t:
if len(frame_indices) == 0:
start, end = 0, max(total_frames_num - 1, 0)
else:
start, end = frame_indices[0], frame_indices[-1]
frame_indices = np.linspace(start, end, extract_t, dtype=int).tolist()
elif len(frame_indices) > extract_t:
frame_indices = np.linspace(
0, total_frames_num - 1, extract_t, dtype=int
).tolist()
# Deduplicate
seen: set[int] = set()
uniq: list[int] = []
for idx in frame_indices:
if idx not in seen:
seen.add(idx)
uniq.append(idx)
# Ensure even frame count
if len(uniq) & 1:
uniq.append(uniq[-1])
return uniq
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = -1,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
return super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=backend,
**kwargs,
)
@VIDEO_LOADER_REGISTRY.register(
"glmga",
video_processor="GlmgaVideoProcessor",
)
class GLMGAVideoBackend(VideoBackend):
@classmethod
def _prepare_source(cls, source: VideoSourceMetadata) -> VideoSourceMetadata:
# Estimate duration from frame count and fps when the container
# does not report it (common for WebM/streaming inputs).
if source.duration:
return source
if source.original_fps > 0:
max_frame_idx = source.total_frames_num - 1
duration = round(max_frame_idx / source.original_fps) + 1
else:
duration = 0
return VideoSourceMetadata(
source.total_frames_num, source.original_fps, duration
)
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
total_frames_num = source.total_frames_num
duration = source.duration
original_fps = source.original_fps
target_fps = target.fps
max_frame_idx = source.total_frames_num - 1
max_frames = kwargs.get("max_frames", 640)
duration = duration or round(max_frame_idx / original_fps) + 1
extract_t = int(duration * target_fps)
extract_t = min(extract_t, max_frames)
duration_per_frame = 1 / original_fps
if total_frames_num < extract_t:
frame_indices = [
math.floor(i * total_frames_num / extract_t) for i in range(extract_t)
]
else:
frame_indices = []
current_second = 0.0
inv_fps = 1 / target_fps
for frame_index in range(total_frames_num):
if frame_index * duration_per_frame >= current_second:
current_second += inv_fps
frame_indices.append(frame_index)
if current_second >= duration - inv_fps:
break
if len(frame_indices) < extract_t:
if len(frame_indices) == 0:
start, end = 0, max(total_frames_num - 1, 0)
else:
start, end = frame_indices[0], frame_indices[-1]
frame_indices = np.linspace(start, end, extract_t, dtype=int).tolist()
elif len(frame_indices) > extract_t:
frame_indices = np.linspace(
0, total_frames_num - 1, extract_t, dtype=int
).tolist()
seen, uniq = set(), []
for idx in frame_indices:
if idx not in seen:
seen.add(idx)
uniq.append(idx)
return uniq
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = 2,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
frames, metadata = super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=backend,
**kwargs,
)
# Ensure even frame count — matches HF's sample_frames even-padding
# and _preprocess temporal_patch_size divisibility check.
if frames.shape[0] & 1:
frames = np.concatenate([frames, frames[-1:]], axis=0)
return frames, metadata
@VIDEO_LOADER_REGISTRY.register(
"molmo2",
video_processor="Molmo2VideoProcessor",
)
class Molmo2VideoBackend(VideoLoader, OpenCVVideoBackendMixin):
@classmethod
def get_candidate_target_fps(
cls,
video_fps: float,
sampling_fps: float,
max_fps: float = 8.0,
) -> list[float]:
"""
Return the subset of `video_fps` factors that remain multiples
of `sampling_fps`.
Examples:
>>> get_candidate_target_fps(video_fps=6, sampling_fps=2)
[2, 6]
>>> get_candidate_target_fps(video_fps=5, sampling_fps=1)
[1, 5]
>>> get_candidate_target_fps(video_fps=2, sampling_fps=2)
[2]
>>> get_candidate_target_fps(video_fps=5, sampling_fps=2)
Traceback (most recent call last):
...
ValueError: sampling_fps=2 must divide video_fps=5 to produce
consistent frame steps.
"""
video_fps = int(video_fps)
sampling_fps = int(sampling_fps)
max_fps = int(max_fps)
if sampling_fps is None:
raise ValueError("sampling_fps must be provided")
if video_fps <= 0 or sampling_fps <= 0:
raise ValueError(
"video_fps and sampling_fps must be positive "
f"(got {video_fps}, {sampling_fps})"
)
if video_fps % sampling_fps != 0:
raise ValueError(
f"sampling_fps={sampling_fps} must divide video_fps={video_fps}."
)
candidates = []
for candidate in range(sampling_fps, video_fps + 1, sampling_fps):
if candidate > max_fps:
break
if video_fps % candidate == 0:
candidates.append(float(candidate))
return candidates
@classmethod
def get_target_fps(
cls,
video_fps: float,
max_frames: int,
total_frames: int,
frame_sample_mode: str,
candidate_target_fps: list[float],
) -> float | None:
"""
Get the target fps that best spans the videoand has the most frames sampled
"""
num_frames_sampled = 0
selected_target_fps = None
for target_fps in candidate_target_fps:
step_size = max(int(video_fps / target_fps), 1)
num_frames_sampled_at_fps = int(total_frames / step_size)
if num_frames_sampled == 0:
if (
"uniform" in frame_sample_mode
and num_frames_sampled_at_fps > max_frames
):
break
selected_target_fps = target_fps
num_frames_sampled = num_frames_sampled_at_fps
else:
# the candidate sampling fps increases so frame count can't decrease
assert num_frames_sampled <= num_frames_sampled_at_fps
if num_frames_sampled_at_fps > max_frames:
# choose the sampling fps that spans the video
continue
elif num_frames_sampled_at_fps > num_frames_sampled:
# both are less than max_frames; choose the one with higher
# density of frames sampled
selected_target_fps = target_fps
num_frames_sampled = num_frames_sampled_at_fps
return selected_target_fps
@classmethod
def get_frame_times_and_chosen_fps(
cls,
selected_target_fps: float | None,
total_frames: int,
max_frames: int,
video_fps: float,
) -> tuple[float | None, npt.NDArray]:
if selected_target_fps is None:
frame_indices = np.linspace(
0, total_frames, max_frames, endpoint=False, dtype=int
)
else:
step_size = max(int(video_fps / selected_target_fps), 1)
frame_indices = np.arange(0, total_frames, step_size)
if len(frame_indices) > max_frames:
frame_indices = frame_indices[:max_frames]
return selected_target_fps, frame_indices
@classmethod
def sample_times(
cls,
duration: float,
max_frames: int,
frame_sample_mode: str,
max_fps: int | None,
candidate_target_fps: list[float] | None = None,
**kwargs,
) -> npt.NDArray:
if frame_sample_mode == "fps":
assert candidate_target_fps is not None
# Try larger and larger FPSs until we hit one that can't span the video
sampling_fps = candidate_target_fps[0]
for candidate_fps in candidate_target_fps[1:]:
if max_frames / candidate_fps < duration:
break
sampling_fps = candidate_fps
times = np.arange(0, max_frames) / sampling_fps
times = times[times < duration]
return times
elif frame_sample_mode == "uniform_last_frame":
if max_fps is not None:
max_duration = (
max_frames - 1
) / max_fps # -1 to include the last frame
if max_duration < duration:
times = np.linspace(
0, duration, num=max_frames, endpoint=True, dtype=np.float64
)
else:
times = np.arange(0.0, stop=duration, step=1 / max_fps)
times = np.concatenate([times, [duration]], axis=0)
assert len(times) <= max_frames
else:
times = np.linspace(
0, duration, num=max_frames, endpoint=True, dtype=np.float64
)
return times
else:
raise NotImplementedError(frame_sample_mode)
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
):
max_fps = kwargs.get("max_fps")
frame_sample_mode = kwargs.get("frame_sample_mode")
if frame_sample_mode is None:
return list(range(0, source.total_frames_num))
if frame_sample_mode not in {"uniform_last_frame", "fps"}:
raise NotImplementedError(
f"Unsupported frame_sample_mode: {frame_sample_mode}"
)
duration = source.duration
video_fps = source.original_fps
total_num_frames = source.total_frames_num
num_frames = target.num_frames
sampling_fps = target.fps
if frame_sample_mode == "uniform_last_frame" and max_fps is not None:
if total_num_frames <= 2:
indices = np.arange(total_num_frames).astype(int)
elif duration > (num_frames - 1) / max_fps: # -1 to include the last frame
# uniform fallback
indices = np.linspace(
0,
total_num_frames - 1,
num=min(num_frames, total_num_frames),
endpoint=True,
).astype(int)
else:
float_indices = np.arange(
0.0,
stop=total_num_frames - 1,
step=float(video_fps / max_fps),
)
if np.round(float_indices[-1]) != total_num_frames - 1:
float_indices = np.concatenate(
[float_indices, [total_num_frames - 1]], axis=0
)
indices = np.round(float_indices).astype(int)
assert indices[-1] < total_num_frames
assert len(float_indices) <= num_frames
elif frame_sample_mode == "uniform_last_frame":
indices = np.linspace(
0,
total_num_frames - 1,
num=min(num_frames, total_num_frames),
endpoint=True,
).astype(int)
elif frame_sample_mode == "fps":
candidate_target_fps = cls.get_candidate_target_fps(video_fps, sampling_fps)
selected_target_fps = cls.get_target_fps(
video_fps,
num_frames,
total_num_frames,
frame_sample_mode,
candidate_target_fps,
)
_, indices = cls.get_frame_times_and_chosen_fps(
selected_target_fps,
total_num_frames,
num_frames,
video_fps,
)
return indices.tolist()
@classmethod
def load_bytes_opencv(
cls,
data: bytes,
frame_sample_mode: str | None = None,
num_frames: int = -1,
max_fps: int = 2,
sampling_fps: int = 2,
frame_recovery: bool = False,
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
cap = cls.open_video_capture(data)
_check_frame_pixel_limit(
int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
)
source = OpenCVVideoBackendMixin.get_video_metadata(cap)
target = VideoTargetMetadata(
num_frames=num_frames,
fps=sampling_fps,
max_duration=source.duration,
)
frame_idx = cls.compute_frames_index_to_sample(
source=source,
target=target,
frame_sample_mode=frame_sample_mode,
max_fps=max_fps,
)
frames, valid_frame_indices = cls.read_frames(
cap,
frame_idx,
total_frames_num=source.total_frames_num,
frame_recovery=frame_recovery,
)
metadata = cls.create_hf_metadata(
source=source,
video_backend="opencv",
valid_frame_indices=valid_frame_indices,
)
return frames, metadata
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
frame_sample_mode = cast(str | None, kwargs.pop("frame_sample_mode", None))
max_fps = cast(int, kwargs.pop("max_fps", 2))
sampling_fps = cast(int, kwargs.pop("sampling_fps", 2))
out = cls.load_bytes_opencv(
data,
frame_sample_mode,
num_frames,
max_fps,
sampling_fps,
**kwargs,
)
return out
@VIDEO_LOADER_REGISTRY.register("nemotron_vl")
class NemotronVLVideoBackend(VideoBackend):
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = -1,
max_duration: int = 300,
frame_recovery: bool = False,
*,
backend: Literal[
"opencv", "pyav", "torchcodec", "pynvvideocodec", "deepstream"
] = "opencv",
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
frames, metadata = super().load_bytes(
data,
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
frame_recovery=frame_recovery,
backend=backend,
**kwargs,
)
metadata = dict(metadata)
metadata["original_video_bytes"] = data
return frames, metadata
@VIDEO_LOADER_REGISTRY.register("openpangu")
class OpenCVDynamicOpenPanguVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
@classmethod
def compute_frames_index_to_sample(
cls,
source: VideoSourceMetadata,
target: VideoTargetMetadata,
**kwargs,
) -> list[int]:
total_frames_num = source.total_frames_num
original_fps = source.original_fps
num_frames = target.num_frames
fps = target.fps
# The timestamp of the rightmost frame, cannot be used to calculate frame 0.
if total_frames_num >= 1 and original_fps > 0:
total_duration = (total_frames_num - 1) / original_fps
else:
total_duration = 0
# `fps` is the FPS parameter passed in for sampling,
# -1 indicates that sampling can be performed directly without FPS limitation.
if fps > 0:
# Num_frames is the maximum number of frames to sample.
# If fewer frames are sampled at this sample_fps, the update duration will be longer. # noqa: E501
if num_frames >= int(total_duration * fps) + 1:
num_frames = int(total_duration * fps) + 1
# Under the new maximum frame rate, the video duration of the rightmost frame, # noqa: E501
# cannot be calculated for frame 0.
total_duration = min(total_duration, (num_frames - 1) / fps)
elif fps != -1:
raise ValueError(
f"requires dataset fps is -1 or greater than 0 but got {fps}"
)
sample_frame_timestamps = np.linspace(
0, total_duration, num_frames, dtype=float
)
frames_indices = [
min(total_frames_num - 1, round(t * original_fps))
for t in sample_frame_timestamps
]
return frames_indices
@classmethod
def load_bytes(
cls,
data: bytes,
num_frames: int = -1,
fps: int = 2,
max_duration: int = 300,
frame_recovery: bool = False,
**kwargs,
) -> tuple[npt.NDArray, dict[str, Any]]:
"""
Load video frames with dynamic sampling based on duration.
Args:
data: Raw video bytes
num_frames: Not used in dynamic backend
fps: Target FPS for sampling (default: 2)
max_duration: Maximum video duration to process (default: 300s)
frame_recovery: Enable forward-scan recovery for failed frames
Returns:
Tuple of (frames_array, metadata_dict)
"""
cap = cls.open_video_capture(data)
_check_frame_pixel_limit(
int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)),
)
source = OpenCVVideoBackendMixin.get_video_metadata(cap)
# recompute source metadata with adjusted duration to ensure correct
# sampling indices computation
target = VideoTargetMetadata(
num_frames=num_frames,
fps=fps,
max_duration=max_duration,
)
frame_indices_list = cls.compute_frames_index_to_sample(
source=source,
target=target,
)
frames, valid_frame_indices = cls.read_frames(
cap,
frame_indices_list,
total_frames_num=source.total_frames_num,
frame_recovery=frame_recovery,
)
# Use transformers.video_utils.VideoMetadata format
metadata = cls.create_hf_metadata(
source=source,
video_backend="opencv_dynamic",
valid_frame_indices=valid_frame_indices,
)
return frames, metadata