diff --git a/docs/source/models/visual-generation.md b/docs/source/models/visual-generation.md index 941e7df4b473..3e80c120b2ef 100644 --- a/docs/source/models/visual-generation.md +++ b/docs/source/models/visual-generation.md @@ -117,7 +117,50 @@ When served via `trtllm-serve`, the following OpenAI-compatible endpoints are av The asynchronous `/v1/videos` job advances through `GET /v1/videos/{id}`: `queued` → `generating` (model inference) → `postprocessing` (encode the media and/or write the output file) → `completed`. The `generating` → `postprocessing` transition marks the end of inference; the video is downloadable via `/content` once `completed`. -`response_format="path"` returns the generated file's server-side path (under `TRTLLM_MEDIA_STORAGE_PATH`) for co-located clients, enabled by default. Set `TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1` to reject such requests with HTTP 400. See the [serve examples](https://github.com/NVIDIA/TensorRT-LLM/tree/main/examples/visual_gen/serve) for the full `response_format` reference. +`response_format="path"` returns the generated file's server-side path (under `TRTLLM_MEDIA_STORAGE_PATH`) for co-located clients, enabled by default. Set `TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1` to reject such requests with HTTP 400; the same switch also rejects a reference sent with `format="path"`, since both ask the server to trust a local filesystem path. See the [serve examples](https://github.com/NVIDIA/TensorRT-LLM/tree/main/examples/visual_gen/serve) for the full `response_format` reference. + +### Reference Inputs + +Conditioning references are supplied through the typed fields `image_reference`, `video_reference`, and `audio_reference`. Each field takes a single reference or a list. A reference is `MediaRef(content=..., format=...)`, and `format` is required. + +| `format` | Content | Notes | +|---|---|---| +| `path` | A local file readable by the coordinator process | Bare path or `file://` URI. | +| `url` | An `http(s)` URL | Fetched on the coordinator through the SSRF-guarded loader. | +| `base64` | Base64 text | A `data:` URI is also accepted. | +| `bytes` | Raw `bytes` | Python API only. | + +Every pipeline declares the reference slots and roles it accepts through `ref_slot_specs`, and a request is validated against that declaration before generation begins. References are resolved to raw bytes on the coordinator, so a worker never needs a filesystem shared with the client. + +Most models take a single reference whose role is unambiguous: + +```python +from tensorrt_llm import VisualGen +from tensorrt_llm.visual_gen import MediaRef + +vg = VisualGen(model="Wan-AI/Wan2.2-TI2V-5B-Diffusers") +params = vg.default_params +params.image_reference = MediaRef(content="start.png", format="path") +output = vg.generate(inputs="the scene comes alive with gentle motion", params=params) +``` + +Models that accept the same modality in more than one role need `role`. Wan 2.1 I2V takes a first frame and an optional last frame: + +```python +from tensorrt_llm import VisualGen +from tensorrt_llm.visual_gen import MediaRef + +vg = VisualGen(model="Wan-AI/Wan2.1-I2V-14B-480P-Diffusers") +params = vg.default_params +params.image_reference = [ + MediaRef(content="start.png", format="path", role="first_frame"), + MediaRef(content="end.png", format="path", role="last_frame"), +] +``` + +FLUX.2 and Qwen-Image-Edit accept a list of reference images on `image_reference`. + +The same fields carry references over `trtllm-serve`; see [`examples/visual_gen/serve/`](https://github.com/NVIDIA/TensorRT-LLM/tree/main/examples/visual_gen/serve) for request examples. ## Optimizations diff --git a/examples/visual_gen/models/cosmos3/cosmos3.py b/examples/visual_gen/models/cosmos3/cosmos3.py index 18b1cba051c8..af390395dc89 100644 --- a/examples/visual_gen/models/cosmos3/cosmos3.py +++ b/examples/visual_gen/models/cosmos3/cosmos3.py @@ -29,6 +29,7 @@ from tensorrt_llm import VisualGen, VisualGenArgs from tensorrt_llm._torch.visual_gen.models.cosmos3.transfer import TRANSFER_HINT_KEYS +from tensorrt_llm.visual_gen import MediaRef _SCRIPT_DIR = Path(__file__).resolve().parent _ACTION_MODES = ("policy", "forward_dynamics", "inverse_dynamics") @@ -336,7 +337,7 @@ def main(): "--image_path", type=str, default=None, - help="Optional conditioning image path or URL for I2V/TI2V", + help="Optional conditioning image path for I2V/TI2V", ) parser.add_argument( "--output_path", @@ -477,7 +478,7 @@ def main(): # Query per-model defaults (resolution, steps, guidance, seed, etc.). params = visual_gen.default_params if image_path is not None: - params.image = image_path + params.image_reference = [MediaRef(content=image_path, format="path")] negative_prompt = resolve_negative_prompt( negative_prompt=args.negative_prompt, @@ -514,7 +515,7 @@ def main(): with open(args.action_json, encoding="utf-8") as f: params.extra_params["action"] = json.load(f) if args.video_path is not None: - params.extra_params["video"] = Path(args.video_path).read_bytes() + params.video_reference = [MediaRef(content=args.video_path, format="path")] if args.extra_params: # Merged last: explicit JSON wins over flag-derived values. params.extra_params.update(args.extra_params) diff --git a/examples/visual_gen/models/flux2.py b/examples/visual_gen/models/flux2.py index a39044448b2f..652f350cef0a 100644 --- a/examples/visual_gen/models/flux2.py +++ b/examples/visual_gen/models/flux2.py @@ -26,6 +26,7 @@ from pathlib import Path from tensorrt_llm import VisualGen, VisualGenArgs +from tensorrt_llm.visual_gen import MediaRef def _output_paths(output_path: str, num_images: int) -> str | list[str]: @@ -118,7 +119,9 @@ def main(): # Start from per-model defaults and override only user-provided request fields. params = visual_gen.default_params params.num_images_per_prompt = args.num_images_per_prompt - params.image = args.image + params.image_reference = ( + [MediaRef(content=path, format="path") for path in args.image] if args.image else None + ) if args.image: # Let FLUX.2 derive omitted dimensions from the first processed reference. params.height = args.height diff --git a/examples/visual_gen/models/qwen_image_edit.py b/examples/visual_gen/models/qwen_image_edit.py index 43f37a73d1c4..c0b389ca2f45 100644 --- a/examples/visual_gen/models/qwen_image_edit.py +++ b/examples/visual_gen/models/qwen_image_edit.py @@ -24,6 +24,7 @@ import argparse from tensorrt_llm import VisualGen, VisualGenArgs +from tensorrt_llm.visual_gen import MediaRef def parse_args() -> argparse.Namespace: @@ -44,7 +45,7 @@ def parse_args() -> argparse.Namespace: "--image", nargs="+", required=True, - help="One or more input image paths or URLs.", + help="One or more input image paths.", ) parser.add_argument( "--prompt", @@ -64,7 +65,7 @@ def main() -> None: extra_args = VisualGenArgs.from_yaml(args.visual_gen_args) if args.visual_gen_args else None visual_gen = VisualGen(model=args.model, args=extra_args) params = visual_gen.default_params - params.image = args.image if len(args.image) > 1 else args.image[0] + params.image_reference = [MediaRef(content=path, format="path") for path in args.image] output = visual_gen.generate(inputs=args.prompt, params=params) saved = output.save(args.output_path) print(f"Saved edited image to {saved}") diff --git a/examples/visual_gen/models/qwen_image_layered.py b/examples/visual_gen/models/qwen_image_layered.py index 06af0d386914..c1485c365ad3 100644 --- a/examples/visual_gen/models/qwen_image_layered.py +++ b/examples/visual_gen/models/qwen_image_layered.py @@ -24,6 +24,7 @@ from pathlib import Path from tensorrt_llm import VisualGen, VisualGenArgs +from tensorrt_llm.visual_gen import MediaRef def parse_args() -> argparse.Namespace: @@ -63,7 +64,7 @@ def main() -> None: visual_gen = VisualGen(model=args.model, args=extra_args) params = visual_gen.default_params - params.image = args.image + params.image_reference = [MediaRef(content=args.image, format="path")] output = visual_gen.generate(inputs=args.prompt, params=params) if output.image is not None and output.image.shape[0] > 1: diff --git a/examples/visual_gen/models/wan_i2v.py b/examples/visual_gen/models/wan_i2v.py index 93b16d51310b..ae7101e0ad2e 100644 --- a/examples/visual_gen/models/wan_i2v.py +++ b/examples/visual_gen/models/wan_i2v.py @@ -24,6 +24,7 @@ import os from tensorrt_llm import VisualGen, VisualGenArgs +from tensorrt_llm.visual_gen import MediaRef _DEFAULT_IMAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cat_piano.png") @@ -62,9 +63,11 @@ def main(): visual_gen = VisualGen(model=args.model, args=extra_args) # --- Model-specific: I2V request construction --- - # Start from per-model defaults (steps, guidance, seed, etc.) and set the input image. + # Start from per-model defaults (steps, guidance, seed, etc.) and set the + # first-frame reference. Wan I2V also accepts a ``last_frame`` role, so the + # role must be given to disambiguate. params = visual_gen.default_params - params.image = args.image + params.image_reference = [MediaRef(content=args.image, format="path", role="first_frame")] output = visual_gen.generate( inputs="A cat presses the piano keys with its paws, soft notes filling the quiet room.", diff --git a/examples/visual_gen/serve/README.md b/examples/visual_gen/serve/README.md index 1a8a3019bdd7..bedec99e6df1 100644 --- a/examples/visual_gen/serve/README.md +++ b/examples/visual_gen/serve/README.md @@ -286,13 +286,31 @@ You can customize these by: - `frame_rate` (canonical) or `fps` (alias): frames per second - `num_frames`: when set, wins over the `seconds * frame_rate` derivation - `seed`, `num_inference_steps`, `guidance_scale`, `max_sequence_length`, `negative_prompt`: per-request denoise controls -- `input_reference`: Reference image (I2V/TI2V) or video (V2V), accepted as a base64-encoded string in JSON or as a file in multipart form-data +- `image_reference`, `video_reference`, `audio_reference`: reference image(s) for I2V/TI2V, video(s) for V2V, audio(s). In JSON each takes a `{content, format, role}` object or a list of them; a multipart file upload needs no `format`. + - `format` declares how to read `content`: `"path"` (a file readable by the server, or a `file://` URI), `"url"` (`http(s)`), or `"base64"` (or a `data:` URI). It is required in JSON, where `"bytes"` is rejected — upload the file instead. + + ```json + {"image_reference": {"content": "iVBORw0KGgoAAAANSUhEUg...", "format": "base64"}} + ``` + + - `"path"` reads a file on the *server*, so it is only meaningful for a co-located client; set `TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1` to reject it (the same switch also disables `response_format="path"`). `"url"` is fetched through the SSRF-guarded loader (private-address block, redirect re-validation, timeout, size cap). + - `format` here is the *input* wire form; the top-level `format` selects the *output* encoding. + - `role` disambiguates a model that accepts the same modality in more than one role — Wan 2.1 I2V takes a first frame and an optional last frame. Roles and lists need a JSON body; a multipart upload is a single file with no role. + + ```json + {"image_reference": [ + {"content": "", "format": "base64", "role": "first_frame"}, + {"content": "", "format": "base64", "role": "last_frame"} + ]} + ``` + - **Supported formats**: PNG and JPEG images; MP4 and AVI video, with H.264 the tested codec and others best-effort. HEIF/AVIF are not supported. +- `input_reference` (deprecated): a single image or video reference, routed by content signature to I2V or V2V. A JSON request carries base64 bytes and a multipart request uploads the file; it is ignored when `image_reference` / `video_reference` is also given. Prefer the typed fields. - `extra_params`: model-specific overflow (see below) - `response_format`: `"file"` (default; `FileResponse` byte download) or `"path"` (server-side output path JSON, for co-located clients) - `format`: Generation content encoding. Video encoders: `"mp4"`, `"avi"`, `"auto"`. Tensor formats: `"safetensors"`, `"pt"` (carries video + audio + scalar metadata in one payload for LTX-2). -> **`response_format="path"`** (image and video) returns absolute server-side file paths under the server's media-storage directory (`TRTLLM_MEDIA_STORAGE_PATH`), for clients co-located with the server (shared filesystem). Enabled by default; set `TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1` to reject `path` requests with HTTP 400. +> **`response_format="path"`** (image and video) returns absolute server-side file paths under the server's media-storage directory (`TRTLLM_MEDIA_STORAGE_PATH`), for clients co-located with the server (shared filesystem). Enabled by default; set `TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1` to reject `path` requests with HTTP 400. One switch covers both directions: it also rejects a reference sent with `format="path"`. #### Tensor-format consumer contract @@ -384,7 +402,7 @@ curl -X POST "http://localhost:8000/v1/videos" \ ```bash curl -X POST "http://localhost:8000/v1/videos" \ -F "prompt=She turns around and smiles" \ - -F "input_reference=@./media/woman_skyline_original_720p.jpeg" \ + -F "image_reference=@./media/woman_skyline_original_720p.jpeg" \ -F "seconds=4.0" \ -F "fps=24" \ -F "size=256x256" \ @@ -393,11 +411,11 @@ curl -X POST "http://localhost:8000/v1/videos" \ ### Video-to-Video (Multipart with File Upload, Cosmos3) ```bash -# The reference is classified by content: image -> I2V, video -> V2V. +# Modality comes from the field name: image_reference -> I2V, video_reference -> V2V. # V2V conditioning knobs ride in extra_params (values below are the defaults). curl -X POST "http://localhost:8000/v1/videos" \ -F "prompt=Continue the same scene with smooth natural motion and consistent subjects." \ - -F "input_reference=@./media/reference.mp4" \ + -F "video_reference=@./media/reference.mp4" \ -F "num_frames=189" \ -F "fps=24" \ -F 'extra_params={"condition_video_latent_indexes": [0, 1], "condition_video_keep": "first"}' diff --git a/examples/visual_gen/serve/async_video_gen.py b/examples/visual_gen/serve/async_video_gen.py index b884de0bbd22..4e9aaf2b2312 100755 --- a/examples/visual_gen/serve/async_video_gen.py +++ b/examples/visual_gen/serve/async_video_gen.py @@ -17,6 +17,8 @@ """ import argparse +import base64 +import json import sys import time from pathlib import Path @@ -28,7 +30,7 @@ def test_async_video_generation( base_url: str = "http://localhost:8000/v1", model: str = "wan", prompt: str = "A video of a cool cat on a motorcycle in the night", - input_reference: str = None, + image_reference: str = None, duration: float = 4.0, fps: int = 24, size: str = "256x256", @@ -41,7 +43,7 @@ def test_async_video_generation( base_url: Base URL of the API server model: Model name to use prompt: Text prompt for generation - input_reference: Path to reference image (optional, for TI2V mode) + image_reference: Path to reference image (optional, for TI2V mode) duration: Video duration in seconds fps: Frames per second size: Video resolution (WxH format) @@ -51,7 +53,7 @@ def test_async_video_generation( The server may return either MP4 (H.264) or AVI (MJPEG) format depending on the available encoder. The output filename extension will be adjusted to match. """ - mode = "TI2V" if input_reference else "T2V" + mode = "TI2V" if image_reference else "T2V" print("=" * 80) print(f"Testing Async Video Generation API - {mode} Mode") print("=" * 80) @@ -62,8 +64,8 @@ def test_async_video_generation( print("\n1. Creating video generation job...") print(f" Mode: {mode}") print(f" Prompt: {prompt}") - if input_reference: - print(f" Input Reference: {input_reference}") + if image_reference: + print(f" Input Reference: {image_reference}") print(f" Duration: {duration}s") print(f" FPS: {fps}") print(f" Size: {size}") @@ -81,14 +83,18 @@ def test_async_video_generation( }, } - # Add input reference if provided (TI2V mode) - if input_reference: - if not Path(input_reference).exists(): - print(f"\n❌ Error: Input reference image not found: {input_reference}") + # Add the conditioning image if provided (TI2V mode). The OpenAI SDK + # only knows one file parameter, `input_reference`, so the typed field + # travels base64-encoded in extra_body. It has to be a JSON string: a + # nested dict would be flattened into `image_reference[content]`. + if image_reference: + if not Path(image_reference).exists(): + print(f"\n❌ Error: Input reference image not found: {image_reference}") return False - create_params["input_reference"] = open(input_reference, "rb") - - # Create video generation job + encoded = base64.b64encode(Path(image_reference).read_bytes()).decode() + create_params["extra_body"]["image_reference"] = json.dumps( + {"content": encoded, "format": "base64"} + ) job = client.videos.create(**create_params) print("Video generation started: \n", job.model_dump_json(indent=2)) @@ -269,7 +275,7 @@ def test_async_video_generation( base_url=args.base_url, model=args.model, prompt=args.prompt, - input_reference=args.image, + image_reference=args.image, duration=args.duration, fps=args.fps, size=args.size, diff --git a/examples/visual_gen/serve/sync_video_gen.py b/examples/visual_gen/serve/sync_video_gen.py index 385369699f3c..d4ee70abd18a 100755 --- a/examples/visual_gen/serve/sync_video_gen.py +++ b/examples/visual_gen/serve/sync_video_gen.py @@ -27,7 +27,7 @@ def test_sync_video_generation( base_url: str = "http://localhost:8000/v1", model: str = "wan", prompt: str = "A video of a cute cat playing with a ball in the park", - input_reference: str = None, + image_reference: str = None, duration: float = 4.0, fps: int = 24, size: str = "256x256", @@ -40,7 +40,7 @@ def test_sync_video_generation( base_url: Base URL of the API server model: Model name to use prompt: Text prompt for generation - input_reference: Path to reference image (optional, for TI2V mode) + image_reference: Path to reference image (optional, for TI2V mode) duration: Video duration in seconds fps: Frames per second size: Video resolution (WxH format) @@ -50,7 +50,7 @@ def test_sync_video_generation( The server may return either MP4 (H.264) or AVI (MJPEG) format depending on the available encoder. The output filename extension will be adjusted to match. """ - mode = "TI2V" if input_reference else "T2V" + mode = "TI2V" if image_reference else "T2V" print("=" * 80) print(f"Testing Sync Video Generation API - {mode} Mode") print("=" * 80) @@ -58,8 +58,8 @@ def test_sync_video_generation( print("\n1. Generating video (waiting for completion)...") print(f" Mode: {mode}") print(f" Prompt: {prompt}") - if input_reference: - print(f" Input Reference: {input_reference}") + if image_reference: + print(f" Input Reference: {image_reference}") print(f" Duration: {duration}s") print(f" FPS: {fps}") print(f" Size: {size}") @@ -67,10 +67,10 @@ def test_sync_video_generation( try: endpoint = f"{base_url}/videos/sync" - if input_reference: + if image_reference: # TI2V mode - Use multipart/form-data with file upload - if not Path(input_reference).exists(): - print(f"\n❌ Error: Input reference image not found: {input_reference}") + if not Path(image_reference).exists(): + print(f"\n❌ Error: Input reference image not found: {image_reference}") return False # Prepare form data (all values as strings for multipart) @@ -83,18 +83,19 @@ def test_sync_video_generation( "format": format, } - # Add the file + # Add the file. Keep the request inside the file's context so the + # handle closes once the upload completes. ## Note: The content-type must be multipart/form-data. - files = { - "input_reference": ( - Path(input_reference).name, - open(input_reference, "rb"), - "multipart/form-data", - ) - } - - print("\n Uploading reference image and generating video...") - response_video = requests.post(endpoint, data=form_data, files=files) + with open(image_reference, "rb") as ref_file: + files = { + "image_reference": ( + Path(image_reference).name, + ref_file, + "multipart/form-data", + ) + } + print("\n Uploading reference image and generating video...") + response_video = requests.post(endpoint, data=form_data, files=files) else: # T2V mode - Use JSON response_video = requests.post( @@ -254,7 +255,7 @@ def test_sync_video_generation( base_url=args.base_url, model=args.model, prompt=args.prompt, - input_reference=args.image, + image_reference=args.image, duration=args.duration, fps=args.fps, size=args.size, diff --git a/tensorrt_llm/_torch/visual_gen/executor.py b/tensorrt_llm/_torch/visual_gen/executor.py index 050d09cdafbd..04efe4b0efbd 100644 --- a/tensorrt_llm/_torch/visual_gen/executor.py +++ b/tensorrt_llm/_torch/visual_gen/executor.py @@ -15,6 +15,7 @@ import torch.multiprocessing as mp import zmq +from tensorrt_llm._torch.shared_tensor import SharedTensorContainer from tensorrt_llm._torch.visual_gen.output import PipelineOutput from tensorrt_llm._torch.visual_gen.pipeline_loader import PipelineLoader from tensorrt_llm.executor.ipc import ZeroMqQueue @@ -25,6 +26,7 @@ if TYPE_CHECKING: from tensorrt_llm.visual_gen.params import VisualGenParams + # Timeouts (seconds) for the client-side coordinator. POLL_TIMEOUT = 0.01 AWAIT_TIMEOUT = 0.05 @@ -257,6 +259,58 @@ class DiffusionRequest: prompt: List[str] params: Optional["VisualGenParams"] = None prepared_inputs: Dict[str, Any] = field(default_factory=dict, repr=False) + # Set only between the two ends of the coordinator -> rank0 hop; see + # ``refs_to_shm``. + ref_handles: Optional[List[Dict[str, Any]]] = field(default=None, repr=False) + # Set only while the request is in flight on the rank0 -> N-rank hop; see + # ``DiffusionExecutor._broadcast_request``. + ref_sizes: Optional[List[int]] = field(default=None, repr=False) + + def refs_to_shm(self) -> None: + """Move reference payloads into shared memory, in place (producer side). + + Only the coordinator -> rank0 hop travels as handles: rank0 restores the + bytes before broadcasting, because a shared-tensor handle is consumed + exactly once and minting one for N ranks would free the block N-1 times. + """ + if self.params is None: + return + self.ref_handles = handles = [] + for slot in ("image_reference", "video_reference", "audio_reference"): + for index, ref in enumerate(getattr(self.params, slot, None) or []): + # A read-only view: the one copy happens inside from_tensor(), + # which is what moves the payload into shared memory. + buffer = torch.frombuffer(ref.content, dtype=torch.uint8) + handles.append( + { + "slot": slot, + "index": index, + "handle": SharedTensorContainer.from_tensor(buffer).dump_to_dict(), + } + ) + ref.content = b"" + if not handles: + self.ref_handles = None + + def refs_from_shm(self) -> None: + """Restore reference payloads from shared memory, in place (consumer side). + + Each handle is taken independently so one that cannot be rebuilt does + not strand the blocks behind it. + """ + failures = [] + for entry in self.ref_handles or []: + try: + ref = getattr(self.params, entry["slot"])[entry["index"]] + container = SharedTensorContainer.from_dict(entry["handle"]) + ref.content = container.get_local_view().numpy().tobytes() + except Exception as exc: + failures.append(f"{entry['slot']}[{entry['index']}]: {exc}") + self.ref_handles = None + if failures: + raise RuntimeError( + "failed to restore reference payloads from shared memory: " + "; ".join(failures) + ) @dataclass @@ -311,6 +365,7 @@ def __init__( self.pipeline = None # initialized in _load_pipeline self.requests_ipc = None self.rank = dist.get_rank() + self.world_size = dist.get_world_size() self.response_queue = queue.Queue() self.sender_thread = None @@ -385,25 +440,73 @@ def _load_pipeline(self): "default_generation_params": self.pipeline.default_generation_params, "extra_param_specs": self.pipeline.extra_param_specs, "supports_image_edit": self.pipeline.supports_image_edit, + "ref_slot_specs": self.pipeline.ref_slot_specs, }, ) ) + def _broadcast_request(self, req: Optional[DiffusionRequest]) -> Optional[DiffusionRequest]: + """Send one request from rank0 to every rank. + + Reference payloads ride as raw uint8 tensors alongside the request + rather than inside it, because ``broadcast_object_list`` pickles the + object into a tensor first and that copy dominates the collective. + """ + payloads = [] + if self.rank == 0 and req is not None: + # Take the payloads out before the object is pickled, and leave their + # sizes behind so the peers can size their receive buffers. + for slot in ("image_reference", "video_reference", "audio_reference"): + for ref in getattr(req.params, slot, None) or []: + payloads.append(ref.content) + ref.content = b"" + req.ref_sizes = [len(p) for p in payloads] + + obj_list = [req] + dist.broadcast_object_list(obj_list, src=0) + req = obj_list[0] + if req is None: + return None + + if self.rank == 0: + # Read-only views: the src rank only reads its buffer. + buffers = [torch.frombuffer(p, dtype=torch.uint8) for p in payloads] + else: + buffers = [torch.empty(n, dtype=torch.uint8) for n in req.ref_sizes or []] + for buffer in buffers: + dist.broadcast(buffer, src=0) + + if self.rank != 0: + payloads = [b.numpy().tobytes() for b in buffers] + + refs = [ + ref + for slot in ("image_reference", "video_reference", "audio_reference") + for ref in getattr(req.params, slot, None) or [] + ] + if len(refs) != len(payloads): + # zip() would silently leave the tail of either side behind, and + # clearing ref_sizes below would erase the evidence. + raise ValueError(f"expected {len(refs)} reference payloads, got {len(payloads)}.") + for ref, payload in zip(refs, payloads): + ref.content = payload + req.ref_sizes = None + return req + def serve_forever(self): """Main execution loop.""" while True: req = None if self.rank == 0: req = self.requests_ipc.get() + if req is not None: + req.refs_from_shm() logger.info(f"Worker {self.device_id}: Request available") - # Broadcast to all ranks. ``req.params.seed`` is already a - # concrete int — resolved once on the coordinator process at - # :meth:`VisualGen.generate_async` entry — so the broadcast - # propagates the same value to every rank. - obj_list = [req] - dist.broadcast_object_list(obj_list, src=0) - req = obj_list[0] + # Skipped at world_size 1: with no peer, the object broadcast would + # still serialize the whole request to a tensor before finding out. + if self.world_size > 1: + req = self._broadcast_request(req) if req is None: logger.info(f"Worker {self.device_id}: Shutdown signal received") @@ -428,7 +531,7 @@ def _merge_defaults(self, req: DiffusionRequest): for field_name, default_value in self.pipeline.default_generation_params.items(): if hasattr(params, field_name) and getattr(params, field_name) is None: if ( - params.image is not None + params.image_reference and getattr(self.pipeline, "derive_output_size_from_reference", False) is True and field_name in ("height", "width") ): @@ -695,7 +798,6 @@ def __init__( # full PipelineOutput tensor does not pin in completed_responses for # the process lifetime. self._abandoned_request_ids: Set[int] = set() - # Iteration-stats tracker — populated on lifecycle events (enqueue, # request started, response received) and drained by # ``get_iteration_stats`` for the /metrics HTTP endpoint. Mirrors @@ -721,6 +823,7 @@ def __init__( self.default_generation_params: Dict = {} self.extra_param_specs: Dict = {} self.supports_image_edit: bool = False + self.ref_slot_specs: Dict = {} # --- Launch workers --- self.worker_processes = [] @@ -880,6 +983,7 @@ def _send_shutdown(self): def _process_requests(self): """Process pending requests.""" + req = None try: req = self.pending_requests.get(timeout=POLL_TIMEOUT) if req is None: @@ -1077,6 +1181,7 @@ async def _wait_ready_async(self): ) self.extra_param_specs = payload.get("extra_param_specs", {}) self.supports_image_edit = bool(payload.get("supports_image_edit", False)) + self.ref_slot_specs = payload.get("ref_slot_specs", {}) elapsed = time.time() - start_time logger.info(f"DiffusionClient: Workers ready ({elapsed:.1f}s)") return diff --git a/tensorrt_llm/_torch/visual_gen/models/cosmos3/defaults.py b/tensorrt_llm/_torch/visual_gen/models/cosmos3/defaults.py index cf18b82bb498..9ab33f752848 100644 --- a/tensorrt_llm/_torch/visual_gen/models/cosmos3/defaults.py +++ b/tensorrt_llm/_torch/visual_gen/models/cosmos3/defaults.py @@ -134,17 +134,6 @@ def _validate_output_type(output_type: str) -> None: raise ValueError(f"Cosmos3 output_type must be 'video' or 'image', got {output_type!r}.") -def _validate_video_reference(video) -> None: - """Preflight for the ``video`` extra param: encoded MP4/AVI bytes.""" - if not video: - raise ValueError("Cosmos3 video reference bytes are empty.") - if sniff_media_kind(video) != "video": - raise ValueError( - "Cosmos3 video reference bytes are not a recognized video " - "container (supported: MP4/AVI)." - ) - - # --------------------------------------------------------------------------- # Transfer preflight validators. # @@ -646,18 +635,6 @@ def _resolve_field( default=None, description="Optional scheduler flow shift override. Uses the Cosmos3 mode default when omitted.", ), - "video": ExtraParamSchema( - type="bytes", - default=None, - description=( - "V2V reference: encoded MP4/AVI bytes (e.g. " - "Path(video).read_bytes()). Each worker rank demuxes them from " - "memory and NVDEC-decodes only the conditioning window per " - "condition_video_latent_indexes / condition_video_keep, resized " - "to the output resolution, then VAE-encodes it." - ), - validator=_validate_video_reference, - ), "action_mode": ExtraParamSchema( type="Literal['policy', 'forward_dynamics', 'inverse_dynamics']", default=None, diff --git a/tensorrt_llm/_torch/visual_gen/models/cosmos3/pipeline_cosmos3.py b/tensorrt_llm/_torch/visual_gen/models/cosmos3/pipeline_cosmos3.py index a78e1bfba4a5..69a4434710a8 100644 --- a/tensorrt_llm/_torch/visual_gen/models/cosmos3/pipeline_cosmos3.py +++ b/tensorrt_llm/_torch/visual_gen/models/cosmos3/pipeline_cosmos3.py @@ -18,6 +18,7 @@ import math import os import time +from io import BytesIO from typing import Any, Iterable, List, Optional, Union import PIL.Image @@ -37,7 +38,7 @@ def tqdm(iterable, **kwargs): from tensorrt_llm._torch.visual_gen.models.wan.vae_loader import load_wan_vae from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer, PipelineOutput -from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline +from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline, RefSlotSpec, RoleSpec from tensorrt_llm._torch.visual_gen.pipeline_registry import PipelineComponent, register_pipeline from tensorrt_llm._torch.visual_gen.utils import ( classify_worker_error, @@ -45,7 +46,7 @@ def tqdm(iterable, **kwargs): synchronize_media_prepare_status, ) from tensorrt_llm._utils import nvtx_range -from tensorrt_llm.inputs.utils import load_image +from tensorrt_llm.inputs.media_io import convert_image_mode from tensorrt_llm.logger import logger from tensorrt_llm.media.decoding import decode_video_reference_window, video_stream_info @@ -236,7 +237,7 @@ def _condition_pixel_frame_count( return max(condition_video_latent_indexes) * int(temporal_compression) + 1 -def _load_reference_image(path: str): +def _load_reference_image(data: bytes): """Load an I2V reference, reporting unreadable content as a client error. The worker's load is the acceptance check — the serve boundary only @@ -246,7 +247,13 @@ def _load_reference_image(path: str): upload would be reported as a server fault. """ try: - return load_image(path, format="pil") + image = PIL.Image.open(BytesIO(data)) + # open() reads the header only. Truncated pixel data raises here and + # nowhere else: an already-RGB image never reaches a decoding convert. + image.load() + # convert_image_mode composites RGBA onto white, which is what this + # pipeline's references went through before. + return convert_image_mode(image, "RGB") except OSError as exc: raise ValueError( f"Image reference could not be decoded; it may be truncated, " @@ -602,6 +609,20 @@ def extra_param_specs(self): # ``default_use_system_prompt``. return dict(COSMOS3_EXTRA_SPECS) + @property + def ref_slot_specs(self) -> dict[str, RefSlotSpec]: + return { + # Both optional: Cosmos3 also runs T2V with neither. + "image_reference": RefSlotSpec( + modality="image", + roles=[RoleSpec(role="first_frame", min=0, max=1)], + ), + "video_reference": RefSlotSpec( + modality="video", + roles=[RoleSpec(role="reference", min=0, max=1)], + ), + } + def _run_warmup(self, height: int, width: int, num_frames: int, steps: int) -> None: # Checkpoint-aware guidance: distilled defaults carry a concrete 1.0; # base defaults leave it None ("by mode") — warmup runs the video mode. @@ -742,7 +763,10 @@ def as_given(field_name): value = getattr(req.params, field_name) return value if field_name in specified else None - video = extra_params.get("video") # encoded MP4/AVI bytes (the extra-param contract) + refs_v = req.params.video_reference + # Container and modality are already checked at the coordinator's + # reference choke point, so the bytes reaching here are known video. + video = refs_v[0].content if refs_v else None is_action = extra_params.get("action_mode") is not None if is_action: # Action resolves its whole recipe in forward() -- the canvas from @@ -807,11 +831,12 @@ def as_given(field_name): width = resolved["width"] num_inference_steps = resolved["num_inference_steps"] guidance_scale = resolved["guidance_scale"] + refs_i = req.params.image_reference return self.forward( prompt=req.prompt, negative_prompt=req.params.negative_prompt, - image=req.params.image, + image=refs_i[0].content if refs_i else None, height=height, width=width, num_frames=req.params.num_frames, @@ -1424,7 +1449,7 @@ def forward( self, prompt: Union[str, List[str]], negative_prompt: Optional[str] = None, - image: Optional[Union[PIL.Image.Image, torch.Tensor, str]] = None, + image: Optional[Union[PIL.Image.Image, torch.Tensor, bytes]] = None, height: Optional[int] = None, width: Optional[int] = None, num_frames: Optional[int] = None, @@ -1732,9 +1757,9 @@ def forward( raise ValueError("Batch generation is not supported for Cosmos3") # Validate image input — only single image is supported for batch generation - if image is not None and not isinstance(image, (PIL.Image.Image, torch.Tensor, str)): + if image is not None and not isinstance(image, (PIL.Image.Image, torch.Tensor, bytes)): raise ValueError( - f"`image` must be a PIL.Image, torch.Tensor, or file path string, " + f"`image` must be a PIL.Image, torch.Tensor, or encoded bytes, " f"got {type(image)}. Batch of different images is not supported; " f"use a single image with multiple prompts instead." ) @@ -1988,7 +2013,7 @@ def forward( elif image is not None: prepare_error: Optional[Exception] = None try: - if isinstance(image, str): + if isinstance(image, bytes): image = _load_reference_image(image) if isinstance(image, PIL.Image.Image): diff --git a/tensorrt_llm/_torch/visual_gen/models/flux/pipeline_flux2.py b/tensorrt_llm/_torch/visual_gen/models/flux/pipeline_flux2.py index 20420592caaa..724695babc08 100644 --- a/tensorrt_llm/_torch/visual_gen/models/flux/pipeline_flux2.py +++ b/tensorrt_llm/_torch/visual_gen/models/flux/pipeline_flux2.py @@ -18,11 +18,11 @@ - 4-axis RoPE: (32, 32, 32, 32) instead of 3-axis """ -import io import json import os import time from contextlib import contextmanager +from io import BytesIO from typing import Any, Iterator, List, Optional, Tuple, Union import numpy as np @@ -44,7 +44,7 @@ register_extractor_from_config, ) from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer, PipelineOutput -from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline +from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline, RefSlotSpec, RoleSpec from tensorrt_llm._torch.visual_gen.pipeline_registry import PipelineComponent, register_pipeline from tensorrt_llm.logger import logger @@ -370,12 +370,24 @@ def default_generation_params(self): "max_sequence_length": 512, } + @property + def ref_slot_specs(self) -> dict[str, RefSlotSpec]: + # Unbounded: one prompt can reference several subjects. FLUX.2 also + # runs plain text-to-image with no reference at all. + return { + "image_reference": RefSlotSpec( + modality="image", + roles=[RoleSpec(role="reference", min=0, max=None)], + ) + } + def prepare_request(self, req: Any) -> None: """Load and preprocess reference images before warmup bookkeeping.""" - if req.params.image is None: + refs = req.params.image_reference + if not refs: return - reference_images = self._load_reference_images(req.params.image) + reference_images = self._load_reference_images([r.content for r in refs]) condition_images = self._preprocess_reference_images(reference_images) req.params.height, req.params.width = self._resolve_target_dimensions( req.params.height, @@ -386,6 +398,7 @@ def prepare_request(self, req: Any) -> None: def infer(self, req): """Run inference from DiffusionRequest.""" + refs = req.params.image_reference return self.forward( prompt=req.prompt, height=req.params.height, @@ -395,7 +408,7 @@ def infer(self, req): seed=req.params.seed, max_sequence_length=req.params.max_sequence_length, num_images_per_prompt=req.params.num_images_per_prompt, - image=req.params.image, + image=[r.content for r in refs] if refs else None, _condition_images=req.prepared_inputs.get("condition_images"), ) @@ -413,9 +426,8 @@ def forward( image: Optional[ Union[ PIL.Image.Image, - str, bytes, - List[Union[PIL.Image.Image, str, bytes]], + List[Union[PIL.Image.Image, bytes]], ] ] = None, _condition_images: Optional[List[torch.Tensor]] = None, @@ -438,8 +450,9 @@ def forward( Each prompt's embeddings are repeated and independent noise is sampled, producing N different images per prompt. image: Reference image or shared list of reference images for - image conditioning. Public ``VisualGenParams`` requests use - file paths or encoded bytes; direct calls may also use PIL images. + image conditioning. References arrive here as encoded bytes, + whatever wire form the request declared; direct calls may also + use PIL images. Returns: PipelineOutput with image tensor (B, H, W, C) where @@ -741,12 +754,11 @@ def _prepare_latent_ids(self, height: int, width: int) -> torch.Tensor: def _load_reference_images( image: Union[ PIL.Image.Image, - str, bytes, - List[Union[PIL.Image.Image, str, bytes]], + List[Union[PIL.Image.Image, bytes]], ], ) -> List[PIL.Image.Image]: - """Normalize supported reference-image inputs to materialized RGB images.""" + """Normalize supported reference-image inputs to decoded RGB images.""" inputs = image if isinstance(image, list) else [image] if not inputs: raise ValueError("`image` must contain at least one reference image.") @@ -756,15 +768,11 @@ def _load_reference_images( try: if isinstance(item, PIL.Image.Image): images.append(item.convert("RGB")) - elif isinstance(item, str): - with PIL.Image.open(item) as loaded: - images.append(loaded.convert("RGB")) elif isinstance(item, bytes): - with PIL.Image.open(io.BytesIO(item)) as loaded: - images.append(loaded.convert("RGB")) + images.append(PIL.Image.open(BytesIO(item)).convert("RGB")) else: raise ValueError( - "Reference images must be PIL images, file paths, or encoded bytes; " + "Reference images must be PIL images or encoded bytes; " f"item {index} has type {type(item).__name__}." ) except OSError as exc: diff --git a/tensorrt_llm/_torch/visual_gen/models/hunyuan_video1_5/pipeline_hunyuan_video1_5.py b/tensorrt_llm/_torch/visual_gen/models/hunyuan_video1_5/pipeline_hunyuan_video1_5.py index 3c1b806391e2..015dc4452f50 100644 --- a/tensorrt_llm/_torch/visual_gen/models/hunyuan_video1_5/pipeline_hunyuan_video1_5.py +++ b/tensorrt_llm/_torch/visual_gen/models/hunyuan_video1_5/pipeline_hunyuan_video1_5.py @@ -548,7 +548,7 @@ def _run_warmup(self, height: int, width: int, num_frames: int, steps: int) -> N def infer(self, req): """Run inference from a DiffusionRequest (serve / high-level API path).""" - if req.params.image is not None: + if req.params.image_reference: raise ValueError( "HunyuanVideo 1.5 currently supports text-to-video only; " "image conditioning (I2V) is not supported." diff --git a/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2.py b/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2.py index 2ebee01b7d35..de6411754878 100644 --- a/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2.py +++ b/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2.py @@ -7,6 +7,7 @@ import json import os import time +from io import BytesIO from pathlib import Path from typing import Any, Dict, List, Optional, Set, Tuple, Union @@ -20,7 +21,12 @@ from tensorrt_llm._torch.visual_gen.checkpoints.prefetch import prefetch_files_to_host_cache from tensorrt_llm._torch.visual_gen.cuda_graph_runner import CUDAGraphRunner, CUDAGraphRunnerConfig from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer, PipelineOutput -from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline, ExtraParamSchema +from tensorrt_llm._torch.visual_gen.pipeline import ( + BasePipeline, + ExtraParamSchema, + RefSlotSpec, + RoleSpec, +) from tensorrt_llm._torch.visual_gen.pipeline_registry import PipelineComponent, register_pipeline from tensorrt_llm._torch.visual_gen.utils import postprocess_video_tensor from tensorrt_llm.logger import logger @@ -1228,10 +1234,10 @@ def _load_and_preprocess_image( Returns: Tensor of shape ``(1, 3, 1, H, W)`` in ``[-1, 1]``. """ - if isinstance(image, str): + if isinstance(image, bytes): from PIL import Image - pil_img = Image.open(image).convert("RGB") + pil_img = Image.open(BytesIO(image)).convert("RGB") pil_img = pil_img.resize((width, height), Image.LANCZOS) import numpy as np @@ -1374,9 +1380,19 @@ def extra_param_specs(self): ), } + @property + def ref_slot_specs(self) -> dict[str, RefSlotSpec]: + return { + "image_reference": RefSlotSpec( + modality="image", + roles=[RoleSpec(role="first_frame", min=0, max=1)], + ), + } + def infer(self, req): """Run inference with request parameters.""" extra = req.params.extra_params or {} + refs = req.params.image_reference return self.forward( prompt=req.prompt, negative_prompt=req.params.negative_prompt, @@ -1390,7 +1406,7 @@ def infer(self, req): output_type=extra["output_type"], guidance_rescale=extra["guidance_rescale"], max_sequence_length=req.params.max_sequence_length, - image=req.params.image, + image=refs[0].content if refs else None, image_cond_strength=extra["image_cond_strength"], stg_scale=extra["stg_scale"], stg_blocks=extra["stg_blocks"], diff --git a/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2_two_stages.py b/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2_two_stages.py index afb2bdd687e9..72377d4113b0 100644 --- a/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2_two_stages.py +++ b/tensorrt_llm/_torch/visual_gen/models/ltx2/pipeline_ltx2_two_stages.py @@ -1226,6 +1226,7 @@ def _load_two_stage_components( def infer(self, req): extra = req.params.extra_params or {} + refs = req.params.image_reference return self.forward( prompt=req.prompt, negative_prompt=req.params.negative_prompt, @@ -1239,7 +1240,7 @@ def infer(self, req): output_type=extra["output_type"], guidance_rescale=extra["guidance_rescale"], max_sequence_length=req.params.max_sequence_length, - image=req.params.image, + image=refs[0].content if refs else None, image_cond_strength=extra["image_cond_strength"], stg_scale=extra["stg_scale"], stg_blocks=extra["stg_blocks"], diff --git a/tensorrt_llm/_torch/visual_gen/models/qwen_image/pipeline_qwen_image_edit.py b/tensorrt_llm/_torch/visual_gen/models/qwen_image/pipeline_qwen_image_edit.py index ddc0c453bf7c..7cf64dabb121 100644 --- a/tensorrt_llm/_torch/visual_gen/models/qwen_image/pipeline_qwen_image_edit.py +++ b/tensorrt_llm/_torch/visual_gen/models/qwen_image/pipeline_qwen_image_edit.py @@ -14,12 +14,13 @@ from typing import Any import numpy as np +import PIL.Image import torch import torch.distributed as dist from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer, PipelineOutput +from tensorrt_llm._torch.visual_gen.pipeline import RefSlotSpec, RoleSpec from tensorrt_llm._torch.visual_gen.pipeline_registry import register_pipeline -from tensorrt_llm.inputs.utils import load_image from tensorrt_llm.logger import logger from .pipeline_qwen_image import QwenImagePipeline, _calculate_shift @@ -131,6 +132,15 @@ def default_generation_params(self) -> dict: def default_warmup_resolutions(self) -> list[tuple[int, int]]: return [(1024, 1024)] + @property + def ref_slot_specs(self) -> dict[str, RefSlotSpec]: + return { + "image_reference": RefSlotSpec( + modality="image", + roles=[RoleSpec(role="reference", min=1, max=None)], + ), + } + def warmup_cache_key(self, height: int | None, width: int | None, **kwargs) -> tuple: return (height, width) @@ -151,18 +161,27 @@ def _run_warmup(self, height: int, width: int, num_frames: int, steps: int) -> N @staticmethod def _load_edit_images(image: Any) -> list[Any]: + """Normalize reference inputs to RGB images. + + A request always arrives as encoded bytes; a direct caller may hand + over a PIL image instead, and converting it here costs nothing while + keeping ``forward`` usable as a library entry point. + """ if image is None: - raise ValueError("Qwen-Image-Edit requires params.image.") + raise ValueError("Qwen-Image-Edit requires image_reference.") images = image if isinstance(image, list) else [image] - pil_images = [] - for item in images: - if isinstance(item, bytes): - from PIL import Image - - pil_images.append(Image.open(BytesIO(item)).convert("RGB")) + loaded = [] + for index, item in enumerate(images): + if isinstance(item, PIL.Image.Image): + loaded.append(item.convert("RGB")) + elif isinstance(item, bytes): + loaded.append(PIL.Image.open(BytesIO(item)).convert("RGB")) else: - pil_images.append(load_image(item, format="pil")) - return pil_images + raise ValueError( + "Reference images must be PIL images or encoded bytes; " + f"item {index} has type {type(item).__name__}." + ) + return loaded def _preprocess_edit_images( self, @@ -341,7 +360,8 @@ def infer(self, req: Any) -> PipelineOutput: "QwenImageEditPlusPipeline currently supports num_images_per_prompt=1 only." ) prompts = req.prompt if isinstance(req.prompt, list) else [req.prompt] - pil_images = self._load_edit_images(params.image) + refs = params.image_reference + pil_images = self._load_edit_images([r.content for r in refs] if refs else None) height = params.height width = params.width if height is None or width is None: diff --git a/tensorrt_llm/_torch/visual_gen/models/qwen_image_layered/pipeline_qwen_image_layered.py b/tensorrt_llm/_torch/visual_gen/models/qwen_image_layered/pipeline_qwen_image_layered.py index 440979a5a287..547e4f960811 100644 --- a/tensorrt_llm/_torch/visual_gen/models/qwen_image_layered/pipeline_qwen_image_layered.py +++ b/tensorrt_llm/_torch/visual_gen/models/qwen_image_layered/pipeline_qwen_image_layered.py @@ -14,16 +14,22 @@ # limitations under the License. """Qwen-Image-Layered image decomposition pipeline.""" -import io import math import time +from io import BytesIO from typing import List, Optional, Tuple, Union import numpy as np +import PIL.Image import torch from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer, PipelineOutput -from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline, ExtraParamSchema +from tensorrt_llm._torch.visual_gen.pipeline import ( + BasePipeline, + ExtraParamSchema, + RefSlotSpec, + RoleSpec, +) from tensorrt_llm._torch.visual_gen.pipeline_registry import PipelineComponent, register_pipeline from tensorrt_llm.logger import logger @@ -248,6 +254,15 @@ def extra_param_specs(self) -> dict: ), } + @property + def ref_slot_specs(self) -> dict[str, RefSlotSpec]: + return { + "image_reference": RefSlotSpec( + modality="image", + roles=[RoleSpec(role="reference", min=1, max=1)], + ), + } + def load_standard_components( self, checkpoint_dir: str, @@ -347,14 +362,11 @@ def load_weights(self, weights: dict) -> None: @staticmethod def _load_image_input(image): - from PIL import Image - if isinstance(image, list): return [QwenImageLayeredPipeline._load_image_input(item) for item in image] - if isinstance(image, str): - return Image.open(image).convert("RGBA") if isinstance(image, bytes): - return Image.open(io.BytesIO(image)).convert("RGBA") + # Layer decomposition needs the alpha channel, not a flattened RGB. + return PIL.Image.open(BytesIO(image)).convert("RGBA") if hasattr(image, "convert") and getattr(image, "mode", None) != "RGBA": return image.convert("RGBA") return image @@ -758,8 +770,9 @@ def infer(self, req): ) negative = [n for n in negatives for _ in range(num_per)] + refs = req.params.image_reference return self.forward( - image=req.params.image, + image=refs[0].content if refs else None, prompt=prompts, negative_prompt=negative, height=req.params.height, diff --git a/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan.py b/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan.py index 148cf659da22..fbf9684736f1 100644 --- a/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan.py +++ b/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan.py @@ -15,6 +15,7 @@ import os import time +from io import BytesIO from typing import List, Optional, Union import diffusers @@ -39,7 +40,7 @@ ) from tensorrt_llm._torch.visual_gen.models.wan.pipeline_wan_utils import retrieve_latents from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer, PipelineOutput -from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline +from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline, RefSlotSpec, RoleSpec from tensorrt_llm._torch.visual_gen.pipeline_registry import PipelineComponent, register_pipeline from tensorrt_llm._torch.visual_gen.utils import postprocess_video_tensor from tensorrt_llm._utils import nvtx_range @@ -424,17 +425,25 @@ def default_generation_params(self): def extra_param_specs(self): return get_wan_extra_param_specs(self.is_wan22_14b) + @property + def ref_slot_specs(self) -> dict[str, RefSlotSpec]: + # Only TI2V-5B takes a reference; the T2V variants declare no slot, so + # an image request fails at preflight instead of inside forward(). + if not self.is_wan22_5b: + return {} + return { + "image_reference": RefSlotSpec( + modality="image", + roles=[RoleSpec(role="first_frame", min=0, max=1)], + ) + } + def infer(self, req): """Run inference with request parameters.""" extra = req.params.extra_params or {} # Wan 2.2 TI2V-5B takes one conditioning image if provided - image = req.params.image - if isinstance(image, list): - if len(image) != 1: - raise ValueError( - f"WanPipeline I2V expects a single image, got list of {len(image)}." - ) - image = image[0] + refs = req.params.image_reference + image = refs[0].content if refs else None return self.forward( prompt=req.prompt, @@ -466,7 +475,7 @@ def forward( guidance_scale_2: Optional[float] = None, boundary_ratio: Optional[float] = None, max_sequence_length: int = 512, - image: Optional[Union[PIL.Image.Image, torch.Tensor, str]] = None, + image: Optional[Union[PIL.Image.Image, torch.Tensor, bytes]] = None, ): pipeline_start = time.time() timer = CudaPhaseTimer() @@ -783,7 +792,7 @@ def _prepare_latents( def _prepare_latents_wan22_5B_i2v( self, batch_size: int, - image: Union[PIL.Image.Image, torch.Tensor, str], + image: Union[PIL.Image.Image, torch.Tensor, bytes], height: int, width: int, num_frames: int, @@ -804,8 +813,8 @@ def _prepare_latents_wan22_5B_i2v( latents = randn_tensor(shape, generator=generator, device=self.device, dtype=self.dtype) # Load and preprocess image - if isinstance(image, str): - image = PIL.Image.open(image).convert("RGB") + if isinstance(image, bytes): + image = PIL.Image.open(BytesIO(image)).convert("RGB") image = ( self.video_processor.preprocess(image, height=height, width=width) .to(self.device, dtype=self.vae.dtype) diff --git a/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan_i2v.py b/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan_i2v.py index d8318fe02fe8..332fb7dc530c 100644 --- a/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan_i2v.py +++ b/tensorrt_llm/_torch/visual_gen/models/wan/pipeline_wan_i2v.py @@ -16,6 +16,7 @@ import json import os import time +from io import BytesIO from typing import List, Optional, Tuple, Union import diffusers @@ -36,7 +37,7 @@ ) from tensorrt_llm._torch.visual_gen.models.wan.pipeline_wan_utils import retrieve_latents from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer, PipelineOutput -from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline, ExtraParamSchema +from tensorrt_llm._torch.visual_gen.pipeline import BasePipeline, RefSlotSpec, RoleSpec from tensorrt_llm._torch.visual_gen.pipeline_registry import PipelineComponent, register_pipeline from tensorrt_llm._torch.visual_gen.utils import postprocess_video_tensor from tensorrt_llm.logger import logger @@ -402,26 +403,34 @@ def default_generation_params(self): @property def extra_param_specs(self): - specs = get_wan_extra_param_specs(self.is_wan22_14b) - specs["last_image"] = ExtraParamSchema( - type="str", - default=None, - description="Last frame path for video interpolation (Wan I2V).", - ) - return specs + return get_wan_extra_param_specs(self.is_wan22_14b) + + @property + def ref_slot_specs(self) -> dict[str, RefSlotSpec]: + # The last frame is what interpolation conditions on. + return { + "image_reference": RefSlotSpec( + modality="image", + roles=[ + RoleSpec(role="first_frame", min=1, max=1), + RoleSpec(role="last_frame", min=0, max=1), + ], + ) + } def infer(self, req): """Run inference with request parameters.""" - # Extract image from request (can be path, PIL Image, or torch.Tensor) - if req.params.image is None: - raise ValueError("I2V pipeline requires 'image' parameter") - - image = req.params.image[0] if isinstance(req.params.image, list) else req.params.image + refs = req.params.image_reference + if not refs: + raise ValueError("I2V pipeline requires an image_reference (first_frame)") + by_role = {r.role: r for r in refs} + first = by_role.get("first_frame") or by_role.get(None) + if first is None: + raise ValueError("I2V pipeline requires a first_frame image_reference") + last = by_role.get("last_frame") + image = first.content + last_image = last.content if last is not None else None extra = req.params.extra_params or {} - last_image = extra.get("last_image") - - if last_image is not None and isinstance(last_image, list): - last_image = last_image[0] if last_image else None return self.forward( image=image, @@ -442,7 +451,7 @@ def infer(self, req): @torch.no_grad() def forward( self, - image: Union[PIL.Image.Image, torch.Tensor, str], + image: Union[PIL.Image.Image, torch.Tensor, bytes], prompt: Union[str, List[str]], seed: int, negative_prompt: Optional[str] = None, @@ -454,16 +463,16 @@ def forward( guidance_scale_2: Optional[float] = None, boundary_ratio: Optional[float] = None, max_sequence_length: int = 512, - last_image: Optional[Union[PIL.Image.Image, torch.Tensor, str]] = None, + last_image: Optional[Union[PIL.Image.Image, torch.Tensor, bytes]] = None, ): pipeline_start = time.time() timer = CudaPhaseTimer() timer.mark_pre_start() # Validate image input — only single image is supported for batch generation - if not isinstance(image, (PIL.Image.Image, torch.Tensor, str)): + if not isinstance(image, (PIL.Image.Image, torch.Tensor, bytes)): raise ValueError( - f"`image` must be a PIL.Image, torch.Tensor, or file path string, " + f"`image` must be a PIL.Image, torch.Tensor, or encoded bytes, " f"got {type(image)}. Batch of different images is not supported; " f"use a single image with multiple prompts instead." ) @@ -715,14 +724,14 @@ def get_embeds(texts): def _encode_image( self, - image: Union[PIL.Image.Image, torch.Tensor, str], - last_image: Optional[Union[PIL.Image.Image, torch.Tensor, str]] = None, + image: Union[PIL.Image.Image, torch.Tensor, bytes], + last_image: Optional[Union[PIL.Image.Image, torch.Tensor, bytes]] = None, ) -> torch.Tensor: """Encode image(s) using CLIP image encoder (Wan 2.1 I2V only).""" - if isinstance(image, str): - image = PIL.Image.open(image).convert("RGB") - if isinstance(last_image, str): - last_image = PIL.Image.open(last_image).convert("RGB") + if isinstance(image, bytes): + image = PIL.Image.open(BytesIO(image)).convert("RGB") + if isinstance(last_image, bytes): + last_image = PIL.Image.open(BytesIO(last_image)).convert("RGB") images_to_encode = [image] if last_image is None else [image, last_image] @@ -736,12 +745,12 @@ def _encode_image( def _prepare_latents( self, batch_size: int, - image: Union[PIL.Image.Image, torch.Tensor, str], + image: Union[PIL.Image.Image, torch.Tensor, bytes], height: int, width: int, num_frames: int, generator: torch.Generator, - last_image: Optional[Union[PIL.Image.Image, torch.Tensor, str]] = None, + last_image: Optional[Union[PIL.Image.Image, torch.Tensor, bytes]] = None, ) -> Tuple[torch.Tensor, torch.Tensor]: """Prepare latents with image conditioning for I2V generation.""" num_channels_latents = 16 @@ -753,15 +762,15 @@ def _prepare_latents( latents = randn_tensor(shape, generator=generator, device=self.device, dtype=self.dtype) # Load and preprocess image(s) - if isinstance(image, str): - image = PIL.Image.open(image).convert("RGB") + if isinstance(image, bytes): + image = PIL.Image.open(BytesIO(image)).convert("RGB") image = self.video_processor.preprocess(image, height=height, width=width).to( self.device, dtype=torch.float32 ) if last_image is not None: - if isinstance(last_image, str): - last_image = PIL.Image.open(last_image).convert("RGB") + if isinstance(last_image, bytes): + last_image = PIL.Image.open(BytesIO(last_image)).convert("RGB") last_image = self.video_processor.preprocess(last_image, height=height, width=width).to( self.device, dtype=torch.float32 ) diff --git a/tensorrt_llm/_torch/visual_gen/pipeline.py b/tensorrt_llm/_torch/visual_gen/pipeline.py index d4978872e471..964c9a89a951 100644 --- a/tensorrt_llm/_torch/visual_gen/pipeline.py +++ b/tensorrt_llm/_torch/visual_gen/pipeline.py @@ -27,9 +27,11 @@ from tensorrt_llm._torch.autotuner import autotune from tensorrt_llm._torch.visual_gen.pipeline_registry import PipelineComponent from tensorrt_llm._utils import nvtx_range +from tensorrt_llm.inputs.media_io import MediaModality from tensorrt_llm.llmapi.utils import StrictBaseModel from tensorrt_llm.logger import logger from tensorrt_llm.mapping import Mapping +from tensorrt_llm.visual_gen.params import MediaRole from .cache import CacheDiTAccelerator, TeaCacheAccelerator from .checkpoints import WeightLoader @@ -70,6 +72,30 @@ class ExtraParamSchema(StrictBaseModel): ) +class RoleSpec(StrictBaseModel): + """One accepted role for a reference modality, with its count bounds.""" + + role: MediaRole = Field(description="Role of the reference input.") + min: int = Field(default=1, description="Minimum count for this role.") + max: Optional[int] = Field( + default=1, description="Maximum count for this role (None = unbounded)." + ) + + +class RefSlotSpec(StrictBaseModel): + """Reference slot a pipeline accepts for one modality. + + A request item's ``role`` is required only when ``roles`` has more than one + entry (same modality carries multiple roles, e.g. first + last frame); + otherwise the single declared role is inferred. Exposed via + ``VisualGen.ref_slot_specs`` and enforced by ``validate_visual_gen_params``. + Pickled to the coordinator in the READY handshake, so keep it plain data. + """ + + modality: MediaModality = Field(description="Reference modality.") + roles: List[RoleSpec] = Field(description="Accepted roles + counts for this modality.") + + if TYPE_CHECKING: from .cache import CacheAccelerator from .config import DiffusionPipelineConfig @@ -350,6 +376,17 @@ def extra_param_specs(self) -> Dict[str, ExtraParamSchema]: """ return {} + @property + def ref_slot_specs(self) -> Dict[str, RefSlotSpec]: + """Reference slots this pipeline accepts. + + Maps a ``VisualGenParams`` reference field name + (``image_reference`` / ``video_reference`` / ``audio_reference``) to a + :class:`RefSlotSpec` declaring the accepted roles and per-role counts. + Empty by default (pipeline takes no reference inputs). + """ + return {} + @property def default_generation_params(self) -> dict: """Model-specific defaults for ``None`` fields in ``VisualGenParams``. diff --git a/tensorrt_llm/inputs/media_io.py b/tensorrt_llm/inputs/media_io.py index 19c10697416f..bdfa472e9afc 100644 --- a/tensorrt_llm/inputs/media_io.py +++ b/tensorrt_llm/inputs/media_io.py @@ -14,6 +14,7 @@ from concurrent.futures import Executor from io import BytesIO from pathlib import Path +from stat import S_ISREG from types import MappingProxyType from typing import ( Any, @@ -386,6 +387,18 @@ def _get_cv2(): } ) +# ISO-BMFF audio: an `.m4a` is an MP4 whose brand says audio-only. Without +# these it falls through to the video default, which is how an audio file ends +# up classified as video. +_ISOBMFF_AUDIO_BRANDS = frozenset( + { + b"M4A ", + b"M4B ", # iTunes audio / audiobook + b"F4A ", + b"F4B ", # Flash audio / audiobook + } +) + # Work bound, not a format rule. The declared box size is client-controlled, # so without a ceiling the scan below costs O(payload): 1.6 s of interpreter # time for a 64 MB buffer, on the serving event loop. This admits 1020 @@ -470,12 +483,33 @@ def sniff_media_kind(data) -> Optional[str]: # major brand alone is not sufficient to identify still images. if brands & _ISOBMFF_IMAGE_BRANDS: return "image" + if brands & _ISOBMFF_AUDIO_BRANDS: + return "audio" return "video" - if header.startswith(b"RIFF") and header[8:12] == b"AVI ": - return "video" + if header.startswith(b"RIFF"): + # RIFF carries both; the form type at [8:12] is what separates them. + if header[8:12] == b"AVI ": + return "video" + if header[8:12] == b"WAVE": + return "audio" + return None + if header.startswith(b"OggS") or header.startswith(b"fLaC") or header.startswith(b"ID3"): + return "audio" + if _is_mpeg_audio_sync(header): + return "audio" return None +def _is_mpeg_audio_sync(header: bytes) -> bool: + """True for a bare MPEG audio / ADTS AAC frame — an MP3 with no ID3 tag. + + The sync word is eleven set bits, so the second byte carries three bits of + version/layer alongside it; matching the mask rather than a byte list keeps + every MPEG-1/2/2.5 layer and ADTS variant in scope. + """ + return len(header) >= 2 and header[0] == 0xFF and (header[1] & 0xE0) == 0xE0 + + def _select_cv2_stream_buffered_backend() -> Optional[int]: """Return a VideoCapture backend that can read from a Python `BytesIO`. @@ -716,6 +750,39 @@ def _normalize_file_uri(uri: str) -> str: return uri +def _safe_read_local_file(location: str) -> bytes: + """Read a local file, refusing anything that has no end. + + Takes a bare path or a ``file://`` URI. + + The counterpart of :func:`_safe_request_get` for the local branch, and it + guards the one case that is unbounded rather than merely large: reading a + character device never reaches EOF and reading a FIFO blocks, so either + turns a caller into a denial of service. A regular file is finite, which is + the property required here. + + Size is deliberately not capped: naming a large file of one's own is the + normal case for a local caller, and no threshold separates that from an + abusive one. This bounds the shape of what may be read, then, not its size + or its reach — any regular file the process can read is still readable. + """ + path = Path(_normalize_file_uri(location)) + try: + stat = path.stat() # follows symlinks, so a link to a device is caught + except OSError as exc: + raise ValueError(f"file could not be read: {exc}") from exc + + if not S_ISREG(stat.st_mode): + raise ValueError( + f"path is not a regular file: {location!r}. Character devices, " + "FIFOs and directories cannot be read as media." + ) + try: + return path.read_bytes() + except OSError as exc: + raise ValueError(f"file could not be read: {exc}") from exc + + _MediaT = TypeVar("_MediaT") diff --git a/tensorrt_llm/serve/openai_protocol.py b/tensorrt_llm/serve/openai_protocol.py index 84fed73f2b9f..f4d329e24b23 100644 --- a/tensorrt_llm/serve/openai_protocol.py +++ b/tensorrt_llm/serve/openai_protocol.py @@ -61,6 +61,7 @@ from tensorrt_llm.sampling_params import (check_logprobs_limit, validate_thinking_token_budget) from tensorrt_llm.scheduling_params import AgentHierarchy +from tensorrt_llm.visual_gen.params import MediaRole _LOGIT_BIAS_MIN = -100.0 _LOGIT_BIAS_MAX = 100.0 @@ -2011,6 +2012,35 @@ class ImageGenerationResponse(OpenAIBaseModel): size: Optional[str] = None +class MediaReferenceItem(OpenAIBaseModel): + """One media reference (image / video / audio) for conditioning (mirrors ``MediaRef``). + + ``format`` declares how to read ``content``; it is required, so no wire form + is ever guessed. The request field it sits in (``image_reference`` / + ``video_reference`` / ``audio_reference``) fixes the modality. ``role`` is + required only when it is ambiguous — a model with more than one required role + for that modality; a single role, or a single required role (e.g. i2v + first_frame), is inferred. + """ + + content: str = Field( + description="The reference payload, in the form declared by ``format``." + ) + format: Literal["path", "url", "base64"] = Field(description=( + "Wire form of ``content``: ``path`` (a file readable by the server; a " + "``file://`` URI is also accepted), ``url`` (``http(s)``, fetched " + "through the SSRF-guarded loader), or ``base64`` (a ``data:`` URI is " + "also accepted). Raw bytes reach the server as a multipart upload, " + "which carries no ``format`` of its own. Distinct from the top-level " + "``format``, which selects the *output* encoding.")) + role: Optional[MediaRole] = Field( + default=None, + description="Which conditioning slot this reference fills. Required only " + "when the target model accepts this modality in more than one slot; omit " + "it when the model leaves no ambiguity.", + ) + + class VideoGenerationRequest(OpenAIBaseModel): """Video generation request (extended API). @@ -2034,14 +2064,46 @@ class VideoGenerationRequest(OpenAIBaseModel): seed: Optional[int] = Field(default=None, ge=0, description="Random seed for reproducibility.") + image_reference: Optional[Union[ + UploadFile, MediaReferenceItem, List[MediaReferenceItem]]] = Field( + default=None, + description= + ("Image reference(s) conditioning generation (e.g. image-to-video " + "first frame). Send a ``{content, format, role}`` object or a list " + "of them, where ``format`` is ``path`` / ``url`` / ``base64``; or " + "upload a single image file via multipart, whose form is implied. " + "PNG or JPEG only — HEIF/AVIF are not supported."), + ) + video_reference: Optional[Union[ + UploadFile, MediaReferenceItem, List[MediaReferenceItem]]] = Field( + default=None, + description= + ("Video reference(s) conditioning generation (video-to-video). Send " + "a ``{content, format}`` object or a list of them, where ``format`` " + "is ``path`` / ``url`` / ``base64``; or upload a single video file " + "via multipart, whose form is implied. MP4 or AVI, with H.264 the " + "tested codec and others best-effort."), + ) + audio_reference: Optional[Union[ + UploadFile, MediaReferenceItem, List[MediaReferenceItem]]] = Field( + default=None, + description= + ("Audio reference(s) conditioning generation. Send a " + "``{content, format}`` object or a list of them, where ``format`` " + "is ``path`` / ``url`` / ``base64``; or upload a single audio " + "file via multipart, whose form is implied. Accepted only by " + "models that declare an audio reference slot."), + ) input_reference: Optional[Union[str, UploadFile]] = Field( default=None, - description=( - "Optional image or video reference that guides generation. PNG or " - "JPEG images condition image-to-video; MP4 or AVI video conditions " - "video-to-video, with H.264 the tested codec and others " - "best-effort. HEIF/AVIF are not supported. JSON requests carry " - "base64 bytes; multipart requests upload the file."), + description= + ("Deprecated. A single image or video reference, routed by content " + "signature to image-to-video or video-to-video. A JSON request carries " + "base64 bytes; a multipart request uploads the file. Kept for backward " + "compatibility; prefer the typed ``image_reference`` / " + "``video_reference`` fields, which take precedence — this field is " + "ignored whenever a typed ``image_reference`` or ``video_reference`` " + "is provided."), ) # Resolution diff --git a/tensorrt_llm/serve/openai_server.py b/tensorrt_llm/serve/openai_server.py index dd5669fac0e9..88bbafed5fc7 100644 --- a/tensorrt_llm/serve/openai_server.py +++ b/tensorrt_llm/serve/openai_server.py @@ -107,8 +107,8 @@ from tensorrt_llm.serve.tool_parser.tool_parser_factory import ToolParserFactory from tensorrt_llm.serve.visual_gen_metrics import ( build_visual_gen_server_timings, build_visual_gen_timing_headers) -from tensorrt_llm.serve.visual_gen_utils import ( - cleanup_materialized_conditioning_inputs, parse_visual_gen_params) +from tensorrt_llm.serve.visual_gen_utils import (local_media_path_is_disallowed, + parse_visual_gen_params) from tensorrt_llm.usage import TerminalOutcome, record_termination_observation from tensorrt_llm.version import __version__ as VERSION @@ -3070,14 +3070,16 @@ async def openai_image_generation(self, request: ImageGenerationRequest, # through to the outer ``except Exception`` → 500 so the # client doesn't get blamed for a server-internal failure. try: - params = parse_visual_gen_params(request, image_id, - self.generator) + params = parse_visual_gen_params(request, self.generator) logger.info( f"Generating image: {image_id} with params: {params} and prompt: {request.prompt}" ) image_gen_start = time.perf_counter() - output = self.generator.generate(inputs=request.prompt, - params=params) + # Offload the blocking resolve/enqueue off the event loop but + # await it (bad params → 400 here); then await generation. + handle = await asyncio.to_thread(self.generator.generate_async, + request.prompt, params) + output = await handle.aresult() except ValueError as exc: logger.error(f"Image request error: {exc}") return self.create_error_response( @@ -3225,20 +3227,12 @@ def _reject_disabled_path( self, response_format: Optional[str]) -> Optional[Response]: """Return a 400 when ``response_format='path'`` but it is disabled. - ``path`` discloses absolute server-side filesystem paths, so it can be - turned off via ``TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1`` on shared / - untrusted deployments (enabled by default). Returns ``None`` when - allowed. + Shares the switch with ``format='path'`` on the request side; see + :func:`local_media_path_is_disallowed`. Returns ``None`` when allowed. """ if response_format != "path": return None - raw = os.environ.get("TRTLLM_DISALLOW_LOCAL_MEDIA_PATH", "0") - if raw not in ("0", "1"): - logger.warning( - "Unrecognized value for TRTLLM_DISALLOW_LOCAL_MEDIA_PATH: " - f"{raw!r}. Expected '0' or '1'. Treating as '0' " - "(response_format='path' enabled).") - if raw == "1": + if local_media_path_is_disallowed(): return self.create_error_response( "response_format='path' is disabled on this server " "(TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1); it returns " @@ -3328,29 +3322,19 @@ async def openai_image_edit(self, raw_request: Request) -> Response: try: image_id = f"image_{uuid.uuid4().hex}" - input_paths = None try: request = await self._parse_image_edit_request(raw_request) path_error = self._reject_disabled_path(request.response_format) if path_error is not None: return path_error - params = parse_visual_gen_params( - request, - image_id, - self.generator, - media_storage_path=str(self.media_storage_path), - ) - input_paths = params.image + params = parse_visual_gen_params(request, self.generator) logger.info( f"Editing image: {image_id} with params: {params} and prompt: {request.prompt}" ) image_edit_start = time.perf_counter() - try: - output = self.generator.generate(inputs=request.prompt, - params=params) - finally: - cleanup_materialized_conditioning_inputs(input_paths) + output = self.generator.generate(inputs=request.prompt, + params=params) except ValidationError as exc: return self._render_pydantic_validation_error(exc) except ValueError as exc: diff --git a/tensorrt_llm/serve/openai_video_routes.py b/tensorrt_llm/serve/openai_video_routes.py index 8fbaca541071..ba61e83a2d57 100644 --- a/tensorrt_llm/serve/openai_video_routes.py +++ b/tensorrt_llm/serve/openai_video_routes.py @@ -41,6 +41,7 @@ # Type-only: importing tensorrt_llm.visual_gen at runtime would pull the # whole visual_gen tree into every LLM serving process. from tensorrt_llm.visual_gen.params import VisualGenParams + from tensorrt_llm.visual_gen.visual_gen import VisualGenResult def _video_content_type(suffix: str) -> str: @@ -56,6 +57,9 @@ def _video_content_type(suffix: str) -> str: # /v1/videos/{id} routes try when the stored output_path is missing. _KNOWN_VIDEO_OUTPUT_SUFFIXES = (".mp4", ".avi", ".safetensors", ".pt") +# Reference fields whose multipart text form is a JSON object, not a scalar. +_REFERENCE_FIELDS = ("image_reference", "video_reference", "audio_reference") + def _resolve_tensor_only_format(fmt, extra_params, extra_param_specs): """Resolve ``format`` for a request whose result an encoder cannot carry. @@ -141,28 +145,21 @@ async def openai_video_generation_sync(self, raw_request: Request) -> Response: Supports both JSON and multipart/form-data requests: - JSON: Send VideoGenerationRequest as application/json - - Multipart: Send form fields + optional input_reference file + - Multipart: Send form fields + optional image_reference / video_reference file """ request_received = raw_request.state.server_arrival_time + # Prefixes this request's output files (``{video_id}_{i}``). + video_id = f"video_{uuid.uuid4().hex}" try: - # Client-side ValueErrors from content-type parsing, request - # translation, encoder-format preflight, parameter validation, - # and the synchronous engine call return 400. Serialization / - # encoder failures further down (server-side) fall through to - # the outer ``except Exception`` → 500. + # ValueError here is the client's fault and returns 400; anything + # further down falls through to the outer handler as a 500. try: # Parse request based on content-type request = await self._parse_video_generation_request(raw_request) path_error = self._reject_disabled_path(request.response_format) if path_error is not None: return path_error - video_id = f"video_{uuid.uuid4().hex}" - params = parse_visual_gen_params( - request, - video_id, - self.generator, - media_storage_path=str(self.media_storage_path), - ) + params = parse_visual_gen_params(request, self.generator) request_format = _resolve_tensor_only_format( request.format, request.extra_params, self.generator.extra_param_specs ) @@ -171,7 +168,12 @@ async def openai_video_generation_sync(self, raw_request: Request) -> Response: f"Generating video: {video_id} with params: {params} and prompt: {request.prompt}" ) sync_video_start = time.perf_counter() - output = self.generator.generate(inputs=request.prompt, params=params) + # Awaited, not fire-and-forget: bad media / bad params must + # surface as a 400 rather than a failed job. + handle = await asyncio.to_thread( + self.generator.generate_async, request.prompt, params + ) + output = await handle.aresult() except ValidationError as exc: return self._render_pydantic_validation_error(exc) except ValueError as exc: @@ -309,8 +311,8 @@ async def _parse_video_generation_request( for key in form: value = form[key] if hasattr(value, "file"): - # Uploaded file (``input_reference``) — pass through - # so the conversion layer reads ``.file``. + # Uploaded reference file (image_reference / video_reference) + # — pass through so the conversion layer reads ``.file``. data[key] = value continue if key == "extra_params": @@ -323,6 +325,19 @@ async def _parse_video_generation_request( f"'extra_params' must be a JSON object string; {exc}" ) from exc continue + if key in _REFERENCE_FIELDS: + # A reference sent as a text part is the object form; a file + # part was already routed above and carries its own format. + if value == "": + continue + try: + data[key] = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError( + f"'{key}' must be an uploaded file or a JSON object string " + f'like {{"content": ..., "format": "url"}}; {exc}' + ) from exc + continue if value == "": continue data[key] = value @@ -373,9 +388,11 @@ async def openai_video_generation_async( Supports both JSON and multipart/form-data requests: - JSON: Send VideoGenerationRequest as application/json - - Multipart: Send form fields + optional input_reference file + - Multipart: Send form fields + optional image_reference / video_reference file """ request_received = raw_request.state.server_arrival_time + # Prefixes this request's output files and keys its VIDEO_STORE entry. + video_id = f"video_{uuid.uuid4().hex}" try: # Parse request based on content-type request = await self._parse_video_generation_request(raw_request) @@ -383,10 +400,7 @@ async def openai_video_generation_async( if path_error is not None: return path_error - video_id = f"video_{uuid.uuid4().hex}" - params = parse_visual_gen_params( - request, video_id, self.generator, media_storage_path=str(self.media_storage_path) - ) + params = parse_visual_gen_params(request, self.generator) # Synchronously validate the resolved params against the # loaded pipeline's extra-param specs / declared defaults # so unknown ``extra_params`` keys and similar engine-side @@ -398,6 +412,7 @@ async def openai_video_generation_async( params, declared_defaults=self.generator.executor.default_generation_params, extra_param_specs=self.generator.executor.extra_param_specs, + ref_slot_specs=self.generator.executor.ref_slot_specs, ) request_format = _resolve_tensor_only_format( request.format, request.extra_params, self.generator.extra_param_specs @@ -407,6 +422,10 @@ async def openai_video_generation_async( f"Generating video: {video_id} with params: {params} and prompt: {request.prompt}" ) + # Awaited before the 202, so bad media / unknown extra_params are a + # 400 rather than a queued job that later fails. + handle = await asyncio.to_thread(self.generator.generate_async, request.prompt, params) + # Persist the queued job before scheduling the background task so # that a fast-completing task can always look it up in VIDEO_STORE. video_job = VideoJob( @@ -423,13 +442,14 @@ async def openai_video_generation_async( ) await VIDEO_STORE.upsert(video_id, video_job) - # Start background generation task + # Start background task to await generation and save the result. task = asyncio.create_task( self._generate_video_background( video_id=video_id, request=request, params=params, request_format=request_format, + handle=handle, ) ) self.video_gen_tasks[video_id] = task @@ -456,8 +476,9 @@ async def _generate_video_background( request: VideoGenerationRequest, params: VisualGenParams, request_format: str, + handle: "VisualGenResult", ): - """Background task to generate video and save to storage. + """Background task to await generation and save to storage. ``request_format`` is the format already resolved by the route (see :func:`_resolve_tensor_only_format`), not ``request.format``: the @@ -470,8 +491,7 @@ async def _generate_video_background( if job: job.status = "generating" await VIDEO_STORE.upsert(video_id, job) - future = self.generator.generate_async(inputs=request.prompt, params=params) - output = await future + output = await handle if output.video is None: # Update job status to failed since we're in a background task diff --git a/tensorrt_llm/serve/visual_gen_utils.py b/tensorrt_llm/serve/visual_gen_utils.py index be916bcc8ba0..0702470f6d4f 100644 --- a/tensorrt_llm/serve/visual_gen_utils.py +++ b/tensorrt_llm/serve/visual_gen_utils.py @@ -11,7 +11,7 @@ from PIL import Image, UnidentifiedImageError -from tensorrt_llm.inputs.media_io import is_isobmff_image_bytes, sniff_media_kind +from tensorrt_llm.inputs.media_io import sniff_media_kind from tensorrt_llm.logger import logger from tensorrt_llm.serve.openai_protocol import ( ImageEditRequest, @@ -20,6 +20,8 @@ ) if TYPE_CHECKING: + from fastapi import UploadFile + # Type-only: importing tensorrt_llm.visual_gen at runtime would pull the # whole visual_gen tree into every LLM serving process. from tensorrt_llm.visual_gen import VisualGen, VisualGenParams @@ -107,21 +109,149 @@ def _merge_extra_params( params.extra_params = None -def _read_reference_payload(reference) -> bytes: - """Read the ``input_reference`` payload (base64 JSON or multipart file). +def local_media_path_is_disallowed() -> bool: + """Whether server-side filesystem paths are refused at the HTTP boundary. + + One switch for both directions, because both are the same trust question: + a ``path`` reference has the server read its own disk, and + ``response_format='path'`` has it disclose where it wrote. Either is what a + co-located client wants and what a remote one has no business doing, and + the code cannot tell the two deployments apart, so both are allowed by + default and turned off together with ``TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1``. + The local Python API is unaffected: this gate is the HTTP boundary's. + """ + raw = os.environ.get("TRTLLM_DISALLOW_LOCAL_MEDIA_PATH", "0") + if raw not in ("0", "1"): + logger.warning( + "Unrecognized value for TRTLLM_DISALLOW_LOCAL_MEDIA_PATH: " + f"{raw!r}. Expected '0' or '1'. Treating as '0' " + "(server-side paths allowed)." + ) + return raw == "1" + + +def _reference_transport(ref: Any) -> tuple[Any, str, Optional[str]]: + """Extract ``(content, format, role)`` from one raw HTTP reference. + + ``ref`` is a multipart ``UploadFile`` (has ``.file``) or a + ``MediaReferenceItem`` exposing ``content`` / ``format`` / ``role``. An + upload is read to ``bytes`` here — the only decode the boundary owns — and + its format is implied by the transport rather than declared by the client; + everything else passes through for the engine to resolve. + """ + if hasattr(ref, "file"): # multipart UploadFile + return ref.file.read(), "bytes", getattr(ref, "role", None) + content = getattr(ref, "content", None) + if not isinstance(content, str): + raise ValueError("reference item must carry a 'content' string.") + if ref.format == "path" and local_media_path_is_disallowed(): + raise ValueError( + "reference format='path' is disallowed on this server " + "(TRTLLM_DISALLOW_LOCAL_MEDIA_PATH=1); it reads server-side files " + "and is only meaningful for co-located clients. Send the file as " + "base64 or upload it via multipart/form-data." + ) + return content, ref.format, getattr(ref, "role", None) + + +def _build_reference_list(value: Any) -> Optional[list]: + """Normalize one HTTP reference field into a list of ``MediaRef`` objects. + + ``value`` is None, a multipart ``UploadFile``, a ``MediaReferenceItem``, or + a list of those. Each entry becomes a ``MediaRef`` carrying its transport + content plus the declared (or, for an upload, implied) wire format. + Resolution to bytes happens later, at the engine choke point. + """ + if value is None: + return None + # Local import: the visual_gen tree is already loaded in a VisualGen serving + # process, and this keeps it out of every plain-LLM process (see TYPE_CHECKING). + from tensorrt_llm.visual_gen.params import MediaRef + + raw_items = value if isinstance(value, list) else [value] + refs = [] + for item in raw_items: + content, content_format, role = _reference_transport(item) + refs.append(MediaRef(content=content, format=content_format, role=role)) + return refs + + +def _read_image_edit_upload(value: Any) -> bytes: + """Read a multipart image-edit upload, capping it while it streams.""" + total = 0 + if hasattr(value.file, "seek"): + value.file.seek(0) + chunks = [] + while True: + chunk = value.file.read(1024 * 1024) + if not chunk: + break + total += len(chunk) + if total > IMAGE_EDIT_MAX_IMAGE_BYTES: + raise ValueError( + "Image edit input exceeds the per-image byte limit " + f"({total} > {IMAGE_EDIT_MAX_IMAGE_BYTES})." + ) + chunks.append(chunk) + return b"".join(chunks) + + +def _decode_image_edit_string(value: str) -> bytes: + """Decode one base64 image-edit input, refusing paths and URLs. - Payload size is deliberately not checked here: encoded size is not part - of the request-validity contract, and body limits belong to the - proxy/ASGI deployment layer (HTTP 413). Base64 decodes strictly so - malformed encodings — not sizes — are rejected. + OpenAI's ``image`` field is base64 or an upload. A path or URL there would + ask the server to fetch on the client's behalf, which this endpoint has + never offered. """ - if isinstance(reference, str): - try: - return base64.b64decode(reference, validate=True) - except ValueError as exc: - # binascii.Error subclasses ValueError. - raise ValueError("input_reference is not valid base64 data.") from exc - return reference.file.read() + decoded = _decode_base64_media(value) + if decoded is not None: + return decoded + if urlparse(value).scheme in ("file", "http", "https"): + raise ValueError( + "Image edit inputs must be uploaded files or base64-encoded images; " + "local paths and URLs are not supported." + ) + raise ValueError("String image edit inputs must be base64-encoded image data.") + + +def _build_image_edit_reference_list(value: Any) -> Optional[list]: + """Build references from an image-edit request's OpenAI-shaped ``image``. + + That field follows OpenAI's schema, which has no place to declare a wire + form, so each entry is decoded here — the same read a multipart upload + already gets — and carried on as ``bytes``. The size and PNG/JPEG checks + stay at the boundary because only this endpoint imposes them. + """ + if value is None: + return None + from tensorrt_llm.visual_gen.params import MediaRef + + refs = [] + total_bytes = 0 + for item in value if isinstance(value, list) else [value]: + if hasattr(item, "file"): # multipart UploadFile + payload = _read_image_edit_upload(item) + elif isinstance(item, str): + payload = _decode_image_edit_string(item) + else: + raise ValueError( + "Image edit inputs must be base64-encoded images or uploaded files, " + f"got {type(item).__name__}." + ) + if len(payload) > IMAGE_EDIT_MAX_IMAGE_BYTES: + raise ValueError( + "Image edit input exceeds the per-image byte limit " + f"({len(payload)} > {IMAGE_EDIT_MAX_IMAGE_BYTES})." + ) + _validate_png_jpeg_image(payload) + total_bytes += len(payload) + if total_bytes > IMAGE_EDIT_MAX_TOTAL_IMAGE_BYTES: + raise ValueError( + "Image edit inputs exceed the total byte limit " + f"({total_bytes} > {IMAGE_EDIT_MAX_TOTAL_IMAGE_BYTES})." + ) + refs.append(MediaRef(content=payload, format="bytes")) + return refs def _decode_inline_media(extra_params: dict | None, specs) -> None: @@ -174,19 +304,6 @@ def _decode_base64_media(value: str) -> Optional[bytes]: return None -def _write_bytes_with_limit(value: bytes, path: str) -> int: - size = len(value) - if size > IMAGE_EDIT_MAX_IMAGE_BYTES: - raise ValueError( - "Image edit input exceeds the per-image byte limit " - f"({size} > {IMAGE_EDIT_MAX_IMAGE_BYTES})." - ) - _validate_png_jpeg_image(value) - with open(path, "wb") as f: - f.write(value) - return size - - def _validate_png_jpeg_image(value: bytes) -> None: try: with Image.open(BytesIO(value)) as image: @@ -198,58 +315,6 @@ def _validate_png_jpeg_image(value: bytes) -> None: raise ValueError(_INVALID_IMAGE_EDIT_INPUT_MESSAGE) -def _copy_upload_with_limit(value: Any, path: str) -> int: - total = 0 - if hasattr(value.file, "seek"): - value.file.seek(0) - chunks = [] - while True: - chunk = value.file.read(1024 * 1024) - if not chunk: - break - total += len(chunk) - if total > IMAGE_EDIT_MAX_IMAGE_BYTES: - raise ValueError( - "Image edit input exceeds the per-image byte limit " - f"({total} > {IMAGE_EDIT_MAX_IMAGE_BYTES})." - ) - chunks.append(chunk) - return _write_bytes_with_limit(b"".join(chunks), path) - - -def _materialize_conditioning_input( - value: Any, - path: str, -) -> tuple[str, int]: - """Return a server-owned file path for upload or base64 inputs.""" - try: - if isinstance(value, str): - decoded = _decode_base64_media(value) - if decoded is None: - parsed = urlparse(value) - if parsed.scheme in ("file", "http", "https"): - raise ValueError( - "Image edit inputs must be uploaded files or base64-encoded images; " - "local paths and URLs are not supported." - ) - raise ValueError("String image edit inputs must be base64-encoded image data.") - return path, _write_bytes_with_limit(decoded, path) - - if isinstance(value, bytes): - return path, _write_bytes_with_limit(value, path) - - if hasattr(value, "file"): - return path, _copy_upload_with_limit(value, path) - except Exception: - try: - os.remove(path) - except FileNotFoundError: - pass - raise - - raise ValueError(f"Unsupported conditioning input type: {type(value)}") - - def _resolve_image_edit_layer_multiplier( request: ImageEditRequest, generator: VisualGen, @@ -296,53 +361,48 @@ def _validate_image_edit_request_limits( return image_count -def _materialize_conditioning_inputs( - value: Any, - *, - id: str, - field_name: str, - media_storage_path: str, -) -> str | List[str]: - values = value if isinstance(value, list) else [value] - paths = [] - total_bytes = 0 - try: - for i, item in enumerate(values): - path, size = _materialize_conditioning_input( - item, - os.path.join(media_storage_path, f"{id}_{field_name}_{i}.png"), - ) - paths.append(path) - total_bytes += size - if total_bytes > IMAGE_EDIT_MAX_TOTAL_IMAGE_BYTES: - raise ValueError( - "Image edit inputs exceed the total byte limit " - f"({total_bytes} > {IMAGE_EDIT_MAX_TOTAL_IMAGE_BYTES})." - ) - except Exception: - cleanup_materialized_conditioning_inputs(paths) - raise - return paths if isinstance(value, list) else paths[0] - - -def cleanup_materialized_conditioning_inputs(value: Any) -> None: - paths = value if isinstance(value, list) else [value] - for path in paths: - if not isinstance(path, str): - continue - try: - os.remove(path) - except FileNotFoundError: - pass - except OSError as exc: - logger.warning("Failed to remove temporary image edit input %r: %s", path, exc) +def _apply_deprecated_input_reference( + input_reference: str | UploadFile | None, + params: VisualGenParams, +) -> None: + """Back-compat for the deprecated single ``input_reference``. + + Sniff-routes the payload to ``image_reference`` (image) or ``video_reference`` + (video), preserving the pre-typed-fields behavior. Ignored when a typed + image/video reference is already set — the typed fields take precedence. + The field has only ever carried base64 in JSON or a multipart upload, so the + wire form follows from the transport; routing needs the bytes, so they are + read here and handed to the engine like any other reference. + """ + if input_reference is None: + return + logger.warning("'input_reference' is deprecated; use 'image_reference' / 'video_reference'.") + if params.image_reference or params.video_reference: + return + from tensorrt_llm.visual_gen.params import MediaRef + + if hasattr(input_reference, "file"): # multipart upload + payload = input_reference.file.read() + else: + # Local import, for the reason given at the first one. + from tensorrt_llm.visual_gen.params import _read_reference_payload + + payload = _read_reference_payload(input_reference) + kind = sniff_media_kind(payload) + if kind == "image": + params.image_reference = [MediaRef(content=payload, format="bytes")] + elif kind == "video": + params.video_reference = [MediaRef(content=payload, format="bytes")] + else: + raise ValueError( + "input_reference is not a recognized media container; supported " + "inputs are PNG/JPEG images and MP4/AVI video." + ) def parse_visual_gen_params( request: ImageGenerationRequest | ImageEditRequest | VideoGenerationRequest, - id: str, generator: VisualGen, - media_storage_path: Optional[str] = None, ) -> VisualGenParams: """Translate an HTTP request into :class:`VisualGenParams`. @@ -391,15 +451,8 @@ def parse_visual_gen_params( raise ValueError("Image edit mask input is not supported yet.") if request.n is not None: params.num_images_per_prompt = request.n - if media_storage_path is None: - raise ValueError("media_storage_path is required when image edit inputs are provided") _validate_image_edit_request_limits(request, generator) - params.image = _materialize_conditioning_inputs( - request.image, - id=id, - field_name="image", - media_storage_path=media_storage_path, - ) + params.image_reference = _build_image_edit_reference_list(request.image) elif isinstance(request, VideoGenerationRequest): if request.frame_rate is not None: @@ -426,42 +479,19 @@ def parse_visual_gen_params( "directly." ) params.num_frames = derived - if request.input_reference is not None: - payload = _read_reference_payload(request.input_reference) - kind = sniff_media_kind(payload) - if kind == "image": - # Rejected on signature alone, not on a failed decode: - # whether Pillow reads HEIF/AVIF depends on optional plugins, - # and the worker process need not have the same ones. - if is_isobmff_image_bytes(payload): - raise ValueError( - "input_reference is a HEIF/AVIF image, which is not " - "a supported reference format; convert it to PNG or " - "JPEG." - ) - # I2V: the stored image file is the cross-model contract. - # every I2V pipeline reads ``params.image`` as a path. - if media_storage_path is None: - raise ValueError( - "media_storage_path is required when input_reference is an image" - ) - ref_path = os.path.join(media_storage_path, f"{id}_reference") - with open(ref_path, "wb") as f: - f.write(payload) - params.image = ref_path - elif kind == "video": - # V2V: encoded bytes pass through untouched; the worker - # demuxes and NVDEC-decodes them (acceptance happens there, - # so corrupt content behind a valid signature still fails as - # a client error). - if params.extra_params is None: - params.extra_params = {} - params.extra_params["video"] = payload - else: - raise ValueError( - "input_reference is not a recognized media container; " - "supported inputs are PNG/JPEG images and MP4/AVI video." - ) + # Reference inputs: hand the pipeline a ``MediaRef`` carrying the + # transport content (``bytes`` for an upload, the string otherwise). + # The engine resolves it to bytes; the boundary never touches disk. + image_refs = _build_reference_list(request.image_reference) + if image_refs: + params.image_reference = image_refs + video_refs = _build_reference_list(request.video_reference) + if video_refs: + params.video_reference = video_refs + audio_refs = _build_reference_list(request.audio_reference) + if audio_refs: + params.audio_reference = audio_refs + _apply_deprecated_input_reference(request.input_reference, params) _warn_if_set_with_no_semantic(request, getattr(generator, "model", None)) _decode_inline_media(request.extra_params, generator.extra_param_specs) diff --git a/tensorrt_llm/visual_gen/__init__.py b/tensorrt_llm/visual_gen/__init__.py index 71d13d91bb17..98027abdf83d 100644 --- a/tensorrt_llm/visual_gen/__init__.py +++ b/tensorrt_llm/visual_gen/__init__.py @@ -56,7 +56,7 @@ VisualGenArgs, ) from .output import VisualGenMetrics, VisualGenOutput - from .params import VisualGenParams + from .params import MediaRef, VisualGenParams from .visual_gen import ExtraParamSchema, VisualGen, VisualGenResult # Public name -> providing module. @@ -82,6 +82,7 @@ "VisualGenMetrics": "tensorrt_llm.visual_gen.output", "VisualGenOutput": "tensorrt_llm.visual_gen.output", "VisualGenParams": "tensorrt_llm.visual_gen.params", + "MediaRef": "tensorrt_llm.visual_gen.params", "QuantConfig": "tensorrt_llm.models.modeling_utils", } @@ -114,6 +115,7 @@ def __dir__(): "VisualGen", "VisualGenArgs", "VisualGenParams", + "MediaRef", "VisualGenResult", "VisualGenOutput", "VisualGenMetrics", diff --git a/tensorrt_llm/visual_gen/params.py b/tensorrt_llm/visual_gen/params.py index cd9d86ae19b8..fb13809c8891 100644 --- a/tensorrt_llm/visual_gen/params.py +++ b/tensorrt_llm/visual_gen/params.py @@ -13,12 +13,101 @@ # See the License for the specific language governing permissions and # limitations under the License. import ast +import base64 from typing import Any, Dict, List, Optional, Union -from pydantic import Field +from pydantic import Field, field_validator, model_validator +from typing_extensions import Literal +from tensorrt_llm.inputs.media_io import ( + _safe_read_local_file, + _safe_request_get, + is_isobmff_image_bytes, + sniff_media_kind, +) from tensorrt_llm.llmapi.utils import StrictBaseModel, set_api_status +MediaRole = Literal["reference", "first_frame", "last_frame"] + + +@set_api_status("prototype") +class MediaRef(StrictBaseModel): + """A single media reference (image / video / audio). + + Carried by ``image_reference`` / ``video_reference`` / ``audio_reference``; + the field it sits in fixes the modality. ``role`` is required only when the + target model accepts that modality in more than one role (e.g. image first + + last frame); otherwise the pipeline knows the reference's meaning and + ``role`` may be omitted (video/audio are always the single ``reference``). + """ + + content: Union[str, bytes] = Field( + description="The reference payload, in the form declared by ``format``." + ) + # Declared rather than sniffed: a bare string is otherwise ambiguous between + # a local path and base64, and guessing lets a mistyped path silently become + # base64 (or a malformed base64 silently become a filesystem read). + format: Literal["path", "url", "base64", "bytes"] = Field( + description=( + "Wire form of ``content``: ``path`` (local file; a ``file://`` URI is " + "also accepted), ``url`` (``http(s)``, fetched through the SSRF-guarded " + "loader), ``base64`` (a ``data:`` URI is also accepted), or ``bytes``." + ) + ) + role: Optional[MediaRole] = Field( + default=None, + description=( + "Which conditioning slot this reference fills. Required only when the " + "target model accepts this modality in more than one slot; omit it when " + "the model leaves no ambiguity." + ), + ) + + @model_validator(mode="after") + def _check_content_matches_format(self): + """Reject a ``content`` whose Python type contradicts ``format``. + + ``bytes`` is the only format carrying a binary payload; the other three + name a location or an encoding and are therefore strings. Checking the + pairing here fails at construction — an HTTP 422 or an immediate + ``ValueError`` — instead of deep in the engine's resolve step. + """ + if self.format == "bytes": + if not isinstance(self.content, bytes): + raise ValueError( + f"format='bytes' requires bytes content, got {type(self.content).__name__}." + ) + elif not isinstance(self.content, str): + raise ValueError( + f"format={self.format!r} requires string content, got " + f"{type(self.content).__name__}." + ) + return self + + +def _reject_bare_refs(value: Any) -> Any: + """Reject the bare path/bytes shorthand with an actionable message. + + Runs before coercion, so the caller sees what to do instead of a union + mismatch reported against an inner model. A bare string has nowhere to + declare its wire form, and guessing is what ``format`` exists to prevent. + """ + for x in value if isinstance(value, list) else [value]: + if isinstance(x, (str, bytes)): + raise ValueError( + "a reference must declare its wire form; a bare " + f"{type(x).__name__} is no longer accepted. Pass " + 'MediaRef(content=..., format="path"|"url"|"base64"|"bytes").' + ) + return value + + +def _normalize_refs(value: Any) -> Optional[list]: + """Coerce a reference field to ``list[MediaRef]`` (or ``None``).""" + if value is None: + return None + return value if isinstance(value, list) else [value] + @set_api_status("prototype") class VisualGenParams(StrictBaseModel): @@ -78,9 +167,31 @@ class VisualGenParams(StrictBaseModel): # Conditioning inputs negative_prompt: Optional[str] = Field(default=None, description="Negative prompt for CFG.") - image: Optional[Union[str, bytes, List[Union[str, bytes]]]] = Field( - default=None, description="Reference image(s) for I2V/I2I." + # Per-modality reference inputs. A single ``MediaRef`` or a list; normalized + # to ``list[MediaRef]``. The field fixes the modality; ``role`` is only + # meaningful where a model declares more than one role for it (e.g. image + # first_frame / last_frame), and each ref declares its own ``format``. + image_reference: Optional[Union[MediaRef, List[MediaRef]]] = Field( + default=None, + description="Reference image(s) for I2V/I2I; normalized to list[MediaRef].", + ) + video_reference: Optional[Union[MediaRef, List[MediaRef]]] = Field( + default=None, description="Reference video(s) for V2V; normalized to list[MediaRef]." + ) + audio_reference: Optional[Union[MediaRef, List[MediaRef]]] = Field( + default=None, description="Reference audio(s); normalized to list[MediaRef]." ) + + @field_validator("image_reference", "video_reference", "audio_reference", mode="before") + @classmethod + def _reject_bare(cls, v): + return v if v is None else _reject_bare_refs(v) + + @field_validator("image_reference", "video_reference", "audio_reference", mode="after") + @classmethod + def _norm_refs(cls, v): + return _normalize_refs(v) + # Per-prompt multiplier num_images_per_prompt: int = Field(default=1, description="Number of images per prompt.") @@ -139,6 +250,7 @@ def validate_visual_gen_params( *, declared_defaults: Optional[Dict[str, Any]], extra_param_specs: Dict[str, Any], + ref_slot_specs: Optional[Dict[str, Any]] = None, ) -> None: """Validate *params* against pipeline-declared defaults and extra specs. @@ -155,6 +267,10 @@ def validate_visual_gen_params( field set can still validate ``extra_params``. - Type mismatches for ``extra_params`` values. - Out-of-range ``extra_params`` values. + - References in a slot the pipeline does not declare, in an unsupported + role, or in counts outside the role's ``min``/``max``. Skipped when + ``ref_slot_specs`` is ``None``; an empty mapping declares no slots and + so rejects every reference. """ messages: List[str] = [] specs = extra_param_specs @@ -228,7 +344,165 @@ def validate_visual_gen_params( f"extra_params['{key}'] value {value} is out of range [{lo}, {hi}]" ) + # The field validators have already normalized each slot to ``list[MediaRef]``. + if ref_slot_specs is not None: + for field in ("image_reference", "video_reference", "audio_reference"): + refs = getattr(params, field, None) or [] + spec = ref_slot_specs.get(field) + if spec is None: + if refs: + messages.append(f"'{field}' is not accepted by the loaded pipeline.") + continue + role_specs = list(spec.roles) + allowed = {rs.role for rs in role_specs} + # A missing role is inferred while it is unambiguous, so only a slot + # with more than one required role forces the caller to name one. + required_roles = [rs.role for rs in role_specs if rs.min >= 1] + counts: Dict[str, int] = {} + for r in refs: + role = getattr(r, "role", None) + if role is None: + if len(role_specs) == 1: + role = role_specs[0].role + elif len(required_roles) == 1: + role = required_roles[0] + else: + messages.append( + f"{field}: 'role' is required for this model " + f"(one of {sorted(allowed)})." + ) + continue + if role not in allowed: + messages.append( + f"{field}: role '{role}' not supported (allowed: {sorted(allowed)})." + ) + continue + counts[role] = counts.get(role, 0) + 1 + # Runs for an absent slot too, which is what catches a missing + # required reference before the worker sees the request. + for rs in role_specs: + n = counts.get(rs.role, 0) + if n < rs.min or (rs.max is not None and n > rs.max): + bound = f"{rs.min}..{'inf' if rs.max is None else rs.max}" + messages.append(f"{field} role '{rs.role}': expected {bound}, got {n}.") + if not messages: return raise ValueError("Parameter validation failed:\n" + "\n".join(f" - {e}" for e in messages)) + + +def _read_reference_payload(reference: str) -> bytes: + """Decode one base64 (optionally ``data:`` URI) reference string to bytes. + + Payload size is deliberately not checked here: encoded size is not part + of the request-validity contract, and body limits belong to the + proxy/ASGI deployment layer (HTTP 413). Base64 decodes strictly so + malformed encodings — not sizes — are rejected. + """ + data = reference + if data.startswith("data:"): + comma = data.find(",") + if comma == -1: + raise ValueError("reference data: URI is malformed (missing comma).") + # Match the LLM loader: only base64 payloads are supported, and saying so + # beats letting a percent-encoded body fail as "not valid base64". + if "base64" not in data[:comma].split(";")[1:]: + raise ValueError("only base64 data: URIs are supported for references.") + data = data[comma + 1 :] + try: + return base64.b64decode(data, validate=True) + except ValueError as exc: + # binascii.Error subclasses ValueError. + raise ValueError("reference is not valid base64 data.") from exc + + +def _resolve_reference(content: Any, content_format: str) -> bytes: + """Resolve one reference to raw bytes using its declared wire form. + + Dispatch is on the caller-declared ``format``, never on the shape of the + value: a bare string is otherwise ambiguous between a local path and + base64, and guessing lets a mistyped path become base64 (or a malformed + base64 become a filesystem read). Fetch/read/decode failures become + ``ValueError`` so a bad reference is a client 400, not a server 500. + """ + if content_format == "bytes": + if not isinstance(content, bytes): + raise ValueError( + f"format='bytes' requires bytes content, got {type(content).__name__}." + ) + return content + if not isinstance(content, str): + raise ValueError( + f"format={content_format!r} requires string content, got {type(content).__name__}." + ) + if content_format == "url": + try: + return _safe_request_get(content).content + except Exception as exc: + raise ValueError(f"reference URL could not be fetched: {exc}") from exc + if content_format == "path": + return _safe_read_local_file(content) + if content_format == "base64": + return _read_reference_payload(content) + raise ValueError(f"unsupported reference format: {content_format!r}") + + +def _validate_reference_payload(payload: bytes, *, modality: str) -> None: + """Reject a payload whose container does not match the declared modality. + + HEIF/AVIF images are rejected on signature alone (Pillow support depends + on optional plugins the worker need not share). Video acceptance beyond the + container signature happens in the worker's NVDEC demux. + """ + if modality == "image": + if sniff_media_kind(payload) != "image": + raise ValueError( + "image_reference is not a recognized image; supported inputs are PNG/JPEG." + ) + if is_isobmff_image_bytes(payload): + raise ValueError( + "image_reference is a HEIF/AVIF image, which is not a supported " + "reference format; convert it to PNG or JPEG." + ) + elif modality == "video": + if sniff_media_kind(payload) != "video": + raise ValueError( + "video_reference is not a recognized media container; supported " + "inputs are MP4/AVI video." + ) + elif modality == "audio": + if sniff_media_kind(payload) != "audio": + raise ValueError( + "audio_reference is not a recognized audio container; supported " + "inputs are WAV/MP3/FLAC/OGG/M4A/AAC." + ) + + +def prepare_reference_slots(params: Any) -> None: + """Resolve every reference to raw bytes, in place. + + The single reference choke point, used by the engine (``generate_async``) + so serve and the standalone Python API share one path. Dispatch is on each + reference's declared ``format``, never on the shape of its content: the + declared form is resolved to bytes, content-validated against the slot's + modality, and written back with ``format`` set to ``"bytes"``. + + ``format`` is rewritten alongside ``content`` because the mutated params + object is what gets broadcast to the workers; a stale format would tell a + worker it is holding base64 when it is holding raw bytes. + + Bytes are the canonical form all the way to the pipeline, so a reference + never touches the filesystem: there is nothing to clean up afterwards, and + a worker needs no shared filesystem to read what the coordinator resolved. + + Runs before the coordinator broadcasts the request, so a bad reference + raises ``ValueError`` synchronously and serve keeps its immediate 400. + """ + for slot in ("image_reference", "video_reference", "audio_reference"): + modality = slot.split("_", 1)[0] + for ref in getattr(params, slot, None) or []: + data = _resolve_reference(ref.content, ref.format) + _validate_reference_payload(data, modality=modality) + ref.content = data + ref.format = "bytes" diff --git a/tensorrt_llm/visual_gen/visual_gen.py b/tensorrt_llm/visual_gen/visual_gen.py index b8f6a2b39e4d..a6274d353406 100644 --- a/tensorrt_llm/visual_gen/visual_gen.py +++ b/tensorrt_llm/visual_gen/visual_gen.py @@ -28,16 +28,21 @@ run_diffusion_worker, ) from tensorrt_llm._torch.visual_gen.output import split_visual_gen_output, to_visual_gen_output -from tensorrt_llm._torch.visual_gen.pipeline import ExtraParamSchema +from tensorrt_llm._torch.visual_gen.pipeline import ExtraParamSchema, RefSlotSpec from tensorrt_llm._torch.visual_gen.pipeline_registry import PIPELINE_REGISTRY, AutoPipeline from tensorrt_llm.visual_gen.args import VisualGenArgs from tensorrt_llm.visual_gen.output import VisualGenOutput -from tensorrt_llm.visual_gen.params import VisualGenParams, validate_visual_gen_params +from tensorrt_llm.visual_gen.params import ( + VisualGenParams, + prepare_reference_slots, + validate_visual_gen_params, +) __all__ = [ "VisualGen", "VisualGenParams", "ExtraParamSchema", + "RefSlotSpec", "VisualGenResult", ] from tensorrt_llm.llmapi.utils import set_api_status @@ -291,6 +296,16 @@ def extra_param_specs(self) -> Dict[str, "ExtraParamSchema"]: """ return self.executor.extra_param_specs + @property + def ref_slot_specs(self) -> Dict[str, "RefSlotSpec"]: + """Reference slots the loaded pipeline accepts. + + Maps ``image_reference`` / ``video_reference`` / ``audio_reference`` to + a ``RefSlotSpec`` (accepted roles + per-role counts). Empty when the + pipeline takes no reference inputs. + """ + return self.executor.ref_slot_specs + @property def default_params(self) -> "VisualGenParams": """Returns a ``VisualGenParams`` with all defaults resolved for the loaded pipeline. @@ -413,6 +428,7 @@ def generate_async( resolved_params, declared_defaults=self.executor.default_generation_params, extra_param_specs=self.executor.extra_param_specs, + ref_slot_specs=self.executor.ref_slot_specs, ) else: resolved_params = self.default_params @@ -425,12 +441,21 @@ def generate_async( if resolved_params.seed is None: resolved_params.seed = secrets.randbits(63) + # Resolve references to bytes here, on the coordinator, before the + # request is broadcast — the single choke point shared by serve and the + # standalone Python API. Runs synchronously so bad-media ``ValueError`` + # reaches the caller before dispatch (serve keeps its 400). + prepare_reference_slots(resolved_params) + request = DiffusionRequest( request_id=req_id, prompt=prompt, params=resolved_params, ) - + # Hand the reference payloads to rank0 through shared memory instead of + # through the request pickle, which copies every reference byte to + # cross one process boundary. + request.refs_to_shm() self.executor.enqueue_requests([request]) return VisualGenResult(req_id, self.executor, batch_size=batch_size) diff --git a/tests/integration/defs/perf/README_test_visual_gen_perf_sanity.md b/tests/integration/defs/perf/README_test_visual_gen_perf_sanity.md index 1c0808c41ea3..916b25b2ff39 100644 --- a/tests/integration/defs/perf/README_test_visual_gen_perf_sanity.md +++ b/tests/integration/defs/perf/README_test_visual_gen_perf_sanity.md @@ -150,7 +150,7 @@ server_configs: `server_config.parallel_config` - `client_configs[].generation_mode` should be set explicitly for stable bucketing, especially for `i2v` and `t2v` -- `client_configs[].extra_body.input_reference` is the current way to express +- `client_configs[].extra_body.image_reference` is the current way to express `i2v` requests in checked-in YAMLs ## Test Case Formats diff --git a/tests/integration/defs/perf/visual_gen_perf_utils.py b/tests/integration/defs/perf/visual_gen_perf_utils.py index 08c7960e15e8..e840b7097b3d 100644 --- a/tests/integration/defs/perf/visual_gen_perf_utils.py +++ b/tests/integration/defs/perf/visual_gen_perf_utils.py @@ -105,7 +105,9 @@ def _infer_generation_mode(client_config: dict[str, Any]) -> str: except json.JSONDecodeError: extra_body = None - if isinstance(extra_body, dict) and "input_reference" in extra_body: + if isinstance(extra_body, dict) and ( + "image_reference" in extra_body or "video_reference" in extra_body + ): return "i2v" if backend == "openai-videos": diff --git a/tests/scripts/perf-sanity/visual_gen/ltx2_blackwell.yaml b/tests/scripts/perf-sanity/visual_gen/ltx2_blackwell.yaml index cfd8016072a6..843a14baef02 100644 --- a/tests/scripts/perf-sanity/visual_gen/ltx2_blackwell.yaml +++ b/tests/scripts/perf-sanity/visual_gen/ltx2_blackwell.yaml @@ -38,7 +38,7 @@ server_configs: max_concurrency: 1 num_prompts: 1 extra_body: - input_reference: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z4h8AAAAASUVORK5CYII= + image_reference: {content: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z4h8AAAAASUVORK5CYII=, format: base64} - name: ltx2_2stage_bf16_t2v_cfg2_ulysses4_compile_on model_name: ltx2_bf16 @@ -98,4 +98,4 @@ server_configs: max_concurrency: 1 num_prompts: 1 extra_body: - input_reference: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z4h8AAAAASUVORK5CYII= + image_reference: {content: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z4h8AAAAASUVORK5CYII=, format: base64} diff --git a/tests/scripts/perf-sanity/visual_gen/wan22_i2v_a14b_blackwell.yaml b/tests/scripts/perf-sanity/visual_gen/wan22_i2v_a14b_blackwell.yaml index a57d9c235f86..4e49506ad25b 100644 --- a/tests/scripts/perf-sanity/visual_gen/wan22_i2v_a14b_blackwell.yaml +++ b/tests/scripts/perf-sanity/visual_gen/wan22_i2v_a14b_blackwell.yaml @@ -35,4 +35,4 @@ server_configs: max_concurrency: 1 num_prompts: 1 extra_body: - input_reference: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z4h8AAAAASUVORK5CYII= + image_reference: {content: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z4h8AAAAASUVORK5CYII=, format: base64, role: first_frame} diff --git a/tests/unittest/_torch/visual_gen/test_cosmos3_pipeline.py b/tests/unittest/_torch/visual_gen/test_cosmos3_pipeline.py index 149bc3be2c7c..5f0e2f96a1f9 100644 --- a/tests/unittest/_torch/visual_gen/test_cosmos3_pipeline.py +++ b/tests/unittest/_torch/visual_gen/test_cosmos3_pipeline.py @@ -25,6 +25,7 @@ """ import gc +import io import json import os from collections.abc import Generator @@ -794,31 +795,23 @@ class TestReferenceImageLoad: """ def test_truncated_image_is_a_client_error(self, tmp_path): - # Incompressible content, so half the file is genuinely half the image. + # Incompressible content, so half the payload is genuinely half the image. noise = PIL.Image.frombytes("RGB", (64, 64), os.urandom(64 * 64 * 3)) whole = tmp_path / "whole.png" noise.save(whole, format="PNG") data = whole.read_bytes() - path = tmp_path / "truncated.png" - path.write_bytes(data[: len(data) // 2]) with pytest.raises(ValueError, match="could not be decoded"): - _load_reference_image(str(path)) + _load_reference_image(data[: len(data) // 2]) - def test_unidentifiable_content_is_a_client_error(self, tmp_path): - path = tmp_path / "notreally.png" - path.write_bytes(b"not an image at all") + def test_unidentifiable_content_is_a_client_error(self): with pytest.raises(ValueError, match="could not be decoded"): - _load_reference_image(str(path)) + _load_reference_image(b"not an image at all") - def test_missing_file_is_a_client_error(self, tmp_path): - with pytest.raises(ValueError, match="could not be decoded"): - _load_reference_image(str(tmp_path / "nope.png")) - - def test_valid_image_loads(self, tmp_path): - path = tmp_path / "ok.png" - PIL.Image.new("RGB", (8, 8), (1, 2, 3)).save(path, format="PNG") - assert _load_reference_image(str(path)).size == (8, 8) + def test_valid_image_loads(self): + buffer = io.BytesIO() + PIL.Image.new("RGB", (8, 8), (1, 2, 3)).save(buffer, format="PNG") + assert _load_reference_image(buffer.getvalue()).size == (8, 8) _V2V_FIXTURE_MP4 = Path(__file__).parent / "test_data" / "cosmos3_v2v_ref_9f_bframes.mp4" diff --git a/tests/unittest/_torch/visual_gen/test_cosmos3_transfer.py b/tests/unittest/_torch/visual_gen/test_cosmos3_transfer.py index 83a11058519e..7e4ded7fc773 100644 --- a/tests/unittest/_torch/visual_gen/test_cosmos3_transfer.py +++ b/tests/unittest/_torch/visual_gen/test_cosmos3_transfer.py @@ -53,7 +53,7 @@ from tensorrt_llm._torch.visual_gen.offloading import PipelineOffloader from tensorrt_llm._torch.visual_gen.output import CudaPhaseTimer from tensorrt_llm.media.decoding import VideoStreamInfo -from tensorrt_llm.visual_gen.params import VisualGenParams +from tensorrt_llm.visual_gen.params import MediaRef, VisualGenParams pytestmark = pytest.mark.cosmos3 @@ -428,7 +428,7 @@ class TestSourceDerivedDefaults: REFERENCE = Path(__file__).parent / "test_data" / "cosmos3_v2v_ref_9f_bframes.mp4" - def _infer_req(self, _params=None, **extra): + def _infer_req(self, _params=None, *, video=None, **extra): # Executor-merged shape: num_frames/frame_rate carry pipeline defaults, # height/width are declared None, and nothing reads as caller intent. params = ( @@ -442,6 +442,10 @@ def _infer_req(self, _params=None, **extra): ) ) params.extra_params = dict(extra) + # The V2V reference reaches a worker resolved, so ``format="bytes"`` is + # the only spelling ``infer()`` can see. + if video is not None: + params.video_reference = [MediaRef(content=video, format="bytes")] return SimpleNamespace(params=params, prompt="a prompt") def _captured(self, req): diff --git a/tests/unittest/_torch/visual_gen/test_flux2_image_conditioning.py b/tests/unittest/_torch/visual_gen/test_flux2_image_conditioning.py index ac4a5ee84b14..551d3a1fbabc 100644 --- a/tests/unittest/_torch/visual_gen/test_flux2_image_conditioning.py +++ b/tests/unittest/_torch/visual_gen/test_flux2_image_conditioning.py @@ -22,16 +22,28 @@ def _png_bytes() -> bytes: return buffer.getvalue() -def test_load_reference_images_accepts_pil_path_and_bytes(tmp_path) -> None: +def test_load_reference_images_accepts_pil_and_bytes(tmp_path) -> None: pil_image = PIL.Image.new("L", (64, 64), color=128) - image_path = tmp_path / "reference.png" - pil_image.save(image_path) - images = Flux2Pipeline._load_reference_images([pil_image, str(image_path), _png_bytes()]) + images = Flux2Pipeline._load_reference_images([pil_image, _png_bytes()]) - assert len(images) == 3 + assert len(images) == 2 assert all(image.mode == "RGB" for image in images) - assert [image.size for image in images] == [(64, 64), (64, 64), (64, 64)] + assert [image.size for image in images] == [(64, 64), (64, 64)] + + +def test_load_reference_images_drops_alpha_without_compositing() -> None: + """RGBA is converted the way diffusers does it: the alpha channel is + dropped, so a fully-transparent pixel keeps its RGB value instead of + turning white.""" + rgba = PIL.Image.new("RGBA", (8, 8), color=(10, 20, 30, 0)) + buffer = io.BytesIO() + rgba.save(buffer, format="PNG") + + (image,) = Flux2Pipeline._load_reference_images([buffer.getvalue()]) + + assert image.mode == "RGB" + assert image.getpixel((0, 0)) == (10, 20, 30) @pytest.mark.parametrize("image", [[], [object()]]) @@ -71,7 +83,7 @@ def test_prepare_request_resolves_dimensions_and_infer_reuses_images() -> None: req = SimpleNamespace( prompt=["edit this image"], params=SimpleNamespace( - image=_png_bytes(), + image_reference=[SimpleNamespace(content=_png_bytes())], height=None, width=None, num_inference_steps=1, diff --git a/tests/unittest/_torch/visual_gen/test_flux_infer.py b/tests/unittest/_torch/visual_gen/test_flux_infer.py index a50435a095ea..1f4f4956cd51 100644 --- a/tests/unittest/_torch/visual_gen/test_flux_infer.py +++ b/tests/unittest/_torch/visual_gen/test_flux_infer.py @@ -30,7 +30,7 @@ def test_infer_forwards_num_images_per_prompt( seed=42, max_sequence_length=512, num_images_per_prompt=2, - image=[b"reference"], + image_reference=[SimpleNamespace(content=b"reference")], ), ) diff --git a/tests/unittest/_torch/visual_gen/test_qwen_image_pipeline.py b/tests/unittest/_torch/visual_gen/test_qwen_image_pipeline.py index 4900421d70bd..283ad37abb63 100644 --- a/tests/unittest/_torch/visual_gen/test_qwen_image_pipeline.py +++ b/tests/unittest/_torch/visual_gen/test_qwen_image_pipeline.py @@ -287,3 +287,61 @@ def test_forward_honors_profile_step_range(tmp_path): str(tmp_path / "visual-gen-trace-rank-0.json") ) cudart.cudaProfilerStop.assert_called_once_with() + + +class TestQwenImageEditReferenceLoading: + """``forward`` doubles as a library entry point, so it takes PIL too. + + A request always arrives as encoded bytes, but a direct caller may already + hold a decoded image and should not have to re-encode it just to get in. + """ + + @staticmethod + def _png(mode="RGB", color=(10, 20, 30)): + import io + + import PIL.Image + + buffer = io.BytesIO() + PIL.Image.new(mode, (8, 8), color).save(buffer, format="PNG") + return buffer.getvalue() + + def test_bytes_and_pil_both_load(self): + import io + + import PIL.Image + + from tensorrt_llm._torch.visual_gen.models.qwen_image.pipeline_qwen_image_edit import ( + QwenImageEditPlusPipeline, + ) + + data = self._png() + images = QwenImageEditPlusPipeline._load_edit_images( + [data, PIL.Image.open(io.BytesIO(data))] + ) + + assert len(images) == 2 + assert all(image.mode == "RGB" for image in images) + assert all(image.size == (8, 8) for image in images) + + def test_alpha_is_dropped_not_composited(self): + """A transparent pixel keeps its stored RGB. + + ``convert("RGB")`` discards the channel; compositing onto white instead + rewrites every pixel with ``alpha < 255``, silently, so a reference with + transparency would reach the model as a different image. + """ + import PIL.Image + + from tensorrt_llm._torch.visual_gen.models.qwen_image.pipeline_qwen_image_edit import ( + QwenImageEditPlusPipeline, + ) + + transparent = PIL.Image.new("RGBA", (4, 4), (10, 20, 30, 0)) + encoded = self._png(mode="RGBA", color=(10, 20, 30, 0)) + + from_pil, from_bytes = QwenImageEditPlusPipeline._load_edit_images([transparent, encoded]) + + assert from_pil.mode == "RGB" and from_bytes.mode == "RGB" + assert from_pil.getpixel((0, 0)) == (10, 20, 30) + assert from_bytes.getpixel((0, 0)) == (10, 20, 30) diff --git a/tests/unittest/_torch/visual_gen/test_trtllm_serve_e2e.py b/tests/unittest/_torch/visual_gen/test_trtllm_serve_e2e.py index 4be47671a28a..2d890dd490ea 100644 --- a/tests/unittest/_torch/visual_gen/test_trtllm_serve_e2e.py +++ b/tests/unittest/_torch/visual_gen/test_trtllm_serve_e2e.py @@ -381,7 +381,7 @@ def test_ti2v_sync(self, server, format_, expected_content_type): "format": format_, }, files={ - "input_reference": ("cat_piano.png", f, "image/png"), + "image_reference": ("cat_piano.png", f, "image/png"), }, ) assert resp.status_code == 200, resp.text @@ -418,7 +418,7 @@ def test_ti2v_async_lifecycle(self, server, format_, expected_content_type): "format": format_, }, files={ - "input_reference": ("cat_piano.png", f, "image/png"), + "image_reference": ("cat_piano.png", f, "image/png"), }, ) assert create_resp.status_code == 202, create_resp.text diff --git a/tests/unittest/_torch/visual_gen/test_trtllm_serve_endpoints.py b/tests/unittest/_torch/visual_gen/test_trtllm_serve_endpoints.py index 27d658371451..48e48cf744d5 100644 --- a/tests/unittest/_torch/visual_gen/test_trtllm_serve_endpoints.py +++ b/tests/unittest/_torch/visual_gen/test_trtllm_serve_endpoints.py @@ -36,6 +36,7 @@ from tensorrt_llm.serve.visual_gen_metrics import SERVER_TIMING_HEADER from tensorrt_llm.serve.visual_gen_utils import VIDEO_STORE from tensorrt_llm.visual_gen.output import VisualGenMetrics, VisualGenOutput +from tensorrt_llm.visual_gen.params import prepare_reference_slots, validate_visual_gen_params pytestmark = pytest.mark.cpu_only @@ -187,6 +188,7 @@ def __init__( extra_param_specs: Optional[dict] = None, model: str = "test-model", supports_image_edit: bool = False, + ref_slot_specs: Optional[dict] = None, ): from types import SimpleNamespace @@ -209,6 +211,8 @@ def __init__( # used by tests to assert forwarded VisualGenParams fields. self.last_inputs = None self.last_params = None + # Resolved reference bytes per slot, recorded at generation time. + self.last_ref_bytes = {} # Stand-in for the coordinator-side executor proxy. The async video # route reads ``default_generation_params`` / ``extra_param_specs`` # directly off this attribute when running synchronous pre-flight @@ -217,6 +221,8 @@ def __init__( # reject legitimate width/height/num_frames/... requests; # ``extra_param_specs`` lists a single known key so tests can # exercise both the accept-known and reject-unknown paths. + from tensorrt_llm._torch.visual_gen.pipeline import RefSlotSpec, RoleSpec + self.executor = SimpleNamespace( default_generation_params={ "height": 64, @@ -230,6 +236,16 @@ def __init__( extra_param_specs=extra_param_specs or {"stg_scale": ExtraParamSchema(type="float", default=1.0)}, supports_image_edit=supports_image_edit, + ref_slot_specs=ref_slot_specs + if ref_slot_specs is not None + else { + "image_reference": RefSlotSpec( + modality="image", roles=[RoleSpec(role="first_frame", min=0, max=1)] + ), + "video_reference": RefSlotSpec( + modality="video", roles=[RoleSpec(role="reference", min=0, max=1)] + ), + }, ) def _maybe_batch(self, tensor, n): @@ -240,36 +256,44 @@ def _maybe_batch(self, tensor, n): # --- VisualGen interface --- + def _snapshot_refs(self, params) -> None: + # Record what each slot resolved to, so tests can assert byte-identity + # against the payload the client sent. + self.last_ref_bytes = {} + for field in ("image_reference", "video_reference", "audio_reference"): + refs = getattr(params, field, None) or [] + self.last_ref_bytes[field] = [ref.content for ref in refs] + def generate(self, inputs=None, params=None) -> VisualGenOutput: - self.last_inputs = inputs - self.last_params = params - if self._validation_error is not None: - raise self._validation_error - if self._generate_error is not None: - raise self._generate_error - if self._should_fail: - raise RuntimeError("Generation intentionally failed") - n = getattr(params, "num_images_per_prompt", 1) if params else 1 - return VisualGenOutput( - request_id=self._next_request_id(), - image=self._maybe_batch(self._image, n), - video=self._maybe_batch(self._video, n), - audio=self._audio, - metrics=_make_dummy_metrics(), - ) + return self.generate_async(inputs=inputs, params=params).result() def generate_async(self, inputs=None, params=None) -> "MockVisualGenResult": self.last_inputs = inputs self.last_params = params if self._validation_error is not None: raise self._validation_error + # Mirror the real engine entry: validate against the pipeline metadata + # (unknown extra_params / undeclared fields / ref arity) before doing any + # work, so the route's synchronous 400 path is exercised end-to-end. + if params is not None: + validate_visual_gen_params( + params, + declared_defaults=self.executor.default_generation_params, + extra_param_specs=self.executor.extra_param_specs, + ref_slot_specs=self.executor.ref_slot_specs, + ) + # Mirror the engine: resolve every reference to bytes at the coordinator. + req_id = self._next_request_id() + prepare_reference_slots(params) + self._snapshot_refs(params) n = getattr(params, "num_images_per_prompt", 1) if params else 1 return MockVisualGenResult( - request_id=self._next_request_id(), + request_id=req_id, image=self._maybe_batch(self._image, n), video=self._maybe_batch(self._video, n), audio=self._audio, should_fail=self._should_fail, + generate_error=self._generate_error, ) def _next_request_id(self) -> int: @@ -323,17 +347,20 @@ def __init__( video: Optional[torch.Tensor] = None, audio: Optional[torch.Tensor] = None, should_fail: bool = False, + generate_error: Optional[BaseException] = None, ): self.request_id = request_id self._image = image self._video = video self._audio = audio self._should_fail = should_fail + # Engine-side failure surfaced through the result (capacity/client), + # distinct from a coordinator preflight rejection. + self._generate_error = generate_error - def __await__(self): - return self.aresult().__await__() - - async def aresult(self, timeout=None): + def _resolve(self) -> VisualGenOutput: + if self._generate_error is not None: + raise self._generate_error if self._should_fail: raise RuntimeError("Async generation intentionally failed") return VisualGenOutput( @@ -344,16 +371,14 @@ async def aresult(self, timeout=None): metrics=_make_dummy_metrics(), ) + def __await__(self): + return self.aresult().__await__() + + async def aresult(self, timeout=None): + return self._resolve() + def result(self, timeout=None): - if self._should_fail: - raise RuntimeError("Async generation intentionally failed") - return VisualGenOutput( - request_id=self.request_id, - image=self._image, - video=self._video, - audio=self._audio, - metrics=_make_dummy_metrics(), - ) + return self._resolve() # --------------------------------------------------------------------------- @@ -971,12 +996,21 @@ def _client( should_fail: bool = False, supports_image_edit: bool = True, ): + from tensorrt_llm._torch.visual_gen.pipeline import RefSlotSpec, RoleSpec + gen = MockVisualGen( image_output=image_output if image_output is not None else _make_dummy_image_tensor(), extra_param_specs=extra_param_specs, model=model, should_fail=should_fail, supports_image_edit=supports_image_edit, + # Edit pipelines take joint conditioning images, not a first frame; + # mirrors Qwen-Image-Edit's own slot declaration. + ref_slot_specs={ + "image_reference": RefSlotSpec( + modality="image", roles=[RoleSpec(role="reference", min=1, max=None)] + ), + }, ) monkeypatch.setenv("TRTLLM_MEDIA_STORAGE_PATH", str(tmp_path)) return _create_server(gen, model_name=model), gen @@ -1034,8 +1068,8 @@ def test_image_edit_accepts_json_base64_image(self, tmp_path, monkeypatch): ) assert resp.status_code == 200 - assert str(gen.last_params.image).startswith(str(tmp_path)) - assert not os.path.exists(gen.last_params.image) + assert gen.last_params.image_reference[0].format == "bytes" + assert isinstance(gen.last_params.image_reference[0].content, bytes) assert gen.last_params.num_images_per_prompt == 2 body = resp.json() assert body["output_format"] == "webp" @@ -1084,8 +1118,8 @@ def test_image_edit_default_url_returns_fetchable_output(self, tmp_path, monkeyp body = resp.json() url = body["data"][0]["url"] assert "/v1/images/" in url and "/content" in url - assert str(gen.last_params.image).startswith(str(tmp_path)) - assert not os.path.exists(gen.last_params.image) + assert gen.last_params.image_reference[0].format == "bytes" + assert isinstance(gen.last_params.image_reference[0].content, bytes) path = url.split("//", 1)[-1].split("/", 1)[1] content = client.get("/" + path) @@ -1306,7 +1340,7 @@ def test_image_edit_allows_max_input_images_without_output_fanout(self, tmp_path ) assert resp.status_code == 200 - assert len(gen.last_params.image) == 16 + assert len(gen.last_params.image_reference) == 16 assert len(resp.json()["data"]) == 1 assert list(tmp_path.iterdir()) == [] @@ -1551,7 +1585,7 @@ def test_sync_video_generation_with_params(self, video_client): assert params.num_frames == int(2.0 * 8) def test_sync_video_generation_multipart(self, video_client, tmp_path): - """Multipart sync request with a real ``input_reference`` file.""" + """Multipart sync request with a real ``image_reference`` file.""" ref_path = tmp_path / "ref.png" Image.new("RGB", (4, 4), (64, 64, 64)).save(str(ref_path)) with open(ref_path, "rb") as f: @@ -1563,7 +1597,7 @@ def test_sync_video_generation_multipart(self, video_client, tmp_path): "seconds": "1.0", "fps": "8", }, - files={"input_reference": ("ref.png", f, "image/png")}, + files={"image_reference": ("ref.png", f, "image/png")}, ) assert resp.status_code == 200 assert len(resp.content) > 0 @@ -1582,25 +1616,20 @@ def test_sync_video_generation_multipart_with_reference(self, video_client, tmp_ "seconds": "1.0", "fps": "8", }, - files={"input_reference": ("ref.png", f, "image/png")}, + files={"image_reference": ("ref.png", f, "image/png")}, ) assert resp.status_code == 200 assert len(resp.content) > 0 - # input_reference should have been written to media storage and passed - # through as params.image (a filesystem path). + # image_reference reaches the engine as a MediaRef carrying raw bytes. params = video_client.mock_gen.last_params - assert isinstance(params.image, str) - assert params.image.endswith("_reference") - assert os.path.exists(params.image) + assert params.image_reference[0].content == ref_path.read_bytes() + assert params.image_reference[0].format == "bytes" def test_sync_video_generation_multipart_with_video_reference(self, video_client): - """A video ``input_reference`` rides through as the encoded payload on - the model-specific ``video`` extra param (V2V), byte-identical — the - serve never decodes video; the worker demuxes/NVDEC-decodes it. - - Routed by container signature, so a checked-in H.264/MP4 fixture drives - the boundary directly. + """A ``video_reference`` upload reaches the engine byte-identical (V2V) — + serve never decodes video; the worker demuxes/NVDEC-decodes it. A + checked-in H.264/MP4 fixture drives the boundary directly. """ payload = _V2V_FIXTURE_MP4.read_bytes() with open(_V2V_FIXTURE_MP4, "rb") as f: @@ -1612,17 +1641,17 @@ def test_sync_video_generation_multipart_with_video_reference(self, video_client "seconds": "1.0", "fps": "8", }, - files={"input_reference": ("ref.mp4", f, "video/mp4")}, + files={"video_reference": ("ref.mp4", f, "video/mp4")}, ) assert resp.status_code == 200 assert len(resp.content) > 0 - # Video content must NOT land on params.image; it rides the - # model-specific ``video`` extra param as the untouched encoded bytes - # (the same intake the offline example's --video_path uses). + # Video conditioning reaches the engine as raw bytes, byte-identical to + # the upload, and no image_reference is set. params = video_client.mock_gen.last_params - assert params.image is None - assert params.extra_params["video"] == payload + assert params.image_reference is None + assert params.video_reference[0].format == "bytes" + assert video_client.mock_gen.last_ref_bytes["video_reference"] == [payload] def test_sync_video_generation_undecodable_reference_400(self, video_client): """Content matching no image or video container signature is rejected @@ -1630,10 +1659,10 @@ def test_sync_video_generation_undecodable_reference_400(self, video_client): resp = video_client.post( "/v1/videos/sync", data={"prompt": "x"}, - files={"input_reference": ("doc.txt", BytesIO(b"not media"), "text/plain")}, + files={"image_reference": ("doc.txt", BytesIO(b"not media"), "text/plain")}, ) assert resp.status_code == 400 - assert "not a recognized media container" in resp.text + assert "not a recognized image" in resp.text def test_sync_video_failure(self, failing_client): resp = failing_client.post( @@ -1883,7 +1912,7 @@ def _blocking_save(self, *args, **kwargs): ) def test_async_video_multipart(self, video_client, tmp_path): - """Multipart async request with a real ``input_reference`` file.""" + """Multipart async request with a real ``image_reference`` file.""" ref_path = tmp_path / "ref.png" Image.new("RGB", (4, 4), (16, 16, 16)).save(str(ref_path)) with open(ref_path, "rb") as f: @@ -1895,7 +1924,7 @@ def test_async_video_multipart(self, video_client, tmp_path): "seconds": "1.0", "fps": "8", }, - files={"input_reference": ("ref.png", f, "image/png")}, + files={"image_reference": ("ref.png", f, "image/png")}, ) assert resp.status_code == 202 diff --git a/tests/unittest/_torch/visual_gen/test_visual_gen_params.py b/tests/unittest/_torch/visual_gen/test_visual_gen_params.py index 7dbf95922e8b..c48d0fa963d1 100644 --- a/tests/unittest/_torch/visual_gen/test_visual_gen_params.py +++ b/tests/unittest/_torch/visual_gen/test_visual_gen_params.py @@ -54,7 +54,9 @@ def test_default_construction(self): assert params.num_frames is None assert params.frame_rate is None assert params.negative_prompt is None - assert params.image is None + assert params.image_reference is None + assert params.video_reference is None + assert params.audio_reference is None # ``image_cond_strength`` moved to per-pipeline ``extra_params`` # (only LTX-2 consumes it). It is no longer a top-level field. assert not hasattr(params, "image_cond_strength") @@ -99,23 +101,22 @@ def test_extra_params_accepted(self): assert params.extra_params["stg_scale"] == 0.5 assert params.extra_params["enhance_prompt"] is True - def test_image_accepts_str(self): - from tensorrt_llm.visual_gen import VisualGenParams - - params = VisualGenParams(image="/path/to/image.png") - assert params.image == "/path/to/image.png" - - def test_image_accepts_bytes(self): - from tensorrt_llm.visual_gen import VisualGenParams + def test_image_reference_accepts_str(self): + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams - params = VisualGenParams(image=b"\x89PNG") - assert params.image == b"\x89PNG" + params = VisualGenParams( + image_reference=MediaRef(content="/path/to/image.png", format="path") + ) + assert params.image_reference[0].content == "/path/to/image.png" + assert params.image_reference[0].format == "path" + assert params.image_reference[0].role is None - def test_image_accepts_list(self): - from tensorrt_llm.visual_gen import VisualGenParams + def test_image_reference_accepts_bytes(self): + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams - params = VisualGenParams(image=["/path/a.png", b"\x89PNG"]) - assert len(params.image) == 2 + params = VisualGenParams(image_reference=MediaRef(content=b"\x89PNG", format="bytes")) + assert params.image_reference[0].content == b"\x89PNG" + assert params.image_reference[0].format == "bytes" def test_model_dump(self): from tensorrt_llm.visual_gen import VisualGenParams @@ -144,6 +145,90 @@ def test_seed_accepts_int64_range(self): assert VisualGenParams(seed=2**40).seed == 2**40 +# ============================================================================= +# MediaRef — wire form is declared, never guessed +# ============================================================================= + + +class TestMediaRefValidation: + """Every reference declares its ``format``; the bare shorthand is gone.""" + + @pytest.mark.parametrize("bare", ["a.png", b"\x89PNG"]) + def test_bare_reference_rejected_with_actionable_message(self, bare): + """A bare str/bytes has nowhere to declare its wire form, and the + rejection must say what to pass instead.""" + from pydantic import ValidationError + + from tensorrt_llm.visual_gen import VisualGenParams + + with pytest.raises(ValidationError, match="must declare its wire form"): + VisualGenParams(image_reference=bare) + + def test_bare_reference_in_list_rejected(self): + from pydantic import ValidationError + + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams + + with pytest.raises(ValidationError, match="must declare its wire form"): + VisualGenParams( + image_reference=[MediaRef(content="a.png", format="path"), "b.png"], + ) + + def test_format_is_required(self): + from pydantic import ValidationError + + from tensorrt_llm.visual_gen import MediaRef + + with pytest.raises(ValidationError, match=r"format\s+Field required"): + MediaRef(content="a.png") + + @pytest.mark.parametrize( + "content,content_format", + [("not-bytes", "bytes"), (b"\x89PNG", "base64"), (b"\x89PNG", "path"), (b"x", "url")], + ) + def test_content_type_must_match_format(self, content, content_format): + """The pairing is enforced at construction, not deep in the engine.""" + from pydantic import ValidationError + + from tensorrt_llm.visual_gen import MediaRef + + with pytest.raises(ValidationError, match="requires (bytes|string) content"): + MediaRef(content=content, format=content_format) + + def test_engine_rewrite_is_not_blocked_by_the_pairing_check(self): + """``prepare_reference_slots`` rewrites content then format, so the + intermediate state contradicts the pairing; assignment must not + re-validate or that rewrite would be impossible.""" + from tensorrt_llm.visual_gen import MediaRef + + ref = MediaRef(content="aGk=", format="base64") + ref.content = "/tmp/ref.png" # contradicts format for one statement + ref.format = "path" + assert (ref.content, ref.format) == ("/tmp/ref.png", "path") + + def test_unknown_format_rejected(self): + from pydantic import ValidationError + + from tensorrt_llm.visual_gen import MediaRef + + with pytest.raises( + ValidationError, match="Input should be 'path', 'url', 'base64' or 'bytes'" + ): + MediaRef(content="a.png", format="filepath") + + def test_single_ref_normalized_to_list_and_list_preserved(self): + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams + + single = VisualGenParams(video_reference=MediaRef(content="v.mp4", format="path")) + assert single.video_reference == [MediaRef(content="v.mp4", format="path")] + + refs = [ + MediaRef(content="https://example.com/a.png", format="url"), + MediaRef(content="Zm9v", format="base64"), + ] + assert VisualGenParams(image_reference=refs).image_reference == refs + + # ============================================================================= # ExtraParamSchema # ============================================================================= @@ -299,9 +384,9 @@ def test_wan_i2v_extra_specs(self): specs = WanImageToVideoPipeline.extra_param_specs.fget( _wan_mock(is_wan22_14b=True, is_wan22_5b=False) ) - assert "last_image" in specs + # ``last_image`` moved to the typed image_reference 'last_frame' role. + assert "last_image" not in specs assert "guidance_scale_2" in specs - assert specs["last_image"].type == "str" def test_flux_no_extra_specs(self): from tensorrt_llm._torch.visual_gen.models.flux.pipeline_flux import FluxPipeline @@ -391,10 +476,11 @@ def test_user_values_not_overwritten(self): def test_flux2_reference_dimensions_remain_unset_for_pipeline_resolution(self): from tensorrt_llm._torch.visual_gen.models.flux.pipeline_flux2 import Flux2Pipeline + from tensorrt_llm.visual_gen.params import MediaRef executor = self._make_mock_executor(Flux2Pipeline) executor.pipeline.derive_output_size_from_reference = True - req = self._make_request(image=b"encoded image") + req = self._make_request(image_reference=MediaRef(content=b"encoded image", format="bytes")) self._merge(executor, req) @@ -404,10 +490,15 @@ def test_flux2_reference_dimensions_remain_unset_for_pipeline_resolution(self): def test_flux2_reference_dimensions_preserve_explicit_values(self): from tensorrt_llm._torch.visual_gen.models.flux.pipeline_flux2 import Flux2Pipeline + from tensorrt_llm.visual_gen.params import MediaRef executor = self._make_mock_executor(Flux2Pipeline) executor.pipeline.derive_output_size_from_reference = True - req = self._make_request(image=b"encoded image", height=768, width=512) + req = self._make_request( + image_reference=MediaRef(content=b"encoded image", format="bytes"), + height=768, + width=512, + ) self._merge(executor, req) @@ -818,8 +909,6 @@ def test_valid_extra_params_accepted(self): def test_spec_validator_runs_at_preflight(self): """Per-param validators turn deterministic client errors into 400s at the boundary instead of worker-side failures (Cosmos3 conditioning).""" - import torch - from tensorrt_llm._torch.visual_gen.models.cosmos3.defaults import COSMOS3_EXTRA_SPECS from tensorrt_llm.visual_gen.params import VisualGenParams, validate_visual_gen_params @@ -832,16 +921,6 @@ def _validate(extras): # Valid values pass. _validate({"condition_video_latent_indexes": [0, 1], "condition_video_keep": "last"}) - # ``video`` carries encoded MP4/AVI bytes: a video signature passes, - # empty / non-video bytes are client errors, and anything that is not - # bytes (e.g. a decoded tensor) fails the type check. - _validate({"video": b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"}) - with pytest.raises(ValueError, match="empty"): - _validate({"video": b""}) - with pytest.raises(ValueError, match="not a recognized video container"): - _validate({"video": b"\x89PNG\r\n\x1a\n and not a video"}) - with pytest.raises(ValueError, match="expected type 'bytes'"): - _validate({"video": torch.zeros(3, 4, 4, 3, dtype=torch.uint8)}) with pytest.raises(ValueError, match="non-negative"): _validate({"condition_video_latent_indexes": [0, -1]}) @@ -887,8 +966,6 @@ def test_spec_validators_survive_pickling(self): specs = pickle.loads(pickle.dumps(COSMOS3_EXTRA_SPECS)) with pytest.raises(ValueError, match="first or last"): specs["condition_video_keep"].validator("middle") - with pytest.raises(ValueError, match="not a recognized video container"): - specs["video"].validator(b"garbage bytes") # --- unsupported universal fields --- @@ -929,13 +1006,17 @@ def test_image_cond_strength_on_wan_via_extra_params_raises(self): with pytest.raises(ValueError, match="Unknown extra_params"): self._validate(executor, req) - def test_image_not_checked_by_validator(self): - """image is a conditioning input — validated at runtime by infer(), not here.""" + def test_image_reference_not_checked_without_ref_specs(self): + """Without ``ref_slot_specs``, image_reference is not role/arity checked + here — the pipeline's infer() consumes it at runtime.""" from tensorrt_llm._torch.visual_gen.models.wan.pipeline_wan import WanPipeline + from tensorrt_llm.visual_gen.params import MediaRef executor = self._make_mock_executor(WanPipeline, _wan_mock(num_heads=12)) - req = self._make_request(image="/path/to/img.png") - # Should not raise — image validation is the pipeline's responsibility + req = self._make_request( + image_reference=MediaRef(content="/path/to/img.png", format="path") + ) + # Should not raise — ``_validate`` here passes no ref_slot_specs. self._merge_and_validate(executor, req) def test_num_frames_on_video_pipeline_ok(self): @@ -946,15 +1027,117 @@ def test_num_frames_on_video_pipeline_ok(self): req = self._make_request(num_frames=81) self._merge_and_validate(executor, req) - def test_image_on_i2v_pipeline_ok(self): - """image is declared by WanImageToVideoPipeline, should not raise.""" - from tensorrt_llm._torch.visual_gen.models.wan.pipeline_wan_i2v import ( - WanImageToVideoPipeline, + def test_ref_slot_required_vs_optional(self): + """``min >= 1`` marks a required reference (clean error when absent); + ``min == 0`` leaves the slot optional; an undeclared slot is fine while + absent but rejected when sent, as is a role the slot never declared.""" + from tensorrt_llm._torch.visual_gen.pipeline import RefSlotSpec, RoleSpec + from tensorrt_llm.visual_gen.params import ( + MediaRef, + VisualGenParams, + validate_visual_gen_params, ) - executor = self._make_mock_executor(WanImageToVideoPipeline, _wan_mock(num_heads=12)) - req = self._make_request(image="/path/to/img.png") - self._merge_and_validate(executor, req) + required = { + "image_reference": RefSlotSpec( + modality="image", roles=[RoleSpec(role="reference", min=1, max=1)] + ) + } + optional = { + "image_reference": RefSlotSpec( + modality="image", roles=[RoleSpec(role="first_frame", min=0, max=1)] + ) + } + + def run(params, spec): + validate_visual_gen_params( + params, declared_defaults=None, extra_param_specs={}, ref_slot_specs=spec + ) + + # Required slot, no image -> clean 400 here instead of a worker crash. + with pytest.raises(ValueError, match=r"expected 1\.\.1, got 0"): + run(VisualGenParams(), required) + # Optional slot, no image -> allowed (e.g. text-to-video). + run(VisualGenParams(), optional) + # Required slot with the image present -> allowed. + run(VisualGenParams(image_reference=MediaRef(content="a.png", format="path")), required) + # Undeclared slot actually sent -> rejected. + with pytest.raises(ValueError, match=r"video_reference.*not accepted"): + run(VisualGenParams(video_reference=MediaRef(content="v.mp4", format="path")), optional) + # A role the slot never declared -> rejected here, not carried to a worker + # that has no conditioning input to put it in. + with pytest.raises(ValueError, match=r"role 'last_frame' not supported"): + run( + VisualGenParams( + image_reference=MediaRef(content="a.png", format="path", role="last_frame") + ), + required, + ) + + def test_multi_role_slot_infers_single_required_role(self): + """A role-less ref against a multi-role slot is inferred when only one + role is required (e.g. i2v first_frame required, last_frame optional), + matching the pipeline's own default; a genuinely ambiguous slot (two + required roles) still demands an explicit role.""" + from tensorrt_llm._torch.visual_gen.pipeline import RefSlotSpec, RoleSpec + from tensorrt_llm.visual_gen.params import ( + MediaRef, + VisualGenParams, + validate_visual_gen_params, + ) + + def run(params, spec): + validate_visual_gen_params( + params, declared_defaults=None, extra_param_specs={}, ref_slot_specs=spec + ) + + # i2v shape: first_frame required, last_frame optional. A single + # role-less upload fills first_frame -> allowed (no explicit role). + i2v = { + "image_reference": RefSlotSpec( + modality="image", + roles=[ + RoleSpec(role="first_frame", min=1, max=1), + RoleSpec(role="last_frame", min=0, max=1), + ], + ) + } + run(VisualGenParams(image_reference=MediaRef(content="a.png", format="path")), i2v) + + # Two required roles -> ambiguous, role stays mandatory. + ambiguous = { + "image_reference": RefSlotSpec( + modality="image", + roles=[ + RoleSpec(role="first_frame", min=1, max=1), + RoleSpec(role="last_frame", min=1, max=1), + ], + ) + } + with pytest.raises(ValueError, match="'role' is required"): + run( + VisualGenParams(image_reference=MediaRef(content="a.png", format="path")), ambiguous + ) + + def test_empty_ref_slot_specs_rejects_references(self): + """An empty (non-None) ref_slot_specs means the pipeline declares no + slots, so a reference is rejected; only ``None`` skips validation.""" + from tensorrt_llm.visual_gen.params import ( + MediaRef, + VisualGenParams, + validate_visual_gen_params, + ) + + def run(params, spec): + validate_visual_gen_params( + params, declared_defaults=None, extra_param_specs={}, ref_slot_specs=spec + ) + + ref = MediaRef(content="a.png", format="path") + with pytest.raises(ValueError, match="not accepted"): + run(VisualGenParams(image_reference=ref), {}) + run(VisualGenParams(image_reference=ref), None) # None -> skipped + run(VisualGenParams(), {}) # no reference -> allowed def test_none_fields_not_flagged(self): """Fields left as None should never trigger unsupported-field errors.""" @@ -1009,15 +1192,10 @@ def test_bool_rejected_for_float_spec(self): self._merge_and_validate(executor, req) def test_wrong_type_str_extra_param(self): - from tensorrt_llm._torch.visual_gen.models.wan.pipeline_wan_i2v import ( - WanImageToVideoPipeline, - ) + from tensorrt_llm._torch.visual_gen.models.ltx2.pipeline_ltx2 import LTX2Pipeline - executor = self._make_mock_executor(WanImageToVideoPipeline, _wan_mock(num_heads=12)) - req = self._make_request( - image="/img.png", - extra_params={"last_image": 123}, - ) + executor = self._make_mock_executor(LTX2Pipeline) + req = self._make_request(extra_params={"output_type": 123}) with pytest.raises(ValueError, match="expected type 'str'"): self._merge_and_validate(executor, req) @@ -1117,21 +1295,6 @@ def test_literal_extra_param_accepts_numeric_choice(self): extra_param_specs=COSMOS3_EXTRA_SPECS, ) - def test_video_reference_must_be_bytes(self): - """A server-local path must not reach the worker: the ``video`` contract - is encoded bytes, so a string (or anything else) fails preflight.""" - from tensorrt_llm._torch.visual_gen.models.cosmos3.defaults import COSMOS3_EXTRA_SPECS - - req = self._make_request(extra_params={"video": "/server/local/path.mp4"}) - with pytest.raises(ValueError, match="expected type 'bytes'"): - from tensorrt_llm.visual_gen.params import validate_visual_gen_params - - validate_visual_gen_params( - req.params, - declared_defaults=None, - extra_param_specs=COSMOS3_EXTRA_SPECS, - ) - # ============================================================================= # Parameter validation — message content per category @@ -1349,7 +1512,7 @@ def test_runtime_error_carried_on_response(self): def test_reference_size_is_prepared_before_warmup_lookup(self): from tensorrt_llm._torch.visual_gen.executor import DiffusionExecutor, DiffusionRequest from tensorrt_llm._torch.visual_gen.models.flux.pipeline_flux2 import Flux2Pipeline - from tensorrt_llm.visual_gen.params import VisualGenParams + from tensorrt_llm.visual_gen.params import MediaRef, VisualGenParams events = [] executor = self._make_executor(Flux2Pipeline) @@ -1375,7 +1538,9 @@ def request_warmup_cache_key(req): req = DiffusionRequest( request_id=8, prompt=["test"], - params=VisualGenParams(image=b"encoded image"), + params=VisualGenParams( + image_reference=MediaRef(content=b"encoded image", format="bytes") + ), ) DiffusionExecutor.process_request(executor, req) @@ -1383,3 +1548,24 @@ def request_warmup_cache_key(req): assert events == ["prepare", "warmup_cache_key", "infer"] executor.pipeline.request_warmup_cache_key.assert_called_once_with(req) executor.pipeline.run_inference.assert_called_once_with(req) + + +class TestDeprecatedInputReferenceStaysCompatible: + """The deprecated field takes a bare string, the typed ones never do. + + Old callers sent base64 with nothing declaring it, and that has to keep + working; a typed reference has somewhere to say what it is, so it must. + """ + + def test_a_bare_string_is_still_accepted(self): + from tensorrt_llm.serve.openai_protocol import VideoGenerationRequest + + assert VideoGenerationRequest(prompt="x", input_reference="aGk=").input_reference == "aGk=" + + def test_the_typed_field_still_requires_a_format(self): + from pydantic import ValidationError + + from tensorrt_llm.serve.openai_protocol import VideoGenerationRequest + + with pytest.raises(ValidationError): + VideoGenerationRequest(prompt="x", image_reference="aGk=") diff --git a/tests/unittest/_torch/visual_gen/test_visual_gen_utils.py b/tests/unittest/_torch/visual_gen/test_visual_gen_utils.py index de9d8956df4c..b8d9aa865d60 100644 --- a/tests/unittest/_torch/visual_gen/test_visual_gen_utils.py +++ b/tests/unittest/_torch/visual_gen/test_visual_gen_utils.py @@ -12,6 +12,8 @@ from __future__ import annotations import base64 +import os +import pickle from io import BytesIO from pathlib import Path from typing import Any, Dict, Optional @@ -21,6 +23,7 @@ from fastapi import UploadFile from PIL import Image +from tensorrt_llm.serve import visual_gen_utils from tensorrt_llm.serve.openai_protocol import ImageGenerationRequest, VideoGenerationRequest from tensorrt_llm.serve.visual_gen_utils import ( _merge_extra_params, @@ -28,6 +31,21 @@ parse_visual_gen_params, ) from tensorrt_llm.visual_gen import VisualGenParams +from tensorrt_llm.visual_gen.params import prepare_reference_slots + + +def _parse_and_prepare(request, generator): + """Run the production reference flow: serve transport, then engine resolve. + + ``parse_visual_gen_params`` only normalizes transport (upload -> bytes, + strings pass through with their declared format); resolution to bytes + happens at the engine choke point, so tests that assert on the resolved + payload drive both stages. + """ + params = parse_visual_gen_params(request, generator) + prepare_reference_slots(params) + return params + pytestmark = pytest.mark.cpu_only @@ -82,7 +100,7 @@ def test_all_none_request_keeps_pipeline_defaults(self, image_request_defaults): generator = _StubVisualGen( defaults={"width": 1024, "height": 1024, "num_inference_steps": 30}, ) - params = parse_visual_gen_params(image_request_defaults, "id-1", generator) + params = parse_visual_gen_params(image_request_defaults, generator) assert params.width == 1024 assert params.height == 1024 assert params.num_inference_steps == 30 @@ -102,7 +120,7 @@ def test_image_explicit_fields_override_defaults(self): n=4, negative_prompt="blurry", ) - params = parse_visual_gen_params(request, "id-2", generator) + params = parse_visual_gen_params(request, generator) assert (params.width, params.height) == (512, 512) assert params.num_inference_steps == 10 assert params.guidance_scale == 4.0 @@ -114,19 +132,19 @@ def test_image_explicit_fields_override_defaults(self): def test_size_string_used_when_width_height_absent(self): generator = _StubVisualGen() request = ImageGenerationRequest(prompt="cat", size="768x256") - params = parse_visual_gen_params(request, "id-3", generator) + params = parse_visual_gen_params(request, generator) assert (params.width, params.height) == (768, 256) def test_width_height_pair_wins_over_size(self): generator = _StubVisualGen() request = ImageGenerationRequest(prompt="cat", size="768x256", width=128, height=64) - params = parse_visual_gen_params(request, "id-4", generator) + params = parse_visual_gen_params(request, generator) assert (params.width, params.height) == (128, 64) def test_image_seed_propagates(self): generator = _StubVisualGen() request = ImageGenerationRequest(prompt="cat", seed=12345) - params = parse_visual_gen_params(request, "id-seed", generator) + params = parse_visual_gen_params(request, generator) assert params.seed == 12345 @@ -193,7 +211,7 @@ def _fake_warning(msg: str, *args: object, **kwargs: object) -> None: def test_quality_hd_does_not_override_steps(self): generator = _StubVisualGen(defaults={"num_inference_steps": 25}) request = ImageGenerationRequest(prompt="cat", quality="hd") - params = parse_visual_gen_params(request, "id-q", generator) + params = parse_visual_gen_params(request, generator) # ``quality`` is an OpenAI-shape no-semantic field. The pipeline # default for ``num_inference_steps`` must reach the engine # unchanged. @@ -233,21 +251,21 @@ class TestVideoFrameBudget: def test_num_frames_wins_over_seconds_times_frame_rate(self): generator = _StubVisualGen(defaults={"frame_rate": 24.0}) request = VideoGenerationRequest(prompt="x", num_frames=33, seconds=10.0) - params = parse_visual_gen_params(request, "id-v1", generator) + params = parse_visual_gen_params(request, generator) assert params.num_frames == 33 def test_seconds_and_frame_rate_derive_num_frames(self): generator = _StubVisualGen(defaults={"frame_rate": 12.0}) # fps alias resolves to frame_rate via populate_by_name=True request = VideoGenerationRequest(prompt="x", seconds=2.5, fps=24) - params = parse_visual_gen_params(request, "id-v2", generator) + params = parse_visual_gen_params(request, generator) assert params.frame_rate == 24.0 assert params.num_frames == int(2.5 * 24.0) def test_seconds_alone_uses_pipeline_frame_rate(self): generator = _StubVisualGen(defaults={"frame_rate": 16.0}) request = VideoGenerationRequest(prompt="x", seconds=4.0) - params = parse_visual_gen_params(request, "id-v3", generator) + params = parse_visual_gen_params(request, generator) assert params.frame_rate == 16.0 assert params.num_frames == int(4.0 * 16.0) @@ -258,42 +276,30 @@ def test_video_does_not_carry_n(self): # leave ``num_images_per_prompt`` unchanged from the pipeline # default. request = VideoGenerationRequest(prompt="x") - params = parse_visual_gen_params(request, "id-v4", generator) + params = parse_visual_gen_params(request, generator) assert params.num_images_per_prompt == 1 # ============================================================================= -# input_reference materialization +# reference resolution # ============================================================================= -class TestInputReferenceMaterialization: - def test_base64_reference_written_to_disk(self, tmp_path): +class TestInputReferenceResolution: + def test_base64_image_reference_resolves_to_bytes(self, tmp_path): generator = _StubVisualGen() img = Image.new("RGB", (4, 4), (10, 20, 30)) buf = BytesIO() img.save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode() - request = VideoGenerationRequest(prompt="x", input_reference=b64) - params = parse_visual_gen_params( - request, "vid-1", generator, media_storage_path=str(tmp_path) + request = VideoGenerationRequest( + prompt="x", image_reference={"content": b64, "format": "base64"} ) - assert params.image is not None - assert str(params.image).endswith("vid-1_reference") - # The decoded image is identical to what we passed in. - with open(params.image, "rb") as f: - decoded = Image.open(f).convert("RGB") - assert decoded.size == (4, 4) - - def test_missing_media_storage_path_raises(self): - generator = _StubVisualGen() - img = Image.new("RGB", (2, 2)) - buf = BytesIO() - img.save(buf, format="PNG") - b64 = base64.b64encode(buf.getvalue()).decode() - request = VideoGenerationRequest(prompt="x", input_reference=b64) - with pytest.raises(ValueError, match="media_storage_path"): - parse_visual_gen_params(request, "vid-2", generator, media_storage_path=None) + params = _parse_and_prepare(request, generator) + assert len(params.image_reference) == 1 + ref_path = params.image_reference[0].content + assert ref_path == buf.getvalue() + assert params.image_reference[0].format == "bytes" _TEST_DATA = Path(__file__).parent / "test_data" @@ -301,114 +307,88 @@ def test_missing_media_storage_path_raises(self): def _mp4_bytes() -> bytes: """9-frame H.264-in-MP4 fixture (provenance: test_data/README.md).""" return ( - TestInputReferenceMaterialization._TEST_DATA / "cosmos3_v2v_ref_9f_bframes.mp4" + TestInputReferenceResolution._TEST_DATA / "cosmos3_v2v_ref_9f_bframes.mp4" ).read_bytes() @staticmethod def _avi_bytes() -> bytes: """Same 9 frames as H.264-in-AVI (provenance: test_data/README.md).""" return ( - TestInputReferenceMaterialization._TEST_DATA / "cosmos3_v2v_ref_9f_bframes.avi" + TestInputReferenceResolution._TEST_DATA / "cosmos3_v2v_ref_9f_bframes.avi" ).read_bytes() - def test_multipart_avi_reference_routes_to_video(self, tmp_path): - # The AVI container signature must survive the real boundary: routed - # to the ``video`` extra param as untouched encoded bytes. + @pytest.mark.parametrize("container", ["avi", "mp4"]) + def test_multipart_video_reference_resolves_to_bytes(self, container): + """The boundary never decodes video: the encoded bytes cross it + untouched, and the worker demuxes the conditioning window itself.""" generator = _StubVisualGen() - payload = self._avi_bytes() - upload = UploadFile(file=BytesIO(payload), filename="clip.avi") - request = VideoGenerationRequest(prompt="x", input_reference=upload) - params = parse_visual_gen_params( - request, "vid-avi", generator, media_storage_path=str(tmp_path) - ) - assert params.image is None - assert params.extra_params["video"] == payload - - def test_multipart_video_reference_routes_to_extra_params_bytes(self, tmp_path): + payload = self._avi_bytes() if container == "avi" else self._mp4_bytes() + upload = UploadFile(file=BytesIO(payload), filename=f"clip.{container}") + request = VideoGenerationRequest(prompt="x", video_reference=upload) + params = _parse_and_prepare(request, generator) + assert params.image_reference is None + assert params.video_reference[0].content == payload + + def test_deprecated_input_reference_routes_by_sniff(self, tmp_path): + # The deprecated single input_reference is sniff-routed to the typed slot. generator = _StubVisualGen() - payload = self._mp4_bytes() - upload = UploadFile(file=BytesIO(payload), filename="clip.mp4") - request = VideoGenerationRequest(prompt="x", input_reference=upload) - params = parse_visual_gen_params( - request, "vid-3", generator, media_storage_path=str(tmp_path) + buf = BytesIO() + Image.new("RGB", (4, 4)).save(buf, format="PNG") + img_b64 = base64.b64encode(buf.getvalue()).decode() + vid_b64 = base64.b64encode(self._mp4_bytes()).decode() + + p = _parse_and_prepare( + VideoGenerationRequest(prompt="x", input_reference=img_b64), + generator, ) - # Video content rides the model-specific ``video`` extra param as the - # encoded payload, byte-identical — the boundary never decodes video; - # the worker demuxes/NVDEC-decodes the conditioning window. - assert params.image is None - assert params.extra_params["video"] == payload - # Nothing lands in media storage for video references. - assert list(tmp_path.iterdir()) == [] - - def test_video_reference_needs_no_media_storage(self): - # Video bytes pass through in memory, so V2V works without a storage - # path at all (only image references persist a file for the worker). - generator = _StubVisualGen() - b64 = base64.b64encode(self._mp4_bytes()).decode() - request = VideoGenerationRequest(prompt="x", input_reference=b64) - params = parse_visual_gen_params(request, "vid-9", generator, media_storage_path=None) - assert params.extra_params["video"] == self._mp4_bytes() - - def test_base64_video_reference_routes_to_extra_params_bytes(self, tmp_path): - # Routing is signature-based, so the JSON/base64 path can carry video - # even though it has no content-type or filename. - generator = _StubVisualGen() - payload = self._mp4_bytes() - b64 = base64.b64encode(payload).decode() - request = VideoGenerationRequest(prompt="x", input_reference=b64) - params = parse_visual_gen_params( - request, "vid-4", generator, media_storage_path=str(tmp_path) + assert len(p.image_reference) == 1 and p.video_reference is None + + p = _parse_and_prepare( + VideoGenerationRequest(prompt="x", input_reference=vid_b64), + generator, ) - assert params.image is None - assert params.extra_params["video"] == payload - - def test_video_reference_bytes_survive_real_specs(self): - """With the real cosmos3 specs loaded, parsing leaves the encoded - payload byte-identical in ``extra_params['video']`` — the boundary - never transforms video content; the worker decodes the window.""" - from tensorrt_llm._torch.visual_gen.models.cosmos3.defaults import COSMOS3_EXTRA_SPECS - - generator = _StubVisualGen(extra_param_specs=COSMOS3_EXTRA_SPECS) - payload = self._mp4_bytes() - b64 = base64.b64encode(payload).decode() - request = VideoGenerationRequest(prompt="x", input_reference=b64) - params = parse_visual_gen_params(request, "vid-10", generator, media_storage_path=None) - assert params.extra_params["video"] == payload - - def test_multipart_image_reference_routes_to_image(self, tmp_path): - # JPEG upload: content sniffing classifies it as an image and routes - # to params.image. The stored file has no type-suffix (PIL identifies - # by content, not name). + assert len(p.video_reference) == 1 and p.image_reference is None + + def test_input_reference_ignored_when_typed_reference_set(self, tmp_path): + # A typed reference takes precedence; the deprecated input_reference is dropped. generator = _StubVisualGen() - img = Image.new("RGB", (4, 4), (10, 20, 30)) buf = BytesIO() - img.save(buf, format="JPEG") - buf.seek(0) - upload = UploadFile(file=buf, filename="ref.jpg") - request = VideoGenerationRequest(prompt="x", input_reference=upload) - params = parse_visual_gen_params( - request, "vid-5", generator, media_storage_path=str(tmp_path) + Image.new("RGB", (4, 4)).save(buf, format="PNG") + img_b64 = base64.b64encode(buf.getvalue()).decode() + vid_b64 = base64.b64encode(self._mp4_bytes()).decode() + p = _parse_and_prepare( + VideoGenerationRequest( + prompt="x", + image_reference={"content": img_b64, "format": "base64"}, + input_reference=vid_b64, + ), + generator, ) - assert params.extra_params is None - assert str(params.image).endswith("vid-5_reference") + assert len(p.image_reference) == 1 + assert p.video_reference is None # input_reference video dropped - def test_undecodable_reference_raises_and_cleans_up(self, tmp_path): - generator = _StubVisualGen() - b64 = base64.b64encode(b"neither an image nor a video").decode() - request = VideoGenerationRequest(prompt="x", input_reference=b64) - with pytest.raises(ValueError, match="not a recognized media container"): - parse_visual_gen_params(request, "vid-6", generator, media_storage_path=str(tmp_path)) - # Classification runs on the bytes; rejected content never touches disk. - assert list(tmp_path.iterdir()) == [] - - def test_malformed_base64_reference_raises_and_cleans_up(self, tmp_path): + def test_wrong_modality_content_raises(self, tmp_path): + # The field name declares modality; mismatched content is a client error. generator = _StubVisualGen() - # "ABC" survives the lenient alphabet filter but has an invalid - # length, so b64decode raises. - request = VideoGenerationRequest(prompt="x", input_reference="ABC") - with pytest.raises(ValueError, match="not valid base64"): - parse_visual_gen_params(request, "vid-7", generator, media_storage_path=str(tmp_path)) - assert list(tmp_path.iterdir()) == [] + buf = BytesIO() + Image.new("RGB", (2, 2)).save(buf, format="PNG") + img_b64 = base64.b64encode(buf.getvalue()).decode() + vid_b64 = base64.b64encode(self._mp4_bytes()).decode() + with pytest.raises(ValueError, match="video_reference is not a recognized"): + _parse_and_prepare( + VideoGenerationRequest( + prompt="x", video_reference={"content": img_b64, "format": "base64"} + ), + generator, + ) + with pytest.raises(ValueError, match="image_reference is not a recognized image"): + _parse_and_prepare( + VideoGenerationRequest( + prompt="x", image_reference={"content": vid_b64, "format": "base64"} + ), + generator, + ) + assert list(tmp_path.iterdir()) == [] # nothing is ever written to disk def test_upload_stream_failure_cleans_up_tmp(self, tmp_path): generator = _StubVisualGen() @@ -418,17 +398,53 @@ def read(self, *args, **kwargs): raise OSError("client went away") upload = UploadFile(file=_BrokenStream(), filename="clip.mp4") - request = VideoGenerationRequest(prompt="x", input_reference=upload) + request = VideoGenerationRequest(prompt="x", video_reference=upload) # I/O failures keep their server-error semantics (no 400 masking) … with pytest.raises(OSError, match="client went away"): - parse_visual_gen_params(request, "vid-8", generator, media_storage_path=str(tmp_path)) - # … but the partial materialization must not leak. - assert list(tmp_path.iterdir()) == [] + _parse_and_prepare(request, generator) + # … and the payload read fails before any file is written, so nothing leaks. + assert list(tmp_path.iterdir()) == [] # nothing is ever written to disk + + def test_missing_file_uri_is_client_error(self, tmp_path): + # A file:// path that does not exist is a client 400, not a server 500. + generator = _StubVisualGen() + missing = (tmp_path / "does_not_exist.png").as_uri() + request = VideoGenerationRequest( + prompt="x", image_reference={"content": missing, "format": "path"} + ) + with pytest.raises(ValueError, match="file could not be read"): + _parse_and_prepare(request, generator) class TestMediaBytesProbes: """The in-memory signature probes the serve boundary routes on.""" + def test_sniff_recognizes_audio_containers(self): + """Audio has to classify as itself, not fall through to ``None``. + + ``None`` means "not media", which is what a caller rejects on, so an + audio reference that sniffs to ``None`` is indistinguishable from a + text file. Headers are the real ones ffmpeg emits. + """ + from tensorrt_llm.inputs.media_io import sniff_media_kind + + assert sniff_media_kind(b"RIFF\x2e\x45\x00\x00WAVEfmt ") == "audio" + assert sniff_media_kind(b"fLaC\x00\x00\x00\x22") == "audio" + assert sniff_media_kind(b"OggS\x00\x02\x00\x00\x00\x00\x00\x00") == "audio" + assert sniff_media_kind(b"ID3\x04\x00\x00\x00\x00\x00\x00") == "audio" + # bare MPEG/ADTS frame sync — an MP3 or AAC with no leading tag + assert sniff_media_kind(b"\xff\xf1\x50\x40\x21\x3f\xfc\xde") == "audio" + assert sniff_media_kind(b"\xff\xfb\x90\x00\x00\x00\x00\x00") == "audio" + + def test_sniff_separates_m4a_from_mp4(self): + """An ``.m4a`` is an MP4 whose brand says audio-only; without the brand + check it takes the video default.""" + from tensorrt_llm.inputs.media_io import sniff_media_kind + + assert sniff_media_kind(self._ftyp(b"M4A ", (b"isom", b"mp42"))) == "audio" + assert sniff_media_kind(self._ftyp(b"M4B ")) == "audio" + assert sniff_media_kind(self._ftyp(b"isom", (b"iso2", b"avc1"))) == "video" + def test_sniff_media_kind(self): from tensorrt_llm.inputs.media_io import sniff_media_kind @@ -438,12 +454,13 @@ def test_sniff_media_kind(self): Image.new("RGB", (2, 2)).save(jpg, format="JPEG") assert sniff_media_kind(png.getvalue()) == "image" assert sniff_media_kind(jpg.getvalue()) == "image" - assert sniff_media_kind(TestInputReferenceMaterialization._mp4_bytes()) == "video" - assert sniff_media_kind(TestInputReferenceMaterialization._avi_bytes()) == "video" + assert sniff_media_kind(TestInputReferenceResolution._mp4_bytes()) == "video" + assert sniff_media_kind(TestInputReferenceResolution._avi_bytes()) == "video" assert sniff_media_kind(b"plain text, not media") is None assert sniff_media_kind(b"") is None - # RIFF alone is not AVI (e.g. WAV audio is RIFF too). - assert sniff_media_kind(b"RIFF\x00\x00\x00\x00WAVEfmt ") is None + # RIFF carries both: the form type at [8:12] is what separates them. + assert sniff_media_kind(b"RIFF\x00\x00\x00\x00WAVEfmt ") == "audio" + assert sniff_media_kind(b"RIFF\x00\x00\x00\x00WEBP") is None @staticmethod def _ftyp(major: bytes, compatible: tuple = (), *, size: int = None) -> bytes: @@ -534,10 +551,11 @@ def test_heif_reference_rejected_with_actionable_message(self): generator = _StubVisualGen() heic = self._ftyp(b"heic", (b"mif1", b"heic")) + b"\x00" * 64 request = VideoGenerationRequest( - prompt="x", input_reference=base64.b64encode(heic).decode() + prompt="x", + image_reference={"content": base64.b64encode(heic).decode(), "format": "base64"}, ) with pytest.raises(ValueError, match="HEIF/AVIF"): - parse_visual_gen_params(request, "vid-heic", generator, media_storage_path=None) + _parse_and_prepare(request, generator) def test_truncated_image_reference_is_routed_not_decoded(self, tmp_path): """The boundary routes on signature and never decodes. @@ -557,12 +575,11 @@ def test_truncated_image_reference_is_routed_not_decoded(self, tmp_path): generator = _StubVisualGen() request = VideoGenerationRequest( - prompt="x", input_reference=base64.b64encode(truncated).decode() + prompt="x", + image_reference={"content": base64.b64encode(truncated).decode(), "format": "base64"}, ) - params = parse_visual_gen_params( - request, "vid-12", generator, media_storage_path=str(tmp_path) - ) - assert Path(params.image).read_bytes() == truncated + params = _parse_and_prepare(request, generator) + assert params.image_reference[0].content == truncated # ============================================================================= @@ -646,7 +663,7 @@ def test_base64_extra_param_reaches_the_pipeline_as_bytes(self): prompt="storm", extra_params={"video": base64.b64encode(b"\x00mp4").decode()}, ) - params = parse_visual_gen_params(request, "id-b64", self._generator()) + params = parse_visual_gen_params(request, self._generator()) assert params.extra_params["video"] == b"\x00mp4" def test_nested_control_reaches_the_pipeline_as_bytes(self): @@ -654,18 +671,336 @@ def test_nested_control_reaches_the_pipeline_as_bytes(self): prompt="storm", extra_params={"edge": {"control": base64.b64encode(b"\x00ctrl").decode()}}, ) - params = parse_visual_gen_params(request, "id-nested", self._generator()) + params = parse_visual_gen_params(request, self._generator()) assert params.extra_params["edge"]["control"] == b"\x00ctrl" def test_non_media_params_are_untouched(self): request = VideoGenerationRequest( prompt="storm", extra_params={"resolution": "720", "edge": True} ) - params = parse_visual_gen_params(request, "id-plain", self._generator()) + params = parse_visual_gen_params(request, self._generator()) assert params.extra_params["resolution"] == "720" assert params.extra_params["edge"] is True def test_malformed_base64_is_a_client_error(self): request = VideoGenerationRequest(prompt="storm", extra_params={"video": "not!b64!"}) with pytest.raises(ValueError, match="not valid base64"): - parse_visual_gen_params(request, "id-bad", self._generator()) + parse_visual_gen_params(request, self._generator()) + + +class TestPrepareReferenceSlots: + """The engine choke point: every declared form resolves to raw bytes.""" + + def test_resolving_rewrites_the_format_to_bytes(self): + """Every wire form leaves here as ``bytes``, which is what stops a + worker being handed a spelling it would have to resolve itself.""" + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams + from tensorrt_llm.visual_gen.params import prepare_reference_slots + + buf = BytesIO() + Image.new("RGB", (4, 4)).save(buf, format="PNG") + params = VisualGenParams( + image_reference=MediaRef( + content=base64.b64encode(buf.getvalue()).decode(), format="base64" + ) + ) + prepare_reference_slots(params) + + assert params.image_reference[0].format == "bytes" + assert params.image_reference[0].content == buf.getvalue() + + def test_audio_slot_rejects_non_audio(self): + """The audio slot is checked like the others: a container that is not + audio must not reach a worker that will try to decode it as one.""" + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams + from tensorrt_llm.visual_gen.params import prepare_reference_slots + + buf = BytesIO() + Image.new("RGB", (2, 2)).save(buf, format="PNG") + params = VisualGenParams(audio_reference=MediaRef(content=buf.getvalue(), format="bytes")) + with pytest.raises(ValueError, match="audio_reference is not a recognized"): + prepare_reference_slots(params) + + +class TestResolveReference: + """``_resolve_reference`` dispatches on the declared format, never on the value.""" + + @staticmethod + def _png() -> bytes: + buf = BytesIO() + Image.new("RGB", (4, 4), (1, 2, 3)).save(buf, format="PNG") + return buf.getvalue() + + def test_every_format_resolves_to_the_same_bytes(self, tmp_path, monkeypatch): + from tensorrt_llm.visual_gen.params import _resolve_reference + + png = self._png() + src = tmp_path / "ref.png" + src.write_bytes(png) + b64 = base64.b64encode(png).decode() + + class _FakeResp: + def __init__(self, content): + self.content = content + + monkeypatch.setattr( + "tensorrt_llm.visual_gen.params._safe_request_get", + lambda url, **kwargs: _FakeResp(png), + ) + assert _resolve_reference(str(src), "path") == png + assert _resolve_reference(src.as_uri(), "path") == png + assert _resolve_reference("https://example.com/ref.png", "url") == png + assert _resolve_reference(b64, "base64") == png + assert _resolve_reference(f"data:image/png;base64,{b64}", "base64") == png + assert _resolve_reference(png, "bytes") == png + + def test_base64_does_not_fall_back_to_a_disk_read(self, tmp_path): + from tensorrt_llm.visual_gen.params import _resolve_reference + + src = tmp_path / "ref.png" + src.write_bytes(self._png()) + with pytest.raises(ValueError, match="not valid base64"): + _resolve_reference(str(src), "base64") + + def test_url_fetch_failure_is_a_client_error(self, monkeypatch): + from tensorrt_llm.visual_gen.params import _resolve_reference + + def _blocked(url, **kwargs): + raise RuntimeError("URL resolves to a non-public address (10.0.0.1)") + + monkeypatch.setattr("tensorrt_llm.visual_gen.params._safe_request_get", _blocked) + with pytest.raises(ValueError, match="reference URL could not be fetched"): + _resolve_reference("http://10.0.0.1/a.png", "url") + + +# ============================================================================= +# reference transport (coordinator -> rank0) +# ============================================================================= + + +class TestReferenceHandleTransport: + """References cross the coordinator -> rank0 hop as shared-memory handles. + + The payload must survive the round trip byte-identically and must not ride + the request pickle, which is the whole point of the handle. + """ + + @staticmethod + def _request(*payloads: bytes): + from tensorrt_llm._torch.visual_gen import DiffusionRequest + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams + + params = VisualGenParams( + image_reference=[MediaRef(content=p, format="bytes") for p in payloads] + ) + return DiffusionRequest(request_id=1, prompt=["x"], params=params) + + def test_survives_a_real_pickle_round_trip(self): + """A handle is only useful if it still resolves after being serialized + and rebuilt, which is what the IPC queue does to it. The bytes must not + ride along in that pickle, or the hop still pays for a full copy.""" + payload = os.urandom(256 * 1024) + req = self._request(payload) + req.refs_to_shm() + + wire = pickle.dumps(req) + assert payload not in wire + assert len(wire) < len(payload) // 2 + + received = pickle.loads(wire) + received.refs_from_shm() + + assert received.params.image_reference[0].content == payload + + +class TestReferenceBroadcastSplit: + """Reference payloads leave the object before the rank0 -> N-rank hop. + + ``broadcast_object_list`` serializes whatever it is handed into a tensor, + so leaving the bytes inside the request would copy every reference byte + before the collective even starts. + """ + + @staticmethod + def _request(*payloads: bytes): + from tensorrt_llm._torch.visual_gen import DiffusionRequest + from tensorrt_llm.visual_gen import MediaRef, VisualGenParams + + params = VisualGenParams( + image_reference=[MediaRef(content=p, format="bytes") for p in payloads], + video_reference=MediaRef(content=b"\x00\x00\x00\x18ftypmp42", format="bytes"), + ) + return DiffusionRequest(request_id=1, prompt=["x"], params=params) + + @staticmethod + def _drive_broadcast(req, rank=0, peer_sizes=None): + """Run ``_broadcast_request`` with the collectives stubbed out. + + Returns ``(result, pickled_object)`` so a test can inspect exactly what + ``broadcast_object_list`` was handed. + """ + from unittest import mock + + from tensorrt_llm._torch.visual_gen import executor as ex + + captured = {} + + def fake_obj_list(obj_list, src=0): + captured["obj"] = pickle.dumps(obj_list[0]) + if rank != 0: + peer = pickle.loads(captured["obj"]) + peer.ref_sizes = peer_sizes if peer_sizes is not None else peer.ref_sizes + obj_list[0] = peer + + self_ = mock.Mock(rank=rank) + with ( + mock.patch.object(ex.dist, "broadcast_object_list", side_effect=fake_obj_list), + mock.patch.object(ex.dist, "broadcast", lambda *a, **k: None), + ): + out = ex.DiffusionExecutor._broadcast_request(self_, req) + return out, captured.get("obj") + + def test_payload_never_reaches_the_object_pickle(self): + """The whole point of the split: the bytes must not be in what + ``broadcast_object_list`` serializes.""" + payloads = (os.urandom(4096), os.urandom(64)) + req = self._request(*payloads) + + out, pickled = self._drive_broadcast(req) + + assert payloads[0] not in pickled + assert payloads[1] not in pickled + assert out is req + + def test_payloads_come_back_to_their_own_slots(self): + payloads = (os.urandom(2048), os.urandom(128)) + req = self._request(*payloads) + + out, _ = self._drive_broadcast(req) + + assert tuple(r.content for r in out.params.image_reference) == payloads + assert out.params.video_reference[0].content == b"\x00\x00\x00\x18ftypmp42" + assert out.ref_sizes is None + + def test_sizes_let_a_peer_size_its_buffers(self): + """Non-source ranks allocate from ``ref_sizes`` alone, so it has to + survive the object hop and match the payloads exactly.""" + payloads = (os.urandom(1024), os.urandom(7)) + req = self._request(*payloads) + + _, pickled = self._drive_broadcast(req) + + peer = pickle.loads(pickled) + assert peer.ref_sizes[:2] == [1024, 7] + assert all(r.content == b"" for r in peer.params.image_reference) + + def test_a_size_count_mismatch_is_refused(self): + """A peer whose ref_sizes disagrees with its slots would pair payloads + to the wrong references; zip() would do it silently.""" + req = self._request(os.urandom(64), os.urandom(32)) + + with pytest.raises(ValueError, match="reference payloads"): + self._drive_broadcast(req, rank=1, peer_sizes=[64]) + + @staticmethod + def _blocks_of(req): + """The shared-memory files this request's handles point at. + + Named per handle rather than scanned out of /dev/shm, which is global + to the machine and picks up unrelated processes. + """ + return [ + Path("/dev/shm") / base64.b64decode(e["handle"]["storage_handle"]).decode().lstrip("/") + for e in req.ref_handles or [] + ] + + def test_consuming_a_handle_releases_its_block(self): + """rank0 frees a block by consuming its handle; nothing else does, so an + unconsumed one stays resident for the life of the process.""" + import gc + + req = self._request(os.urandom(1024 * 1024)) + req.refs_to_shm() + blocks = self._blocks_of(req) + assert blocks and all(b.exists() for b in blocks) + + # What the sender thread does when the request never reaches rank0. + req.refs_from_shm() + del req + gc.collect() + assert not any(b.exists() for b in blocks) + + +class TestSafeLocalFileRead: + """A ``path`` reference bounds what an unlucky path can cost. + + A remote caller naming the path is the case worth defending: reading a + character device or a FIFO never returns, so an unbounded read is a denial + of service rather than a bad request. + """ + + @staticmethod + def _png(tmp_path): + buffer = BytesIO() + Image.new("RGB", (8, 8)).save(buffer, format="PNG") + target = tmp_path / "ref.png" + target.write_bytes(buffer.getvalue()) + return target + + def test_a_regular_file_reads(self, tmp_path): + from tensorrt_llm.inputs.media_io import _safe_read_local_file + + target = self._png(tmp_path) + + assert _safe_read_local_file(str(target)) == target.read_bytes() + assert _safe_read_local_file(target.as_uri()) == target.read_bytes() + + def test_a_non_regular_file_is_refused(self, tmp_path): + """Character devices, FIFOs and directories share one check, and + ``stat`` follows the link so it sees what will actually be read.""" + from tensorrt_llm.inputs.media_io import _safe_read_local_file + + link = tmp_path / "innocent.png" + link.symlink_to(tmp_path) + + with pytest.raises(ValueError, match="not a regular file"): + _safe_read_local_file(str(link)) + + +class TestLocalMediaPathCanBeDisallowed: + """``format='path'`` reads server-side files, so a deployment can refuse it. + + Allowed by default: a co-located client naming a shared path is a real + setup, and the code cannot tell that deployment from an untrusted one. + """ + + @staticmethod + def _request(fmt="path", content="/tmp/ref.png"): + return VideoGenerationRequest( + prompt="x", image_reference={"content": content, "format": fmt} + ) + + def test_path_is_accepted_by_default(self, monkeypatch): + monkeypatch.delenv("TRTLLM_DISALLOW_LOCAL_MEDIA_PATH", raising=False) + params = parse_visual_gen_params(self._request(), _StubVisualGen()) + + assert params.image_reference[0].format == "path" + + def test_path_is_refused_when_disallowed(self, monkeypatch): + monkeypatch.setenv("TRTLLM_DISALLOW_LOCAL_MEDIA_PATH", "1") + + with pytest.raises(ValueError, match="is disallowed on this server"): + parse_visual_gen_params(self._request(), _StubVisualGen()) + + def test_an_unrecognized_value_warns_and_stays_allowed(self, monkeypatch): + """Silently reading a typo as "1" would break working deployments, and + silently reading it as "0" would leave one that believes it is locked + down wide open. Neither is safe to do quietly.""" + monkeypatch.setenv("TRTLLM_DISALLOW_LOCAL_MEDIA_PATH", "true") + warnings: list[str] = [] + monkeypatch.setattr(visual_gen_utils.logger, "warning", warnings.append) + + params = parse_visual_gen_params(self._request(), _StubVisualGen()) + + assert params.image_reference[0].format == "path" + assert any("TRTLLM_DISALLOW_LOCAL_MEDIA_PATH" in w for w in warnings) diff --git a/tests/unittest/_torch/visual_gen/test_wan_i2v_reference_conditioning.py b/tests/unittest/_torch/visual_gen/test_wan_i2v_reference_conditioning.py new file mode 100644 index 000000000000..7b960b4a8a21 --- /dev/null +++ b/tests/unittest/_torch/visual_gen/test_wan_i2v_reference_conditioning.py @@ -0,0 +1,54 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Unit tests for Wan I2V reference-role dispatch. + +The slot declares two roles, so ``infer`` has to decide which reference is the +first frame and which is the last. Getting that backwards conditions the model +on the wrong frame and still returns a video, so nothing downstream would say +anything was wrong. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from tensorrt_llm._torch.visual_gen.models.wan.pipeline_wan_i2v import WanImageToVideoPipeline +from tensorrt_llm.visual_gen.params import MediaRef, VisualGenParams + + +def _request(*refs) -> SimpleNamespace: + """A request as the coordinator hands it over: references already bytes.""" + return SimpleNamespace( + prompt=["a prompt"], + params=VisualGenParams(image_reference=list(refs), seed=0), + ) + + +def _forward_kwargs(req) -> dict: + pipeline = WanImageToVideoPipeline.__new__(WanImageToVideoPipeline) + pipeline.forward = MagicMock(return_value=object()) + + pipeline.infer(req) + + return pipeline.forward.call_args.kwargs + + +def test_roles_select_the_frames() -> None: + kwargs = _forward_kwargs( + _request( + MediaRef(content=b"last", format="bytes", role="last_frame"), + MediaRef(content=b"first", format="bytes", role="first_frame"), + ) + ) + + # Declaration order must not decide: the roles do. + assert kwargs["image"] == b"first" + assert kwargs["last_image"] == b"last" + + +def test_an_omitted_role_is_the_first_frame() -> None: + """Plain I2V leaves ``role`` off, and the slot's other role is optional.""" + kwargs = _forward_kwargs(_request(MediaRef(content=b"only", format="bytes"))) + + assert kwargs["image"] == b"only" + assert kwargs["last_image"] is None diff --git a/tests/unittest/api_stability/references/trtllm_serve_api.yaml b/tests/unittest/api_stability/references/trtllm_serve_api.yaml index eae652bda660..9e25b6abc66c 100644 --- a/tests/unittest/api_stability/references/trtllm_serve_api.yaml +++ b/tests/unittest/api_stability/references/trtllm_serve_api.yaml @@ -1471,11 +1471,29 @@ models: default: null status: stable required: false + image_reference: + kind: extension + type: Optional[Union[UploadFile, MediaReferenceItem, List[MediaReferenceItem]]] + default: null + status: prototype + required: false + video_reference: + kind: extension + type: Optional[Union[UploadFile, MediaReferenceItem, List[MediaReferenceItem]]] + default: null + status: prototype + required: false + audio_reference: + kind: extension + type: Optional[Union[UploadFile, MediaReferenceItem, List[MediaReferenceItem]]] + default: null + status: prototype + required: false input_reference: kind: extension type: Optional[Union[str, UploadFile]] default: null - status: prototype + status: deprecated required: false size: kind: extension @@ -1549,3 +1567,23 @@ models: default: null status: stable required: false + MediaReferenceItem: + fields: + content: + kind: extension + type: str + default: null + status: prototype + required: true + format: + kind: extension + type: Literal['path', 'url', 'base64'] + default: null + status: prototype + required: true + role: + kind: extension + type: Optional[MediaRole] + default: null + status: prototype + required: false