[Refactor] Migrate VACE example to standard task examples + model_extras (#4648)

Signed-off-by: ForestWisdom <zszhang01@gmail.com>
This commit is contained in:
Zhisen Zhang authored and GitHub committed 2026-06-24 13:54:36 +00:00
1 parent bb973cb10c
commit 0814d845dc
13 files changed
+645 -398

No files matched your search

@@ -1,8 +1,7 @@
# Image-To-Video
This example demonstrates how to generate videos from images (image-to-video)
using Wan2.2, LTX2, HunyuanVideo-1.5, and Cosmos3 with vLLM-Omni's offline
inference API.
This shared example generates videos from images with VACE, Wan2.2, LTX2,
HunyuanVideo-1.5, Cosmos3, and other compatible pipelines.
## Local CLI Usage
@@ -68,7 +67,10 @@ python image_to_video.py \
Key arguments:
- `--model`: Model ID (I2V-A14B for MoE, TI2V-5B for unified T2V+I2V).
- `--image`: Path to input image (required).
- `--image`: Path to the first-frame or source image.
- `--last-image`: Optional last-frame condition for models such as VACE.
- `--mask-image`: Optional inpainting mask. White pixels are regenerated and black pixels are preserved.
- `--reference-image`: Optional reference image; repeat it to provide multiple references.
- `--extra-body`: JSON object of model-specific generation params, filtered against the model's declared `extra_body_params` (see [`vllm_omni/model_extras`](../../../vllm_omni/model_extras)). Used by Cosmos3.
- `--prompt`: Text description of desired motion/animation.
- `--height/--width`: Output resolution (auto-calculated from image if not set). Dimensions should be multiples of 16.
@@ -94,5 +96,98 @@ Key arguments:
> ℹ️ If you encounter OOM errors, try using `--vae-use-slicing` and `--vae-use-tiling` to reduce memory usage.
## Wan2.1 VACE Conditional Tasks
The shared script selects the VACE conditioning structure from the media inputs.
No explicit mode parameter is required: the script constructs the source video,
mask, or reference images consumed by the VACE pipeline.
Download the same Hugging Face assets used by the original VACE example:
```bash
wget -O astronaut.jpg https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/astronaut.jpg
wget -O vace_first_frame.png https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/flf2v_input_first_frame.png
wget -O vace_last_frame.png https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/flf2v_input_last_frame.png
```
### Image-to-Video (I2V)
```bash
python image_to_video.py \
--model Wan-AI/Wan2.1-VACE-1.3B-diffusers \
--image astronaut.jpg \
--prompt "An astronaut emerging from a cracked, otherworldly egg on the surface of the moon" \
--seed 42 --height 480 --width 832 --num-frames 81 \
--num-inference-steps 30 --guidance-scale 5.0 --flow-shift 5.0 \
--vae-use-tiling --output vace_i2v_output.mp4
```
### Video-to-Last-Frame (V2LF)
```bash
python image_to_video.py \
--model Wan-AI/Wan2.1-VACE-1.3B-diffusers \
--last-image astronaut.jpg \
--prompt "An astronaut emerging from a cracked, otherworldly egg on the surface of the moon" \
--seed 42 --height 480 --width 832 --num-frames 81 \
--num-inference-steps 30 --guidance-scale 5.0 --flow-shift 5.0 \
--vae-use-tiling --output vace_v2lf_output.mp4
```
### First-Last-Frame-to-Video (FLF2V)
```bash
python image_to_video.py \
--model Wan-AI/Wan2.1-VACE-1.3B-diffusers \
--image vace_first_frame.png \
--last-image vace_last_frame.png \
--prompt "CG animation style, a small blue bird takes off from a branch and lands on another branch" \
--seed 42 --height 512 --width 512 --num-frames 81 \
--num-inference-steps 30 --guidance-scale 5.0 --flow-shift 5.0 \
--vae-use-tiling --output vace_flf2v_output.mp4
```
### Inpainting
Create a mask matching the original VACE example: a 160-pixel-wide white
vertical stripe marks the region to regenerate.
```bash
python - <<'PY'
from PIL import Image
mask = Image.new("L", (832, 480), 0)
mask.paste(255, (336, 0, 496, 480))
mask.save("vace_center_mask.png")
PY
```
```bash
python image_to_video.py \
--model Wan-AI/Wan2.1-VACE-1.3B-diffusers \
--image astronaut.jpg \
--mask-image vace_center_mask.png \
--prompt "Shrek, the ogre, walks out of a building in a happy mood" \
--seed 42 --height 480 --width 832 --num-frames 81 \
--num-inference-steps 30 --guidance-scale 5.0 --flow-shift 5.0 \
--vae-use-tiling --output vace_inpaint_output.mp4
```
### Reference-to-Video (R2V)
Repeat `--reference-image` to provide more than one reference image.
```bash
python image_to_video.py \
--model Wan-AI/Wan2.1-VACE-1.3B-diffusers \
--reference-image astronaut.jpg \
--prompt "Camera slowly zooms out from the character walking in a garden" \
--seed 42 --height 480 --width 832 --num-frames 81 \
--num-inference-steps 30 --guidance-scale 5.0 --flow-shift 5.0 \
--vae-use-tiling --output vace_r2v_output.mp4
```
The VACE T2V command is documented in the shared
[`text_to_video.py`](../text_to_video/text_to_video.md#wan21-vace-t2v) example.
For Wan2.2 LightX2V-converted local Diffusers directories and related LoRA
assets, see the [LoRA guide](../../../docs/user_guide/diffusion/lora.md#wan22-lightx2v-offline-assembly).
@@ -2,13 +2,14 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Image-to-Video generation example using Wan2.2 I2V/TI2V models, LTX2, or HunyuanVideo-1.5.
Image-to-Video generation example using Wan2.2 I2V/TI2V models, LTX2, HunyuanVideo-1.5, or Wan2.1 VACE.
Supports:
- Wan2.2-I2V-A14B-Diffusers: MoE model with CLIP image encoder
- Wan2.2-TI2V-5B-Diffusers: Unified T2V+I2V model (dense 5B)
- LTX2 image-to-video pipeline
- HunyuanVideo-1.5 I2V: SigLIP + VAE dual image conditioning
- Wan2.1 VACE: first/last-frame, inpainting, and reference conditioning
Usage:
# Wan I2V-A14B (MoE)
@@ -53,7 +54,11 @@ from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_extras import get_extra_body_params, get_model_class_name
from vllm_omni.model_extras import (
build_image_to_video_prompt,
get_extra_body_params,
get_model_class_name,
)
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
@@ -73,18 +78,28 @@ parse_profiler_config = functools.partial(parse_json_object, flag_name="--profil
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate a video from an image (Wan2.2, LTX2, HunyuanVideo-1.5).")
parser = argparse.ArgumentParser(
description="Generate a video from one or more images (Wan2.2, LTX2, HunyuanVideo-1.5, or Wan2.1 VACE)."
)
parser.add_argument(
"--model",
default="Wan-AI/Wan2.2-I2V-A14B-Diffusers",
help="Diffusers I2V model ID or local path (Wan2.2 or HunyuanVideo-1.5).",
help="Diffusers I2V model ID or local path (Wan2.2, HunyuanVideo-1.5, or Wan2.1 VACE).",
)
parser.add_argument(
"--model-class-name",
default=None,
help="Override model class name (e.g., LTX2ImageToVideoPipeline).",
)
parser.add_argument("--image", required=True, help="Path to input image.")
parser.add_argument("--image", help="Path to the first-frame or source image.")
parser.add_argument("--last-image", help="Path to a last-frame condition (used by models such as VACE).")
parser.add_argument("--mask-image", help="Path to an inpainting mask (used by models such as VACE).")
parser.add_argument(
"--reference-image",
action="append",
default=None,
help="Path to a reference image. Repeat to provide multiple references.",
)
parser.add_argument("--prompt", default="", help="Text prompt describing the desired motion.")
parser.add_argument("--negative-prompt", default="", help="Negative prompt.")
parser.add_argument("--seed", type=int, default=42, help="Random seed.")
@@ -302,8 +317,15 @@ def main():
if model_class_name is None and is_ltx2:
model_class_name = "LTX2ImageToVideoPipeline"
# Load input image
image = PIL.Image.open(args.image).convert("RGB")
image = PIL.Image.open(args.image).convert("RGB") if args.image else None
last_image = PIL.Image.open(args.last_image).convert("RGB") if args.last_image else None
mask_image = PIL.Image.open(args.mask_image).convert("L") if args.mask_image else None
reference_images = (
[PIL.Image.open(path).convert("RGB") for path in args.reference_image] if args.reference_image else None
)
dimension_image = image or last_image or (reference_images[0] if reference_images else None)
if dimension_image is None:
raise ValueError("Provide --image, --last-image, or at least one --reference-image.")
# Per-model generation defaults, applied only when the matching flag is omitted.
# Cosmos3 would otherwise silently inherit the Wan2.2 defaults (wrong size/steps/shift).
@@ -333,12 +355,21 @@ def main():
height = args.height
width = args.width
if height is None or width is None:
calc_height, calc_width = calculate_dimensions(image, max_area=d_max_area, mod_value=d_mod)
calc_height, calc_width = calculate_dimensions(dimension_image, max_area=d_max_area, mod_value=d_mod)
height = height or calc_height
width = width or calc_width
# Resize image to target dimensions
image = image.resize((width, height), PIL.Image.Resampling.LANCZOS)
media_inputs: dict[str, Any] = {}
if image is not None:
media_inputs["image"] = image.resize((width, height), PIL.Image.Resampling.LANCZOS)
if last_image is not None:
media_inputs["last_image"] = last_image.resize((width, height), PIL.Image.Resampling.LANCZOS)
if mask_image is not None:
media_inputs["mask"] = mask_image.resize((width, height), PIL.Image.Resampling.NEAREST)
if reference_images is not None:
media_inputs["reference_images"] = [
reference.resize((width, height), PIL.Image.Resampling.LANCZOS) for reference in reference_images
]
# Configure cache based on backend type
cache_config = None
@@ -409,8 +440,8 @@ def main():
if args.extra_body and "guardrails" in args.extra_body:
omni_kwargs["model_config"] = {"guardrails": bool(args.extra_body["guardrails"])}
omni = Omni(**omni_kwargs)
resolved_model_class_name = get_model_class_name(omni)
declared_extra_body_params = get_extra_body_params(resolved_model_class_name)
model_class_name = get_model_class_name(omni) or model_class_name
declared_extra_body_params = get_extra_body_params(model_class_name)
if profiler_enabled:
print("[Profiler] Starting profiling...")
@@ -434,6 +465,15 @@ def main():
print(f" Video size: {args.width}x{args.height}")
print(f"{'=' * 60}\n")
prompt_dict = build_image_to_video_prompt(
model_class_name=model_class_name,
prompt=args.prompt,
negative_prompt=args.negative_prompt,
media_inputs=media_inputs,
height=height,
width=width,
num_frames=num_frames,
)
sampling_params = OmniDiffusionSamplingParams(
height=height,
width=width,
@@ -461,14 +501,7 @@ def main():
generation_start = time.perf_counter()
# omni.generate() returns Generator[OmniRequestOutput, None, None]
frames = omni.generate(
{
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
"multi_modal_data": {"image": image},
},
sampling_params,
)
frames = omni.generate(prompt_dict, sampling_params)
generation_end = time.perf_counter()
generation_time = generation_end - generation_start
@@ -6,6 +6,7 @@ A unified script for text-to-video generation. Supports multiple models with mod
| Model | Default Resolution | Default Frames | Default Steps | Guidance | VRAM (BF16) |
|---|---|---|---|---|---|
| `Wan-AI/Wan2.1-VACE-1.3B-diffusers` | 480x832 | 81 | 30 | 5.0 | ~20 GiB (RTX 5090, VAE tiling) |
| `Wan-AI/Wan2.2-T2V-A14B-Diffusers` | 720x1280 | 81 | 40 | 4.0 | ~60 GiB |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v` | 480x832 | 121 | 50 | 6.0 | 1×A100 80GB |
| `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | 720x1280 | 121 | 50 | 6.0 | FP8 + VAE tiling required |
@@ -31,6 +32,28 @@ python text_to_video.py \
--output t2v_out.mp4
```
### Wan2.1 VACE (T2V)
VACE text-to-video uses this shared entrypoint. Conditional VACE tasks use
the shared [`image_to_video.py`](../image_to_video/README.md#wan21-vace-conditional-tasks)
entrypoint, which constructs the pipeline-native conditioning data from the
provided media inputs. No explicit mode parameter is required.
```bash
python text_to_video.py \
--model Wan-AI/Wan2.1-VACE-1.3B-diffusers \
--prompt "A sleek, humanoid robot stands in a vast warehouse filled with neatly stacked cardboard boxes on industrial shelves." \
--seed 0 \
--height 480 \
--width 832 \
--num-frames 81 \
--num-inference-steps 30 \
--guidance-scale 5.0 \
--flow-shift 5.0 \
--vae-use-tiling \
--output vace_t2v_output.mp4
```
LTX2 example:
```bash
@@ -19,6 +19,15 @@ from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
_MODEL_PRESETS = {
"vace": {
"height": 480,
"width": 832,
"num_frames": 81,
"num_inference_steps": 30,
"guidance_scale": 5.0,
"fps": 16,
"output": "vace_t2v_output.mp4",
},
"wan": {
"height": 720,
"width": 1280,
@@ -61,6 +70,8 @@ _MODEL_PRESETS = {
def _detect_preset(model: str) -> dict:
model_lower = model.lower()
if "vace" in model_lower:
return _MODEL_PRESETS["vace"]
if "cosmos" in model_lower:
return _MODEL_PRESETS["cosmos"]
if "hunyuan" in model_lower:
@@ -381,7 +392,7 @@ def main():
omni_kwargs["model_config"] = {"guardrails": bool(args.extra_body["guardrails"])}
omni = Omni(**omni_kwargs)
model_class_name = get_model_class_name(omni)
model_class_name = get_model_class_name(omni) or model_class_name
declared_extra_body_params = get_extra_body_params(model_class_name)
if profiler_enabled:
@@ -419,7 +430,6 @@ def main():
sampling_kwargs["guidance_scale_2"] = args.guidance_scale_high
sampling_params = OmniDiffusionSamplingParams(**sampling_kwargs)
# Route model-specific knobs through extra_body, filtered against the model's
# declared extra_body_params. Models without a declaration only forward explicit
# --extra-body JSON (preserving the generic flags' legacy behavior).
@@ -1,148 +0,0 @@
# VACE Video Generation
Generate videos from text prompts, images, or video conditions using vLLM-Omni's VACE diffusion pipeline.
- `vace_video_generation.py`: command-line script for multi-mode video generation with advanced options.
## Table of Contents
- [Overview](#overview)
- [Quick Start](#quick-start)
- [Key Arguments](#key-arguments)
- [More CLI Examples](#more-cli-examples)
## Overview
[VACE](https://github.com/ali-vilab/VACE) (Video All-in-one Creation Engine) supports multiple video tasks through a single unified model, including text-to-video, image-to-video, first-last-frame interpolation, inpainting, and reference image-guided generation.
### Supported Models
| Model | Architecture | Peak VRAM (GiB) * | Model Weights (GiB) | HuggingFace |
|-------|-------------|-------------------|---------------------|-------------|
| Wan2.1-VACE (1.3B) | Wan2.1 | TBD | ~10 | [Wan-AI/Wan2.1-VACE-1.3B-diffusers](https://huggingface.co/Wan-AI/Wan2.1-VACE-1.3B-diffusers) |
| Wan2.1-VACE (14B) | Wan2.1 | TBD | ~38 | [Wan-AI/Wan2.1-VACE-14B-diffusers](https://huggingface.co/Wan-AI/Wan2.1-VACE-14B-diffusers) |
!!! info
*Peak VRAM: based on basic single-card usage, 480x832 resolution, 81 frames, 30 inference steps, without any acceleration/optimization features.
Default model: `Wan-AI/Wan2.1-VACE-14B-diffusers`
## Quick Start
### Python API
Text-to-video generation:
```python
import torch
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
if __name__ == "__main__":
omni = Omni(model="Wan-AI/Wan2.1-VACE-1.3B-diffusers")
prompt = "A sleek robot stands in a vast warehouse filled with boxes"
outputs = omni.generate(
prompt,
OmniDiffusionSamplingParams(
height=480,
width=832,
num_frames=81,
num_inference_steps=30,
guidance_scale=5.0,
),
)
video = outputs[0].images
from diffusers.utils import export_to_video
export_to_video(list(video[0]), "t2v_output.mp4", fps=16)
omni.close()
```
### Local CLI Usage
```bash
python vace_video_generation.py \
--mode t2v \
--prompt "A sleek robot stands in a vast warehouse filled with boxes" \
--height 480 --width 832 --num-frames 81 \
--output t2v_output.mp4
```
## Key Arguments
| Argument | Type | Default | Description |
| -------- | ---- | ------- | ----------- |
| `--mode` | str | `"t2v"` | VACE task mode: `t2v`, `i2v`, `v2lf`, `flf2v`, `inpaint`, `r2v` |
| `--model` | str | `"Wan-AI/Wan2.1-VACE-14B-diffusers"` | Model ID or local path |
| `--prompt` | str | `"A cat walking in a garden"` | Text description of desired video |
| `--negative-prompt` | str | `""` | Negative prompt for classifier-free guidance |
| `--image` | str | `None` | Input image path (for I2V, R2V, FLF2V, inpaint modes) |
| `--last-image` | str | `None` | Last frame image path (for FLF2V mode) |
| `--height` | int | `480` | Output video height in pixels (should be a multiple of 16) |
| `--width` | int | `832` | Output video width in pixels (should be a multiple of 16) |
| `--num-frames` | int | `81` | Number of video frames to generate |
| `--num-inference-steps` | int | `30` | Number of denoising steps (more steps = higher quality, slower) |
| `--guidance-scale` | float | `5.0` | Classifier-free guidance scale |
| `--flow-shift` | float | `5.0` | Scheduler flow shift parameter |
| `--seed` | int | `42` | Random seed for deterministic sampling |
| `--fps` | int | `16` | Frames per second for the saved MP4 |
| `--output` | str | `"vace_output.mp4"` | Path to save the generated video |
| `--vae-use-tiling` | flag | on | Enable VAE tiling for memory optimization |
| `--cfg-parallel-size` | int | `1` | Set to `2` to enable CFG Parallel |
| `--ulysses-degree` | int | `1` | Ulysses sequence parallel degree for multi-GPU inference |
| `--ring-degree` | int | `1` | Ring sequence parallel degree for hybrid Ulysses + Ring inference |
| `--tensor-parallel-size` | int | — | Tensor parallel size |
| `--enforce-eager` | flag | off | Disable torch.compile |
> If you encounter OOM errors, try `--vae-use-tiling` or multi-GPU parallelism options (`--ulysses-degree`, `--cfg-parallel-size`).
## More CLI Examples
### Image-to-Video (I2V)
First frame is kept, remaining frames are generated:
```bash
python vace_video_generation.py \
--mode i2v \
--image astronaut.jpg \
--prompt "An astronaut emerging from a cracked egg on the moon" \
--height 480 --width 832 --num-frames 81 \
--output i2v_output.mp4
```
### First-Last-Frame Interpolation (FLF2V)
```bash
python vace_video_generation.py \
--mode flf2v \
--image first_frame.jpg --last-image last_frame.jpg \
--prompt "A bird takes off from a branch and lands on another" \
--height 512 --width 512 --num-frames 81 \
--output flf2v_output.mp4
```
### Inpainting
Center vertical stripe is masked and regenerated:
```bash
python vace_video_generation.py \
--mode inpaint \
--image scene.jpg \
--prompt "Shrek walks out of a building" \
--height 480 --width 832 --num-frames 81 \
--output inpaint_output.mp4
```
### Reference Image-guided (R2V)
```bash
python vace_video_generation.py \
--mode r2v \
--image reference.jpg \
--prompt "Camera slowly zooms out from the character" \
--height 480 --width 832 --num-frames 81 \
--output r2v_output.mp4
```
@@ -1,209 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""VACE video generation example.
VACE (Video All-in-one Creation Engine) supports multiple video tasks:
- T2V: Text-to-Video
- I2V: Image-to-Video (first frame conditioning)
- V2LF: Video-to-Last-Frame
- FLF2V: First-Last-Frame interpolation
- Inpainting: Masked region generation
- R2V: Reference image-guided generation
Usage examples:
# T2V (text-to-video)
python vace_video_generation.py --mode t2v --prompt "A robot in a warehouse"
# I2V (image-to-video, first frame kept)
python vace_video_generation.py --mode i2v --image input.jpg --prompt "..."
# FLF2V (first-last frame interpolation)
python vace_video_generation.py --mode flf2v --image first.jpg --last-image last.jpg
# R2V (reference image guided)
python vace_video_generation.py --mode r2v --image ref.jpg --prompt "..."
"""
import argparse
import time
from pathlib import Path
import numpy as np
import PIL.Image
import torch
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="VACE video generation.")
parser.add_argument(
"--model",
default="Wan-AI/Wan2.1-VACE-14B-diffusers",
help="VACE model ID or local path.",
)
parser.add_argument(
"--mode",
default="t2v",
choices=["t2v", "i2v", "v2lf", "flf2v", "inpaint", "r2v"],
help="Generation mode.",
)
parser.add_argument("--prompt", default="A cat walking in a garden", help="Text prompt.")
parser.add_argument("--negative-prompt", default="", help="Negative prompt.")
parser.add_argument("--image", type=str, default=None, help="Input image path (for I2V, R2V, FLF2V, inpaint).")
parser.add_argument("--last-image", type=str, default=None, help="Last frame image path (for FLF2V).")
parser.add_argument("--video-dir", type=str, default=None, help="Directory of video frames (for inpaint).")
parser.add_argument("--seed", type=int, default=42, help="Random seed.")
parser.add_argument("--guidance-scale", type=float, default=5.0, help="CFG guidance scale.")
parser.add_argument("--height", type=int, default=480, help="Video height.")
parser.add_argument("--width", type=int, default=832, help="Video width.")
parser.add_argument("--num-frames", type=int, default=81, help="Number of frames.")
parser.add_argument("--num-inference-steps", type=int, default=30, help="Sampling steps.")
parser.add_argument("--flow-shift", type=float, default=5.0, help="Scheduler flow_shift.")
parser.add_argument("--output", type=str, default="vace_output.mp4", help="Output video path.")
parser.add_argument("--fps", type=int, default=16, help="Output video FPS.")
parser.add_argument("--vae-use-tiling", action="store_true", default=True, help="Enable VAE tiling.")
parser.add_argument("--enforce-eager", action="store_true", help="Disable torch.compile.")
parser.add_argument("--ulysses-degree", type=int, default=1, help="Ulysses SP degree.")
parser.add_argument("--ring-degree", type=int, default=1, help="Ring attention degree.")
parser.add_argument("--cfg-parallel-size", type=int, default=1, choices=[1, 2], help="CFG parallel size.")
return parser.parse_args()
def build_prompts(args):
"""Build prompt dict with multi_modal_data based on mode."""
h, w, nf = args.height, args.width, args.num_frames
gray = PIL.Image.new("RGB", (w, h), (128, 128, 128))
mask_black = PIL.Image.new("L", (w, h), 0)
mask_white = PIL.Image.new("L", (w, h), 255)
prompt_data = {
"prompt": args.prompt,
"negative_prompt": args.negative_prompt,
}
if args.mode == "t2v":
return prompt_data
if args.mode == "r2v":
assert args.image, "--image required for R2V mode"
ref_img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
prompt_data["multi_modal_data"] = {"reference_images": [ref_img]}
return prompt_data
if args.mode == "i2v":
assert args.image, "--image required for I2V mode"
img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
prompt_data["multi_modal_data"] = {
"video": [img] + [gray] * (nf - 1),
"mask": [mask_black] + [mask_white] * (nf - 1),
}
return prompt_data
if args.mode == "v2lf":
assert args.image, "--image required for V2LF mode"
img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
prompt_data["multi_modal_data"] = {
"video": [gray] * (nf - 1) + [img],
"mask": [mask_white] * (nf - 1) + [mask_black],
}
return prompt_data
if args.mode == "flf2v":
assert args.image and args.last_image, "--image and --last-image required for FLF2V"
first = PIL.Image.open(args.image).convert("RGB").resize((w, h))
last = PIL.Image.open(args.last_image).convert("RGB").resize((w, h))
prompt_data["multi_modal_data"] = {
"video": [first] + [gray] * (nf - 2) + [last],
"mask": [mask_black] + [mask_white] * (nf - 2) + [mask_black],
}
return prompt_data
if args.mode == "inpaint":
assert args.image, "--image required for inpaint mode"
img = PIL.Image.open(args.image).convert("RGB").resize((w, h))
d = 80
frames, masks = [], []
for _ in range(nf):
base = np.array(img).copy()
mask = PIL.Image.new("L", (w, h), 0)
stripe = PIL.Image.new("L", (2 * d, h), 255)
mask.paste(stripe, (w // 2 - d, 0))
base[np.array(mask) > 128] = 128
frames.append(PIL.Image.fromarray(base))
masks.append(mask)
prompt_data["multi_modal_data"] = {"video": frames, "mask": masks}
return prompt_data
raise ValueError(f"Unknown mode: {args.mode}")
def main():
args = parse_args()
generator = torch.Generator(device=current_omni_platform.device_type).manual_seed(args.seed)
parallel_config = DiffusionParallelConfig(
ulysses_degree=args.ulysses_degree,
ring_degree=args.ring_degree,
cfg_parallel_size=args.cfg_parallel_size,
)
omni = Omni(
model=args.model,
vae_use_tiling=args.vae_use_tiling,
flow_shift=args.flow_shift,
enforce_eager=args.enforce_eager,
parallel_config=parallel_config,
)
prompt_data = build_prompts(args)
print(f"\n{'=' * 60}")
print(f"VACE {args.mode.upper()} Generation")
print(f" Model: {args.model}")
print(f" Size: {args.width}x{args.height}, {args.num_frames} frames, {args.num_inference_steps} steps")
print(f"{'=' * 60}\n")
start = time.perf_counter()
outputs = omni.generate(
prompt_data,
OmniDiffusionSamplingParams(
height=args.height,
width=args.width,
num_frames=args.num_frames,
num_inference_steps=args.num_inference_steps,
guidance_scale=args.guidance_scale,
generator=generator,
),
)
elapsed = time.perf_counter() - start
video = outputs[0].images
if isinstance(video, list):
video = video[0]
if isinstance(video, torch.Tensor):
video = video.cpu().numpy()
if video.ndim == 5:
video = video[0]
print(f"Output shape: {video.shape}, Time: {elapsed:.1f}s")
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
from diffusers.utils import export_to_video
if np.issubdtype(video.dtype, np.integer):
video = video.astype(np.float32) / 255.0
export_to_video(list(video), str(output_path), fps=args.fps)
print(f"Saved to {output_path}")
omni.close()
if __name__ == "__main__":
main()
+1
View File
@@ -63,6 +63,7 @@ recipes/
| [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 |
| [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md) | Image-to-video serving (Wan2.2 14B) | 8x Ascend NPU (A2/A3) |
| [`Wan-AI/Wan2.2-S2V.md`](./Wan-AI/Wan2.2-S2V.md) | Speech-to-video serving (Wan2.2 14B) | 2x A100/H100 80GB |
| [`Wan-AI/Wan2.1-VACE.md`](./Wan-AI/Wan2.1-VACE.md) | Unified T2V, I2V, V2LF, FLF2V, inpaint, and R2V | 1x RTX 5090 (1.3B) / 1x L40S 48GB with layerwise offload (14B) |
| [`StabilityAI/Stable-Diffusion-3.5.md`](./StabilityAI/Stable-Diffusion-3.5.md) | Text-to-image serving (SD 3.5-medium and SD 3.5-large) | 1x RTX A6000 48GB |
| [`zai-org/GLM-TTS.md`](./zai-org/GLM-TTS.md) | Online serving for Chinese/English zero-shot voice-cloned TTS | 1x A40 48GB |
| [`GLM/GLM-Image.md`](./GLM/GLM-Image.md) | Online serving for image generation | 1x A800 80GB / 2x A800 80GB |
+147
View File
@@ -0,0 +1,147 @@
# Wan2.1 VACE Unified Video Generation
## Summary
- Vendor: Wan-AI
- Models: `Wan2.1-VACE-1.3B-diffusers`, `Wan2.1-VACE-14B-diffusers`
- Tasks: T2V, I2V, V2LF, FLF2V, inpainting, and R2V
- Mode: offline inference through the shared task examples
The shared examples construct VACE's pipeline-native conditioning data from the
provided media inputs. They do not require a mode parameter or a model-specific
example script.
## References
- [VACE project](https://github.com/ali-vilab/VACE)
- [Hugging Face 14B checkpoint](https://huggingface.co/Wan-AI/Wan2.1-VACE-14B-diffusers)
- [ModelScope 14B checkpoint](https://modelscope.cn/models/Wan-AI/Wan2.1-VACE-14B-diffusers)
- [Shared text-to-video example](../../examples/offline_inference/text_to_video/text_to_video.md)
- [Shared image-to-video example](../../examples/offline_inference/image_to_video/README.md)
- [Migration RFC #4539](https://github.com/vllm-project/vllm-omni/issues/4539)
## Hardware Support
The 1.3B checkpoint was validated on an RTX 5090; the 81-frame T2V command below
used approximately 20 GiB of peak reserved GPU memory with VAE tiling. The 14B
checkpoint requires approximately 70 GiB of disk space and was validated on a
single 48 GiB L40S with VAE tiling and layerwise offload. Start with the smoke
configuration before attempting an 81-frame run.
## Setup
The public examples use the Hugging Face model ID by default:
```bash
MODEL="${MODEL:-Wan-AI/Wan2.1-VACE-14B-diffusers}"
ASSET_DIR="${ASSET_DIR:-$HOME/datasets/vace}"
OUT="${OUT:-$PWD/vace-results/14b}"
mkdir -p "$ASSET_DIR" "$OUT"
```
For environments where Hugging Face downloads are unavailable, download the
same checkpoint from ModelScope and point `MODEL` at the local directory:
```bash
MODEL="$HOME/models/Wan2.1-VACE-14B-diffusers"
modelscope download --model Wan-AI/Wan2.1-VACE-14B-diffusers --local_dir "$MODEL"
```
Download the public conditioning images and create a mask matching the output
size. White pixels are regenerated and black pixels are preserved.
```bash
wget -O "$ASSET_DIR/astronaut.jpg" \
https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/astronaut.jpg
wget -O "$ASSET_DIR/vace_first_frame.png" \
https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/flf2v_input_first_frame.png
wget -O "$ASSET_DIR/vace_last_frame.png" \
https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/flf2v_input_last_frame.png
python - "$ASSET_DIR/vace_center_mask.png" <<'PY'
import sys
from PIL import Image
mask = Image.new("L", (832, 480), 0)
mask.paste(255, (336, 0, 496, 480))
mask.save(sys.argv[1])
PY
ASTRONAUT="$ASSET_DIR/astronaut.jpg"
FIRST="$ASSET_DIR/vace_first_frame.png"
LAST="$ASSET_DIR/vace_last_frame.png"
MASK="$ASSET_DIR/vace_center_mask.png"
```
## Commands
The commands below reproduce the original VACE examples with `81 frames / 30
steps`. Most tasks use `480x832`; FLF2V uses `512x512`. For a smoke test,
replace the frame and step counts with `5 frames / 2 steps`.
```bash
# T2V
python examples/offline_inference/text_to_video/text_to_video.py \
--model "$MODEL" \
--prompt "A sleek, humanoid robot stands in a vast warehouse filled with neatly stacked cardboard boxes on industrial shelves." \
--seed 0 \
--height 480 --width 832 --num-frames 81 --num-inference-steps 30 \
--guidance-scale 5.0 --flow-shift 5.0 --vae-use-tiling --enable-layerwise-offload \
--output "$OUT/t2v.mp4"
# I2V
python examples/offline_inference/image_to_video/image_to_video.py \
--model "$MODEL" --image "$ASTRONAUT" \
--prompt "An astronaut emerging from a cracked, otherworldly egg on the surface of the moon" \
--seed 42 \
--height 480 --width 832 --num-frames 81 --num-inference-steps 30 \
--guidance-scale 5.0 --flow-shift 5.0 --vae-use-tiling --enable-layerwise-offload \
--output "$OUT/i2v.mp4"
# V2LF
python examples/offline_inference/image_to_video/image_to_video.py \
--model "$MODEL" --last-image "$ASTRONAUT" \
--prompt "An astronaut emerging from a cracked, otherworldly egg on the surface of the moon" \
--seed 42 \
--height 480 --width 832 --num-frames 81 --num-inference-steps 30 \
--guidance-scale 5.0 --flow-shift 5.0 --vae-use-tiling --enable-layerwise-offload \
--output "$OUT/v2lf.mp4"
# FLF2V
python examples/offline_inference/image_to_video/image_to_video.py \
--model "$MODEL" --image "$FIRST" --last-image "$LAST" \
--prompt "CG animation style, a small blue bird takes off from a branch and lands on another branch" \
--seed 42 --height 512 --width 512 --num-frames 81 --num-inference-steps 30 \
--guidance-scale 5.0 --flow-shift 5.0 --vae-use-tiling --enable-layerwise-offload \
--output "$OUT/flf2v.mp4"
# Inpaint
python examples/offline_inference/image_to_video/image_to_video.py \
--model "$MODEL" --image "$ASTRONAUT" --mask-image "$MASK" \
--prompt "Shrek, the ogre, walks out of a building in a happy mood" \
--seed 42 \
--height 480 --width 832 --num-frames 81 --num-inference-steps 30 \
--guidance-scale 5.0 --flow-shift 5.0 --vae-use-tiling --enable-layerwise-offload \
--output "$OUT/inpaint.mp4"
# R2V; repeat --reference-image to add references
python examples/offline_inference/image_to_video/image_to_video.py \
--model "$MODEL" --reference-image "$ASTRONAUT" \
--prompt "Camera slowly zooms out from the character walking in a garden" \
--seed 42 \
--height 480 --width 832 --num-frames 81 --num-inference-steps 30 \
--guidance-scale 5.0 --flow-shift 5.0 --vae-use-tiling --enable-layerwise-offload \
--output "$OUT/r2v.mp4"
```
## Verification
```bash
for video in "$OUT"/*.mp4; do
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,width,height,nb_frames,r_frame_rate,duration \
-of default=noprint_wrappers=1 "$video"
done
```
For the 1.3B checkpoint, `--enable-layerwise-offload` is generally unnecessary.
@@ -1,11 +1,9 @@
"""
Offline inference tests: vace video generation.
See examples/offline_inference/vace/vace_video_generation.md
"""
"""VACE coverage through the shared text-to-video and image-to-video examples."""
from pathlib import Path
import pytest
from PIL import Image
from tests.examples.helpers import EXAMPLES, ExampleRunner, ReadmeSnippet
from tests.helpers.assertions import assert_video_valid
@@ -14,22 +12,83 @@ from tests.helpers.mark import hardware_marks
pytestmark = [
pytest.mark.usefixtures("clean_gpu_memory_between_tests"),
pytest.mark.full_model,
pytest.mark.diffusion,
pytest.mark.example,
*hardware_marks(res={"cuda": "H100"}),
]
VACE_SCRIPT = EXAMPLES / "offline_inference" / "vace" / "vace_video_generation.py"
README_PATH = VACE_SCRIPT.with_name("vace_video_generation.md")
TEXT_TO_VIDEO_README = EXAMPLES / "offline_inference" / "text_to_video" / "text_to_video.md"
IMAGE_TO_VIDEO_README = EXAMPLES / "offline_inference" / "image_to_video" / "README.md"
EXAMPLE_OUTPUT_SUBFOLDER = "example_offline_vace"
README_SNIPPETS = ReadmeSnippet.extract_readme_snippets(README_PATH)
TEXT_TO_VIDEO_SNIPPET = next(
snippet
for snippet in ReadmeSnippet.extract_readme_snippets(TEXT_TO_VIDEO_README)
if "vace_t2v_output.mp4" in snippet.code
)
CONDITIONAL_OUTPUTS = (
"vace_i2v_output.mp4",
"vace_v2lf_output.mp4",
"vace_flf2v_output.mp4",
"vace_inpaint_output.mp4",
"vace_r2v_output.mp4",
)
IMAGE_TO_VIDEO_SNIPPETS = [
snippet
for snippet in ReadmeSnippet.extract_readme_snippets(IMAGE_TO_VIDEO_README)
if snippet.h2_title == "Wan2.1 VACE Conditional Tasks"
and any(output in snippet.code for output in CONDITIONAL_OUTPUTS)
]
@pytest.mark.parametrize("snippet", README_SNIPPETS, ids=lambda snippet: snippet.test_id)
def test_vace_video_generation(snippet: ReadmeSnippet, example_runner: ExampleRunner):
should_skip, reason = snippet.skip
if should_skip:
pytest.skip(reason)
def _smoke_snippet(snippet: ReadmeSnippet, asset_dir: Path) -> ReadmeSnippet:
# Six fresh model launches share a 90-minute X2V job. Keep the documented
# quality settings intact while reducing only the CI executions.
replacements = {
"astronaut.jpg": str(asset_dir / "astronaut.jpg"),
"vace_first_frame.png": str(asset_dir / "vace_first_frame.png"),
"vace_last_frame.png": str(asset_dir / "vace_last_frame.png"),
"vace_center_mask.png": str(asset_dir / "vace_center_mask.png"),
"--num-frames 81": "--num-frames 5",
"--num-inference-steps 30": "--num-inference-steps 2",
}
code = snippet.code
for source, target in replacements.items():
code = code.replace(source, target)
return snippet._replace(code=code)
@pytest.fixture
def vace_asset_dir(tmp_path: Path) -> Path:
asset_dir = tmp_path / "vace_assets"
asset_dir.mkdir(parents=True, exist_ok=True)
Image.new("RGB", (832, 480), (180, 180, 180)).save(asset_dir / "astronaut.jpg")
Image.new("RGB", (512, 512), (80, 140, 220)).save(asset_dir / "vace_first_frame.png")
Image.new("RGB", (512, 512), (220, 140, 80)).save(asset_dir / "vace_last_frame.png")
mask = Image.new("L", (832, 480), 0)
mask.paste(255, (336, 0, 496, 480))
mask.save(asset_dir / "vace_center_mask.png")
return asset_dir
def test_vace_text_to_video_shared_example(example_runner: ExampleRunner) -> None:
snippet = TEXT_TO_VIDEO_SNIPPET._replace(
code=TEXT_TO_VIDEO_SNIPPET.code.replace("--num-frames 81", "--num-frames 5").replace(
"--num-inference-steps 30", "--num-inference-steps 2"
)
)
result = example_runner.run(snippet, output_subfolder=Path(EXAMPLE_OUTPUT_SUBFOLDER))
for asset in result.assets:
assert_video_valid(asset)
@pytest.mark.parametrize("snippet", IMAGE_TO_VIDEO_SNIPPETS, ids=lambda snippet: snippet.test_id)
def test_vace_conditional_shared_examples(
snippet: ReadmeSnippet,
example_runner: ExampleRunner,
vace_asset_dir: Path,
) -> None:
snippet = _smoke_snippet(snippet, vace_asset_dir)
result = example_runner.run(snippet, output_subfolder=Path(EXAMPLE_OUTPUT_SUBFOLDER))
for asset in result.assets:
assert_video_valid(asset)
+61
View File
@@ -10,6 +10,7 @@ from vllm_omni.diffusion.utils.param_utils import apply_declared_extra_args
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.model_extras import (
build_image_to_image_prompt,
build_image_to_video_prompt,
build_text_to_image_prompt,
get_extra_body_params,
get_extra_output_params,
@@ -176,6 +177,14 @@ def test_helios_extra_registry_declares_request_and_response_params() -> None:
assert should_init_extra_args_for_non_diffusion_stages(cls) is False
@pytest.mark.core_model
@pytest.mark.cpu
def test_vace_extra_registry_has_no_pipeline_params() -> None:
assert get_extra_body_params("WanVACEPipeline") == frozenset()
assert get_extra_output_params("WanVACEPipeline") == frozenset()
assert should_init_extra_args_for_non_diffusion_stages("WanVACEPipeline") is False
@pytest.mark.core_model
@pytest.mark.cpu
def test_unknown_pipeline_has_empty_extra_registry() -> None:
@@ -253,6 +262,58 @@ def test_unknown_pipeline_uses_default_image_to_image_prompt() -> None:
}
def _build_vace_prompt(media_inputs: dict[str, object], *, num_frames: int = 5) -> dict:
return build_image_to_video_prompt(
"WanVACEPipeline",
prompt="a bird flying",
negative_prompt=None,
media_inputs=media_inputs,
height=16,
width=320,
num_frames=num_frames,
)
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize(
"media_inputs",
[
{"image": Image.new("RGB", (320, 16), "red")},
{"last_image": Image.new("RGB", (320, 16), "blue")},
{
"image": Image.new("RGB", (320, 16), "red"),
"last_image": Image.new("RGB", (320, 16), "blue"),
},
{"image": Image.new("RGB", (320, 16), "red"), "mask": Image.new("L", (320, 16), 0)},
{"reference_images": [Image.new("RGB", (64, 64), "red")]},
],
ids=["i2v", "v2lf", "flf2v", "inpaint", "r2v"],
)
def test_vace_image_to_video_prompt_builder(media_inputs: dict[str, object]) -> None:
result = _build_vace_prompt(media_inputs)
mmd = result["multi_modal_data"]
if "reference_images" in mmd:
assert mmd["reference_images"] is media_inputs["reference_images"]
else:
assert len(mmd["video"]) == len(mmd["mask"]) == 5
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize(
("media_inputs", "message"),
[
({}, "requires a conditioning media input"),
({"mask": Image.new("L", (320, 16))}, "mask input requires an image"),
({"control_image": Image.new("RGB", (320, 16))}, "Unsupported VACE media input"),
],
)
def test_vace_rejects_invalid_media_combinations(media_inputs: dict[str, object], message: str) -> None:
with pytest.raises(ValueError, match=message):
_build_vace_prompt(media_inputs)
@pytest.mark.core_model
@pytest.mark.cpu
def test_declared_extra_args_apply_to_existing_sampling_params() -> None:
+2
View File
@@ -3,6 +3,7 @@
from vllm_omni.model_extras.registry import (
build_image_to_image_prompt,
build_image_to_video_prompt,
build_text_to_image_prompt,
get_extra_body_params,
get_extra_output_params,
@@ -12,6 +13,7 @@ from vllm_omni.model_extras.registry import (
__all__ = [
"build_image_to_image_prompt",
"build_image_to_video_prompt",
"build_text_to_image_prompt",
"get_extra_body_params",
"get_extra_output_params",
+56 -3
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Mapping
from typing import Any
from PIL import Image
@@ -38,9 +38,14 @@ from vllm_omni.model_extras.sensenova_u1 import (
SENSENOVA_U1_EXTRA_BODY_PARAMS,
SENSENOVA_U1_EXTRA_OUTPUT_PARAMS,
)
from vllm_omni.model_extras.vace import (
VACE_EXTRA_BODY_PARAMS,
VACE_EXTRA_OUTPUT_PARAMS,
)
from vllm_omni.model_extras.vace import (
build_image_to_video_prompt as build_vace_image_to_video_prompt,
)
# TODO: extend with TextToVideoPromptBuilder, ImageToVideoPromptBuilder
# once a video model needs model-specific prompt construction (follow-up PR).
TextToImagePromptBuilder = Callable[
[str, str | None, int | None, int | None],
dict[str, Any],
@@ -49,6 +54,17 @@ ImageToImagePromptBuilder = Callable[
[str, str | None, "Image.Image | list[Image.Image]", int | None, int | None],
dict[str, Any],
]
ImageToVideoPromptBuilder = Callable[
[
str,
str | None,
"Mapping[str, Any]",
int | None,
int | None,
int | None,
],
dict[str, Any],
]
def default_text_to_image_prompt(
@@ -79,6 +95,20 @@ def default_image_to_image_prompt(
return result
def default_image_to_video_prompt(
prompt: str,
negative_prompt: str | None,
media_inputs: Mapping[str, Any],
height: int | None = None,
width: int | None = None,
num_frames: int | None = None,
) -> dict[str, Any]:
del height, width, num_frames
if set(media_inputs) != {"image"} or not isinstance(media_inputs["image"], Image.Image):
raise ValueError("This model only supports a single --image input in the shared image-to-video example.")
return default_image_to_image_prompt(prompt, negative_prompt, media_inputs["image"])
_EXTRA_SPECS: dict[str, dict[str, Any]] = {
"BagelPipeline": {
"extra_body_params": BAGEL_EXTRA_BODY_PARAMS,
@@ -108,6 +138,11 @@ _EXTRA_SPECS: dict[str, dict[str, Any]] = {
"extra_body_params": HELIOS_EXTRA_BODY_PARAMS,
"extra_output_params": HELIOS_EXTRA_OUTPUT_PARAMS,
},
"WanVACEPipeline": {
"extra_body_params": VACE_EXTRA_BODY_PARAMS,
"extra_output_params": VACE_EXTRA_OUTPUT_PARAMS,
"image_to_video_prompt_builder": build_vace_image_to_video_prompt,
},
}
@@ -177,3 +212,21 @@ def build_image_to_image_prompt(
else default_image_to_image_prompt
)
return builder(prompt, negative_prompt, input_image, height, width)
def build_image_to_video_prompt(
model_class_name: str | None,
prompt: str,
negative_prompt: str | None,
media_inputs: Mapping[str, Any],
height: int | None = None,
width: int | None = None,
num_frames: int | None = None,
) -> dict[str, Any]:
spec = _get_spec(model_class_name)
builder: ImageToVideoPromptBuilder = (
spec.get("image_to_video_prompt_builder", default_image_to_video_prompt)
if spec is not None
else default_image_to_video_prompt
)
return builder(prompt, negative_prompt, media_inputs, height, width, num_frames)
+120
View File
@@ -0,0 +1,120 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import numpy as np
from PIL import Image
# VACE conditioning is expressed through prompt media, and the pipeline does not
# currently read any model-specific values from sampling_params.extra_args.
VACE_EXTRA_BODY_PARAMS: frozenset[str] = frozenset()
VACE_EXTRA_OUTPUT_PARAMS: frozenset[str] = frozenset()
_MEDIA_KEYS = frozenset({"image", "last_image", "mask", "reference_images"})
def _base_prompt(prompt: str, negative_prompt: str | None) -> dict[str, Any]:
result: dict[str, Any] = {"prompt": prompt}
if negative_prompt is not None:
result["negative_prompt"] = negative_prompt
return result
def _require_dimensions(
height: int | None,
width: int | None,
num_frames: int | None,
) -> tuple[int, int, int]:
if height is None or width is None or num_frames is None:
raise ValueError("VACE conditional inputs require height, width, and num_frames.")
if height <= 0 or width <= 0 or num_frames <= 0:
raise ValueError("VACE height, width, and num_frames must be positive.")
return height, width, num_frames
def _validate_media_inputs(media_inputs: Mapping[str, Any]) -> None:
unknown = set(media_inputs) - _MEDIA_KEYS
if unknown:
raise ValueError(f"Unsupported VACE media input(s): {', '.join(sorted(unknown))}.")
has_references = bool(media_inputs.get("reference_images"))
has_frame_conditioning = any(media_inputs.get(key) is not None for key in ("image", "last_image", "mask"))
if has_references and has_frame_conditioning:
raise ValueError("VACE reference images cannot be combined with image, last_image, or mask inputs.")
if media_inputs.get("mask") is not None and media_inputs.get("image") is None:
raise ValueError("VACE mask input requires an image input.")
if media_inputs.get("mask") is not None and media_inputs.get("last_image") is not None:
raise ValueError("VACE mask and last_image inputs cannot be combined.")
if not has_references and not has_frame_conditioning:
raise ValueError("VACE image-to-video generation requires a conditioning media input.")
def build_image_to_video_prompt(
prompt: str,
negative_prompt: str | None,
media_inputs: Mapping[str, Any],
height: int | None = None,
width: int | None = None,
num_frames: int | None = None,
) -> dict[str, Any]:
"""Build VACE's native video, mask, or reference-image conditioning."""
_validate_media_inputs(media_inputs)
result = _base_prompt(prompt, negative_prompt)
reference_images = media_inputs.get("reference_images")
if reference_images:
if not isinstance(reference_images, list) or not all(
isinstance(image, Image.Image) for image in reference_images
):
raise ValueError("VACE reference_images must be a non-empty list of PIL images.")
result["multi_modal_data"] = {"reference_images": reference_images}
return result
height, width, num_frames = _require_dimensions(height, width, num_frames)
first_image = media_inputs.get("image")
last_image = media_inputs.get("last_image")
mask_image = media_inputs.get("mask")
for name, image in (("image", first_image), ("last_image", last_image), ("mask", mask_image)):
if image is not None and not isinstance(image, Image.Image):
raise ValueError(f"VACE {name} must be a PIL image.")
gray = Image.new("RGB", (width, height), (128, 128, 128))
mask_black = Image.new("L", (width, height), 0)
mask_white = Image.new("L", (width, height), 255)
if mask_image is not None:
mask = mask_image.convert("L")
source = np.asarray(first_image).copy()
source[np.asarray(mask) > 128] = 128
frame = Image.fromarray(source)
result["multi_modal_data"] = {
"video": [frame.copy() for _ in range(num_frames)],
"mask": [mask.copy() for _ in range(num_frames)],
}
return result
if first_image is not None and last_image is not None:
if num_frames < 2:
raise ValueError("VACE first/last-frame conditioning requires at least two frames.")
result["multi_modal_data"] = {
"video": [first_image] + [gray] * (num_frames - 2) + [last_image],
"mask": [mask_black] + [mask_white] * (num_frames - 2) + [mask_black],
}
return result
if first_image is not None:
result["multi_modal_data"] = {
"video": [first_image] + [gray] * (num_frames - 1),
"mask": [mask_black] + [mask_white] * (num_frames - 1),
}
return result
result["multi_modal_data"] = {
"video": [gray] * (num_frames - 1) + [last_image],
"mask": [mask_white] * (num_frames - 1) + [mask_black],
}
return result