Skip to content

Commit e4d594e

Browse files
committed
feat: add Wan2.2 Animate 2 video models
1 parent e458949 commit e4d594e

8 files changed

Lines changed: 266 additions & 3 deletions

File tree

xinference/api/restful_api.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2474,6 +2474,7 @@ async def create_videos_from_images(
24742474
request: Request,
24752475
model: str = Form(...),
24762476
image: UploadFile = File(media_type="application/octet-stream"),
2477+
video: Optional[UploadFile] = File(None, media_type="application/octet-stream"),
24772478
prompt: Optional[Union[str, List[str]]] = Form(None),
24782479
negative_prompt: Optional[Union[str, List[str]]] = Form(None),
24792480
n: Optional[int] = Form(1),
@@ -2493,6 +2494,8 @@ async def create_videos_from_images(
24932494
parsed_kwargs = json.loads(kwargs)
24942495
else:
24952496
parsed_kwargs = {}
2497+
if video is not None:
2498+
parsed_kwargs["video"] = await video.read()
24962499
request_id = parsed_kwargs.get("request_id")
24972500
self._add_running_task(request_id)
24982501
video_list = await model_ref.image_to_video(

xinference/client/restful/async_restful_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ async def image_to_video(
666666
prompt: str,
667667
negative_prompt: Optional[str] = None,
668668
n: int = 1,
669+
video: Optional[Union[str, bytes]] = None,
669670
**kwargs,
670671
) -> "VideoList":
671672
"""
@@ -681,6 +682,8 @@ async def image_to_video(
681682
The prompt or prompts not to guide the image generation.
682683
n: `int`, defaults to 1
683684
The number of videos to generate per prompt. Must be between 1 and 10.
685+
video: `Union[str, bytes]`, optional
686+
The driving video for character animation models.
684687
Returns
685688
-------
686689
VideoList
@@ -699,6 +702,18 @@ async def image_to_video(
699702
for key, value in params.items():
700703
files.append((key, (None, value)))
701704
files.append(("image", ("image", image, "application/octet-stream")))
705+
if video is not None:
706+
if isinstance(video, str):
707+
with open(video, "rb") as f:
708+
video_data = f.read()
709+
else:
710+
video_data = video
711+
files.append(
712+
(
713+
"video",
714+
("video", video_data, "application/octet-stream"),
715+
)
716+
)
702717
response = await self.session.post(url, data=files, headers=self.auth_headers)
703718
if response.status != 200:
704719
raise RuntimeError(

xinference/client/restful/restful_client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ def image_to_video(
599599
prompt: str,
600600
negative_prompt: Optional[str] = None,
601601
n: int = 1,
602+
video: Optional[Union[str, bytes]] = None,
602603
**kwargs,
603604
) -> "VideoList":
604605
"""
@@ -614,6 +615,8 @@ def image_to_video(
614615
The prompt or prompts not to guide the image generation.
615616
n: `int`, defaults to 1
616617
The number of videos to generate per prompt. Must be between 1 and 10.
618+
video: `Union[str, bytes]`, optional
619+
The driving video for character animation models.
617620
Returns
618621
-------
619622
VideoList
@@ -631,6 +634,18 @@ def image_to_video(
631634
for key, value in params.items():
632635
files.append((key, (None, value)))
633636
files.append(("image", ("image", image, "application/octet-stream")))
637+
if video is not None:
638+
if isinstance(video, str):
639+
with open(video, "rb") as f:
640+
video_data = f.read()
641+
else:
642+
video_data = video
643+
files.append(
644+
(
645+
"video",
646+
("video", video_data, "application/octet-stream"),
647+
)
648+
)
634649
response = self.session.post(url, files=files, headers=self.auth_headers)
635650
if response.status_code != 200:
636651
raise RuntimeError(

xinference/client/tests/test_async_client.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from ..restful.async_restful_client import (
2424
AsyncRESTfulChatModelHandle,
2525
AsyncRESTfulEmbeddingModelHandle,
26+
AsyncRESTfulVideoModelHandle,
2627
)
2728

2829

@@ -42,9 +43,11 @@ async def wait_for_close(self):
4243
class _DummyAsyncSession:
4344
def __init__(self):
4445
self.last_json = None
46+
self.last_data = None
4547

46-
async def post(self, url, json=None, headers=None):
48+
async def post(self, url, json=None, headers=None, data=None):
4749
self.last_json = json
50+
self.last_data = data
4851
return _DummyAsyncResponse()
4952

5053
async def close(self):
@@ -713,6 +716,24 @@ async def test_async_restful_chat_enable_thinking_injected():
713716
handle.session = None
714717

715718

719+
@pytest.mark.asyncio
720+
async def test_async_restful_video_reads_video_path(tmp_path):
721+
video_path = tmp_path / "driving.mp4"
722+
video_path.write_bytes(b"video-content")
723+
handle = AsyncRESTfulVideoModelHandle.__new__(AsyncRESTfulVideoModelHandle)
724+
handle._model_uid = "test-model"
725+
handle._base_url = "http://localhost"
726+
handle.auth_headers = {}
727+
dummy_session = _DummyAsyncSession()
728+
handle.session = dummy_session
729+
730+
await handle.image_to_video(b"image-content", "prompt", video=str(video_path))
731+
732+
video_file = next(item for item in dummy_session.last_data if item[0] == "video")
733+
assert video_file[1][1] == b"video-content"
734+
handle.session = None
735+
736+
716737
@pytest.mark.asyncio
717738
async def test_add_model_replica_sends_empty_json_body():
718739
client = AsyncRESTfulClient.__new__(AsyncRESTfulClient)

xinference/client/tests/test_client.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from ..restful.restful_client import (
2727
RESTfulChatModelHandle,
2828
RESTfulEmbeddingModelHandle,
29+
RESTfulVideoModelHandle,
2930
_get_error_string,
3031
)
3132

@@ -40,9 +41,11 @@ def json(self):
4041
class _DummySession:
4142
def __init__(self):
4243
self.last_json = None
44+
self.last_files = None
4345

44-
def post(self, url, json=None, stream=None, headers=None):
46+
def post(self, url, json=None, stream=None, headers=None, files=None):
4547
self.last_json = json
48+
self.last_files = files
4649
return _DummyResponse()
4750

4851
def close(self):
@@ -638,6 +641,20 @@ def test_restful_chat_enable_thinking_injected():
638641
handle.session = None
639642

640643

644+
def test_restful_video_reads_video_path(tmp_path):
645+
video_path = tmp_path / "driving.mp4"
646+
video_path.write_bytes(b"video-content")
647+
handle = RESTfulVideoModelHandle("test-model", "http://localhost", {})
648+
dummy_session = _DummySession()
649+
handle.session = dummy_session
650+
651+
handle.image_to_video(b"image-content", "prompt", video=str(video_path))
652+
653+
video_file = next(item for item in dummy_session.last_files if item[0] == "video")
654+
assert video_file[1][1] == b"video-content"
655+
handle.session = None
656+
657+
641658
def test_add_model_replica_sends_empty_json_body():
642659
client = RESTfulClient.__new__(RESTfulClient)
643660
client.base_url = "http://localhost:9997"

xinference/model/video/diffusers.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import logging
1919
import operator
2020
import os
21+
import tempfile
2122
import time
2223
import uuid
2324
from concurrent.futures import ThreadPoolExecutor
@@ -156,6 +157,12 @@ def load(self):
156157
pipeline = self._model = HunyuanVideoPipeline.from_pretrained(
157158
self._model_path, transformer=transformer, **kwargs
158159
)
160+
elif self.model_spec.model_family == "WanAnimate2":
161+
from diffusers import WanAnimate2Pipeline
162+
163+
pipeline = self._model = WanAnimate2Pipeline.from_pretrained(
164+
self._model_path, **kwargs
165+
)
159166
elif self.model_spec.model_family == "Wan":
160167
from diffusers import AutoencoderKLWan, WanImageToVideoPipeline, WanPipeline
161168
from transformers import CLIPVisionModel
@@ -312,9 +319,50 @@ def image_to_video(
312319
assert callable(self._model)
313320
generate_kwargs = self._model_spec.default_generate_config.copy()
314321
generate_kwargs.update(kwargs)
315-
generate_kwargs["num_videos_per_prompt"] = n
316322
if num_inference_steps:
317323
generate_kwargs["num_inference_steps"] = num_inference_steps
324+
325+
if self.model_spec.model_family == "WanAnimate2":
326+
if n != 1:
327+
raise ValueError(
328+
"Wan-Animate-2 only supports generating one video per request"
329+
)
330+
331+
video = generate_kwargs.pop("video", None)
332+
if video is None:
333+
raise ValueError("`video` is required for Wan-Animate-2")
334+
335+
fps = generate_kwargs.get("fps", 24)
336+
temp_video_path = None
337+
output: Any
338+
try:
339+
if isinstance(video, bytes):
340+
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as f:
341+
temp_video_path = f.name
342+
f.write(video)
343+
video = temp_video_path
344+
elif isinstance(video, str):
345+
if not os.path.isfile(video):
346+
raise FileNotFoundError(
347+
f"Video path does not exist or is not a file: {video}"
348+
)
349+
else:
350+
raise TypeError("`video` must be video bytes or a local path")
351+
352+
self._process_progressor(generate_kwargs)
353+
output = self._model(
354+
image=image,
355+
driving_video=video,
356+
prompt=prompt,
357+
**generate_kwargs,
358+
)
359+
finally:
360+
if temp_video_path and os.path.exists(temp_video_path):
361+
os.remove(temp_video_path)
362+
363+
return self._output_to_video(output, fps, response_format)
364+
365+
generate_kwargs["num_videos_per_prompt"] = n
318366
fps = generate_kwargs.pop("fps", 10)
319367

320368
# process image

xinference/model/video/model_spec.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,5 +418,97 @@
418418
},
419419
"updated_at": 1766385310,
420420
"featured": false
421+
},
422+
{
423+
"version": 2,
424+
"model_name": "Wan2.2-Animate-2-14B",
425+
"model_family": "WanAnimate2",
426+
"model_ability": [
427+
"image2video"
428+
],
429+
"default_model_config": {
430+
"torch_dtype": "bfloat16"
431+
},
432+
"default_generate_config": {
433+
"width": 640,
434+
"height": 800,
435+
"fps": 24,
436+
"num_inference_steps": 40,
437+
"guidance_scale": 3.0,
438+
"flow_solver": "dpm"
439+
},
440+
"virtualenv": {
441+
"packages": [
442+
"diffusers @ git+https://github.com/huggingface/diffusers.git@refs/pull/14412/head",
443+
"transformers>=4.57.6",
444+
"accelerate>=1.13.0",
445+
"flash-attn",
446+
"decord==0.6.0",
447+
"opencv-python",
448+
"ftfy",
449+
"imageio-ffmpeg",
450+
"imageio",
451+
"#system_numpy#"
452+
],
453+
"no_build_isolation": true
454+
},
455+
"model_src": {
456+
"huggingface": {
457+
"model_id": "Wan-AI/Wan2.2-Animate-2-14B-Diffusers",
458+
"model_revision": "main"
459+
},
460+
"modelscope": {
461+
"model_id": "Wan-AI/Wan2.2-Animate-2-14B-Diffusers",
462+
"model_revision": "master"
463+
}
464+
},
465+
"updated_at": 1786492826,
466+
"featured": false
467+
},
468+
{
469+
"version": 2,
470+
"model_name": "Wan2.2-Animate-2-14B-Distilled",
471+
"model_family": "WanAnimate2",
472+
"model_ability": [
473+
"image2video"
474+
],
475+
"default_model_config": {
476+
"torch_dtype": "bfloat16"
477+
},
478+
"default_generate_config": {
479+
"width": 640,
480+
"height": 800,
481+
"fps": 24,
482+
"num_inference_steps": 10,
483+
"guidance_scale": 1.0,
484+
"flow_solver": "euler"
485+
},
486+
"virtualenv": {
487+
"packages": [
488+
"diffusers @ git+https://github.com/huggingface/diffusers.git@refs/pull/14412/head",
489+
"transformers>=4.57.6",
490+
"accelerate>=1.13.0",
491+
"flash-attn",
492+
"decord==0.6.0",
493+
"opencv-python",
494+
"ftfy",
495+
"imageio-ffmpeg",
496+
"imageio",
497+
"#system_numpy#"
498+
],
499+
"no_build_isolation": true
500+
},
501+
"model_src": {
502+
"huggingface": {
503+
"model_id": "Wan-AI/Wan2.2-Animate-2-14B-Distilled-Diffusers",
504+
"model_revision": "main"
505+
},
506+
"modelscope": {
507+
"model_id": "Wan-AI/Wan2.2-Animate-2-14B-Distilled-Diffusers",
508+
"model_revision": "master"
509+
}
510+
},
511+
"updated_at": 1786492826,
512+
"featured": false
421513
}
422514
]

0 commit comments

Comments
 (0)