Skip to content

vllm.multimodal.video_decoders.pyav

Classes:

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.

Methods:

  • decode_frames

    Decode target frames via per-frame seek + forward decode to PTS.

Source code in vllm/multimodal/video_decoders/pyav.py
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)
        elif duration > 0 and fps > 0 and total_frames > round(duration * fps) + 1:
            # The header sample count can exceed the frames the presentation
            # timeline actually holds — e.g. an mp4 edit list hides the decode
            # lead-in of a stream-copied cut, but ``stream.frames`` still
            # counts the hidden samples. Sampling indices past the visible
            # range would silently collapse onto the last visible frame, so
            # trust the (edit-list-aware) duration instead.
            total_frames = round(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

decode_frames(container, frame_indices, fps, duration) staticmethod

Decode target frames via per-frame seek + forward decode to PTS.

Source code in vllm/multimodal/video_decoders/pyav.py
@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