Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d2a38bc
build(container): add a media-codec allowlist gate to the compliance …
dmitry-tokarev-nv Jul 13, 2026
869ddaf
build(container): restrict the in-tree ffmpeg to a narrow media-codec…
dmitry-tokarev-nv Jul 13, 2026
72658d0
chore(container): drop preinstalled opencv-python-headless from trtll…
dmitry-tokarev-nv Jul 13, 2026
ccdf441
build(sglang): remove FFmpeg codec support
saturley-hall Jul 16, 2026
3569f30
fix(sglang): remove decord2 codec libraries
saturley-hall Jul 16, 2026
aae72bc
chore(container): merge main into ffmpeg allowlist
saturley-hall Jul 17, 2026
75e0323
test(container): move pathlib import to module scope, drop ticket ref
dmitry-tokarev-nv Jul 17, 2026
b405a9b
chore(compliance): address review nits on the codec gate
dmitry-tokarev-nv Jul 17, 2026
295a55b
Merge branch 'main' into dtokarev/ops-7625-remove-opencv-python-headless
dmitry-tokarev-nv Jul 17, 2026
38ab67e
Merge branch 'main' into dtokarev/ops-7665-codec-scan
dmitry-tokarev-nv Jul 17, 2026
2584f9b
chore(compliance): harden codec scan per further review
dmitry-tokarev-nv Jul 17, 2026
eea17fb
Merge branch 'main' into dtokarev/ops-7665-ffmpeg-decoder-allowlist
dmitry-tokarev-nv Jul 17, 2026
c45909b
docs(container): address review nits on the media-codec allowlist
dmitry-tokarev-nv Jul 17, 2026
642de0e
build(container): keep the h264 parser for h264_nvenc mp4 muxing
dmitry-tokarev-nv Jul 17, 2026
eaabac2
build(vllm): remove codec-bearing video-decode wheels from vLLM images
dmitry-tokarev-nv Jul 17, 2026
ce0dbc4
Merge remote-tracking branch 'origin/dtokarev/ops-7625-remove-opencv-…
dmitry-tokarev-nv Jul 17, 2026
56aa9d5
Merge remote-tracking branch 'origin/dtokarev/ops-7665-codec-scan' in…
dmitry-tokarev-nv Jul 17, 2026
25e3a2d
fix(compliance): widen the DALI exception to its whole vendored .libs
dmitry-tokarev-nv Jul 17, 2026
806881e
Merge remote-tracking branch 'origin/dtokarev/ops-7665-codec-scan' in…
dmitry-tokarev-nv Jul 17, 2026
dd29e8f
fix(container): enable ffmpeg fd protocol for stdin encode path
dmitry-tokarev-nv Jul 17, 2026
1b43852
test(serve): install video decoder at test time for codec-stripped im…
dmitry-tokarev-nv Jul 17, 2026
5887e6e
build(container): build in-tree ffmpeg VP9-only, drop the H.264/NVENC…
dmitry-tokarev-nv Jul 17, 2026
da86b4c
Merge branch 'main' into dtokarev/ops-7665-codec-compliance
cursoragent Jul 17, 2026
474b327
fix(compliance): harden codec gate (fail-closed, hashed globs, purge …
dmitry-tokarev-nv Jul 18, 2026
9faaa3d
style(compliance): wrap long raise line to satisfy ruff-format
dmitry-tokarev-nv Jul 18, 2026
57e5490
fix(container): drop bsfs from the ffmpeg codec guard (false positive)
dmitry-tokarev-nv Jul 18, 2026
60e5eb5
fix(vllm): encode omni video output with VP9
dmitry-tokarev-nv Jul 20, 2026
b890c58
fix(sglang): add the in-tree VP9 ffmpeg for video-generation encode
dmitry-tokarev-nv Jul 20, 2026
e94ee03
test(container): real-encode regression tests + VP9 build guard
dmitry-tokarev-nv Jul 20, 2026
3c8eabc
test(ci): temporarily run post_merge markers on the #11836 PR lanes
dmitry-tokarev-nv Jul 20, 2026
fb528ec
feat(media): actionable errors for unsupported video/audio codecs
dmitry-tokarev-nv Jul 18, 2026
b8232c4
docs(multimodal): add Supported Media Codecs page
dmitry-tokarev-nv Jul 18, 2026
968d8cc
fix(media): address review — sharper unsupported-codec errors
dmitry-tokarev-nv Jul 19, 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
18 changes: 9 additions & 9 deletions .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,8 @@ jobs:
cuda_version: '["13.0"]'
platform: '["amd64", "arm64"]' # arm64 for CPU tests, single GPU tests are skipped
run_cpu_only_tests: true
cpu_only_test_markers: pre_merge and vllm and gpu_0
gpu_test_markers: pre_merge and vllm and gpu_1
cpu_only_test_markers: '(pre_merge or post_merge) and vllm and gpu_0' # TEMP-11836: post_merge validation, revert to pre_merge before merge
gpu_test_markers: '(pre_merge or post_merge) and vllm and gpu_1' # TEMP-11836
gpu_test_timeout_minutes: 45
# Profiled tests run in the parallel stage; unprofiled fall through to sequential.
# 24 GiB admits all currently profiled vLLM tests (max is ~20.4 GiB) on a 48 GiB GPU.
Expand All @@ -617,7 +617,7 @@ jobs:
cuda_version: '["13.0"]'
platform: '["amd64"]' # No ARM GPUs available
run_sanity_check: false
gpu_test_markers: pre_merge and vllm and (gpu_2 or gpu_4)
gpu_test_markers: '(pre_merge or post_merge) and vllm and (gpu_2 or gpu_4)' # TEMP-11836
gpu_test_timeout_minutes: 60
secrets: inherit

Expand All @@ -634,8 +634,8 @@ jobs:
cuda_version: '["13.0"]'
platform: '["amd64", "arm64"]' # arm64 for CPU tests, single GPU tests are skipped
run_cpu_only_tests: true
cpu_only_test_markers: pre_merge and sglang and gpu_0
gpu_test_markers: pre_merge and sglang and gpu_1
cpu_only_test_markers: '(pre_merge or post_merge) and sglang and gpu_0' # TEMP-11836
gpu_test_markers: '(pre_merge or post_merge) and sglang and gpu_1' # TEMP-11836
# Profiled tests run in the VRAM-aware GPU stage; unprofiled fall through to sequential.
# Current single-GPU runners are 24 GiB, so this cap admits the profiled SGLang pool
# while yielding one auto slot today. Larger runners will get more slots from the same markers.
Expand All @@ -656,7 +656,7 @@ jobs:
cuda_version: '["13.0"]'
platform: '["amd64"]' # No ARM GPUs available
run_sanity_check: false
gpu_test_markers: pre_merge and sglang and (gpu_2 or gpu_4)
gpu_test_markers: '(pre_merge or post_merge) and sglang and (gpu_2 or gpu_4)' # TEMP-11836
gpu_test_timeout_minutes: 60
secrets: inherit

Expand All @@ -673,8 +673,8 @@ jobs:
cuda_version: '["13.1"]'
platform: '["amd64", "arm64"]' # arm64 for CPU tests, single GPU tests are skipped
run_cpu_only_tests: true
cpu_only_test_markers: pre_merge and trtllm and gpu_0
gpu_test_markers: pre_merge and trtllm and gpu_1
cpu_only_test_markers: '(pre_merge or post_merge) and trtllm and gpu_0' # TEMP-11836
gpu_test_markers: '(pre_merge or post_merge) and trtllm and gpu_1' # TEMP-11836
# Profiled tests run in the VRAM-aware GPU stage; unprofiled fall through to sequential.
# Current single-GPU runners are 24 GiB, so this cap admits the profiled TRT-LLM pool
# while yielding one auto slot today. Larger runners will get more slots from the same markers.
Expand All @@ -695,7 +695,7 @@ jobs:
cuda_version: '["13.1"]'
platform: '["amd64"]' # No ARM GPUs available
run_sanity_check: false
gpu_test_markers: pre_merge and trtllm and (gpu_2 or gpu_4)
gpu_test_markers: '(pre_merge or post_merge) and trtllm and (gpu_2 or gpu_4)' # TEMP-11836
gpu_test_timeout_minutes: 60
secrets: inherit

Expand Down
19 changes: 19 additions & 0 deletions components/src/dynamo/common/multimodal/video_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ async def load_video(self, video_url: str) -> tuple[np.ndarray, Dict[str, Any]]:
return np.ascontiguousarray(frames), metadata
except FileNotFoundError:
raise
except ImportError as exc:
# The runtime image ships without the backend video decoder (OpenCV)
# for media-licensing reasons, so cv2 is imported lazily and fails
# here. Surface an actionable message instead of a bare
# "No module named 'cv2'".
missing = getattr(exc, "name", None) or "the video decoder"
logger.error(
"Backend video decoder missing (%s) while loading %s",
missing,
video_url,
)
raise RuntimeError(
f"Video decoding is unavailable in this image: the backend decoder "
f"'{missing}' is not installed. This backend path decodes every "
f"`video_url` (any codec, including VP8/VP9) via OpenCV — install it "
f"with `pip install opencv-python-headless`. Alternatively, enable "
f"frontend decoding so the frontend decodes the video (VP8/VP9) and "
f"sends decoded frames, and the backend no longer needs OpenCV."
) from exc
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
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ async def test_load_video_rejects_http_by_default():
await loader.load_video("http://example.com/x.mp4")


@pytest.mark.asyncio
async def test_load_video_missing_opencv_gives_install_guidance():
"""A missing backend decoder (OpenCV) yields actionable install guidance,
not a bare ``No module named 'cv2'``."""
loader = VideoLoader()
loader._url_policy = UrlValidationPolicy()
# vLLM's VideoMediaIO imports cv2 lazily; simulate that failing.
loader._load_video_with_vllm = AsyncMock( # type: ignore[method-assign]
side_effect=ModuleNotFoundError("No module named 'cv2'", name="cv2")
)

with pytest.raises(RuntimeError, match="opencv-python-headless"):
await loader.load_video("data:video/mp4;base64,Zm9v")


@pytest.mark.asyncio
async def test_load_video_uses_vllm_media_connector():
loader = VideoLoader()
Expand Down
5 changes: 3 additions & 2 deletions components/src/dynamo/common/tests/test_video_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _mock_iio_v2(self):
iio.get_writer = MagicMock(return_value=writer)
return iio, writer

def test_mp4_selects_h264_nvenc_codec(self):
def test_mp4_selects_vp9_codec(self):
from dynamo.common.utils.video_utils import encode_to_video_bytes

iio = self._mock_iio_v3()
Expand All @@ -56,7 +56,8 @@ def test_mp4_selects_h264_nvenc_codec(self):

iio.imwrite.assert_called_once()
_, kwargs = iio.imwrite.call_args
assert kwargs.get("codec") == "h264_nvenc"
# Royalty-free: mp4 output uses VP9, not h264_nvenc.
assert kwargs.get("codec") == "libvpx-vp9"
assert kwargs.get("fps") == 8

def test_webm_selects_libvpx_vp9_codec(self):
Expand Down
19 changes: 10 additions & 9 deletions components/src/dynamo/common/utils/video_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,15 +154,16 @@ def encode_to_mp4(
logger.info(f"Encoding {len(frames)} frames to {output_path} at {fps} fps")

try:
# Use imageio to write MP4. We use h264_nvenc (NVIDIA HW encoder) instead
# of libx264 because the in-tree ffmpeg build is LGPL-only and libx264
# is GPL-licensed; see container/templates/wheel_builder.Dockerfile.
# Requires a CUDA-capable GPU at runtime.
# Encode with VP9 (libvpx-vp9). The in-tree ffmpeg build is LGPL-only and
# royalty-free: it carries no H.264 codec (not even the h264_nvenc HW
# encoder), so VP9 is the video encoder we ship. VP9-in-mp4 is valid and
# decodes with our VP8/VP9 decoder allowlist; see
# container/templates/wheel_builder.Dockerfile.
if hasattr(iio, "imwrite"):
iio.imwrite(output_path, frames, fps=fps, codec="h264_nvenc")
iio.imwrite(output_path, frames, fps=fps, codec="libvpx-vp9")
else:
# Fall back to v2 API
writer = iio.get_writer(output_path, fps=fps, codec="h264_nvenc") # type: ignore[attr-defined]
writer = iio.get_writer(output_path, fps=fps, codec="libvpx-vp9") # type: ignore[attr-defined]
try:
for frame in frames:
writer.append_data(frame)
Expand Down Expand Up @@ -213,11 +214,11 @@ def encode_to_video_bytes(
try:
buffer = io.BytesIO()

# VP9 (libvpx-vp9) for both containers: the in-tree ffmpeg is royalty-free
# and carries no H.264 encoder. VP9-in-mp4 and VP9-in-webm are both valid.
kwargs: dict = {"fps": fps}
if output_format == "webm":
if output_format in ("webm", "mp4"):
kwargs["codec"] = "libvpx-vp9"
elif output_format == "mp4":
kwargs["codec"] = "h264_nvenc"
else:
raise ValueError(f"No codec specified for response format: {output_format}")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@
VIDEO_URL_KEY = "video_url"


def _ensure_video_decoder_available() -> None:
"""Fail fast with actionable guidance when the SGLang video decoder is absent.

SGLang decodes video with ``decord`` (via ``MMEncoder``). The runtime image
ships without it for media-licensing reasons, so a video request would
otherwise fail deep inside SGLang with a bare ``No module named 'decord'``.
"""
import importlib.util

if importlib.util.find_spec("decord") is None:
raise RuntimeError(
"Video decoding is unavailable in this image: the SGLang video path "
"requires the 'decord' decoder, which is not installed. Install it "
"with `pip install decord2` (it provides the `decord` module), or "
"send images instead of video."
)


class MultimodalEncodeWorkerHandler(BaseWorkerHandler[SglangMultimodalRequest, str]):
"""
Handler for multimodal encode worker component that processes images/videos
Expand Down Expand Up @@ -483,6 +501,13 @@ def _extract_media_urls(
else:
raise ValueError(f"Unsupported video data variant: {item}")

# Only after the payload is validated (malformed / Decoded-variant items
# already rejected above) preflight the video decoder, so a genuine video
# URL request fails with actionable guidance instead of a bare
# "No module named 'decord'" from SGLang.
if video_urls:
_ensure_video_decoder_available()

return image_urls, video_urls

@_nvtx.range_decorator("mm:enc:generate", color="blue")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ async def _generate_video(
return video_bytes

async def _frames_to_video(
self, frames: list, fps: int, codec: str = "h264_nvenc"
self, frames: list, fps: int, codec: str = "libvpx-vp9"
) -> bytes:
"""Convert list of frames to video bytes.

Expand Down Expand Up @@ -302,15 +302,9 @@ def encode_with_codec(codec_name: str) -> bytes:
output_buffer.seek(0)
return output_buffer.read()

try:
return encode_with_codec(codec)
except OSError:
if codec != "h264_nvenc":
raise
logger.warning(
"h264_nvenc failed; retrying video encoding with libx264"
)
return encode_with_codec("libx264")
# VP9 (libvpx-vp9) is a royalty-free CPU encoder in the in-tree LGPL
# ffmpeg; no HW/GPU fallback and no libx264 (GPL, H.264) fallback.
return encode_with_codec(codec)

except ImportError as e:
raise RuntimeError(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from unittest.mock import patch

import pytest
import torch

Expand Down Expand Up @@ -31,19 +33,25 @@
]


# Where the video-decoder preflight lives; patched to a no-op below to simulate a
# decord-enabled image (the runtime image ships without it).
_HANDLER_MOD = "dynamo.sglang.request_handlers.multimodal.encode_worker_handler"


def test_extract_media_urls_supports_video_urls():
handler = MultimodalEncodeWorkerHandler.__new__(MultimodalEncodeWorkerHandler)

image_urls, video_urls = handler._extract_media_urls(
{
"multi_modal_data": {
"video_url": [
{"Url": "https://example.com/clip.mp4"},
"file:///tmp/local.mp4",
]
with patch(f"{_HANDLER_MOD}._ensure_video_decoder_available"):
image_urls, video_urls = handler._extract_media_urls(
{
"multi_modal_data": {
"video_url": [
{"Url": "https://example.com/clip.mp4"},
"file:///tmp/local.mp4",
]
}
}
}
)
)

assert image_urls == []
assert video_urls == ["https://example.com/clip.mp4", "file:///tmp/local.mp4"]
Expand All @@ -52,19 +60,31 @@ def test_extract_media_urls_supports_video_urls():
def test_extract_media_urls_supports_mixed_image_and_video():
handler = MultimodalEncodeWorkerHandler.__new__(MultimodalEncodeWorkerHandler)

image_urls, video_urls = handler._extract_media_urls(
{
"multi_modal_data": {
"image_url": [{"Url": "https://example.com/image.png"}],
"video_url": [{"Url": "https://example.com/clip.mp4"}],
with patch(f"{_HANDLER_MOD}._ensure_video_decoder_available"):
image_urls, video_urls = handler._extract_media_urls(
{
"multi_modal_data": {
"image_url": [{"Url": "https://example.com/image.png"}],
"video_url": [{"Url": "https://example.com/clip.mp4"}],
}
}
}
)
)

assert image_urls == ["https://example.com/image.png"]
assert video_urls == ["https://example.com/clip.mp4"]


def test_extract_media_urls_video_without_decord_raises_actionable_error():
handler = MultimodalEncodeWorkerHandler.__new__(MultimodalEncodeWorkerHandler)

# Simulate an image built without the video decoder.
with patch("importlib.util.find_spec", return_value=None):
with pytest.raises(RuntimeError, match="decord2"):
handler._extract_media_urls(
{"multi_modal_data": {"video_url": [{"Url": "https://x/c.mp4"}]}}
)


@pytest.mark.asyncio
async def test_build_mm_items_routes_video_to_video_data():
embeddings = torch.arange(24, dtype=torch.float16).reshape(6, 4)
Expand Down
80 changes: 80 additions & 0 deletions components/src/dynamo/sglang/tests/test_video_encode_real.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Real (un-mocked) video-encode regression test for the SGLang output path.

Drives VideoGenerationWorkerHandler._frames_to_video through the ACTUAL ffmpeg
baked into the shipped runtime image. This is the pre_merge guard for the gap
that let SGLang ship with no ffmpeg at all (video generation would fail at the
encode step) go green through the PR pipeline. Runs on the CUDA image only
(gpu_0, no xpu) because the in-tree VP9 ffmpeg is copied into the CUDA runtime;
encoding VP9 (libvpx-vp9) is CPU-only, so no GPU is used.
"""

import os
import subprocess
import tempfile

import numpy as np
import pytest

try:
from PIL import Image

from dynamo.sglang.request_handlers.video_generation.video_generation_handler import (
VideoGenerationWorkerHandler,
)
except ImportError:
pytest.skip(
"SGLang video-generation dependencies not available", allow_module_level=True
)

pytestmark = [
pytest.mark.unit,
pytest.mark.sglang,
pytest.mark.pre_merge,
pytest.mark.gpu_0,
pytest.mark.timeout(120),
]


def _synthetic_pil_frames(n: int = 8, size: int = 64) -> list:
return [
Image.fromarray(np.full((size, size, 3), (i * 24) % 256, dtype=np.uint8))
for i in range(n)
]


def _probe_video_codec(video_bytes: bytes) -> str:
"""Return the video stream codec of encoded bytes, via the shipped ffmpeg."""
exe = os.environ.get("IMAGEIO_FFMPEG_EXE")
if not exe:
try:
import imageio_ffmpeg

exe = imageio_ffmpeg.get_ffmpeg_exe()
except Exception:
exe = "ffmpeg"
with tempfile.NamedTemporaryFile(suffix=".mp4") as tmp:
tmp.write(video_bytes)
tmp.flush()
stderr = subprocess.run(
[exe, "-hide_banner", "-i", tmp.name],
capture_output=True,
text=True,
).stderr
for line in stderr.splitlines():
if "Video:" in line:
return line.split("Video:", 1)[1].split(",")[0].split()[0]
return "?"


@pytest.mark.asyncio
async def test_sglang_video_output_is_vp9_in_shipped_image():
# _frames_to_video uses no instance state, so bypass the engine-bound
# constructor and call it directly.
handler = VideoGenerationWorkerHandler.__new__(VideoGenerationWorkerHandler)
video_bytes = await handler._frames_to_video(_synthetic_pil_frames(), 8)
assert video_bytes, "encoder produced no bytes"
codec = _probe_video_codec(video_bytes)
assert codec == "vp9", f"expected vp9-encoded output, got codec={codec!r}"
Loading
Loading