A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 7, 02:45 UTC
[Refactor] Modular video loader backend refactoring (#35202)
Signed-off-by: Isotr0py <mozf@mail2.sysu.edu.cn>
Isotr0py committed
Mar 6, 2026 at 22:06 UTC
e4ae148a787df846beb194078c35655c44784bd5
2 files changed
+444
-267
tests/multimodal/test_video.py
+80
-1
@@ -7,7 +7,13 @@ import numpy as np
7
import numpy.typing as npt
8
import pytest
9
10
-from vllm.multimodal.video import VIDEO_LOADER_REGISTRY, VideoLoader
10
+from vllm.assets.base import get_vllm_public_assets
11
+from vllm.multimodal.video import (
12
+ VIDEO_LOADER_REGISTRY,
13
+ VideoLoader,
14
+)
15
+
16
+from .utils import create_video_from_image
17
18
pytestmark = pytest.mark.cpu_test
19
@@ -291,3 +297,76 @@ def test_video_recovery_dynamic_backend(monkeypatch: pytest.MonkeyPatch):
297
f"Got {frames_with_recovery.shape[0]} with recovery vs "
298
f"{frames_no_recovery.shape[0]} without"
299
)
300
+
301
+
302
+@pytest.fixture
303
+def dummy_video_path(tmp_path):
304
+ image_path = get_vllm_public_assets(
305
+ filename="stop_sign.jpg", s3_prefix="vision_model_images"
306
+ )
307
+
308
+ video_path = tmp_path / "test_RGB_video.mp4"
309
+ create_video_from_image(str(image_path), str(video_path), num_frames=1800, fps=30)
310
+ return video_path
311
+
312
+
313
+@pytest.mark.parametrize(
314
+ "backend, kwargs, expected_num_frames",
315
+ [
316
+ # opencv: num_frames directly controls count
317
+ pytest.param("opencv", {"num_frames": 32}, 32, id="opencv-num_frames"),
318
+ pytest.param("opencv", {"fps": 2}, 120, id="opencv-fps"),
319
+ pytest.param(
320
+ "opencv",
321
+ {"num_frames": 500, "fps": 2},
322
+ 120,
323
+ id="opencv-num_frames_wins_fps",
324
+ ),
325
+ pytest.param(
326
+ "opencv_dynamic",
327
+ {"fps": 1, "max_duration": 60},
328
+ 60,
329
+ id="opencv_dynamic-within_max_duration",
330
+ ),
331
+ pytest.param(
332
+ "opencv_dynamic",
333
+ {"fps": 2, "max_duration": 30},
334
+ 60,
335
+ id="opencv_dynamic-exceeds_max_duration",
336
+ ),
337
+ pytest.param(
338
+ "openpangu", {"num_frames": 32, "fps": -1}, 32, id="openpangu-num_frames"
339
+ ),
340
+ pytest.param(
341
+ "molmo2",
342
+ {"num_frames": 32, "frame_sample_mode": "uniform_last_frame"},
343
+ 32,
344
+ id="molmo2-uniform_last_frame",
345
+ ),
346
+ pytest.param(
347
+ "molmo2",
348
+ {"fps": 2, "frame_sample_mode": "fps"},
349
+ 119,
350
+ id="molmo2-fps",
351
+ ),
352
+ ],
353
+)
354
+def test_video_loader_frames_sampling(
355
+ dummy_video_path,
356
+ monkeypatch: pytest.MonkeyPatch,
357
+ backend: str,
358
+ kwargs: dict,
359
+ expected_num_frames: int,
360
+):
361
+ """Test video loader frames sampling functionality."""
362
+ monkeypatch.setenv("VLLM_VIDEO_LOADER_BACKEND", backend)
363
+ loader = VIDEO_LOADER_REGISTRY.load(backend)
364
+
365
+ with open(dummy_video_path, "rb") as f:
366
+ long_video_bytes = f.read()
367
+
368
+ frames, _ = loader.load_bytes(long_video_bytes, **kwargs)
369
+
370
+ assert frames.ndim == 4
371
+ assert frames.shape[3] == 3 # RGB
372
+ assert frames.shape[0] == expected_num_frames
vllm/multimodal/video.py
+364
-266
@@ -3,17 +3,23 @@
3
import math
4
from abc import abstractmethod
5
from io import BytesIO
6
-from typing import TYPE_CHECKING, Any, cast
6
+from typing import Any, NamedTuple, cast
7
8
import numpy as np
9
import numpy.typing as npt
10
11
-if TYPE_CHECKING:
12
- import cv2
13
-
11
from vllm.logger import init_logger
12
+from vllm.utils.import_utils import PlaceholderModule
13
from vllm.utils.registry import ExtensionManager
14
15
+try:
16
+ import cv2
17
+ import cv2.videoio_registry as vr
18
+except ImportError:
19
+ cv2 = PlaceholderModule("cv2")
20
+ vr = PlaceholderModule("cv2").placeholder_attr("videoio_registry")
21
+
22
+
23
logger = init_logger(__name__)
24
25
@@ -23,8 +29,6 @@ def resize_video(frames: npt.NDArray, size: tuple[int, int]) -> npt.NDArray:
29
resized_frames = np.empty(
30
(num_frames, new_height, new_width, channels), dtype=frames.dtype
31
)
26
- # lazy import cv2 to avoid bothering users who only use text models
27
- import cv2
32
33
for i, frame in enumerate(frames):
34
resized_frame = cv2.resize(frame, (new_width, new_height))
@@ -50,16 +54,100 @@ def sample_frames_from_video(frames: npt.NDArray, num_frames: int) -> npt.NDArra
54
return sampled_frames
55
56
57
+class VideoTargetMetadata(NamedTuple):
58
+ """Metadata represents target video."""
59
+
60
+ num_frames: int
61
+ fps: float
62
+ max_duration: float
63
+
64
+
65
+class VideoSourceMetadata(NamedTuple):
66
+ """Metadata represents source video."""
67
+
68
+ total_frames_num: int
69
+ original_fps: float
70
+ duration: float
71
+
72
+
73
class VideoLoader:
74
+ @classmethod
75
+ def compute_frames_index_to_sample(
76
+ cls,
77
+ source: VideoSourceMetadata,
78
+ target: VideoTargetMetadata,
79
+ **kwargs,
80
+ ) -> list[int]:
81
+ """Return the list of frame indices to sample from the video."""
82
+ raise NotImplementedError
83
+
84
@classmethod
85
@abstractmethod
86
def load_bytes(
57
- cls, data: bytes, num_frames: int = -1, **kwargs
87
+ cls,
88
+ data: bytes,
89
+ **kwargs,
90
) -> tuple[npt.NDArray, dict[str, Any]]:
91
+ """Load video frames from bytes and return (frames_array, metadata_dict)."""
92
raise NotImplementedError
93
94
+ @classmethod
95
+ def create_hf_metadata(
96
+ cls,
97
+ source: VideoSourceMetadata,
98
+ valid_frame_indices: list[int],
99
+ video_backend: str,
100
+ ):
101
+ return {
102
+ "total_num_frames": source.total_frames_num,
103
+ "fps": source.original_fps,
104
+ "duration": source.duration,
105
+ "video_backend": video_backend,
106
+ "frames_indices": valid_frame_indices,
107
+ "do_sample_frames": len(valid_frame_indices) == source.total_frames_num,
108
+ }
109
+
110
+
111
+VIDEO_LOADER_REGISTRY = ExtensionManager()
112
+
113
+
114
+class OpenCVVideoBackendMixin:
115
@staticmethod
116
+ def get_cv2_video_api():
117
+ api_pref = None
118
+ for backend in vr.getStreamBufferedBackends():
119
+ if not vr.hasBackend(backend):
120
+ continue
121
+ if not vr.isBackendBuiltIn(backend):
122
+ _, abi, api = vr.getStreamBufferedBackendPluginVersion(backend)
123
+ if abi < 1 or (abi == 1 and api < 2):
124
+ continue
125
+ api_pref = backend
126
+ break
127
+ return api_pref
128
+
129
+ @classmethod
130
+ def open_video_capture(cls, data: bytes) -> "cv2.VideoCapture":
131
+ backend = cls.get_cv2_video_api()
132
+ cap = cv2.VideoCapture(BytesIO(data), backend, [])
133
+ if not cap.isOpened():
134
+ raise ValueError("Could not open video stream")
135
+ return cap
136
+
137
+ @staticmethod
138
+ def get_video_metadata(cap: "cv2.VideoCapture") -> VideoSourceMetadata:
139
+ total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
140
+ original_fps = cap.get(cv2.CAP_PROP_FPS)
141
+ duration = total_frames_num / original_fps if original_fps > 0 else 0
142
+ return VideoSourceMetadata(
143
+ total_frames_num=total_frames_num,
144
+ original_fps=original_fps,
145
+ duration=duration,
146
+ )
147
+
148
+ @classmethod
149
def _can_use_for_recovery(
150
+ cls,
151
idx: int,
152
failed_frames: list[int],
153
next_target_map: dict[int, int],
@@ -72,8 +160,9 @@ class VideoLoader:
160
limit = next_target_map.get(oldest_failed, total_frames)
161
return idx < limit
162
75
- @staticmethod
163
+ @classmethod
164
def _read_frames_with_recovery(
165
+ cls,
166
cap: "cv2.VideoCapture",
167
frame_indices: list[int],
168
total_frames: int,
@@ -95,8 +184,6 @@ class VideoLoader:
184
- valid_frame_indices: List of frame indices that were loaded
185
- recovered_map: Dict mapping recovered_idx -> source_idx
186
"""
98
- import cv2
99
-
187
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
188
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
189
@@ -135,7 +222,7 @@ class VideoLoader:
222
continue
223
224
# Check if we should retrieve: target frame OR can recover a failed one
138
- can_recover = VideoLoader._can_use_for_recovery(
225
+ can_recover = cls._can_use_for_recovery(
226
idx, failed_frames_idx, next_target_map, total_frames
227
)
228
@@ -179,15 +266,14 @@ class VideoLoader:
266
267
return frames, valid_frame_indices, recovered_map
268
182
- @staticmethod
183
- def _read_frames(
269
+ @classmethod
270
+ def _read_frames_no_recovery(
271
+ cls,
272
cap,
273
frame_indices: set[int],
186
- num_expected_frames: int,
274
max_frame_idx: int,
188
- ) -> tuple[npt.NDArray, int, list[int]]:
189
- import cv2
190
-
275
+ ) -> tuple[npt.NDArray, list[int]]:
276
+ num_expected_frames = len(frame_indices)
277
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
278
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
279
frames = np.empty((num_expected_frames, height, width, 3), dtype=np.uint8)
@@ -229,63 +315,60 @@ class VideoLoader:
315
valid_num_frames,
316
)
317
232
- return frames[:valid_num_frames], valid_num_frames, valid_frame_indices
318
+ return frames[:valid_num_frames], valid_frame_indices
319
320
+ @classmethod
321
+ def read_frames(
322
+ cls,
323
+ cap: "cv2.VideoCapture",
324
+ frame_idx: list[int],
325
+ total_frames_num: int,
326
+ *,
327
+ frame_recovery: bool = False,
328
+ ) -> tuple[npt.NDArray, list[int]]:
329
+ if frame_recovery:
330
+ num_frames_to_sample = len(frame_idx)
331
+ frames, valid_frame_indices, recovered_map = cls._read_frames_with_recovery(
332
+ cap, frame_idx, total_frames_num
333
+ )
334
235
-VIDEO_LOADER_REGISTRY = ExtensionManager()
335
+ if recovered_map:
336
+ logger.info(
337
+ "Frame recovery: %d frames recovered using forward scan.",
338
+ len(recovered_map),
339
+ )
340
+ else:
341
+ frame_idx_set = set(frame_idx)
342
+ num_frames_to_sample = len(frame_idx_set)
343
+ frames, valid_frame_indices = cls._read_frames_no_recovery(
344
+ cap, frame_idx_set, max(frame_idx)
345
+ )
346
+ valid_num_frames = len(valid_frame_indices)
347
+ if valid_num_frames < num_frames_to_sample:
348
+ logger.warning(
349
+ "Video loading completed with %d broken/unreadable frames. "
350
+ "Expected to sample %d frames but only loaded %d frames.",
351
+ num_frames_to_sample - valid_num_frames,
352
+ num_frames_to_sample,
353
+ valid_num_frames,
354
+ )
355
+ return frames, valid_frame_indices
356
357
358
@VIDEO_LOADER_REGISTRY.register("opencv")
239
-class OpenCVVideoBackend(VideoLoader):
240
- def get_cv2_video_api(self):
241
- import cv2.videoio_registry as vr
242
-
243
- api_pref = None
244
- for backend in vr.getStreamBufferedBackends():
245
- if not vr.hasBackend(backend):
246
- continue
247
- if not vr.isBackendBuiltIn(backend):
248
- _, abi, api = vr.getStreamBufferedBackendPluginVersion(backend)
249
- if abi < 1 or (abi == 1 and api < 2):
250
- continue
251
- api_pref = backend
252
- break
253
- return api_pref
254
-
359
+class OpenCVVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
360
@classmethod
256
- def load_bytes(
361
+ def compute_frames_index_to_sample(
362
cls,
258
- data: bytes,
259
- num_frames: int = -1,
260
- fps: int = -1,
261
- max_duration: int = 300,
262
- frame_recovery: bool = False,
363
+ source: VideoSourceMetadata,
364
+ target: VideoTargetMetadata,
365
**kwargs,
264
- ) -> tuple[npt.NDArray, dict[str, Any]]:
265
- """
266
- Load video frames from bytes.
267
-
268
- Args:
269
- data: Raw video bytes
270
- num_frames: Target number of frames to sample (-1 for all)
271
- fps: Target FPS for sampling (-1 for original)
272
- max_duration: Maximum duration (unused in base backend)
273
- frame_recovery: Enable forward-scan recovery for failed frames
274
-
275
- Returns:
276
- Tuple of (frames_array, metadata_dict)
277
- """
278
- import cv2
279
-
280
- backend = cls().get_cv2_video_api()
281
- cap = cv2.VideoCapture(BytesIO(data), backend, [])
282
- if not cap.isOpened():
283
- raise ValueError("Could not open video stream")
284
-
285
- total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
286
- original_fps = cap.get(cv2.CAP_PROP_FPS)
287
- duration = total_frames_num / original_fps if original_fps > 0 else 0
366
+ ) -> list[int]:
367
+ total_frames_num = source.total_frames_num
368
+ duration = source.duration
369
370
+ num_frames = target.num_frames
371
+ fps = target.fps
372
# resample video to target num_frames and fps
373
# - the minimum of the two will be used
374
num_frames_to_sample = total_frames_num
@@ -302,81 +385,79 @@ class OpenCVVideoBackend(VideoLoader):
385
0, total_frames_num - 1, num_frames_to_sample, dtype=int
386
)
387
frame_idx = uniform_sampled_frames.tolist()
388
+ return frame_idx
389
306
- if frame_recovery:
307
- frames, valid_frame_indices, recovered_map = cls._read_frames_with_recovery(
308
- cap, frame_idx, total_frames_num
309
- )
310
- valid_num_frames = len(valid_frame_indices)
311
-
312
- if recovered_map:
313
- logger.info(
314
- "Frame recovery: %d frames recovered using forward scan.",
315
- len(recovered_map),
316
- )
317
- else:
318
- frame_idx_set = set(frame_idx)
319
- frames, valid_num_frames, valid_frame_indices = cls._read_frames(
320
- cap, frame_idx_set, num_frames_to_sample, max(frame_idx)
321
- )
322
-
323
- # Use transformers transformers.video_utils.VideoMetadata format
324
- # NOTE(Isotr0py): For models like Qwen3-VL/GLM4.5V, this metadata
325
- # can cause incorrect timestamp calculation without num_frames=-1.
326
- metadata = {
327
- "total_num_frames": total_frames_num,
328
- "fps": original_fps,
329
- "duration": duration,
330
- "video_backend": "opencv",
331
- "frames_indices": valid_frame_indices,
332
- # extra field used to control hf processor's video
333
- # sampling behavior
334
- "do_sample_frames": valid_num_frames == total_frames_num,
335
- }
336
-
337
- return frames, metadata
338
-
339
-
340
-@VIDEO_LOADER_REGISTRY.register("opencv_dynamic")
341
-class OpenCVDynamicVideoBackend(OpenCVVideoBackend):
390
@classmethod
391
def load_bytes(
392
cls,
393
data: bytes,
394
num_frames: int = -1,
347
- fps: int = 2,
395
+ fps: int = -1,
396
max_duration: int = 300,
397
frame_recovery: bool = False,
398
**kwargs,
399
) -> tuple[npt.NDArray, dict[str, Any]]:
400
"""
353
- Load video frames with dynamic sampling based on duration.
401
+ Load video frames from bytes.
402
403
Args:
404
data: Raw video bytes
357
- num_frames: Not used in dynamic backend
358
- fps: Target FPS for sampling (default: 2)
359
- max_duration: Maximum video duration to process (default: 300s)
405
+ num_frames: Target number of frames to sample (-1 for all)
406
+ fps: Target FPS for sampling (-1 for original)
407
+ max_duration: Maximum duration (unused in base backend)
408
frame_recovery: Enable forward-scan recovery for failed frames
409
410
Returns:
411
Tuple of (frames_array, metadata_dict)
412
"""
365
- import cv2
413
+ cap = cls.open_video_capture(data)
414
367
- backend = cls().get_cv2_video_api()
368
- cap = cv2.VideoCapture(BytesIO(data), backend, [])
369
- if not cap.isOpened():
370
- raise ValueError("Could not open video stream")
415
+ source = OpenCVVideoBackendMixin.get_video_metadata(cap)
416
+ target = VideoTargetMetadata(
417
+ num_frames=num_frames,
418
+ fps=fps,
419
+ max_duration=max_duration,
420
+ )
421
372
- total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
373
- original_fps = cap.get(cv2.CAP_PROP_FPS)
374
- duration = total_frames_num / original_fps if original_fps > 0 else 0
422
+ # resample video to target num_frames and fps
423
+ # - the minimum of the two will be used
424
+ frame_idx = cls.compute_frames_index_to_sample(
425
+ source=source,
426
+ target=target,
427
+ )
428
+
429
+ frames, valid_frame_indices = cls.read_frames(
430
+ cap,
431
+ frame_idx,
432
+ total_frames_num=source.total_frames_num,
433
+ frame_recovery=frame_recovery,
434
+ )
435
376
- # resample video to target num_frames
377
- max_frame_idx = total_frames_num - 1
378
- duration = duration or round(max_frame_idx / original_fps) + 1
436
+ metadata = cls.create_hf_metadata(
437
+ source=source,
438
+ video_backend="opencv",
439
+ valid_frame_indices=valid_frame_indices,
440
+ )
441
442
+ return frames, metadata
443
+
444
+
445
+@VIDEO_LOADER_REGISTRY.register("opencv_dynamic")
446
+class OpenCVDynamicVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
447
+ @classmethod
448
+ def compute_frames_index_to_sample(
449
+ cls,
450
+ source: VideoSourceMetadata,
451
+ target: VideoTargetMetadata,
452
+ **kwargs,
453
+ ) -> list[int]:
454
+ total_frames_num = source.total_frames_num
455
+ duration = source.duration
456
+ original_fps = source.original_fps
457
+ max_duration = target.max_duration
458
+ fps = target.fps
459
+
460
+ max_frame_idx = source.total_frames_num - 1
461
# Refer to:
462
# https://github.com/huggingface/transformers/blob/v4.55.4/src/transformers/models/glm4v/video_processing_glm4v.py#L103-L140
463
frame_indices_list: list[int]
@@ -400,54 +481,75 @@ class OpenCVDynamicVideoBackend(OpenCVVideoBackend):
481
for t in target_seconds
482
}
483
)
484
+ return frame_indices_list
485
404
- if frame_recovery:
405
- frames, valid_frame_indices, recovered_map = cls._read_frames_with_recovery(
406
- cap, frame_indices_list, total_frames_num
407
- )
408
- valid_num_frames = len(valid_frame_indices)
486
+ @classmethod
487
+ def load_bytes(
488
+ cls,
489
+ data: bytes,
490
+ num_frames: int = -1,
491
+ fps: int = 2,
492
+ max_duration: int = 300,
493
+ frame_recovery: bool = False,
494
+ **kwargs,
495
+ ) -> tuple[npt.NDArray, dict[str, Any]]:
496
+ """
497
+ Load video frames with dynamic sampling based on duration.
498
410
- if recovered_map:
411
- logger.info(
412
- "Frame recovery: %d frames recovered using forward scan.",
413
- len(recovered_map),
414
- )
415
- else:
416
- frame_indices_set = set(frame_indices_list)
417
- frames, valid_num_frames, valid_frame_indices = cls._read_frames(
418
- cap, frame_indices_set, len(frame_indices_list), total_frames_num - 1
419
- )
499
+ Args:
500
+ data: Raw video bytes
501
+ num_frames: Not used in dynamic backend
502
+ fps: Target FPS for sampling (default: 2)
503
+ max_duration: Maximum video duration to process (default: 300s)
504
+ frame_recovery: Enable forward-scan recovery for failed frames
505
421
- # Use transformers transformers.video_utils.VideoMetadata format
422
- metadata = {
423
- "total_num_frames": total_frames_num,
424
- "fps": original_fps,
425
- "duration": duration,
426
- "video_backend": "opencv_dynamic",
427
- "frames_indices": valid_frame_indices,
428
- "do_sample_frames": False,
429
- }
506
+ Returns:
507
+ Tuple of (frames_array, metadata_dict)
508
+ """
509
+ cap = cls.open_video_capture(data)
510
431
- return frames, metadata
511
+ orig_source = OpenCVVideoBackendMixin.get_video_metadata(cap)
512
+ max_frame_idx = orig_source.total_frames_num - 1
513
+ duration = (
514
+ orig_source.duration or round(max_frame_idx / orig_source.original_fps) + 1
515
+ )
516
517
+ # recompute source metadata with adjusted duration to ensure correct
518
+ # sampling indices computation
519
+ source = VideoSourceMetadata(
520
+ total_frames_num=orig_source.total_frames_num,
521
+ original_fps=orig_source.original_fps,
522
+ duration=duration,
523
+ )
524
+ target = VideoTargetMetadata(
525
+ num_frames=num_frames,
526
+ fps=fps,
527
+ max_duration=max_duration,
528
+ )
529
434
-@VIDEO_LOADER_REGISTRY.register("molmo2")
435
-class Molmo2VideoBackend(VideoLoader):
436
- def get_cv2_video_api(self):
437
- import cv2.videoio_registry as vr
530
+ frame_indices_list = cls.compute_frames_index_to_sample(
531
+ source=source,
532
+ target=target,
533
+ )
534
+
535
+ frames, valid_frame_indices = cls.read_frames(
536
+ cap,
537
+ frame_indices_list,
538
+ total_frames_num=source.total_frames_num,
539
+ frame_recovery=frame_recovery,
540
+ )
541
+
542
+ metadata = cls.create_hf_metadata(
543
+ source=source,
544
+ video_backend="opencv_dynamic",
545
+ valid_frame_indices=valid_frame_indices,
546
+ )
547
+
548
+ return frames, metadata
549
439
- api_pref = None
440
- for backend in vr.getStreamBufferedBackends():
441
- if not vr.hasBackend(backend):
442
- continue
443
- if not vr.isBackendBuiltIn(backend):
444
- _, abi, api = vr.getStreamBufferedBackendPluginVersion(backend)
445
- if abi < 1 or (abi == 1 and api < 2):
446
- continue
447
- api_pref = backend
448
- break
449
- return api_pref
550
551
+@VIDEO_LOADER_REGISTRY.register("molmo2")
552
+class Molmo2VideoBackend(VideoLoader, OpenCVVideoBackendMixin):
553
@classmethod
554
def get_candidate_target_fps(
555
cls,
@@ -599,16 +701,28 @@ class Molmo2VideoBackend(VideoLoader):
701
raise NotImplementedError(frame_sample_mode)
702
703
@classmethod
602
- def _sample_frames(
704
+ def compute_frames_index_to_sample(
705
cls,
604
- total_num_frames: int,
605
- video_fps: float,
606
- duration: float,
607
- frame_sample_mode: str,
608
- num_frames: int,
609
- max_fps: int,
610
- sampling_fps: int,
611
- ) -> npt.NDArray:
706
+ source: VideoSourceMetadata,
707
+ target: VideoTargetMetadata,
708
+ **kwargs,
709
+ ):
710
+ max_fps = kwargs.get("max_fps")
711
+ frame_sample_mode = kwargs.get("frame_sample_mode")
712
+ if frame_sample_mode is None:
713
+ return list(range(0, source.total_frames_num))
714
+
715
+ if frame_sample_mode not in {"uniform_last_frame", "fps"}:
716
+ raise NotImplementedError(
717
+ f"Unsupported frame_sample_mode: {frame_sample_mode}"
718
+ )
719
+
720
+ duration = source.duration
721
+ video_fps = source.original_fps
722
+ total_num_frames = source.total_frames_num
723
+ num_frames = target.num_frames
724
+ sampling_fps = target.fps
725
+
726
if frame_sample_mode == "uniform_last_frame" and max_fps is not None:
727
if total_num_frames <= 2:
728
indices = np.arange(total_num_frames).astype(int)
@@ -655,10 +769,7 @@ class Molmo2VideoBackend(VideoLoader):
769
num_frames,
770
video_fps,
771
)
658
- else:
659
- raise NotImplementedError(frame_sample_mode)
660
-
661
- return indices
772
+ return indices.tolist()
773
774
@classmethod
775
def load_bytes_opencv(
@@ -668,63 +779,37 @@ class Molmo2VideoBackend(VideoLoader):
779
num_frames: int = -1,
780
max_fps: int = 2,
781
sampling_fps: int = 2,
782
+ frame_recovery: bool = False,
783
**kwargs,
784
) -> tuple[npt.NDArray, dict[str, Any]]:
673
- import cv2
785
+ cap = cls.open_video_capture(data)
786
675
- backend = cls().get_cv2_video_api()
676
- cap = cv2.VideoCapture(BytesIO(data), backend, [])
677
- if not cap.isOpened():
678
- raise ValueError("Could not open video stream")
679
-
680
- total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
681
- original_fps = cap.get(cv2.CAP_PROP_FPS)
682
- duration = total_frames_num / original_fps if original_fps > 0 else 0
787
+ source = OpenCVVideoBackendMixin.get_video_metadata(cap)
788
+ target = VideoTargetMetadata(
789
+ num_frames=num_frames,
790
+ fps=sampling_fps,
791
+ max_duration=source.duration,
792
+ )
793
684
- if frame_sample_mode is None:
685
- # Use transformers transformers.video_utils.VideoMetadata format
686
- frame_idx = list(range(0, total_frames_num))
687
- frame_idx_set = set(frame_idx)
688
- frames, valid_num_frames, valid_frame_indices = cls._read_frames(
689
- cap, frame_idx_set, total_frames_num, max(frame_idx)
690
- )
691
- do_sample_frames = valid_num_frames == total_frames_num
692
- metadata = {
693
- "total_num_frames": total_frames_num,
694
- "fps": original_fps,
695
- "duration": duration,
696
- "video_backend": "opencv",
697
- "do_sample_frames": do_sample_frames,
698
- }
699
- if not do_sample_frames:
700
- metadata["frames_indices"] = valid_frame_indices
701
- return frames, metadata
702
-
703
- frame_idx = cls._sample_frames(
704
- total_frames_num,
705
- original_fps,
706
- duration,
707
- frame_sample_mode,
708
- num_frames,
709
- max_fps,
710
- sampling_fps,
711
- ).tolist()
794
+ frame_idx = cls.compute_frames_index_to_sample(
795
+ source=source,
796
+ target=target,
797
+ frame_sample_mode=frame_sample_mode,
798
+ max_fps=max_fps,
799
+ )
800
713
- frames, valid_num_frames, valid_frame_indices = cls._read_frames(
801
+ frames, valid_frame_indices = cls.read_frames(
802
cap,
715
- set(frame_idx),
716
- len(frame_idx),
717
- total_frames_num - 1,
803
+ frame_idx,
804
+ total_frames_num=source.total_frames_num,
805
+ frame_recovery=frame_recovery,
806
)
807
720
- metadata = {
721
- "total_num_frames": total_frames_num,
722
- "fps": original_fps,
723
- "duration": duration,
724
- "video_backend": "opencv",
725
- "frames_indices": valid_frame_indices,
726
- "do_sample_frames": False,
727
- }
808
+ metadata = cls.create_hf_metadata(
809
+ source=source,
810
+ video_backend="opencv",
811
+ valid_frame_indices=valid_frame_indices,
812
+ )
813
814
return frames, metadata
815
@@ -777,42 +862,19 @@ class NemotronVLVideoBackend(OpenCVVideoBackend):
862
863
864
@VIDEO_LOADER_REGISTRY.register("openpangu")
780
-class OpenCVDynamicOpenPanguVideoBackend(OpenCVVideoBackend):
865
+class OpenCVDynamicOpenPanguVideoBackend(VideoLoader, OpenCVVideoBackendMixin):
866
@classmethod
782
- def load_bytes(
867
+ def compute_frames_index_to_sample(
868
cls,
784
- data: bytes,
785
- num_frames: int = 32,
786
- fps: int = 1,
787
- max_duration: int = 300,
788
- frame_recovery: bool = False,
869
+ source: VideoSourceMetadata,
870
+ target: VideoTargetMetadata,
871
**kwargs,
790
- ) -> tuple[npt.NDArray, dict[str, Any]]:
791
- """
792
- Load video frames with dynamic sampling based on duration.
793
- Assume that total_num_frames = 10 and fps = 1.
794
- The timestamp of frame 0 is 0.0.
795
- The timestamp of frame 1 is 1.0.…
796
- The timestamp of frame 9 (the last frame) should be 9.0, that is,
797
- (total_frames_num – 1) / original_fps.
798
-
799
- Args:
800
- data: Raw video bytes
801
- num_frames: Not used in dynamic backend
802
- fps: Target FPS for sampling (default: 1)
803
-
804
- Returns:
805
- Tuple of (frames_array, metadata_dict)
806
- """
807
- import cv2
808
-
809
- backend = cls().get_cv2_video_api()
810
- cap = cv2.VideoCapture(BytesIO(data), backend, [])
811
- if not cap.isOpened():
812
- raise ValueError("Could not open video stream")
872
+ ) -> list[int]:
873
+ total_frames_num = source.total_frames_num
874
+ original_fps = source.original_fps
875
+ num_frames = target.num_frames
876
+ fps = target.fps
877
814
- total_frames_num = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
815
- original_fps = float(cap.get(cv2.CAP_PROP_FPS))
878
# The timestamp of the rightmost frame, cannot be used to calculate frame 0.
879
if total_frames_num >= 1 and original_fps > 0:
880
total_duration = (total_frames_num - 1) / original_fps
@@ -841,23 +903,59 @@ class OpenCVDynamicOpenPanguVideoBackend(OpenCVVideoBackend):
903
min(total_frames_num - 1, round(t * original_fps))
904
for t in sample_frame_timestamps
905
]
906
+ return frames_indices
907
+
908
+ @classmethod
909
+ def load_bytes(
910
+ cls,
911
+ data: bytes,
912
+ num_frames: int = -1,
913
+ fps: int = 2,
914
+ max_duration: int = 300,
915
+ frame_recovery: bool = False,
916
+ **kwargs,
917
+ ) -> tuple[npt.NDArray, dict[str, Any]]:
918
+ """
919
+ Load video frames with dynamic sampling based on duration.
920
+
921
+ Args:
922
+ data: Raw video bytes
923
+ num_frames: Not used in dynamic backend
924
+ fps: Target FPS for sampling (default: 2)
925
+ max_duration: Maximum video duration to process (default: 300s)
926
+ frame_recovery: Enable forward-scan recovery for failed frames
927
845
- frames, valid_frame_indices, recovered_map = cls._read_frames_with_recovery(
846
- cap, frames_indices, total_frames_num
928
+ Returns:
929
+ Tuple of (frames_array, metadata_dict)
930
+ """
931
+ cap = cls.open_video_capture(data)
932
+
933
+ source = OpenCVVideoBackendMixin.get_video_metadata(cap)
934
+
935
+ # recompute source metadata with adjusted duration to ensure correct
936
+ # sampling indices computation
937
+ target = VideoTargetMetadata(
938
+ num_frames=num_frames,
939
+ fps=fps,
940
+ max_duration=max_duration,
941
)
942
849
- if recovered_map:
850
- logger.info(
851
- "Frame recovery: %d frames recovered using forward scan.",
852
- len(recovered_map),
853
- )
943
+ frame_indices_list = cls.compute_frames_index_to_sample(
944
+ source=source,
945
+ target=target,
946
+ )
947
855
- metadata = {
856
- "total_num_frames": total_frames_num,
857
- "fps": original_fps,
858
- "duration": total_duration,
859
- "video_backend": "opencv_dynamic_openpangu",
860
- "frames_indices": valid_frame_indices,
861
- "do_sample_frames": False,
862
- }
948
+ frames, valid_frame_indices = cls.read_frames(
949
+ cap,
950
+ frame_indices_list,
951
+ total_frames_num=source.total_frames_num,
952
+ frame_recovery=frame_recovery,
953
+ )
954
+
955
+ # Use transformers transformers.video_utils.VideoMetadata format
956
+ metadata = cls.create_hf_metadata(
957
+ source=source,
958
+ video_backend="opencv_dynamic",
959
+ valid_frame_indices=valid_frame_indices,
960
+ )
961
return frames, metadata