[Feature] feat: add video frame interpolation postprocess (#2555)

Signed-off-by: David Chen <530634352@qq.com>
This commit is contained in:
WeiQing Chen authored and GitHub committed 2026-04-15 11:52:20 +08:00
1 parent 61a3cbdff5
commit 6c6551dff8
17 files changed
+961 -9

No files matched your search

+1
View File
@@ -64,6 +64,7 @@ nav:
- FP8: user_guide/diffusion/quantization/fp8.md
- Int8: user_guide/diffusion/quantization/int8.md
- GGUF: user_guide/diffusion/quantization/gguf.md
- Frame Interpolation: user_guide/diffusion/frame_interpolation.md
- Parallelism:
- Overview: user_guide/diffusion/parallelism/overview.md
- CFG Parallel: user_guide/diffusion/parallelism/cfg_parallel.md
@@ -0,0 +1,92 @@
# Frame Interpolation
## Overview
vLLM-Omni supports post-generation frame interpolation for supported video
diffusion pipelines. This feature inserts synthesized intermediate frames
between adjacent generated frames to improve temporal smoothness without
rerunning the diffusion denoising loop.
Frame interpolation runs in the diffusion worker post-processing path instead
of the API server encoding path. This allows the interpolation step to reuse
the worker's current accelerator device and keeps the FastAPI event loop free
from heavy synchronous PyTorch work.
For an input video with `N` generated frames and interpolation exponent `exp`,
the output frame count is:
```text
(N - 1) * 2**exp + 1
```
The output FPS is multiplied by `2**exp` so the clip duration remains close to
the original generated video.
## Supported Pipelines
Frame interpolation is currently supported for:
- `WanPipeline` (Wan2.2 text-to-video)
- `WanImageToVideoPipeline`
- `Wan22TI2VPipeline`
## Request Parameters
The video APIs `/v1/videos` and `/v1/videos/sync` accept:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enable_frame_interpolation` | bool | `false` | Enable post-generation frame interpolation |
| `frame_interpolation_exp` | int | `1` | Interpolation exponent. `1=2x`, `2=4x`, etc. |
| `frame_interpolation_scale` | float | `1.0` | RIFE inference scale |
| `frame_interpolation_model_path` | str | `None` | Local directory or Hugging Face repo ID containing `flownet.pkl` |
## Execution Flow
For supported Wan2.2 pipelines, the execution order is:
1. Diffusion worker finishes denoising and decodes the raw video tensor.
2. Worker-side model-specific post-processing runs.
3. If frame interpolation is enabled, RIFE interpolates the decoded video
tensor on the worker side and records a FPS multiplier in `custom_output`.
4. The API server receives the already-interpolated video and only performs
MP4 export.
This design keeps interpolation close to the generated tensor and avoids
introducing another heavyweight GPU context in the API server process.
## Example
Start the server:
```bash
vllm serve Wan-AI/Wan2.2-T2V-A14B-Diffusers --omni --port 8091
```
Run a sync request with interpolation enabled:
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=A dog running through a park" \
-F "num_frames=81" \
-F "width=832" \
-F "height=480" \
-F "fps=16" \
-F "num_inference_steps=40" \
-F "guidance_scale=1.0" \
-F "guidance_scale_2=1.0" \
-F "enable_frame_interpolation=true" \
-F "frame_interpolation_exp=1" \
-F "frame_interpolation_scale=1.0" \
-F "seed=42" \
-o sync_t2v_interpolated.mp4
```
## Notes
- This is a post-processing feature. It does not modify the diffusion denoising
schedule.
- Higher interpolation exponents increase post-processing time and memory usage.
- If the interpolation model weights are not available locally,
`frame_interpolation_model_path` may point to a Hugging Face repo containing
`flownet.pkl`.
+8 -1
View File
@@ -14,7 +14,7 @@ vLLM-Omni supports various advanced features for diffusion models:
- Acceleration: **cache methods**, **parallelism methods**, **startup optimizations**
- Memory optimization: **cpu offloading**, **quantization**
- Extensions: **LoRA inference**
- Extensions: **LoRA inference**, **frame interpolation**
- Execution modes: **step execution**
## Supported Features
@@ -69,6 +69,7 @@ Extension methods add specialized capabilities to diffusion models beyond standa
| Method | Description | Best For |
|--------|-------------|----------|
| **[LoRA Inference](diffusion/lora.md)** | Enables inference with Low-Rank Adaptation (LoRA) adapters weights | Reinforcement learning extensions |
| **[Frame Interpolation](diffusion/frame_interpolation.md)** | Inserts intermediate video frames after generation for smoother motion | Video generation pipelines that need higher temporal smoothness |
### Execution Modes
@@ -143,6 +144,11 @@ The following tables show which models support each feature:
| **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ (decode) | ✅ | ❌ |
| **DreamID-Omni** | ❌ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
**Frame Interpolation Support**
- **Supported**: Wan2.2 text-to-video, image-to-video, and TI2V pipelines
- **Not supported**: Wan2.1-VACE, LTX-2, Helios, HunyuanVideo-1.5, DreamID-Omni
### AudioGen
| Model | ⚡TeaCache | ⚡Cache-DiT | 🔀SP (Ulysses & Ring) | 🔀CFG-Parallel | 🔀Tensor-Parallel | 🔀HSDP | 💾CPU Offload (Layerwise) | 💾VAE-Patch-Parallel | 💾Quantization | 🔄Step Execution |
@@ -258,6 +264,7 @@ Measured on NVIDIA H800:
**Extensions:**
- **[LoRA Inference Guide](diffusion/lora.md)** - Low-Rank Adaptation for style customization and fine-tuning
- **[Frame Interpolation Guide](diffusion/frame_interpolation.md)** - Worker-side post-generation video frame interpolation for smoother motion
**Execution Modes:**
@@ -72,6 +72,9 @@ curl -X POST http://localhost:8091/v1/videos/sync \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "enable_frame_interpolation=true" \
-F "frame_interpolation_exp=1" \
-F "frame_interpolation_scale=1.0" \
-F "seed=42" \
-o sync_i2v_output.mp4
```
@@ -114,6 +117,9 @@ create_response=$(curl -s http://localhost:8091/v1/videos \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "enable_frame_interpolation=true" \
-F "frame_interpolation_exp=1" \
-F "frame_interpolation_scale=1.0" \
-F "seed=42")
video_id=$(echo "$create_response" | jq -r '.id')
@@ -172,9 +178,35 @@ curl -X POST http://localhost:8091/v1/videos \
-F "guidance_scale_2=1.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=12.0" \
-F "enable_frame_interpolation=true" \
-F "frame_interpolation_exp=1" \
-F "frame_interpolation_scale=1.0" \
-F "seed=42"
```
Frame interpolation is also available for supported Wan2.2 I2V requests. See
[Frame Interpolation](../../diffusion/frame_interpolation.md) for worker-side
execution details and feature constraints.
### Frame Interpolation Example
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=A bear playing with yarn, smooth motion" \
-F "input_reference=@/path/to/qwen-bear.png" \
-F "width=832" \
-F "height=480" \
-F "num_frames=33" \
-F "fps=16" \
-F "num_inference_steps=40" \
-F "guidance_scale=1.0" \
-F "guidance_scale_2=1.0" \
-F "enable_frame_interpolation=true" \
-F "frame_interpolation_exp=1" \
-F "frame_interpolation_scale=1.0" \
-o sync_i2v_interpolated.mp4
```
## Create Response Format
`POST /v1/videos` returns a job record, not inline base64 video data.
@@ -165,6 +165,9 @@ curl -X POST http://localhost:8091/v1/videos \
-F "guidance_scale_2=4.0" \
-F "boundary_ratio=0.875" \
-F "flow_shift=5.0" \
-F "enable_frame_interpolation=true" \
-F "frame_interpolation_exp=1" \
-F "frame_interpolation_scale=1.0" \
-F "seed=42"
```
@@ -187,6 +190,35 @@ curl -X POST http://localhost:8091/v1/videos \
| `flow_shift` | float | None | Scheduler flow shift (Wan2.2) |
| `seed` | int | None | Random seed (reproducible) |
| `lora` | object | None | LoRA configuration |
| `enable_frame_interpolation` | bool | false | Enable RIFE frame interpolation before MP4 encoding |
| `frame_interpolation_exp` | int | 1 | Interpolation exponent; 1=2x temporal resolution, 2=4x |
| `frame_interpolation_scale` | float | 1.0 | RIFE inference scale; use 0.5 for high-resolution inputs |
| `frame_interpolation_model_path` | str | None | Local directory or Hugging Face repo ID with `flownet.pkl`; defaults to `elfgum/RIFE-4.22.lite` |
## Frame Interpolation
Frame interpolation is an optional post-processing step for `/v1/videos` and
`/v1/videos/sync`. It synthesizes intermediate frames between generated frames
without rerunning the diffusion model. If the generated video has `N` frames,
the interpolated output frame count is `(N - 1) * 2**exp + 1`. The encoder FPS
is multiplied by `2**exp` so the output duration remains close to the original.
Frame interpolation runs in the diffusion worker post-processing path instead of
the API server encoding path, so it can reuse the worker's current accelerator
device without blocking the FastAPI event loop.
Example: generate 5 frames and interpolate to 9 frames:
```bash
curl -X POST http://localhost:8091/v1/videos/sync \
-F "prompt=A dog running through a park" \
-F "num_frames=5" \
-F "fps=8" \
-F "enable_frame_interpolation=true" \
-F "frame_interpolation_exp=1" \
-F "frame_interpolation_scale=1.0" \
-o sync_t2v_interpolated.mp4
```
## Create Response Format
@@ -0,0 +1,92 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for OpenAI-compatible video API encoding helpers."""
import numpy as np
import pytest
import torch
from vllm_omni.diffusion.postprocess import rife_interpolator
from vllm_omni.entrypoints.openai import video_api_utils
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _install_fake_video_mux(monkeypatch, mux_calls):
def _fake_mux_video_audio_bytes(frames, audio, fps, audio_sample_rate, video_codec_options=None):
mux_calls.append(
{
"frames": frames,
"audio": audio,
"fps": fps,
"audio_sample_rate": audio_sample_rate,
"video_codec_options": video_codec_options,
}
)
return b"fake-video"
monkeypatch.setattr(
"vllm_omni.diffusion.utils.media_utils.mux_video_audio_bytes",
_fake_mux_video_audio_bytes,
)
def test_encode_video_bytes_exports_frames_without_interpolation(monkeypatch):
mux_calls = []
_install_fake_video_mux(monkeypatch, mux_calls)
frames = [np.full((2, 2, 3), fill_value=i / 5, dtype=np.float32) for i in range(5)]
video_bytes = video_api_utils._encode_video_bytes(
frames,
fps=8,
)
assert video_bytes == b"fake-video"
assert mux_calls[0]["frames"].shape == (5, 2, 2, 3)
assert mux_calls[0]["frames"].dtype == np.uint8
assert mux_calls[0]["fps"] == 8.0
assert mux_calls[0]["audio"] is None
def test_rife_model_inference_runs_on_dummy_tensors():
model = rife_interpolator.Model().eval()
img0 = torch.rand(1, 3, 32, 32)
img1 = torch.rand(1, 3, 32, 32)
output = model.inference(img0, img1, scale=1.0)
assert output.shape == (1, 3, 32, 32)
assert torch.isfinite(output).all()
def test_frame_interpolator_runs_actual_torch_tensor_path(monkeypatch):
model = rife_interpolator.Model().eval()
interpolator = rife_interpolator.FrameInterpolator()
monkeypatch.setattr(interpolator, "_ensure_model_loaded", lambda preferred_device=None: model)
video = torch.zeros(1, 3, 2, 32, 32)
output_video, multiplier = interpolator.interpolate_tensor(video, exp=1, scale=1.0)
assert multiplier == 2
assert output_video.shape == (1, 3, 3, 32, 32)
assert torch.isfinite(output_video).all()
def test_frame_interpolator_prefers_input_tensor_device(monkeypatch):
chosen_devices = []
model = rife_interpolator.Model().eval()
def _fake_ensure_model_loaded(*, preferred_device=None):
chosen_devices.append(preferred_device)
return model
interpolator = rife_interpolator.FrameInterpolator()
monkeypatch.setattr(interpolator, "_ensure_model_loaded", _fake_ensure_model_loaded)
monkeypatch.setattr(model.flownet, "to", lambda device: model.flownet)
video = torch.zeros(1, 3, 2, 32, 32)
output_video, multiplier = interpolator.interpolate_tensor(video, exp=1, scale=1.0)
assert chosen_devices == [video.device]
assert multiplier == 2
assert output_video.shape == (1, 3, 3, 32, 32)
@@ -34,15 +34,28 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class MockVideoResult:
def __init__(self, videos, audios=None, sample_rate=None, stage_durations=None, peak_memory_mb=0.0):
def __init__(
self,
videos,
audios=None,
sample_rate=None,
custom_output=None,
stage_durations=None,
peak_memory_mb=0.0,
):
self.multimodal_output = {"video": videos}
if audios is not None:
self.multimodal_output["audio"] = audios
if sample_rate is not None:
self.multimodal_output["audio_sample_rate"] = sample_rate
self._custom_output = custom_output or {}
self.stage_durations = stage_durations or {}
self.peak_memory_mb = peak_memory_mb
@property
def custom_output(self):
return self._custom_output
class FakeAsyncOmni:
def __init__(self):
@@ -400,6 +413,67 @@ def test_sampling_params_pass_through(test_client, mocker: MockerFixture):
assert captured.extra_args["flow_shift"] == 0.25
def test_frame_interpolation_params_pass_to_diffusion_sampling_params(test_client, mocker: MockerFixture):
"""Frame interpolation parameters should be forwarded to diffusion worker sampling params."""
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
return_value=b"fake-video",
)
response = test_client.post(
"/v1/videos",
data={
"prompt": "smooth motion",
"fps": "8",
"enable_frame_interpolation": "true",
"frame_interpolation_exp": "2",
"frame_interpolation_scale": "0.5",
"frame_interpolation_model_path": "local-rife",
},
)
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.enable_frame_interpolation is True
assert captured.frame_interpolation_exp == 2
assert captured.frame_interpolation_scale == 0.5
assert captured.frame_interpolation_model_path == "local-rife"
def test_worker_fps_multiplier_is_applied_to_async_encoding(test_client, mocker: MockerFixture):
fps_values = []
engine = test_client.app.state.openai_serving_video._engine_client
async def _generate(prompt, request_id, sampling_params_list):
engine.captured_prompt = prompt
engine.captured_sampling_params_list = sampling_params_list
import numpy as np
yield MockVideoResult([np.zeros((1, 64, 64, 3), dtype=np.uint8)], custom_output={"video_fps_multiplier": 2})
engine.generate = _generate
def _fake_encode(video, fps, **kwargs):
del video, kwargs
fps_values.append(fps)
return b"fake-video"
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
side_effect=_fake_encode,
)
response = test_client.post("/v1/videos", data={"prompt": "fps multiplier", "fps": "8"})
assert response.status_code == 200
video_id = response.json()["id"]
_wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value)
assert fps_values == [16]
def test_audio_sample_rate_comes_from_model_config(test_client, mocker: MockerFixture):
audio_sample_rates = []
@@ -595,6 +669,10 @@ def test_video_request_validation():
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", image_reference={"file_id": "file-1", "image_url": "https://example.com"})
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", frame_interpolation_exp=0)
with pytest.raises(ValueError):
VideoGenerationRequest(prompt="test", frame_interpolation_scale=0)
def test_list_videos_supports_order_after_and_limit(test_client, mocker: MockerFixture):
@@ -1032,3 +1110,57 @@ def test_sync_sampling_params_pass_through(test_client, mocker: MockerFixture):
assert captured.num_inference_steps == 30
assert captured.guidance_scale == 6.5
assert captured.seed == 42
def test_sync_frame_interpolation_params_pass_to_sampling_params(test_client, mocker: MockerFixture):
"""Frame interpolation parameters should be forwarded on the sync path."""
encode_mock = _mock_encode_video_bytes(mocker)
response = test_client.post(
"/v1/videos/sync",
data={
"prompt": "smooth sync",
"fps": "8",
"enable_frame_interpolation": "true",
"frame_interpolation_exp": "2",
"frame_interpolation_scale": "0.5",
"frame_interpolation_model_path": "local-rife",
},
)
assert response.status_code == 200
engine = test_client.app.state.openai_serving_video._engine_client
captured = engine.captured_sampling_params_list[0]
assert captured.enable_frame_interpolation is True
assert captured.frame_interpolation_exp == 2
assert captured.frame_interpolation_scale == 0.5
assert captured.frame_interpolation_model_path == "local-rife"
_, kwargs = encode_mock.call_args
assert kwargs["fps"] == 8
def test_worker_fps_multiplier_is_applied_to_sync_encoding(test_client, mocker: MockerFixture):
engine = test_client.app.state.openai_serving_video._engine_client
fps_values = []
async def _generate(prompt, request_id, sampling_params_list):
engine.captured_prompt = prompt
engine.captured_sampling_params_list = sampling_params_list
yield MockVideoResult([object()], custom_output={"video_fps_multiplier": 2})
engine.generate = _generate
def _fake_encode(video, fps, **kwargs):
del video, kwargs
fps_values.append(fps)
return b"fps-multiplied"
mocker.patch(
"vllm_omni.entrypoints.openai.serving_video._encode_video_bytes",
side_effect=_fake_encode,
)
response = test_client.post("/v1/videos/sync", data={"prompt": "fps multiplier", "fps": "8"})
assert response.status_code == 200
assert response.content == b"fps-multiplied"
assert fps_values == [16]
+20 -3
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import inspect
import queue
import threading
import time
@@ -78,6 +79,12 @@ class DiffusionEngine:
self.post_process_func = get_diffusion_post_process_func(od_config)
self.pre_process_func = get_diffusion_pre_process_func(od_config)
# Cache whether the model-specific postprocess accepts request-level
# sampling params so step() can support both legacy and extended hooks.
self._post_process_accepts_sampling_params = bool(
self.post_process_func is not None
and "sampling_params" in inspect.signature(self.post_process_func).parameters
)
executor_class = DiffusionExecutor.get_class(od_config)
self.executor = executor_class(od_config)
@@ -143,12 +150,22 @@ class DiffusionEngine:
output_data = output_data.cpu()
postprocess_start_time = time.perf_counter()
outputs = self.post_process_func(output_data) if self.post_process_func is not None else output_data
if self.post_process_func is not None:
# Some video pipelines need request-level controls during
# postprocess (for example worker-side frame interpolation).
if self._post_process_accepts_sampling_params:
outputs = self.post_process_func(output_data, sampling_params=request.sampling_params)
else:
outputs = self.post_process_func(output_data)
else:
outputs = output_data
audio_payload = None
custom_output = output.custom_output or {}
model_audio_sample_rate = None
model_fps = None
if isinstance(outputs, dict):
audio_payload = outputs.get("audio")
custom_output.update(outputs.get("custom_output") or {})
model_audio_sample_rate = outputs.get("audio_sample_rate")
model_fps = outputs.get("fps")
outputs = outputs.get("video", outputs)
@@ -225,7 +242,7 @@ class DiffusionEngine:
trajectory_timesteps=output.trajectory_timesteps,
trajectory_log_probs=output.trajectory_log_probs,
trajectory_decoded=output.trajectory_decoded,
custom_output=output.custom_output or {},
custom_output=custom_output,
multimodal_output=mm_output,
stage_durations=output.stage_durations,
peak_memory_mb=output.peak_memory_mb,
@@ -295,7 +312,7 @@ class DiffusionEngine:
trajectory_timesteps=output.trajectory_timesteps,
trajectory_log_probs=output.trajectory_log_probs,
trajectory_decoded=output.trajectory_decoded,
custom_output=output.custom_output or {},
custom_output=custom_output,
multimodal_output=mm_output,
stage_durations=output.stage_durations,
peak_memory_mb=output.peak_memory_mb,
@@ -26,6 +26,7 @@ from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin, _is_rank_z
from vllm_omni.diffusion.models.schedulers import FlowUniPCMultistepScheduler
from vllm_omni.diffusion.models.wan2_2.scheduling_wan_euler import WanEulerScheduler
from vllm_omni.diffusion.models.wan2_2.wan2_2_transformer import WanTransformer3DModel
from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
@@ -162,10 +163,23 @@ def get_wan22_post_process_func(
def post_process_func(
video: torch.Tensor,
output_type: str = "np",
sampling_params=None,
):
if output_type == "latent":
return video
return video_processor.postprocess_video(video, output_type=output_type)
custom_output = {}
if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False):
video, multiplier = interpolate_video_tensor(
video,
exp=sampling_params.frame_interpolation_exp,
scale=sampling_params.frame_interpolation_scale,
model_path=sampling_params.frame_interpolation_model_path,
)
custom_output["video_fps_multiplier"] = multiplier
return {
"video": video_processor.postprocess_video(video, output_type=output_type),
"custom_output": custom_output,
}
return post_process_func
@@ -32,6 +32,7 @@ from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
resolve_wan_sample_solver,
retrieve_latents,
)
from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
@@ -74,10 +75,23 @@ def get_wan22_i2v_post_process_func(
def post_process_func(
video: torch.Tensor,
output_type: str = "np",
sampling_params=None,
):
if output_type == "latent":
return video
return video_processor.postprocess_video(video, output_type=output_type)
custom_output = {}
if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False):
video, multiplier = interpolate_video_tensor(
video,
exp=sampling_params.frame_interpolation_exp,
scale=sampling_params.frame_interpolation_scale,
model_path=sampling_params.frame_interpolation_model_path,
)
custom_output["video_fps_multiplier"] = multiplier
return {
"video": video_processor.postprocess_video(video, output_type=output_type),
"custom_output": custom_output,
}
return post_process_func
@@ -44,6 +44,7 @@ from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
resolve_wan_sample_solver,
retrieve_latents,
)
from vllm_omni.diffusion.postprocess import interpolate_video_tensor
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniTextPrompt
from vllm_omni.platforms import current_omni_platform
@@ -61,10 +62,23 @@ def get_wan22_ti2v_post_process_func(
def post_process_func(
video: torch.Tensor,
output_type: str = "np",
sampling_params=None,
):
if output_type == "latent":
return video
return video_processor.postprocess_video(video, output_type=output_type)
custom_output = {}
if sampling_params is not None and getattr(sampling_params, "enable_frame_interpolation", False):
video, multiplier = interpolate_video_tensor(
video,
exp=sampling_params.frame_interpolation_exp,
scale=sampling_params.frame_interpolation_scale,
model_path=sampling_params.frame_interpolation_model_path,
)
custom_output["video_fps_multiplier"] = multiplier
return {
"video": video_processor.postprocess_video(video, output_type=output_type),
"custom_output": custom_output,
}
return post_process_func
@@ -0,0 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Diffusion post-processing helpers."""
from vllm_omni.diffusion.postprocess.rife_interpolator import (
FrameInterpolator,
interpolate_video_tensor,
)
__all__ = ["FrameInterpolator", "interpolate_video_tensor"]
@@ -0,0 +1,440 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
RIFE 4.22.lite frame interpolation for vLLM-Omni video generation.
RIFE model code is vendored and adapted from:
- https://github.com/hzwer/ECCV2022-RIFE (MIT License)
- https://github.com/hzwer/Practical-RIFE (MIT License)
Copyright (c) 2021 Zhewei Huang
The FrameInterpolator wrapper and vLLM-Omni integration code are original work.
"""
from __future__ import annotations
import os
import threading
from typing import Any
import torch
import torch.nn as nn
import torch.nn.functional as F
from vllm.logger import init_logger
logger = init_logger(__name__)
_DEFAULT_RIFE_HF_REPO = "elfgum/RIFE-4.22.lite"
_MODEL_CACHE: dict[tuple[str, str], Model] = {}
_MODEL_CACHE_LOCK = threading.Lock()
def warp(ten_input: torch.Tensor, ten_flow: torch.Tensor) -> torch.Tensor:
"""Warp input tensor by optical flow using grid_sample."""
ten_horizontal = (
torch.linspace(-1.0, 1.0, ten_flow.shape[3], device=ten_flow.device)
.view(1, 1, 1, ten_flow.shape[3])
.expand(ten_flow.shape[0], -1, ten_flow.shape[2], -1)
)
ten_vertical = (
torch.linspace(-1.0, 1.0, ten_flow.shape[2], device=ten_flow.device)
.view(1, 1, ten_flow.shape[2], 1)
.expand(ten_flow.shape[0], -1, -1, ten_flow.shape[3])
)
ten_grid = torch.cat([ten_horizontal, ten_vertical], dim=1)
ten_flow = torch.cat(
[
ten_flow[:, 0:1, :, :] / ((ten_input.shape[3] - 1.0) / 2.0),
ten_flow[:, 1:2, :, :] / ((ten_input.shape[2] - 1.0) / 2.0),
],
dim=1,
)
grid = (ten_grid + ten_flow).permute(0, 2, 3, 1)
return F.grid_sample(
input=ten_input,
grid=grid,
mode="bilinear",
padding_mode="border",
align_corners=True,
)
def _conv(
in_planes: int,
out_planes: int,
kernel_size: int = 3,
stride: int = 1,
padding: int = 1,
dilation: int = 1,
) -> nn.Sequential:
return nn.Sequential(
nn.Conv2d(
in_planes,
out_planes,
kernel_size=kernel_size,
stride=stride,
padding=padding,
dilation=dilation,
bias=True,
),
nn.LeakyReLU(0.2, True),
)
class ResConv(nn.Module):
"""Residual convolution block with learnable beta scaling."""
def __init__(self, c: int, dilation: int = 1):
super().__init__()
self.conv = nn.Conv2d(c, c, 3, 1, dilation, dilation=dilation, groups=1)
self.beta = nn.Parameter(torch.ones((1, c, 1, 1)), requires_grad=True)
self.relu = nn.LeakyReLU(0.2, True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.relu(self.conv(x) * self.beta + x)
class IFBlock(nn.Module):
"""Single-scale optical flow, mask, and feature block."""
def __init__(self, in_planes: int, c: int = 64):
super().__init__()
self.conv0 = nn.Sequential(
_conv(in_planes, c // 2, 3, 2, 1),
_conv(c // 2, c, 3, 2, 1),
)
self.convblock = nn.Sequential(
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
ResConv(c),
)
self.lastconv = nn.Sequential(
nn.ConvTranspose2d(c, 4 * 13, 4, 2, 1),
nn.PixelShuffle(2),
)
def forward(
self,
x: torch.Tensor,
flow: torch.Tensor | None = None,
scale: float = 1.0,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
x = F.interpolate(x, scale_factor=1.0 / scale, mode="bilinear", align_corners=False)
if flow is not None:
flow = (
F.interpolate(
flow,
scale_factor=1.0 / scale,
mode="bilinear",
align_corners=False,
)
* 1.0
/ scale
)
x = torch.cat((x, flow), 1)
feat = self.conv0(x)
feat = self.convblock(feat)
tmp = self.lastconv(feat)
tmp = F.interpolate(tmp, scale_factor=scale, mode="bilinear", align_corners=False)
flow = tmp[:, :4] * scale
mask = tmp[:, 4:5]
feat = tmp[:, 5:]
return flow, mask, feat
class Head(nn.Module):
"""Feature encoder producing four-channel features at full resolution."""
def __init__(self):
super().__init__()
self.cnn0 = nn.Conv2d(3, 16, 3, 2, 1)
self.cnn1 = nn.Conv2d(16, 16, 3, 1, 1)
self.cnn2 = nn.Conv2d(16, 16, 3, 1, 1)
self.cnn3 = nn.ConvTranspose2d(16, 4, 4, 2, 1)
self.relu = nn.LeakyReLU(0.2, True)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x0 = self.cnn0(x)
x = self.relu(x0)
x1 = self.cnn1(x)
x = self.relu(x1)
x2 = self.cnn2(x)
x = self.relu(x2)
x3 = self.cnn3(x)
return x3
class IFNet(nn.Module):
"""Four-scale IFNet optical flow network."""
def __init__(self):
super().__init__()
self.block0 = IFBlock(7 + 8, c=192)
self.block1 = IFBlock(8 + 4 + 8 + 8, c=128)
self.block2 = IFBlock(8 + 4 + 8 + 8, c=64)
self.block3 = IFBlock(8 + 4 + 8 + 8, c=32)
self.encode = Head()
def forward(
self,
x: torch.Tensor,
timestep: float = 0.5,
scale_list: list[float] | None = None,
) -> tuple[list[torch.Tensor], torch.Tensor, list[tuple[torch.Tensor, torch.Tensor] | torch.Tensor]]:
if scale_list is None:
scale_list = [8, 4, 2, 1]
channel = x.shape[1] // 2
img0 = x[:, :channel]
img1 = x[:, channel:]
if not torch.is_tensor(timestep):
timestep = (x[:, :1].clone() * 0 + 1) * timestep
else:
timestep = timestep.repeat(1, 1, img0.shape[2], img0.shape[3])
f0 = self.encode(img0[:, :3])
f1 = self.encode(img1[:, :3])
flow_list: list[torch.Tensor] = []
merged: list[tuple[torch.Tensor, torch.Tensor] | torch.Tensor] = []
mask_list: list[torch.Tensor] = []
warped_img0 = img0
warped_img1 = img1
flow = None
mask = None
for i, block in enumerate([self.block0, self.block1, self.block2, self.block3]):
if flow is None:
flow, mask, feat = block(
torch.cat((img0[:, :3], img1[:, :3], f0, f1, timestep), 1),
None,
scale=scale_list[i],
)
else:
wf0 = warp(f0, flow[:, :2])
wf1 = warp(f1, flow[:, 2:4])
fd, m0, feat = block(
torch.cat(
(
warped_img0[:, :3],
warped_img1[:, :3],
wf0,
wf1,
timestep,
mask,
feat,
),
1,
),
flow,
scale=scale_list[i],
)
mask = m0
flow = flow + fd
mask_list.append(mask)
flow_list.append(flow)
warped_img0 = warp(img0, flow[:, :2])
warped_img1 = warp(img1, flow[:, 2:4])
merged.append((warped_img0, warped_img1))
mask = torch.sigmoid(mask)
merged[3] = warped_img0 * mask + warped_img1 * (1 - mask)
return flow_list, mask_list[3], merged
class Model:
"""Wraps IFNet and exposes RIFE-compatible load/inference helpers."""
def __init__(self):
self.flownet = IFNet()
def eval(self) -> Model:
self.flownet.eval()
return self
def device(self) -> torch.device:
return next(self.flownet.parameters()).device
def load_model(self, path: str) -> None:
flownet_path = os.path.join(path, "flownet.pkl")
if not os.path.isfile(flownet_path):
raise FileNotFoundError(
f"RIFE weight file not found: {flownet_path}. Expected layout: <model_path>/flownet.pkl"
)
state = torch.load(flownet_path, map_location="cpu", weights_only=False)
state = {k.removeprefix("module."): v for k, v in state.items()}
self.flownet.load_state_dict(state, strict=False)
logger.info("Loaded RIFE weights from %s", flownet_path)
def inference(
self,
img0: torch.Tensor,
img1: torch.Tensor,
scale: float = 1.0,
timestep: float = 0.5,
) -> torch.Tensor:
_n, _c, h, w = img0.shape
ph = ((h - 1) // 32 + 1) * 32
pw = ((w - 1) // 32 + 1) * 32
pad = (0, pw - w, 0, ph - h)
img0 = F.pad(img0, pad)
img1 = F.pad(img1, pad)
imgs = torch.cat((img0, img1), 1)
scale_list = [8 / scale, 4 / scale, 2 / scale, 1 / scale]
with torch.no_grad():
_flow_list, _mask, merged = self.flownet(
imgs,
timestep=timestep,
scale_list=scale_list,
)
return merged[3][:, :, :h, :w]
def _resolve_rife_model_path(model_path: str | None) -> str:
model_path = model_path or _DEFAULT_RIFE_HF_REPO
if os.path.isdir(model_path):
return model_path
from vllm_omni.model_executor.model_loader.weight_utils import (
download_weights_from_hf_specific,
)
return download_weights_from_hf_specific(
model_path,
cache_dir=None,
allow_patterns=["flownet.pkl"],
require_all=True,
)
def _select_torch_device() -> torch.device:
try:
from vllm_omni.platforms import current_omni_platform
return current_omni_platform.get_torch_device()
except Exception as exc:
logger.warning("Failed to resolve current vLLM-Omni torch device: %s", exc)
if torch.cuda.is_available():
return torch.device("cuda")
return torch.device("cpu")
def _normalize_video_tensor_layout(video: torch.Tensor) -> tuple[torch.Tensor, Any]:
if video.ndim == 5:
if video.shape[1] in (3, 4):
return video, lambda out: out
if video.shape[2] in (3, 4):
return video.permute(0, 2, 1, 3, 4), lambda out: out.permute(0, 2, 1, 3, 4)
elif video.ndim == 4:
if video.shape[0] in (3, 4):
return video.unsqueeze(0), lambda out: out.squeeze(0)
if video.shape[1] in (3, 4):
return video.permute(1, 0, 2, 3).unsqueeze(0), lambda out: out.squeeze(0).permute(1, 0, 2, 3)
raise ValueError(f"Unsupported video tensor shape for interpolation: {tuple(video.shape)}")
def _normalize_video_tensor_range(video: torch.Tensor) -> tuple[torch.Tensor, Any]:
original_dtype = video.dtype
video = video.detach()
if video.is_floating_point():
video = video.to(torch.float32)
if torch.amin(video) < 0.0 or torch.amax(video) > 1.0:
return video.clamp(-1.0, 1.0) * 0.5 + 0.5, lambda out: (out * 2.0 - 1.0).to(original_dtype)
return video.clamp(0.0, 1.0), lambda out: out.to(original_dtype)
return video.to(torch.float32) / 255.0, lambda out: (out * 255.0).round().clamp(0, 255).to(original_dtype)
class FrameInterpolator:
"""Lazy-loaded RIFE 4.22.lite frame interpolator."""
def __init__(self, model_path: str | None = None):
self._model_path = model_path
self._resolved_path: str | None = None
def _ensure_model_loaded(self, preferred_device: torch.device | None = None) -> Model:
resolved_path = _resolve_rife_model_path(self._model_path)
self._resolved_path = resolved_path
device = preferred_device or _select_torch_device()
cache_key = (resolved_path, str(device))
with _MODEL_CACHE_LOCK:
if cache_key in _MODEL_CACHE:
return _MODEL_CACHE[cache_key]
model = Model()
model.load_model(resolved_path)
model.eval()
model.flownet = model.flownet.to(device)
_MODEL_CACHE[cache_key] = model
logger.info("RIFE model loaded on device: %s", device)
return model
def _make_inference(
self,
model: Model,
img0: torch.Tensor,
img1: torch.Tensor,
n: int,
scale: float,
) -> list[torch.Tensor]:
if n == 1:
return [model.inference(img0, img1, scale=scale)]
mid = model.inference(img0, img1, scale=scale)
return (
self._make_inference(model, img0, mid, n // 2, scale)
+ [mid]
+ self._make_inference(model, mid, img1, n // 2, scale)
)
def interpolate_tensor(
self,
video: torch.Tensor,
exp: int = 1,
scale: float = 1.0,
) -> tuple[torch.Tensor, int]:
if exp < 1:
raise ValueError(f"frame interpolation exp must be >= 1, got {exp}")
if scale <= 0:
raise ValueError(f"frame interpolation scale must be > 0, got {scale}")
video, restore_layout = _normalize_video_tensor_layout(video)
if video.shape[2] < 2:
return restore_layout(video), 1
video, restore_range = _normalize_video_tensor_range(video)
# Prefer the decoded video's current device so CPU-offloaded requests do
# not move the tensor back to GPU just for interpolation.
model = self._ensure_model_loaded(preferred_device=video.device)
video = video.to(model.device())
intermediates_per_pair = 2**exp // 2
result_frames: list[torch.Tensor] = []
for idx in range(video.shape[2] - 1):
img0 = video[:, :, idx, :, :]
img1 = video[:, :, idx + 1, :, :]
result_frames.append(img0)
result_frames.extend(self._make_inference(model, img0, img1, intermediates_per_pair, scale))
result_frames.append(video[:, :, -1, :, :])
result = torch.stack(result_frames, dim=2)
return restore_layout(restore_range(result)), 2**exp
def interpolate_video_tensor(
video: torch.Tensor,
exp: int = 1,
scale: float = 1.0,
model_path: str | None = None,
) -> tuple[torch.Tensor, int]:
"""Interpolate a video tensor and return the FPS multiplier."""
interpolator = FrameInterpolator(model_path=model_path)
return interpolator.interpolate_tensor(video, exp=exp, scale=scale)
@@ -2075,6 +2075,10 @@ async def _parse_video_form(
true_cfg_scale: float | None = Form(default=None),
seed: int | None = Form(default=None),
negative_prompt: str | None = Form(default=None),
enable_frame_interpolation: bool = Form(default=False),
frame_interpolation_exp: int = Form(default=1, ge=1),
frame_interpolation_scale: float = Form(default=1.0, gt=0.0),
frame_interpolation_model_path: str | None = Form(default=None),
lora: str | None = Form(default=None),
extra_params: str | None = Form(default=None),
) -> tuple[VideoGenerationRequest, "OmniOpenAIServingVideo", str, ReferenceImage | None]:
@@ -2111,6 +2115,10 @@ async def _parse_video_form(
"true_cfg_scale": true_cfg_scale,
"seed": seed,
"negative_prompt": negative_prompt,
"enable_frame_interpolation": enable_frame_interpolation,
"frame_interpolation_exp": frame_interpolation_exp,
"frame_interpolation_scale": frame_interpolation_scale,
"frame_interpolation_model_path": frame_interpolation_model_path,
"lora": _parse_form_json(lora, expected_type=dict),
"extra_params": _parse_form_json(extra_params, expected_type=dict),
}
@@ -150,6 +150,29 @@ class VideoGenerationRequest(BaseModel):
)
seed: int | None = Field(default=None, description="Random seed for reproducibility")
# vllm-omni extensions for post-generation frame interpolation.
enable_frame_interpolation: bool = Field(
default=False,
description="Enable post-generation RIFE frame interpolation before MP4 encoding.",
)
frame_interpolation_exp: int = Field(
default=1,
ge=1,
description="Interpolation exponent: 1=2x temporal resolution, 2=4x, etc.",
)
frame_interpolation_scale: float = Field(
default=1.0,
gt=0.0,
description="RIFE inference scale. Use 0.5 for high-resolution inputs to save memory.",
)
frame_interpolation_model_path: str | None = Field(
default=None,
description=(
"Local directory or Hugging Face repo ID containing RIFE flownet.pkl weights. "
"Defaults to elfgum/RIFE-4.22.lite."
),
)
# vllm-omni extension for per-request LoRA.
lora: dict[str, Any] | None = Field(
default=None,
+21 -1
View File
@@ -113,6 +113,10 @@ class OmniOpenAIServingVideo:
if vp.fps is not None:
gen_params.fps = vp.fps
gen_params.frame_rate = float(vp.fps)
gen_params.enable_frame_interpolation = request.enable_frame_interpolation
gen_params.frame_interpolation_exp = request.frame_interpolation_exp
gen_params.frame_interpolation_scale = request.frame_interpolation_scale
gen_params.frame_interpolation_model_path = request.frame_interpolation_model_path
if request.num_inference_steps is not None:
gen_params.num_inference_steps = request.num_inference_steps
@@ -160,7 +164,7 @@ class OmniOpenAIServingVideo:
videos = self._extract_video_outputs(result)
audios = self._extract_audio_outputs(result, expected_count=len(videos))
audio_sample_rate = self._resolve_audio_sample_rate(result)
output_fps = vp.fps or self._resolve_fps(result) or 24
output_fps = (vp.fps or self._resolve_fps(result) or 24) * self._resolve_video_fps_multiplier(result)
return VideoGenerationArtifacts(
videos=videos,
audios=audios,
@@ -243,6 +247,22 @@ class OmniOpenAIServingVideo:
logger.info("Video response encoding (MP4 bytes): %.2f ms", _t_encode_ms)
return video_bytes, artifacts.stage_durations, artifacts.peak_memory_mb
@staticmethod
def _resolve_video_fps_multiplier(result: Any) -> int:
custom_output = getattr(result, "custom_output", None)
if isinstance(custom_output, dict):
multiplier = custom_output.get("video_fps_multiplier")
if multiplier is not None:
return int(multiplier)
request_output = getattr(result, "request_output", None)
if request_output is not None:
custom_output = getattr(request_output, "custom_output", None)
if isinstance(custom_output, dict):
multiplier = custom_output.get("video_fps_multiplier")
if multiplier is not None:
return int(multiplier)
return 1
@staticmethod
def _apply_lora(lora_body: Any, gen_params: OmniDiffusionSamplingParams) -> None:
try:
+4
View File
@@ -227,6 +227,10 @@ class OmniDiffusionSamplingParams:
frame_rate: float | None = None # Floating-point rate used by the diffusion model when it differs from `fps`.
height_not_provided: bool = False
width_not_provided: bool = False
enable_frame_interpolation: bool = False
frame_interpolation_exp: int = 1
frame_interpolation_scale: float = 1.0
frame_interpolation_model_path: str | None = None
# Timesteps
timesteps: torch.Tensor | None = None