Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
9f27b15
feat(media): actionable errors for unsupported video/audio codecs
dmitry-tokarev-nv Aug 5, 2026
28cb337
merge: bring in merged #12051 (install_media_decoders + VALIDATED_SPECS)
dmitry-tokarev-nv Aug 5, 2026
a70d233
test(media): scope the no-implicit-install sweep to entrypoints
dmitry-tokarev-nv Aug 5, 2026
4ef91aa
fix(media): address the review round on the actionable-error PR
dmitry-tokarev-nv Aug 5, 2026
5fbce17
fix(sglang): preflight the NVDEC-disabled path too
dmitry-tokarev-nv Aug 5, 2026
3761381
Merge remote-tracking branch 'origin/main' into dtokarev/ops-7779-act…
dmitry-tokarev-nv Aug 6, 2026
ef42823
test(sglang): hoist importlib to module scope per review
dmitry-tokarev-nv Aug 6, 2026
1df5df6
Merge branch 'main' into dtokarev/ops-7779-actionable-errors-for-unsu…
nv-tusharma Aug 6, 2026
81976cf
fix(media): keep media payloads out of errors; validate URLs before d…
dmitry-tokarev-nv Aug 7, 2026
a72b2ce
Merge branch 'dtokarev/ops-7779-actionable-errors-for-unsupported-vid…
dmitry-tokarev-nv Aug 7, 2026
6eb057d
Merge remote-tracking branch 'origin/main' into dtokarev/ops-7779-act…
dmitry-tokarev-nv Aug 7, 2026
35cdfa5
test(sglang): allow internal URLs in the gated video E/PD serve test
dmitry-tokarev-nv Aug 7, 2026
64c646b
docs: state the media URL policy on the backend multimodal pages
dmitry-tokarev-nv Aug 7, 2026
a07d009
test(sglang): keep the cache unit tests off the network
dmitry-tokarev-nv Aug 8, 2026
bb939bd
test(sglang): stop the cache unit tests reaching the network
dmitry-tokarev-nv Aug 8, 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
22 changes: 22 additions & 0 deletions components/src/dynamo/common/multimodal/audio_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
UrlValidationPolicy,
validate_media_url,
)
from dynamo.common.multimodal.codec_errors import (
MissingMediaDecoderError,
audio_decoder_missing,
)
from dynamo.common.utils import nvtx_utils as _nvtx
from dynamo.common.utils.runtime import run_async

Expand Down Expand Up @@ -155,6 +159,14 @@ async def load_audio(self, audio_url: str) -> tuple[np.ndarray, float]:
# its type and prevent the frontend from returning a 4xx.
logger.error("URL rejected loading audio: '%s'", audio_url)
raise
except ImportError as exc:
# The image ships no audio decoder (PyAV is deliberately omitted).
# vLLM's own hint here is "pip install vllm[audio]", which drags in
# an unpinned stack; point at the validated bounded install
# instead. NVDEC never decodes audio, so there is no hardware
# alternative to mention.
logger.error("No audio decoder available loading '%s'", audio_url)
raise audio_decoder_missing("vllm", cause=str(exc)) from exc
except Exception as exc:
logger.error("Error loading audio from %s: %s", audio_url, exc)
raise ValueError(f"Failed to load audio from {audio_url}: {exc}") from exc
Expand Down Expand Up @@ -218,6 +230,7 @@ async def load_audio_batch(
collective_exceptions: list[str] = []
status_error: HttpStatusError | None = None
url_error: UrlValidationError | None = None
decoder_error: MissingMediaDecoderError | None = None
for media_item, result in zip(audio_mm_items, results, strict=True):
if isinstance(result, BaseException):
if isinstance(result, asyncio.CancelledError):
Expand All @@ -231,13 +244,22 @@ async def load_audio_batch(
status_error = result
elif url_error is None and isinstance(result, UrlValidationError):
url_error = result
elif decoder_error is None and isinstance(
result, MissingMediaDecoderError
):
decoder_error = result
continue
loaded_audio.append(result)

if status_error is not None:
raise status_error
if url_error is not None:
raise url_error
if decoder_error is not None:
# Keep the actionable type: the generic aggregate below would erase
# it, and a missing decoder is deployment configuration handlers
# must be able to distinguish from a bad request.
raise decoder_error

if collective_exceptions:
raise Exception("".join(collective_exceptions))
Expand Down
112 changes: 112 additions & 0 deletions components/src/dynamo/common/multimodal/codec_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Actionable errors for media the shipped images cannot decode.

The runtime images deliberately omit the software media decoders (OpenCV,
PyAV, decord) and route H.264/H.265 video to NVDEC hardware decode instead.
Any other input codec needs one of those Python packages, so without an
explicit install the failure surfaces as a bare ``ModuleNotFoundError`` from
deep inside the backend -- no codec, no remedy, and in one observed case the
whole video payload embedded in the message. The builders here name the codec,
the missing package at its validated version bounds, the installer command,
and the hardware alternative, in one place so the three backends cannot drift.

The version bounds come from
:mod:`dynamo.common.utils.install_media_decoders` (the explicit installer);
importing its constants here is deliberate single-sourcing -- nothing in this
module installs anything.
"""

from __future__ import annotations

from dynamo.common.multimodal.nvdec_decoder import HW_ROUTED_CODECS, nvdec_available
from dynamo.common.utils.install_media_decoders import VALIDATED_SPECS

INSTALLER_CMD = "python -m dynamo.common.utils.install_media_decoders"


class MissingMediaDecoderError(RuntimeError):
"""A media request needs a decoder package the image does not ship.

Deliberately not a ``ValueError``: the input may be perfectly valid media.
The gap is deployment configuration, so handlers that map ``ValueError``
to a client 4xx should not blame the request for it.
"""


def _install_hint(backend: str, package: str) -> str:
spec = VALIDATED_SPECS[package]
return (
f"install the validated decoder with `pip install --no-deps '{spec}'` "
f"(or `{INSTALLER_CMD} {backend}`)"
)


def _with_cause(message: str, cause: str | None) -> str:
"""Append the underlying decoder text so diagnostics survive the wrap.

``raise ... from exc`` preserves the cause for tracebacks, but handlers
that ship only ``str(exc)`` to the client (HTTP error bodies) would drop
it -- and the underlying reason is part of this error's contract.
"""
if cause:
return f"{message} (decoder reported: {cause})"
return message


def video_decoder_missing(
backend: str,
package: str,
module: str,
codec: str | None,
cause: str | None = None,
) -> MissingMediaDecoderError:
"""Build the error for a video whose decode path has no decoder.

Two distinct situations produce it, and the remedy differs:

* ``codec`` is H.264/H.265 but NVDEC is unavailable in this container --
the primary fix is granting the ``video`` driver capability, not
installing software.
* any other codec -- NVDEC never decodes it, so the fix is the software
decoder install or re-encoding the input to H.264/H.265.
"""
codec_desc = f"codec '{codec}'" if codec else "an undetected codec"
if codec in HW_ROUTED_CODECS and not nvdec_available():
lead = (
f"this video ({codec_desc}) normally decodes in hardware via NVDEC, "
"but NVDEC is unavailable in this container. Grant the 'video' "
"driver capability (NVIDIA_DRIVER_CAPABILITIES) to enable it, or "
)
else:
lead = (
f"this video ({codec_desc}) has no decoder in this image: shipped "
"images decode only H.264/H.265 (in hardware, via NVDEC), and the "
f"software decoder '{module}' is deliberately not installed. "
"Re-encode the input to H.264/H.265, or "
)
return MissingMediaDecoderError(
_with_cause(
"Cannot decode video: " + lead + _install_hint(backend, package) + ".",
cause,
)
)


def audio_decoder_missing(
backend: str, cause: str | None = None
) -> MissingMediaDecoderError:
"""Build the error for audio input with no decoder in the image.

NVDEC never decodes audio, so unlike video there is no hardware
alternative -- the only remedy is the PyAV install.
"""
return MissingMediaDecoderError(
_with_cause(
"Cannot decode audio: this input needs the PyAV decoder ('av'), which "
"this image deliberately does not ship, and NVDEC does not decode "
"audio. To enable audio input, " + _install_hint(backend, "av") + ".",
cause,
)
)
26 changes: 26 additions & 0 deletions components/src/dynamo/common/multimodal/media_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import base64
import binascii
import logging
from typing import Final
from urllib.parse import unquote, urlparse
from urllib.request import url2pathname

Expand All @@ -44,6 +45,31 @@ def is_local_media_url(url: str) -> bool:
return urlparse(url).scheme in LOCAL_MEDIA_SCHEMES


# Longest a media source may render as inside an error message or log line.
# Generous enough to keep an ordinary URL intact and identifiable.
SOURCE_LABEL_LIMIT: Final = 120


def describe_media_source(source: str, limit: int = SOURCE_LABEL_LIMIT) -> str:
"""Render ``source`` as a bounded label safe to put in an error or log.

A ``data:`` URI carries the whole media payload inline, so echoing one into
an error message serializes megabytes of base64 -- to the client, and to
every log sink that records the failure. Describe those by media type and
size instead, never by content. Other sources are truncated, since a URL
identifies the request without being unbounded.
"""
if not isinstance(source, str):
return "<non-string media source>"
if source.startswith("data:"):
meta = source[len("data:") :].partition(",")[0]
media_type = meta.split(";")[0] or "application/octet-stream"
return f"data:{media_type} ({len(source)} chars, payload elided)"
if len(source) > limit:
return f"{source[:limit]}... ({len(source)} chars)"
return source


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

Expand Down
3 changes: 2 additions & 1 deletion components/src/dynamo/common/multimodal/nvdec_decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@

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.
raises the actionable unsupported-codec error built by
``common.multimodal.codec_errors``.
"""

from __future__ import annotations
Expand Down
85 changes: 51 additions & 34 deletions components/src/dynamo/common/multimodal/video_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
UrlValidationPolicy,
validate_media_url,
)
from dynamo.common.multimodal.codec_errors import (
MissingMediaDecoderError,
video_decoder_missing,
)
from dynamo.common.multimodal.media_source import (
is_local_media_url,
read_local_media_bytes,
Expand Down Expand Up @@ -134,15 +138,7 @@ async def _load_video_with_vllm(
content = await fetch_bytes(
normalized_url, self._http_timeout, policy=self._url_policy
)
# Dual decode path: hardware-decode H.264/H.265 on NVDEC; every other
# codec falls through to vLLM's software decoder below. Note the
# runtime images purge the software decode wheels (opencv/av/decord/
# torchcodec) for codec compliance, so that fallback only resolves
# where a decoder has been installed separately.
decoded = await self._maybe_decode_with_nvdec(content)
if decoded is not None:
return decoded
return await asyncio.to_thread(media_io.load_bytes, content)
return await self._decode_video_bytes(content, media_io)

# file:// and data: never touch the network, but they still deserve
# hardware decode: without this they reach only the software decoder,
Expand All @@ -152,40 +148,44 @@ async def _load_video_with_vllm(
# connector below uses, so this adds no local-read surface.
if is_local_media_url(normalized_url):
content = await read_local_media_bytes(normalized_url, self._url_policy)
decoded = await self._maybe_decode_with_nvdec(content)
if decoded is not None:
return decoded
return await asyncio.to_thread(media_io.load_bytes, content)
return await self._decode_video_bytes(content, media_io)

connector = self._get_vllm_media_connector()
return await connector.load_from_url_async(
normalized_url, media_io, fetch_timeout=self._http_timeout
)

async def _maybe_decode_with_nvdec(
self, content: bytes
) -> tuple[np.ndarray, Dict[str, Any]] | None:
"""Hardware-decode H.264/H.265 via NVDEC, or None to use the software path.

Returns None for VP8/VP9/AV1 (handled by the existing decoder), when NVDEC
is unavailable, or when NVDEC fails -- the caller then falls back to the
software decoder, which surfaces the actionable "unsupported codec" error
if it also cannot decode.
async def _decode_video_bytes(
self, content: bytes, media_io: Any
) -> tuple[np.ndarray, Dict[str, Any]]:
"""Decode video bytes: H.264/H.265 on NVDEC, all else software.

The runtime images purge the software decode wheels (opencv/av/decord/
torchcodec) for codec compliance, so the software fallback only
resolves where a decoder was installed separately. When it is absent,
vLLM's lazy import surfaces a bare ``No module named 'cv2'`` with no
codec and no remedy -- convert that into the actionable
unsupported-codec error, which can name the codec because the probe
already ran here.
"""
codec = probe_video_codec(content)
if not should_use_nvdec(codec):
return None
if should_use_nvdec(codec):
try:
return await asyncio.to_thread(
decode_video_nvdec, content, self._num_frames
)
except Exception as exc: # noqa: BLE001 - fall back to software decode
logger.warning(
"NVDEC decode failed for a %s clip (%s); using software decode",
codec,
exc,
)
try:
return await asyncio.to_thread(
decode_video_nvdec, content, self._num_frames
)
except Exception as exc: # noqa: BLE001 - fall back to software decode
logger.warning(
"NVDEC decode failed for a %s clip (%s); using software decode",
codec,
exc,
)
return None
return await asyncio.to_thread(media_io.load_bytes, content)
except ImportError as exc:
raise video_decoder_missing(
"vllm", "opencv-python-headless", "cv2", codec, cause=str(exc)
) from exc

async def load_video(self, video_url: str) -> tuple[np.ndarray, Dict[str, Any]]:
try:
Expand All @@ -203,6 +203,12 @@ async def load_video(self, video_url: str) -> tuple[np.ndarray, Dict[str, Any]]:
# its type and prevent the frontend from returning a 4xx.
logger.error("URL rejected loading video: '%s'", video_url)
raise
except MissingMediaDecoderError:
# Already actionable (names the codec and the install); a missing
# decoder is deployment configuration, not a bad request, so keep
# the type instead of degrading it to the ValueError below.
logger.error("No decoder available for video: '%s'", video_url)
raise
Comment thread
dmitry-tokarev-nv marked this conversation as resolved.
except Exception as exc:
logger.error("Error loading video from %s: %s", video_url, exc)
raise ValueError(f"Failed to load video from {video_url}: {exc}") from exc
Expand Down Expand Up @@ -249,6 +255,7 @@ async def load_video_batch(
collective_exceptions: list[str] = []
status_error: HttpStatusError | None = None
url_error: UrlValidationError | None = None
decoder_error: MissingMediaDecoderError | None = None
for media_item, result in zip(video_mm_items, results):
if isinstance(result, BaseException):
if isinstance(result, asyncio.CancelledError):
Expand All @@ -262,6 +269,10 @@ async def load_video_batch(
status_error = result
elif url_error is None and isinstance(result, UrlValidationError):
url_error = result
elif decoder_error is None and isinstance(
result, MissingMediaDecoderError
):
decoder_error = result
continue
frames, metadata = result
loaded_videos.append((np.ascontiguousarray(frames), metadata))
Expand All @@ -271,6 +282,12 @@ async def load_video_batch(
if url_error is not None:
raise url_error

if decoder_error is not None:
# Keep the actionable type: the generic aggregate below would erase
# it, and a missing decoder is deployment configuration handlers
# must be able to distinguish from a bad request.
raise decoder_error

if collective_exceptions:
raise Exception("".join(collective_exceptions))

Expand Down
Loading
Loading