Skip to content
Merged
Show file tree
Hide file tree
Changes from 67 commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
4b2ef8c
feat(media): codec-policy video pipeline with NVDEC H.264/H.265 decod…
dmitry-tokarev-nv Jul 29, 2026
4e8e543
build(container): retry the ffmpeg source download in wheel_builder
dmitry-tokarev-nv Jul 29, 2026
8d76a3e
build(compliance): waive PyNvVideoCodec's vendored libavutil/libavformat
dmitry-tokarev-nv Jul 29, 2026
9dfd852
build(container): retry the ffmpeg download in a shell loop (covers S…
dmitry-tokarev-nv Jul 29, 2026
cbc5da2
test(trtllm): cover the NVDEC VideoData transform + H.264 routing
dmitry-tokarev-nv Jul 29, 2026
1a77571
test(vllm): NVDEC H.264/H.265 video serve case + shared http fixtures
dmitry-tokarev-nv Jul 29, 2026
81bb27b
fix(container): upgrade PyNvVideoCodec to 2.2.0 (drops bundled libavc…
dmitry-tokarev-nv Jul 29, 2026
f5746bf
ci(mypy): ignore missing PyNvVideoCodec import (GPU-only optional dep)
dmitry-tokarev-nv Jul 29, 2026
494aabe
fix(vllm): accept numpy frames in omni video-output encode path
dmitry-tokarev-nv Jul 29, 2026
aa69e41
ci(vllm): raise parallel GPU test timeout 45->90 (TEMP-11836)
dmitry-tokarev-nv Jul 29, 2026
a2b2729
test(vllm): skip NVDEC H.264/H.265 serve cases when video capability …
dmitry-tokarev-nv Jul 29, 2026
ae6842e
fix(sglang): stamp valid video_metadata for pre-decoded NVDEC frames
dmitry-tokarev-nv Jul 30, 2026
02fb0ef
ci(sglang): raise GPU test timeout to 90m (TEMP-11836)
dmitry-tokarev-nv Jul 30, 2026
d4e4c66
test(trtllm): fix uint8 overflow in NVDEC frame synthesis (NEP 50)
dmitry-tokarev-nv Jul 30, 2026
a51a96b
ci: raise trtllm GPU test timeout to 90m, vllm to 120m (TEMP-11836)
dmitry-tokarev-nv Jul 30, 2026
fe393bd
fix(media): stop a third-party DeprecationWarning from disabling NVDE…
dmitry-tokarev-nv Jul 30, 2026
55fd15a
fix(media): report a real source fps from NVDEC instead of 0
dmitry-tokarev-nv Jul 30, 2026
e53571e
docs(multimodal): document NVDEC GPU requirements; warn on missing vi…
dmitry-tokarev-nv Jul 30, 2026
25cfa78
docs(multimodal): add RTX 6000 Ada to the NVDEC support table
dmitry-tokarev-nv Jul 30, 2026
23a6a51
docs(trtllm): correct stale claim that video input is unsupported
dmitry-tokarev-nv Jul 30, 2026
6c13678
docs(multimodal): qualify vLLM/SGLang video support by codec
dmitry-tokarev-nv Jul 30, 2026
8e0e216
ci: revert TEMP-11836 marker and timeout scaffolding
dmitry-tokarev-nv Jul 30, 2026
8376b33
ci(sglang): raise GPU test timeout to 90m as a hang shock-absorber
dmitry-tokarev-nv Jul 30, 2026
88bf0cb
fix(media): hardware-decode file:// and data: video, not just http(s)
dmitry-tokarev-nv Jul 30, 2026
3ddcca6
docs(multimodal): file:// and data: video are hardware-decoded now
dmitry-tokarev-nv Jul 30, 2026
dad8d67
test: mark decoder-installing video tests as installs_extra_dependencies
dmitry-tokarev-nv Jul 30, 2026
dc4a55f
test(sglang): serve video fixtures locally instead of a third-party host
dmitry-tokarev-nv Jul 30, 2026
d8ae3ad
test(sglang): add an NVDEC H.264/H.265 video serve case
dmitry-tokarev-nv Jul 30, 2026
21f3e4f
test(trtllm): add the first video-input serve case (NVDEC H.264/H.265)
dmitry-tokarev-nv Jul 30, 2026
ed7504d
fix(sglang): put the NVDEC serve case on the encode-worker path
dmitry-tokarev-nv Jul 30, 2026
f1c7f4f
docs(multimodal): repoint NVDEC links after the docs-tree restructure
dmitry-tokarev-nv Jul 31, 2026
6e29800
ci: run post_merge markers on this PR, with per-stage timeout headroom
dmitry-tokarev-nv Jul 31, 2026
376100e
ci: revert the temporary post_merge markers and raised timeouts
dmitry-tokarev-nv Jul 31, 2026
9f0650b
fix(sglang): let SGLang own video frame selection, and decode without…
dmitry-tokarev-nv Jul 31, 2026
510f688
fix(sglang): make lost pinned memory loud instead of silent
dmitry-tokarev-nv Jul 31, 2026
50a6cc1
ci: re-enable the post_merge markers and raised timeouts
dmitry-tokarev-nv Jul 31, 2026
10b5619
fix(sglang): patch load_video where encode_server actually binds it
dmitry-tokarev-nv Aug 1, 2026
ba8814e
test(sglang): stop the passthrough test leaking a half-built decoder
dmitry-tokarev-nv Aug 1, 2026
c60d651
fix(sglang): stop a URL-policy refusal degrading to an unvalidated fetch
dmitry-tokarev-nv Aug 1, 2026
d12480f
test(serve): assert video content the fixtures actually contain
dmitry-tokarev-nv Aug 1, 2026
e0868fd
docs(multimodal): make the video-capability setup actionable
dmitry-tokarev-nv Aug 1, 2026
a887cfb
test(container): assert the shipped images carry no software H.264/H.…
dmitry-tokarev-nv Aug 1, 2026
bb5e1b2
test(serve): assert video content that cannot match by luck
dmitry-tokarev-nv Aug 1, 2026
3d0a860
test(trtllm): assert the VP9 rejection contract
dmitry-tokarev-nv Aug 2, 2026
3ddbdbb
ci: drop the temporary pre-merge widening for this PR
dmitry-tokarev-nv Aug 2, 2026
1ee3306
ci: re-widen the backend lanes for one more pre-merge run
dmitry-tokarev-nv Aug 2, 2026
03765d3
build(container): scope the media-codec gate to the CUDA images
dmitry-tokarev-nv Aug 2, 2026
a71c212
build(container): keep the NVIDIA-only media changes off the XPU image
dmitry-tokarev-nv Aug 2, 2026
7313dd2
test(router): skip the SGLang indexer-sync case while it hangs
dmitry-tokarev-nv Aug 2, 2026
200987c
build(compliance): correct the DALI waiver with what was measured
dmitry-tokarev-nv Aug 3, 2026
98024f8
build(trtllm): upgrade DALI past its own media-codec cleanup
dmitry-tokarev-nv Aug 3, 2026
359269b
build(trtllm): guard the DALI whiteout where it can actually fail
dmitry-tokarev-nv Aug 3, 2026
9c154b6
build(compliance): keep the DALI waiver, and say why it is still needed
dmitry-tokarev-nv Aug 3, 2026
bf981b9
ci: restore the stock pre-merge lane configuration
dmitry-tokarev-nv Aug 3, 2026
deb6323
build: pin DALI to 2.1.1 and mark the upstream workarounds transitional
dmitry-tokarev-nv Aug 3, 2026
e1d8c45
fix(multimodal): report do_sample_frames from the NVDEC decoder
dmitry-tokarev-nv Aug 3, 2026
a2b75b5
docs(sglang): disambiguate "encode" in the video section
dmitry-tokarev-nv Aug 4, 2026
b73530c
merge: bring branch up to date with main
dmitry-tokarev-nv Aug 4, 2026
a191963
fix(compliance): validate allow_paths, which is the key that fails open
dmitry-tokarev-nv Aug 4, 2026
a4ba547
build(container): scope the SGLang media purge to CUDA
dmitry-tokarev-nv Aug 4, 2026
0f4e712
fix(sglang): read DYN_DISABLE_NVDEC the same way as every other backend
dmitry-tokarev-nv Aug 4, 2026
e8ed9f2
docs: correct claims this branch made stale
dmitry-tokarev-nv Aug 4, 2026
df4c232
merge: bring branch up to date with main
dmitry-tokarev-nv Aug 4, 2026
8d64ea9
docs(diffusion): flag the mp4 output codec change for consumers
dmitry-tokarev-nv Aug 4, 2026
1da12f9
fix(sglang): stop refetching video that the encode worker already dow…
dmitry-tokarev-nv Aug 4, 2026
c92b09a
docs: drop references to a switch this branch does not ship
dmitry-tokarev-nv Aug 4, 2026
ea37435
test(serve): cover video loaded from a local file, not only over http
dmitry-tokarev-nv Aug 5, 2026
b891d06
docs: correct the claim that VP8/VP9 video input has a software path
dmitry-tokarev-nv Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions components/src/dynamo/common/multimodal/media_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Read media bytes from the non-HTTP sources the hardware decoder can use.

``fetch_bytes`` only speaks HTTP(S), so ``file://`` and ``data:`` media never
produced bytes at the routing layer and could not reach NVDEC -- they fell
through to the software decoder instead. The codec-compliant runtime images
ship no software video decoder, so that fallback no longer resolves and those
schemes lost video support entirely. This module supplies the missing bytes so
H.264/H.265 from a local file or a data URI decodes on the GPU like any other
source.

``file://`` stays behind ``validate_local_path``: local access is refused
unless ``DYN_MM_LOCAL_PATH`` is set, paths are resolved before the prefix check
(so symlinks cannot escape), and this adds no read surface beyond what the
existing media connectors already allow under the same policy.
"""

from __future__ import annotations

import asyncio
import base64
import binascii
import logging
from urllib.parse import unquote, urlparse
from urllib.request import url2pathname

from dynamo.common.http.url_validator import (
UrlValidationError,
UrlValidationPolicy,
validate_local_path,
)

logger = logging.getLogger(__name__)

# Schemes this module can turn into bytes. http(s) is deliberately absent: it
# belongs to fetch_bytes, which applies SSRF revalidation on every redirect hop.
LOCAL_MEDIA_SCHEMES = frozenset({"file", "data"})


def is_local_media_url(url: str) -> bool:
"""True when :func:`read_local_media_bytes` can produce bytes for ``url``."""
return urlparse(url).scheme in LOCAL_MEDIA_SCHEMES


def _decode_data_uri(url: str) -> bytes:
"""Decode a ``data:`` URI body to bytes.

Only base64 payloads are accepted: a percent-encoded body would have to be
re-encoded to bytes by guessing a charset, and media data URIs are base64
in practice.
"""
_, _, remainder = url.partition(":")
meta, sep, payload = remainder.partition(",")
if not sep:
raise UrlValidationError("Malformed data URI: missing ',' separator")
if "base64" not in meta.split(";"):
raise UrlValidationError("Unsupported data URI: expected base64 payload")
try:
return base64.b64decode(unquote(payload), validate=True)
except (binascii.Error, ValueError) as exc:
raise UrlValidationError(f"Malformed base64 in data URI: {exc}") from exc


async def read_local_media_bytes(url: str, policy: UrlValidationPolicy) -> bytes:
"""Return the bytes behind a ``file://`` or ``data:`` media URL.

Raises ``UrlValidationError`` when the scheme is unsupported, when local
access is disabled or the path escapes ``allowed_local_path``, or when a
data URI is malformed.
"""
scheme = urlparse(url).scheme
if scheme == "data":
return _decode_data_uri(url)
if scheme != "file":
raise UrlValidationError(f"Unsupported local media scheme: {scheme!r}")

# url2pathname handles the platform's file:// -> path rules; the policy
# check below is what actually authorizes the read.
parsed = urlparse(url)
path = validate_local_path(url2pathname(parsed.path), policy)
return await asyncio.to_thread(path.read_bytes)
282 changes: 282 additions & 0 deletions components/src/dynamo/common/multimodal/nvdec_decoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""GPU (NVDEC) video decode for H.264/H.265 via PyNvVideoCodec.

Dual-path companion to the VP8/VP9 in-tree FFmpeg decoders. Royalty-free codecs
(VP8/VP9/AV1) stay on the existing CPU path; H.264/H.265 decode on the GPU
through NVDEC, which links ``libnvcuvid`` at runtime and carries no bundled
software codec. NVDEC does not use SMs beyond a small YUV->RGB conversion, so the
inference-time impact is minimal.

This module is backend-agnostic. Each backend's decode site (vLLM ``VideoLoader``,
TRT-LLM ``multimodal_processor``, SGLang encode worker) routes on the probed codec:

codec = probe_video_codec(video_bytes)
if should_use_nvdec(codec):
frames, meta = decode_video_nvdec(video_bytes, num_frames)
else:
frames, meta = <existing decoder>

The returned frames match the existing ``VideoLoader`` contract: a host numpy
array of shape ``(T, H, W, 3)``, dtype ``uint8``, RGB, C-contiguous, plus a
metadata dict ``{"fps", "frames_indices", "total_num_frames"}``.

Gating: NVDEC is used when PyNvVideoCodec is importable and ``DYN_DISABLE_NVDEC``
is not set. When it is unavailable (CPU image, unsupported profile) the caller
surfaces the actionable "unsupported codec" error.
"""

from __future__ import annotations

import functools
import importlib.util
import logging
import os
import tempfile
import warnings

import numpy as np

from dynamo.common.utils.env import env_bool

logger = logging.getLogger(__name__)

DISABLE_ENV = "DYN_DISABLE_NVDEC"
GPU_ID_ENV = "DYN_NVDEC_GPU_ID"

# Used only when the decoder cannot report a source frame rate (see
# _source_fps). Consumers divide by this to build frame timestamps, so it must
# stay non-zero; 30 fps is a common capture rate and keeps timestamps in a
# plausible range rather than failing the request outright.
FALLBACK_FPS = 30.0

# Codecs routed to NVDEC. VP8/VP9/AV1 stay on the existing royalty-free path.
HW_ROUTED_CODECS = frozenset({"h264", "hevc"})

# Container byte markers -> codec id. A video file carries exactly one video
# codec, so the first match wins. Covers ISO-BMFF (mp4/mov) sample-entry fourccs
# and Matroska/WebM CodecID strings. HEVC/AV1 checked before H.264 so a more
# specific marker is not shadowed. This is a routing hint, not a full demux --
# PyNvVideoCodec re-parses the stream authoritatively at decode time.
_CODEC_MARKERS: tuple[tuple[bytes, str], ...] = (
(b"hev1", "hevc"),
(b"hvc1", "hevc"),
(b"V_MPEGH/ISO/HEVC", "hevc"),
(b"av01", "av1"),
(b"V_AV1", "av1"),
(b"avc1", "h264"),
(b"avc3", "h264"),
(b"V_MPEG4/ISO/AVC", "h264"),
(b"vp09", "vp9"),
(b"V_VP9", "vp9"),
(b"vp08", "vp8"),
(b"V_VP8", "vp8"),
)


def probe_video_codec(data: bytes) -> str | None:
"""Best-effort codec identification from container bytes.

Returns a codec id (``"h264"``/``"hevc"``/``"vp9"``/``"vp8"``/``"av1"``) or
``None`` when it can't tell. Scans the whole buffer because an mp4 ``moov``
(which holds the codec fourcc) may sit at the end of a non-faststart file;
multimodal clips are small and already in memory.
"""
if not data:
return None
for marker, codec in _CODEC_MARKERS:
if marker in data:
return codec
return None


def should_use_nvdec(codec: str | None) -> bool:
"""True if `codec` is one we route to NVDEC and NVDEC is available."""
return codec in HW_ROUTED_CODECS and nvdec_available()


@functools.lru_cache(maxsize=1)
def nvdec_available() -> bool:
"""True if NVDEC decode can run in this process.

Cached: PyNvVideoCodec is only installed in GPU images, and the import is the
reliable signal. ``DYN_DISABLE_NVDEC`` forces the software path. A GPU that is
absent at decode time still raises and is caught by the caller's fallback.
"""
if env_bool(DISABLE_ENV):
return False
try:
with warnings.catch_warnings():
# A third-party deprecation must not decide whether the capability
# exists. PyNvVideoCodec 2.2.0's __init__ does `from ast import Str`,
# which warns on Python 3.12; under pytest's filterwarnings=error
# that warning is *raised*, the import "fails", and this lru_cache
# then pins False for the whole process -- silently disabling NVDEC
# for every test in the session (a second import would have
# succeeded, since the warning is only raised once).
warnings.simplefilter("ignore")
import PyNvVideoCodec # noqa: F401
except Exception as exc: # noqa: BLE001
# Two very different situations reach here, and they need different
# log levels. The wheel being absent is normal on CPU-only images, so
# it stays at debug. The wheel being *installed* but unimportable is a
# misconfiguration -- almost always a container that did not request
# the "video" driver capability, so libnvcuvid is not mounted -- and it
# silently costs H.264/H.265 decode, which the codec-compliant images
# have no software fallback for. That deserves a warning; keeping it at
# debug is how this class of failure goes unnoticed.
if importlib.util.find_spec("PyNvVideoCodec") is None:
logger.debug(
"PyNvVideoCodec is not installed; NVDEC decode disabled (%s)", exc
)
else:
logger.warning(
"PyNvVideoCodec is installed but failed to import (%s); NVDEC "
"hardware decode is DISABLED. This usually means the container "
"was not given the 'video' driver capability, so libnvcuvid is "
"unavailable -- set NVIDIA_DRIVER_CAPABILITIES to include "
"'video'. H.264/H.265 video input has no software decoder in "
"the codec-compliant images and will fail until this is fixed.",
exc,
)
return False
return True


def _gpu_id() -> int:
raw = os.environ.get(GPU_ID_ENV, "").strip()
if not raw:
return 0
try:
return int(raw)
except ValueError:
logger.warning("invalid %s=%r; using GPU 0", GPU_ID_ENV, raw)
return 0


def _frame_to_rgb_hwc(frame) -> np.ndarray:
"""Copy a decoded (device) RGB frame to a host ``(H, W, 3)`` uint8 array.

A 2.x ``DecodedFrame`` (``output_color_type=RGB``) holds a CUDA buffer and
supports the DLPack protocol, so torch wraps it zero-copy on the GPU and
``.cpu()`` copies to host. Validated on PyNvVideoCodec 2.1.1 for H.264/H.265.
"""
import torch

try:
tensor = torch.from_dlpack(frame)
except Exception: # noqa: BLE001 - fall back to the CUDA-array-interface path
tensor = torch.as_tensor(frame, device="cuda")
arr = tensor.cpu().numpy()
if arr.dtype != np.uint8:
arr = arr.astype(np.uint8)
return arr


def _source_fps(decoder) -> float:
"""Source frame rate for the metadata dict. Never returns 0.

This value is not cosmetic. ``load_video`` hands ``(frames, metadata)``
straight to vLLM as ``mm_data["video"]``, and Qwen3-VL turns sampled frame
indices into timestamps with ``idx / fps`` -- a zero raises
ZeroDivisionError and the request 500s.

PyNvVideoCodec 2.2.0 exposes the rate as
``get_stream_metadata().average_fps``; older probes are kept for other
versions, then ``num_frames / duration``, then a documented fallback so a
future API change degrades timestamp accuracy instead of failing requests.
"""
meta = getattr(decoder, "get_stream_metadata", None)
if callable(meta):
try:
info = meta()
val = getattr(info, "average_fps", None)
if val and float(val) > 0:
return float(val)
# Derive it when the rate is absent but the span is known.
frames = float(getattr(info, "num_frames", 0) or 0)
duration = float(getattr(info, "duration", 0) or 0)
if frames > 0 and duration > 0:
return frames / duration
except Exception: # noqa: BLE001 - metadata only, never fatal
pass

for attr in ("get_fps", "fps", "GetFPS"):
val = getattr(decoder, attr, None)
try:
val = val() if callable(val) else val
if val and float(val) > 0:
return float(val)
except Exception: # noqa: BLE001 - metadata only, never fatal
continue

logger.warning(
"NVDEC could not determine source fps; falling back to %.1f. Frame "
"timestamps handed to the model will be approximate.",
FALLBACK_FPS,
)
return FALLBACK_FPS


def decode_video_nvdec(
data: bytes, num_frames: int, gpu_id: int | None = None
) -> tuple[np.ndarray, dict]:
"""Decode H.264/H.265 (or any NVDEC-supported codec) bytes to sampled frames.

Returns ``(frames, metadata)`` where ``frames`` is host numpy
``(num_frames, H, W, 3)`` uint8 RGB C-contiguous, uniformly sampled across the
clip, and ``metadata`` has ``fps``/``frames_indices``/``total_num_frames`` --
the same contract as ``VideoLoader.load_video``. Raises on decode failure so
the caller can fall back.
"""
import PyNvVideoCodec as nvc

if gpu_id is None:
gpu_id = _gpu_id()
if num_frames < 1:
raise ValueError(f"num_frames must be >= 1, got {num_frames}")

# SimpleDecoder takes a file PATH (not bytes) and reads frames lazily, so the
# temp file must stay alive for the whole decode -- keep it inside the context.
with tempfile.NamedTemporaryFile(suffix=".mp4") as tmp:
tmp.write(data)
tmp.flush()
decoder = nvc.SimpleDecoder(
tmp.name,
gpu_id=gpu_id,
output_color_type=nvc.OutputColorType.RGB,
use_device_memory=False,
)
total = len(decoder)
if total <= 0:
raise RuntimeError("NVDEC decode produced no frames")
n = min(num_frames, total)
indices = np.unique(np.linspace(0, total - 1, n).astype(int))
frames = [_frame_to_rgb_hwc(decoder[int(i)]) for i in indices]
fps = _source_fps(decoder)

stacked = np.ascontiguousarray(np.stack(frames)).astype(np.uint8, copy=False)
if stacked.ndim != 4 or stacked.shape[-1] != 3:
raise RuntimeError(
f"NVDEC frames have unexpected shape {stacked.shape}; expected (T,H,W,3)"
)
metadata = {
"fps": fps,
"frames_indices": indices.tolist(),
"total_num_frames": int(total),
# Whether the consumer may still sample these frames. vLLM's own video
# loader reports `len(frames_indices) == total_num_frames`: true when the
# loader handed over every source frame, false when it already picked a
# subset. Qwen3-VL treats a MISSING flag as false, so omitting it here
# made short clips -- the case where we return everything -- look
# pre-sampled, and the model's own fps policy never ran. Reported with a
# reproduction against vLLM 0.26.0's Qwen3-VL processor: the 10-frame
# fixture yielded video_grid_thw [[5,16,20]] instead of [[2,16,20]].
#
# Reported by @Chokoyo on #11836. Note the E2E video tests cannot see
# this: they assert on what the model says about the clip, which does not
# change when the frame count does. The unit test is the check that can.
"do_sample_frames": len(indices) == total,
}
Comment thread
dmitry-tokarev-nv marked this conversation as resolved.
Resolved
return stacked, metadata
Loading
Loading