[Bugfix] Align OpenCV video metadata timeline (#47099)

Signed-off-by: VectorPeak <73048950+VectorPeak@users.noreply.github.com>

VectorPeak committed Jul 1, 2026 at 04:43 UTC 68294739d1b5caaa1d537adcd79762a1ee9283fc
2 files changed +27 -4
tests/multimodal/media/test_video.py
+19 -1
@@ -10,7 +10,11 @@ import pytest
10 from PIL import Image
11
12 from vllm.assets.base import get_vllm_public_assets
13 -from vllm.assets.video import video_to_ndarrays, video_to_pil_images_list
13 +from vllm.assets.video import (
14 + video_get_metadata,
15 + video_to_ndarrays,
16 + video_to_pil_images_list,
17 +)
18 from vllm.multimodal.media import ImageMediaIO, VideoMediaIO
19 from vllm.multimodal.video import VIDEO_LOADER_REGISTRY, VideoLoader
20
@@ -112,6 +116,20 @@ def test_opencv_video_io_colorspace(tmp_path, is_color: bool, fourcc: str, ext:
116 assert np.nanmean(sim) > 0.99
117
118
119 +def test_opencv_video_metadata_matches_sampled_frame_timeline(tmp_path):
120 + image_path = f"{tmp_path}/test_metadata_image.png"
121 + Image.new("RGB", (8, 8), color=(255, 0, 0)).save(image_path)
122 + video_path = f"{tmp_path}/test_metadata_video.mp4"
123 + create_video_from_image(image_path, video_path, num_frames=10, fps=5.0)
124 +
125 + metadata = video_get_metadata(video_path, num_frames=4)
126 +
127 + assert metadata["fps"] == pytest.approx(5.0)
128 + assert metadata["duration"] == pytest.approx(2.0)
129 + assert metadata["frames_indices"] == [0, 3, 6, 9]
130 + assert metadata["total_num_frames"] == 4
131 +
132 +
133 NUM_FRAMES = 10
134 FAKE_OUTPUT_1 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
135 FAKE_OUTPUT_2 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
vllm/assets/video.py
+8 -3
@@ -15,6 +15,10 @@ from vllm.transformers_utils.repo_utils import hf_api
15 from .base import get_cache_dir
16
17
18 +def _sample_frame_indices(total_frames: int, num_frames: int) -> npt.NDArray:
19 + return np.linspace(0, total_frames - 1, num_frames, dtype=int)
20 +
21 +
22 @lru_cache
23 def download_video_asset(filename: str) -> str:
24 """
@@ -47,7 +51,7 @@ def video_to_ndarrays(path: str, num_frames: int = -1) -> npt.NDArray:
51 frames = []
52
53 num_frames = num_frames if num_frames > 0 else total_frames
50 - frame_indices = np.linspace(0, total_frames - 1, num_frames, dtype=int)
54 + frame_indices = _sample_frame_indices(total_frames, num_frames)
55 for idx in range(total_frames):
56 ok = cap.grab() # next img
57 if not ok:
@@ -86,13 +90,14 @@ def video_get_metadata(path: str, num_frames: int = -1) -> dict[str, Any]:
90
91 if num_frames == -1 or num_frames > total_frames:
92 num_frames = total_frames
93 + frame_indices = _sample_frame_indices(total_frames, num_frames)
94
95 metadata = {
96 "total_num_frames": num_frames,
92 - "fps": duration / num_frames,
97 + "fps": fps,
98 "duration": duration,
99 "video_backend": "opencv",
95 - "frames_indices": list(range(num_frames)),
100 + "frames_indices": frame_indices.tolist(),
101 # extra field used to control hf processor's video
102 # sampling behavior
103 "do_sample_frames": num_frames == total_frames,