diff --git a/fastdeploy/input/encodings/ernie_encoding.py b/fastdeploy/input/encodings/ernie_encoding.py index 5335339fed4..fab82c8db4f 100644 --- a/fastdeploy/input/encodings/ernie_encoding.py +++ b/fastdeploy/input/encodings/ernie_encoding.py @@ -176,9 +176,12 @@ def add_processed_video(self, frames_cache, outputs, uuid, token_len=None): def load_video(self, url, item): from fastdeploy.input.utils.render_timestamp import render_frame_timestamp - from fastdeploy.input.utils.video import read_frames_decord, read_video_decord + from fastdeploy.input.utils.video import ( + read_frames_paddlecodec, + read_video_paddlecodec, + ) - reader, meta, path = read_video_decord(url, save_to_disk=False) + reader, meta, path = read_video_paddlecodec(url, save_to_disk=False) video_frame_args = { "fps": item.get("fps", self.fps), @@ -189,7 +192,7 @@ def load_video(self, url, item): } video_frame_args = self.set_video_frame_args(video_frame_args, meta) - frames_data, _, timestamps = read_frames_decord( + frames_data, _, timestamps = read_frames_paddlecodec( path, reader, meta, diff --git a/fastdeploy/input/encodings/paddleocr_encoding.py b/fastdeploy/input/encodings/paddleocr_encoding.py index 0ae677e8551..dc28e78d214 100644 --- a/fastdeploy/input/encodings/paddleocr_encoding.py +++ b/fastdeploy/input/encodings/paddleocr_encoding.py @@ -22,7 +22,7 @@ from fastdeploy.input.encodings.registry import EncodingRegistry from fastdeploy.input.mm_model_config import PADDLEOCR_VL from fastdeploy.input.utils import IDS_TYPE_FLAG -from fastdeploy.input.utils.video import read_video_decord +from fastdeploy.input.utils.video import read_video_paddlecodec from fastdeploy.input.utils.video import sample_frames_paddleocr as _sample_paddleocr from fastdeploy.multimodal.hasher import MultimodalHasher @@ -154,7 +154,7 @@ def add_processed_video(self, frames_cache, outputs, uuid, token_len=None): outputs["vit_position_ids"].append(np.arange(numel) % numel) def load_video(self, url, item): - reader, meta, _ = read_video_decord(url, save_to_disk=False) + reader, meta, _ = read_video_paddlecodec(url, save_to_disk=False) fps = item.get("fps", self.fps) num_frames = item.get("target_frames", self.target_frames) diff --git a/fastdeploy/input/encodings/qwen_encoding.py b/fastdeploy/input/encodings/qwen_encoding.py index 4bbdffdea41..8058bcf1ab9 100644 --- a/fastdeploy/input/encodings/qwen_encoding.py +++ b/fastdeploy/input/encodings/qwen_encoding.py @@ -24,7 +24,7 @@ from fastdeploy.input.encodings.registry import EncodingRegistry from fastdeploy.input.mm_model_config import QWEN3_VL, QWEN_VL from fastdeploy.input.utils import IDS_TYPE_FLAG -from fastdeploy.input.utils.video import read_video_decord +from fastdeploy.input.utils.video import read_video_paddlecodec from fastdeploy.input.utils.video import sample_frames_qwen as _sample_qwen from fastdeploy.multimodal.hasher import MultimodalHasher @@ -152,7 +152,7 @@ def add_processed_video(self, frames_cache, outputs, uuid, token_len=None): outputs["fps"].append(fps) def load_video(self, url, item): - reader, meta, _ = read_video_decord(url, save_to_disk=False) + reader, meta, _ = read_video_paddlecodec(url, save_to_disk=False) fps = item.get("fps", self.fps) num_frames = item.get("target_frames", self.target_frames) diff --git a/fastdeploy/input/utils/__init__.py b/fastdeploy/input/utils/__init__.py index 01f3d6b368e..1f73b55c982 100644 --- a/fastdeploy/input/utils/__init__.py +++ b/fastdeploy/input/utils/__init__.py @@ -22,7 +22,7 @@ ) from fastdeploy.input.utils.video import ( VideoReaderWrapper, - read_video_decord, + read_video_paddlecodec, sample_frames, sample_frames_paddleocr, sample_frames_qwen, @@ -34,7 +34,7 @@ "process_stop_token_ids", "validate_model_path", "VideoReaderWrapper", - "read_video_decord", + "read_video_paddlecodec", "sample_frames", "sample_frames_paddleocr", "sample_frames_qwen", diff --git a/fastdeploy/input/utils/video.py b/fastdeploy/input/utils/video.py index 2acae05aee1..9eeb9689eb8 100644 --- a/fastdeploy/input/utils/video.py +++ b/fastdeploy/input/utils/video.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Shared video utilities: VideoReaderWrapper, read_video_decord, sample_frames, read_frames_decord.""" +"""Shared video utilities: VideoReaderWrapper, read_video_paddlecodec, sample_frames, read_frames_paddlecodec.""" import datetime import hashlib @@ -26,19 +26,22 @@ from typing import Optional, Union import numpy as np +import paddle from PIL import Image from fastdeploy.input.image_processors.common import ceil_by_factor, floor_by_factor -from fastdeploy.utils import data_processor_logger +from fastdeploy.utils import data_processor_logger, get_logger + +logger = get_logger("video_utils") __all__ = [ "VideoReaderWrapper", - "read_video_decord", + "read_video_paddlecodec", "sample_frames", "sample_frames_qwen", "sample_frames_paddleocr", "get_frame_indices", - "read_frames_decord", + "read_frames_paddlecodec", "EXTRACTED_FRAME_DIR", "get_filename", ] @@ -54,15 +57,20 @@ def _is_gif(data: bytes) -> bool: return data[:6] in (b"GIF87a", b"GIF89a") -class VideoReaderWrapper: - """decord.VideoReader wrapper that fixes a memory leak and adds GIF support. +class _NumpyFrame: + """Wrapper so that frame[idx].asnumpy() keeps working with paddlecodec.""" - Reference: https://github.com/dmlc/decord/issues/208 - """ + def __init__(self, array): + self._array = array + + def asnumpy(self): + return self._array - def __init__(self, video_path, *args, **kwargs): - import decord +class VideoReaderWrapper: + """paddlecodec VideoDecoder wrapper with GIF support.""" + + def __init__(self, video_path, *args, **kwargs): try: # moviepy 1.0 import moviepy.editor as mp @@ -101,22 +109,53 @@ def __init__(self, video_path, *args, **kwargs): video_path = mp4_path self.original_file = video_path # temp mp4, cleaned up in __del__ - self._reader = decord.VideoReader(video_path, *args, **kwargs) - self._reader.seek(0) + with paddle.use_compat_guard(enable=True, scope={"torchcodec"}): + try: + import sys + + from torchcodec.decoders import VideoDecoder + + sys.modules["torchcodec"] = None + except (ImportError, RuntimeError) as e: + logger.error( + f"Failed to load 'torchcodec' backend via Paddle proxy.\n" + f" - Common Causes:\n" + f" 1. Conflict with official 'torch' or 'torchcodec' packages.\n" + f" 2. Missing FFmpeg libraries or System library mismatch (CXXABI).\n" + f" - Recommended Fix Steps:\n" + f" 1. Install dependencies: `conda install ffmpeg -c conda-forge` or `apt-get update && apt-get install ffmpeg` \n" + f" 2. Uninstall conflicts: `pip uninstall torchcodec paddlecodec -y`\n" + f" 3. Reinstall packages: `pip install paddlecodec --force-reinstall`\n" + f" - If you encounter 'CXXABI' or 'libstdc++' errors, your system libraries might be outdated.\n" + f" Try prioritizing Conda libraries by running: `LD_LIBRARY_PATH=$CONDA_PREFIX/lib:$LD_LIBRARY_PATH python your_script.py`\n" + f" - Original Error: {e}" + ) + raise + PADDLECODEC_NUM_THREADS = int(os.environ.get("PADDLECODEC_NUM_THREADS", 0)) + self._decoder = VideoDecoder( + video_path, + seek_mode="exact", + num_ffmpeg_threads=PADDLECODEC_NUM_THREADS, + device=kwargs.get("device", "cpu"), + dimension_order="NHWC", + ) def __len__(self): - return len(self._reader) + return self._decoder.metadata.num_frames def __getitem__(self, key): - frames = self._reader[key] - self._reader.seek(0) - return frames + if isinstance(key, (int, np.integer)): + frame = self._decoder.get_frames_at(indices=[int(key)]).data[0] + return _NumpyFrame(frame.numpy()) + if isinstance(key, slice): + indices = list(range(*key.indices(len(self)))) + else: + indices = list(key) if not isinstance(key, list) else key + frames = self._decoder.get_frames_at(indices=indices).data + return _NumpyFrame(frames.numpy()) def get_avg_fps(self): - return self._reader.get_avg_fps() - - def seek(self, pos): - return self._reader.seek(pos) + return self._decoder.metadata.average_fps def __del__(self): original_file = getattr(self, "original_file", None) @@ -128,11 +167,11 @@ def __del__(self): # --------------------------------------------------------------------------- -# read_video_decord +# read_video_paddlecodec # --------------------------------------------------------------------------- -def read_video_decord(video_path, save_to_disk: bool = False): +def read_video_paddlecodec(video_path, save_to_disk: bool = False): """Load a video file and return (video_reader, video_meta, video_path). video_meta contains keys: "fps", "duration", "num_of_frame". @@ -306,7 +345,7 @@ def get_filename(url=None): # --------------------------------------------------------------------------- -# get_frame_indices / read_frames_decord +# get_frame_indices / read_frames_paddlecodec # (migrated from ernie4_5_vl_processor/process_video.py) # --------------------------------------------------------------------------- @@ -376,7 +415,7 @@ def get_frame_indices( return frame_indices -def read_frames_decord( +def read_frames_paddlecodec( video_path, video_reader, video_meta, @@ -389,7 +428,7 @@ def read_frames_decord( frame_indices=None, tol=10, ): - """Read frames from a video using decord, with retry logic for corrupt frames.""" + """Read frames from a video using paddlecodec, with retry logic for corrupt frames.""" if cache_dir is None: cache_dir = EXTRACTED_FRAME_DIR diff --git a/requirements.txt b/requirements.txt index 14aea691f2c..b2941d4aa00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,7 +23,7 @@ xlwt visualdl setuptools-scm>=8 prometheus-client -decord +paddlecodec moviepy triton crcmod diff --git a/requirements_dcu.txt b/requirements_dcu.txt index d8dc43d10a3..4242384dffe 100644 --- a/requirements_dcu.txt +++ b/requirements_dcu.txt @@ -22,7 +22,7 @@ xlwt visualdl setuptools-scm>=8 prometheus-client -decord +paddlecodec moviepy use-triton-in-paddle crcmod diff --git a/requirements_iluvatar.txt b/requirements_iluvatar.txt index 4c687150737..aea8372e95f 100644 --- a/requirements_iluvatar.txt +++ b/requirements_iluvatar.txt @@ -22,7 +22,7 @@ xlwt visualdl setuptools-scm>=8 prometheus-client -decord +paddlecodec moviepy triton crcmod diff --git a/requirements_metaxgpu.txt b/requirements_metaxgpu.txt index 5f4e0228419..2acf8e9069d 100644 --- a/requirements_metaxgpu.txt +++ b/requirements_metaxgpu.txt @@ -23,7 +23,7 @@ xlwt visualdl setuptools-scm>=8 prometheus-client -decord +paddlecodec moviepy triton use-triton-in-paddle diff --git a/tests/input/test_encodings.py b/tests/input/test_encodings.py index f284ec801a4..afae084c361 100644 --- a/tests/input/test_encodings.py +++ b/tests/input/test_encodings.py @@ -802,11 +802,11 @@ def test_load_video(self): with ( patch( - "fastdeploy.input.utils.video.read_video_decord", + "fastdeploy.input.utils.video.read_video_paddlecodec", return_value=(mock_reader, mock_meta, mock_path), ) as mock_read_video, patch( - "fastdeploy.input.utils.video.read_frames_decord", + "fastdeploy.input.utils.video.read_frames_paddlecodec", return_value=([mock_frame1, mock_frame2], None, [0.0, 0.5]), ) as mock_read_frames, patch( @@ -840,11 +840,11 @@ def test_load_video_odd_frames_padded(self): with ( patch( - "fastdeploy.input.utils.video.read_video_decord", + "fastdeploy.input.utils.video.read_video_paddlecodec", return_value=(mock_reader, mock_meta, mock_path), ), patch( - "fastdeploy.input.utils.video.read_frames_decord", + "fastdeploy.input.utils.video.read_frames_paddlecodec", return_value=([mock_frame1, mock_frame2, mock_frame3], None, [0.0, 0.5, 1.0]), ), patch( @@ -870,11 +870,11 @@ def test_load_video_with_item_overrides(self): with ( patch( - "fastdeploy.input.utils.video.read_video_decord", + "fastdeploy.input.utils.video.read_video_paddlecodec", return_value=(mock_reader, mock_meta, mock_path), ), patch( - "fastdeploy.input.utils.video.read_frames_decord", + "fastdeploy.input.utils.video.read_frames_paddlecodec", return_value=([MagicMock(), MagicMock()], None, [0.0, 0.5]), ) as mock_read_frames, patch( @@ -886,7 +886,7 @@ def test_load_video_with_item_overrides(self): frames, meta = enc.load_video("http://example.com/video.mp4", item) self.assertEqual(len(frames), 2) - # Verify read_frames_decord got the overridden target_frames + # Verify read_frames_paddlecodec got the overridden target_frames call_kwargs = mock_read_frames.call_args self.assertEqual( call_kwargs[1].get("target_frames", call_kwargs[0][3] if len(call_kwargs[0]) > 3 else None), 20 @@ -1023,6 +1023,86 @@ def test_prompt_token_ids2outputs_video_count_mismatch(self): # ------------------------------------------------------------------ # prompt_token_ids2outputs — with raw image (non-tuple) # ------------------------------------------------------------------ + def test_load_video_qwen_with_sampling_updates_meta(self): + enc, _ = _make_encoding(QWEN_VL, {"video_fps": -1, "target_frames": -1}) + reader = MagicMock() + reader.__getitem__.side_effect = [ + MagicMock(asnumpy=MagicMock(return_value=np.full((2, 2, 3), idx, dtype=np.uint8))) for idx in range(2) + ] + meta = {"num_of_frame": 5, "fps": 10, "duration": 0.5} + + from unittest.mock import patch + + with ( + patch( + "fastdeploy.input.encodings.qwen_encoding.read_video_paddlecodec", return_value=(reader, meta, None) + ), + patch( + "fastdeploy.input.encodings.qwen_encoding._sample_qwen", return_value=np.array([1, 3], dtype=np.int32) + ) as mock_sample, + ): + frames, out_meta = enc.load_video( + "video.mp4", {"target_frames": 2, "fps": 5, "min_frames": 2, "max_frames": 4} + ) + + mock_sample.assert_called_once() + self.assertEqual(frames.shape, (2, 2, 2, 3)) + self.assertEqual(out_meta["num_of_frame"], 2) + self.assertEqual(out_meta["fps"], 5) + self.assertEqual(out_meta["duration"], 0.4) + self.assertEqual(reader.__getitem__.call_args_list[0][0][0], 1) + self.assertEqual(reader.__getitem__.call_args_list[1][0][0], 3) + + def test_load_video_qwen_without_sampling_reads_all_frames(self): + enc, _ = _make_encoding(QWEN_VL, {"video_fps": -1, "target_frames": -1}) + reader = MagicMock() + reader.__getitem__.side_effect = [ + MagicMock(asnumpy=MagicMock(return_value=np.full((2, 2, 3), idx, dtype=np.uint8))) for idx in range(3) + ] + meta = {"num_of_frame": 3, "fps": 6, "duration": 0.5} + + from unittest.mock import patch + + with patch( + "fastdeploy.input.encodings.qwen_encoding.read_video_paddlecodec", return_value=(reader, meta, None) + ): + frames, out_meta = enc.load_video("video.mp4", {}) + + self.assertEqual(frames.shape, (3, 2, 2, 3)) + self.assertEqual(out_meta["num_of_frame"], 3) + self.assertEqual([call[0][0] for call in reader.__getitem__.call_args_list], [0, 1, 2]) + + def test_load_video_paddleocr_with_sampling_updates_meta(self): + enc, _ = _make_encoding(PADDLEOCR_VL, {"video_fps": -1, "target_frames": -1}) + reader = MagicMock() + reader.__getitem__.side_effect = [ + MagicMock(asnumpy=MagicMock(return_value=np.full((2, 2, 3), idx, dtype=np.uint8))) for idx in range(2) + ] + meta = {"num_of_frame": 5, "fps": 10, "duration": 0.5} + + from unittest.mock import patch + + with ( + patch( + "fastdeploy.input.encodings.paddleocr_encoding.read_video_paddlecodec", + return_value=(reader, meta, None), + ), + patch( + "fastdeploy.input.encodings.paddleocr_encoding._sample_paddleocr", + return_value=np.array([0, 4], dtype=np.int32), + ) as mock_sample, + ): + frames, out_meta = enc.load_video( + "video.mp4", {"target_frames": 2, "fps": 5, "min_frames": 2, "max_frames": 4} + ) + + mock_sample.assert_called_once() + self.assertEqual(frames.shape, (2, 2, 2, 3)) + self.assertEqual(out_meta["num_of_frame"], 2) + self.assertEqual(out_meta["fps"], 5) + self.assertEqual(out_meta["duration"], 0.4) + self.assertEqual([call[0][0] for call in reader.__getitem__.call_args_list], [0, 4]) + def test_prompt_token_ids2outputs_with_raw_image(self): """prompt_token_ids with raw image (non-tuple) triggers add_image.""" enc, mock_proc = self._make_enc() diff --git a/tests/input/test_process_video.py b/tests/input/test_process_video.py index a853904aa06..74226229568 100644 --- a/tests/input/test_process_video.py +++ b/tests/input/test_process_video.py @@ -27,8 +27,8 @@ import fastdeploy.input.utils.video as process_video_module from fastdeploy.input.utils.video import ( get_frame_indices, - read_frames_decord, - read_video_decord, + read_frames_paddlecodec, + read_video_paddlecodec, ) @@ -88,12 +88,12 @@ def __getitem__(self, idx): class TestReadVideoDecord(unittest.TestCase): - def test_read_video_decord_with_wrapper(self): + def test_read_video_paddlecodec_with_wrapper(self): """Test passing an existing VideoReaderWrapper instance directly.""" # Patch VideoReaderWrapper in the target module so isinstance checks use our mock class with patch.object(process_video_module, "VideoReaderWrapper", MockVideoReaderWrapper): mock_reader = MockVideoReaderWrapper("dummy", vlen=10, fps=5) - reader, meta, path = read_video_decord(mock_reader, save_to_disk=False) + reader, meta, path = read_video_paddlecodec(mock_reader, save_to_disk=False) self.assertIs(reader, mock_reader) self.assertEqual(meta["fps"], 5) @@ -102,11 +102,11 @@ def test_read_video_decord_with_wrapper(self): # The original reader object should be returned unchanged self.assertIs(path, mock_reader) - def test_read_video_decord_with_bytes(self): + def test_read_video_paddlecodec_with_bytes(self): """Test that bytes input is wrapped into BytesIO and passed to VideoReaderWrapper.""" with patch.object(process_video_module, "VideoReaderWrapper", MockVideoReaderWrapper): data = b"\x00\x01\x02\x03" - reader, meta, path = read_video_decord(data, save_to_disk=False) + reader, meta, path = read_video_paddlecodec(data, save_to_disk=False) self.assertIsInstance(reader, MockVideoReaderWrapper) self.assertEqual(meta["fps"], 6) @@ -261,7 +261,7 @@ def test_basic_read_no_save(self): reader = MockVideoReaderWrapper("dummy", vlen=8, fps=4) meta = {"fps": 4, "duration": 8 / 4, "num_of_frame": 8} - ret, idxs, ts = read_frames_decord( + ret, idxs, ts = read_frames_paddlecodec( video_path="dummy", video_reader=reader, video_meta=meta, @@ -294,7 +294,7 @@ def test_read_and_save_to_disk(self): return_value="det_id", ), ): - ret, idxs, ts = read_frames_decord( + ret, idxs, ts = read_frames_paddlecodec( video_path="dummy", video_reader=reader, video_meta=meta, @@ -316,7 +316,7 @@ def test_fallback_previous_success(self): meta = {"fps": 5, "duration": 10 / 5, "num_of_frame": 10} idxs = [1, 2, 3, 6] - ret, new_idxs, ts = read_frames_decord( + ret, new_idxs, ts = read_frames_paddlecodec( video_path="dummy", video_reader=reader, video_meta=meta, @@ -335,7 +335,7 @@ def test_fallback_next_when_prev_fails(self): meta = {"fps": 5, "duration": 10 / 5, "num_of_frame": 10} idxs = [1, 2, 3, 6] - ret, new_idxs, ts = read_frames_decord( + ret, new_idxs, ts = read_frames_paddlecodec( video_path="dummy", video_reader=reader, video_meta=meta, @@ -371,7 +371,7 @@ def __getitem__(self, idx): # Request 2 frames: index 0 succeeds, index 1 always fails, # and tol=0 disallows searching neighbors -> stack and length assertion should fail with self.assertRaises(AssertionError): - read_frames_decord( + read_frames_paddlecodec( video_path="dummy", video_reader=reader, video_meta=meta, diff --git a/tests/input/test_video_utils.py b/tests/input/test_video_utils.py index 76629127e7d..23283fa920d 100644 --- a/tests/input/test_video_utils.py +++ b/tests/input/test_video_utils.py @@ -20,7 +20,7 @@ from fastdeploy.input.utils.video import ( _is_gif, - read_video_decord, + read_video_paddlecodec, sample_frames, sample_frames_paddleocr, sample_frames_qwen, @@ -36,14 +36,20 @@ def _make_mock_reader(num_frames=100, fps=25.0): - """Return a mock that mimics decord.VideoReader.""" + """Return a mock that mimics paddlecodec VideoDecoder.""" + metadata = MagicMock() + metadata.num_frames = num_frames + metadata.average_fps = fps + + frame_data = MagicMock() + frame_data.numpy.return_value = np.zeros((480, 640, 3), dtype=np.uint8) + + frames_result = MagicMock() + frames_result.data = [frame_data] + reader = MagicMock() - reader.__len__ = MagicMock(return_value=num_frames) - reader.get_avg_fps = MagicMock(return_value=fps) - reader.seek = MagicMock(return_value=None) - frame = MagicMock() - frame.asnumpy = MagicMock(return_value=np.zeros((480, 640, 3), dtype=np.uint8)) - reader.__getitem__ = MagicMock(return_value=frame) + reader.metadata = metadata + reader.get_frames_at = MagicMock(return_value=frames_result) return reader @@ -67,56 +73,109 @@ def test_short_bytes(self): # --------------------------------------------------------------------------- -# VideoReaderWrapper (mock decord + moviepy) +# VideoReaderWrapper (mock paddlecodec + moviepy) # --------------------------------------------------------------------------- class TestVideoReaderWrapper(unittest.TestCase): - def _make_wrapper(self, video_path, mock_reader=None): - """Construct a VideoReaderWrapper with decord mocked out.""" - from fastdeploy.input.utils.video import VideoReaderWrapper + @staticmethod + def _sys_modules_patch(mock_decoder): + mock_VideoDecoder = MagicMock(return_value=mock_decoder) + mock_torchcodec_decoders = MagicMock() + mock_torchcodec_decoders.VideoDecoder = mock_VideoDecoder + mock_torchcodec = MagicMock() + return patch.dict( + "sys.modules", + { + "torchcodec": mock_torchcodec, + "torchcodec.decoders": mock_torchcodec_decoders, + "moviepy": MagicMock(), + "moviepy.editor": MagicMock(), + }, + ) + + @staticmethod + def _paddle_patch(): + """Patch `paddle` inside video module so use_compat_guard becomes a no-op.""" + from contextlib import contextmanager + + from fastdeploy.input.utils import video + + @contextmanager + def _noop_guard(*args, **kwargs): + yield - if mock_reader is None: - mock_reader = _make_mock_reader() + fake_paddle = MagicMock() + fake_paddle.use_compat_guard = _noop_guard + return patch.object(video, "paddle", fake_paddle) - mock_decord = MagicMock() - mock_decord.VideoReader.return_value = mock_reader + def _make_wrapper(self, video_path, mock_decoder=None): + """Construct a VideoReaderWrapper with paddlecodec mocked out.""" + from fastdeploy.input.utils.video import VideoReaderWrapper + + if mock_decoder is None: + mock_decoder = _make_mock_reader() - with patch.dict("sys.modules", {"decord": mock_decord, "moviepy": MagicMock(), "moviepy.editor": MagicMock()}): + with self._paddle_patch(), self._sys_modules_patch(mock_decoder): wrapper = VideoReaderWrapper(video_path) - wrapper._reader = mock_reader + wrapper._decoder = mock_decoder return wrapper def test_len(self): - reader = _make_mock_reader(num_frames=42) - wrapper = self._make_wrapper("/fake/video.mp4", reader) + decoder = _make_mock_reader(num_frames=42) + wrapper = self._make_wrapper("/fake/video.mp4", decoder) self.assertEqual(len(wrapper), 42) - def test_getitem_resets_seek(self): - reader = _make_mock_reader() - wrapper = self._make_wrapper("/fake/video.mp4", reader) - _ = wrapper[0] - reader.seek.assert_called_with(0) + def test_getitem_returns_numpyframe(self): + decoder = _make_mock_reader() + wrapper = self._make_wrapper("/fake/video.mp4", decoder) + frame = wrapper[0] + self.assertTrue(hasattr(frame, "asnumpy")) + arr = frame.asnumpy() + self.assertEqual(arr.shape, (480, 640, 3)) + + def test_getitem_slice_returns_numpyframe(self): + decoder = _make_mock_reader() + frame_data = MagicMock() + frame_data.numpy.return_value = np.zeros((2, 480, 640, 3), dtype=np.uint8) + frames_result = MagicMock() + frames_result.data = frame_data + decoder.get_frames_at.return_value = frames_result + wrapper = self._make_wrapper("/fake/video.mp4", decoder) + + frames = wrapper[1:3] + + self.assertTrue(hasattr(frames, "asnumpy")) + self.assertEqual(frames.asnumpy().shape, (2, 480, 640, 3)) + decoder.get_frames_at.assert_called_with(indices=[1, 2]) + + def test_init_reraises_torchcodec_import_error(self): + from fastdeploy.input.utils.video import VideoReaderWrapper + + original_import = __import__ + + def mock_import(name, *args, **kwargs): + if name == "torchcodec.decoders": + raise RuntimeError("backend missing") + return original_import(name, *args, **kwargs) + + with self._paddle_patch(), patch("builtins.__import__", side_effect=mock_import): + with self.assertRaisesRegex(RuntimeError, "backend missing"): + VideoReaderWrapper("/fake/video.mp4") def test_get_avg_fps(self): - reader = _make_mock_reader(fps=30.0) - wrapper = self._make_wrapper("/fake/video.mp4", reader) + decoder = _make_mock_reader(fps=30.0) + wrapper = self._make_wrapper("/fake/video.mp4", decoder) self.assertEqual(wrapper.get_avg_fps(), 30.0) - def test_seek(self): - reader = _make_mock_reader() - wrapper = self._make_wrapper("/fake/video.mp4", reader) - wrapper.seek(5) - reader.seek.assert_called_with(5) - def test_del_no_original_file(self): """__del__ should be a no-op when original_file is None.""" from fastdeploy.input.utils.video import VideoReaderWrapper wrapper = object.__new__(VideoReaderWrapper) wrapper.original_file = None - wrapper._reader = _make_mock_reader() + wrapper._decoder = _make_mock_reader() # Should not raise wrapper.__del__() @@ -132,7 +191,7 @@ def test_del_removes_temp_file(self): wrapper = object.__new__(VideoReaderWrapper) wrapper.original_file = tmp_path - wrapper._reader = _make_mock_reader() + wrapper._decoder = _make_mock_reader() wrapper.__del__() self.assertFalse(os.path.exists(tmp_path)) @@ -140,11 +199,8 @@ def test_non_gif_string_path_does_not_set_original_file(self): """Passing a non-GIF string path must NOT set original_file (bug fix).""" from fastdeploy.input.utils.video import VideoReaderWrapper - mock_reader = _make_mock_reader() - mock_decord = MagicMock() - mock_decord.VideoReader.return_value = mock_reader - - with patch.dict("sys.modules", {"decord": mock_decord, "moviepy": MagicMock(), "moviepy.editor": MagicMock()}): + mock_decoder = _make_mock_reader() + with self._paddle_patch(), self._sys_modules_patch(mock_decoder): wrapper = VideoReaderWrapper("/fake/video.mp4") self.assertIsNone(wrapper.original_file) @@ -153,23 +209,20 @@ def test_bytesio_non_gif_path_does_not_set_original_file(self): """Passing a BytesIO that is NOT a GIF must not set original_file.""" from fastdeploy.input.utils.video import VideoReaderWrapper - mock_reader = _make_mock_reader() - mock_decord = MagicMock() - mock_decord.VideoReader.return_value = mock_reader - + mock_decoder = _make_mock_reader() bio = io.BytesIO(NOT_GIF) - with patch.dict("sys.modules", {"decord": mock_decord, "moviepy": MagicMock(), "moviepy.editor": MagicMock()}): + with self._paddle_patch(), self._sys_modules_patch(mock_decoder): wrapper = VideoReaderWrapper(bio) self.assertIsNone(wrapper.original_file) # --------------------------------------------------------------------------- -# read_video_decord +# read_video_paddlecodec # --------------------------------------------------------------------------- -class TestReadVideoDecord(unittest.TestCase): +class TestReadVideoPaddlecodec(unittest.TestCase): def _patch_wrapper(self, num_frames=100, fps=25.0): """Return a context manager that replaces VideoReaderWrapper with a mock.""" from fastdeploy.input.utils import video @@ -187,7 +240,7 @@ def test_existing_wrapper_passthrough(self): mock_wrapper.__len__ = MagicMock(return_value=50) mock_wrapper.get_avg_fps = MagicMock(return_value=10.0) - reader, meta, path = read_video_decord(mock_wrapper) + reader, meta, path = read_video_paddlecodec(mock_wrapper) self.assertIs(reader, mock_wrapper) self.assertEqual(meta["num_of_frame"], 50) @@ -211,7 +264,7 @@ def get_avg_fps(self): return 10.0 with patch.object(video, "VideoReaderWrapper", FakeWrapper): - reader, meta, path = read_video_decord(b"fake_video_bytes") + reader, meta, path = read_video_paddlecodec(b"fake_video_bytes") self.assertIsInstance(captured[0], io.BytesIO) @@ -230,7 +283,7 @@ def get_avg_fps(self): return 30.0 with patch.object(video, "VideoReaderWrapper", FakeWrapper): - reader, meta, path = read_video_decord("/fake/path.mp4") + reader, meta, path = read_video_paddlecodec("/fake/path.mp4") self.assertEqual(meta["num_of_frame"], 60) self.assertAlmostEqual(meta["duration"], 2.0)