[Feature] feat: add video frame interpolation postprocess (#2555)
Signed-off-by: David Chen <530634352@qq.com>
This commit is contained in:
1 parent
61a3cbdff5
commit
6c6551dff8
17 files changed
+961
-9
No files matched your search
@@ -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`.
|
||||
@@ -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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user