feat(新功能): 新增wan2.2 pose-transform模型接口,comfyui-api形式

fix(修复bug):
docs(文档变更):
refactor(重构):
test(增加测试):
This commit is contained in:
zhh
2025-11-03 11:52:39 +08:00
parent 385ff2d4aa
commit 7459583377
3 changed files with 755 additions and 0 deletions

View File

@@ -1,10 +1,13 @@
import json
import logging
import requests
from fastapi import APIRouter, BackgroundTasks, HTTPException
from app.core.config import COMFYUI_SERVER_ADDRESS
from app.schemas.pose_transform import PoseTransformModel
from app.schemas.response_template import ResponseModel
from app.service.comfyui_I2V.server import ComfyUIServerI2V
from app.service.generate_image.service_pose_transform import PoseTransformService, infer_cancel as pose_transform_infer_cancel
router = APIRouter()
@@ -47,3 +50,49 @@ def pose_transform_cancel(tasks_id: str):
logger.warning(f"pose_transform_cancel Run Exception @@@@@@:{e}")
raise HTTPException(status_code=404, detail=str(e))
return ResponseModel(data=data['data'])
@router.post("/comfyui_pose_transform")
def comfyui_pose_transform(request_item: PoseTransformModel, background_tasks: BackgroundTasks):
"""
创建一个具有以下参数的请求体:
- **tasks_id**: 任务id 用于取消生成任务和获取生成结果
- **image_url**: 被生成图片的S3或minio url地址
- **pose_id**: 1
示例参数:
{
"tasks_id": "123-89",
"image_url": "aida-results/result_0000b606-1902-11ef-9424-0242ac180002.png",
"pose_id": "1"
}
"""
try:
logger.info(f"pose_transform request item is : @@@@@@:{json.dumps(request_item.dict())}")
service = ComfyUIServerI2V(request_item)
background_tasks.add_task(service.get_result)
except Exception as e:
logger.warning(f"pose_transform Run Exception @@@@@@:{e}")
raise HTTPException(status_code=404, detail=str(e))
return ResponseModel()
@router.get("/comfyui_pose_transform_cancel/{tasks_id}")
def pose_transform_cancel(tasks_id: str):
try:
logger.info(f"pose_transform_cancel request item is : @@@@@@:{tasks_id}")
response = requests.post(
f"http://{COMFYUI_SERVER_ADDRESS}/interrupt",
json={"prompt_id": tasks_id}
)
data = {}
if response.status_code == 200:
data['data']['message'] = "任务已成功中断"
else:
data['data']['message'] = f"中断失败:{response.text}"
logger.info(f"pose_transform_cancel response @@@@@@:{data}")
except Exception as e:
logger.warning(f"pose_transform_cancel Run Exception @@@@@@:{e}")
raise HTTPException(status_code=404, detail=str(e))
return ResponseModel(data=data['data'])

View File

@@ -230,3 +230,6 @@ TABLE_CATEGORIES = {
"male_bottoms": "male/bottoms",
"male_outwear": "male/outwear"
}
# --- ComfyUI 配置信息 ---
COMFYUI_SERVER_ADDRESS = "10.1.2.227:8080" # 替换为您的 ComfyUI 服务器地址

View File

@@ -0,0 +1,703 @@
import io
import json
import logging
import os
import random
import tempfile
import time
import uuid
import redis
import requests
from PIL import Image
from minio import Minio, S3Error
from moviepy.video.io.VideoFileClip import VideoFileClip
from app.core.config import REDIS_HOST, REDIS_PORT, REDIS_DB, MINIO_URL, MINIO_ACCESS, MINIO_SECRET, MINIO_SECURE, COMFYUI_SERVER_ADDRESS, PS_RABBITMQ_QUEUES, DEBUG
from app.schemas.pose_transform import PoseTransformModel
logger = logging.getLogger()
workflow_json = {
"162": {
"inputs": {
"text": "色调艳丽过曝静态细节模糊不清字幕风格作品画作画面静止整体发灰最差质量低质量JPEG压缩残留丑陋的残缺的多余的手指画得不好的手部画得不好的脸部畸形的毁容的形态畸形的肢体手指融合静止不动的画面杂乱的背景三条腿背景人很多倒着走",
"clip": [
"167",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"163": {
"inputs": {
"fps": 24,
"images": [
"164",
0
]
},
"class_type": "CreateVideo",
"_meta": {
"title": "创建视频"
}
},
"164": {
"inputs": {
"samples": [
"175",
0
],
"vae": [
"168",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE解码"
}
},
"165": {
"inputs": {
"unet_name": "wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"166": {
"inputs": {
"unet_name": "wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "UNet加载器"
}
},
"167": {
"inputs": {
"clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
"type": "wan",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "加载CLIP"
}
},
"168": {
"inputs": {
"vae_name": "wan_2.1_vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "加载VAE"
}
},
"169": {
"inputs": {
"add_noise": "enable",
"noise_seed": 8860422635573,
"steps": 4,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"start_at_step": 0,
"end_at_step": 2,
"return_with_leftover_noise": "enable",
"model": [
"176",
0
],
"positive": [
"180",
0
],
"negative": [
"180",
1
],
"latent_image": [
"180",
2
]
},
"class_type": "KSamplerAdvanced",
"_meta": {
"title": "K采样器高级"
}
},
"170": {
"inputs": {
"filename_prefix": "video/wan2.2_fun_control",
"format": "auto",
"codec": "auto",
"video-preview": "",
"video": [
"163",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "保存视频"
}
},
"171": {
"inputs": {
"video": [
"174",
0
]
},
"class_type": "GetVideoComponents",
"_meta": {
"title": "获取视频组件"
}
},
"174": {
"inputs": {
"file": "skeleton_3.mp4"
},
"class_type": "LoadVideo",
"_meta": {
"title": "加载视频"
}
},
"175": {
"inputs": {
"add_noise": "disable",
"noise_seed": 0,
"steps": 4,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"start_at_step": 2,
"end_at_step": 4,
"return_with_leftover_noise": "disable",
"model": [
"177",
0
],
"positive": [
"180",
0
],
"negative": [
"180",
1
],
"latent_image": [
"169",
0
]
},
"class_type": "KSamplerAdvanced",
"_meta": {
"title": "K采样器高级"
}
},
"176": {
"inputs": {
"shift": 8.000000000000002,
"model": [
"181",
0
]
},
"class_type": "ModelSamplingSD3",
"_meta": {
"title": "采样算法SD3"
}
},
"177": {
"inputs": {
"shift": 8.000000000000002,
"model": [
"182",
0
]
},
"class_type": "ModelSamplingSD3",
"_meta": {
"title": "采样算法SD3"
}
},
"178": {
"inputs": {
"image": "296f5fd6-c5e4-4003-9798-f378a4f08411-0-89.png"
},
"class_type": "LoadImage",
"_meta": {
"title": "加载图像"
}
},
"179": {
"inputs": {
"text": "On a sunny summer day, there are marshmallow - like clouds, and the sunlight is bright and warm. A girl with white curly double - ponytails is wearing unique sunglasses, distinctive clothes and shoes. Her posture is natural and full of dynamic tension. The background is the scene of the Leaning Tower of Pisa in Italy, emphasizing the realistic contrast of details in reality. The whole picture is in a realistic 3D style, rich in details and with a relaxed atmosphere. She is dancing slowly, waving her hands.",
"clip": [
"167",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"180": {
"inputs": {
"width": 512,
"height": 768,
"length": 121,
"batch_size": 1,
"positive": [
"179",
0
],
"negative": [
"162",
0
],
"vae": [
"168",
0
],
"ref_image": [
"178",
0
],
"control_video": [
"171",
0
]
},
"class_type": "Wan22FunControlToVideo",
"_meta": {
"title": "Wan22FunControlToVideo"
}
},
"181": {
"inputs": {
"lora_name": "wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
"strength_model": 1,
"model": [
"165",
0
]
},
"class_type": "LoraLoaderModelOnly",
"_meta": {
"title": "LoRA加载器仅模型"
}
},
"182": {
"inputs": {
"lora_name": "wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
"strength_model": 1,
"model": [
"166",
0
]
},
"class_type": "LoraLoaderModelOnly",
"_meta": {
"title": "LoRA加载器仅模型"
}
}
}
pose_video_map = {
"1": "input_pose_video/1.mp4",
"2": "input_pose_video/2.mp4",
"3": "input_pose_video/3.mp4",
"4": "input_pose_video/4.mp4",
"5": "input_pose_video/5.mp4",
"6": "input_pose_video/6.mp4"
}
class ComfyUIServerI2V:
def __init__(self, request_data):
self.image_url = request_data.image_url
self.pose_num = request_data.pose_id
self.tasks_id = request_data.tasks_id
self.user_id = self.tasks_id[self.tasks_id.rfind('-') + 1:]
self.redis_client = redis.StrictRedis(host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True)
self.pose_transform_data = {'tasks_id': self.tasks_id, 'status': 'PENDING', 'message': "pending", 'gif_url': '',
'video_url': '', 'image_url': ''}
self.redis_client.set(self.tasks_id, json.dumps(self.pose_transform_data))
self.redis_client.expire(self.tasks_id, 600)
self.minio_client = Minio(MINIO_URL, access_key=MINIO_ACCESS, secret_key=MINIO_SECRET, secure=MINIO_SECURE)
def get_result(self):
logger.info("11111111111111")
workflow_json['174']['inputs']['file'] = pose_video_map[self.pose_num ]
workflow_json['169']['inputs']['noise_seed'] = random.randint(0, 10 ** 18)
# 下载图片 上传 comfyui server
in_memory_file, object_name = self.download_from_minio_in_memory()
if in_memory_file and object_name:
uploaded_filename = self.upload_in_memory_file_to_comfyui(in_memory_file, object_name)
# 1. 提交任务
prompt_response = self.queue_prompt(workflow_json, self.tasks_id)
if not prompt_response:
return
prompt_id = prompt_response.get("prompt_id")
print(f" 任务已提交Prompt ID: {prompt_id}")
outputs = self.poll_history(prompt_id)
file_list = {}
for node_id, node_output in outputs.items():
# 检查当前节点输出中是否包含 'images' 列表
if 'images' in node_output and isinstance(node_output['images'], list):
# 'images' 列表中的每个元素都是一个文件对象
for file_info in node_output['images']:
# 确保关键字段存在
if all(key in file_info for key in ['filename', 'subfolder', 'type']):
file_list = {
'filename': file_info['filename'],
'subfolder': file_info['subfolder'],
'type': file_info['type']
}
print(file_list)
return self.process_and_upload_comfyui_video(filename=file_list['filename'], subfolder=file_list['subfolder'], prompt_id=prompt_response['prompt_id']), prompt_id
def read_tasks_status(self):
status_data = self.redis_client.get(self.tasks_id)
return json.loads(status_data), status_data
def download_from_minio_in_memory(self):
bucket = self.image_url.split('/')[0]
object_name = self.image_url[self.image_url.find('/') + 1:]
print("🚀 正在连接 MinIO 客户端...")
try:
# get_object 返回一个 ResponseStream 对象
response_stream = self.minio_client.get_object(
bucket,
object_name,
)
# 读取整个流到内存 (BytesIO),避免写入本地文件
image_bytes = response_stream.read()
response_stream.close()
response_stream.release_conn()
in_memory_file = io.BytesIO(image_bytes)
print(f"✅ 图片已下载到内存 ({len(image_bytes)} 字节)。")
return in_memory_file, object_name.rsplit('/')[-1]
except S3Error as e:
print(f"❌ MinIO S3 错误 (例如,对象不存在): {e}")
return None, None
except Exception as e:
print(f"❌ MinIO 下载过程中发生未知错误: {e}")
return None, None
def upload_video_to_minio(self, BUCKET_NAME, OBJECT_NAME, LOCAL_FILE_PATH):
"""使用 fput_object 从本地路径上传 MP4 文件"""
try:
# 使用 fput_object 上传文件
# content_type 对于视频流播放非常重要MP4 文件应使用 'video/mp4'
result = self.minio_client.fput_object(
bucket_name=BUCKET_NAME,
object_name=OBJECT_NAME,
file_path=LOCAL_FILE_PATH,
content_type="video/mp4" # 设置正确的内容类型
)
print(f"✅ 文件 '{LOCAL_FILE_PATH}' 已成功上传至 MinIO:")
print(f" 对象名: {result.object_name}")
print(f" Etag: {result.etag}")
except S3Error as e:
print(f"❌ MinIO 操作失败: {e}")
except FileNotFoundError:
print(f"❌ 找不到本地文件: {LOCAL_FILE_PATH}")
except Exception as e:
print(f"❌ 发生未知错误: {e}")
def upload_gif_to_minio(self, BUCKET_NAME, OBJECT_NAME, LOCAL_FILE_PATH):
"""使用 fput_object 从本地路径上传 MP4 文件"""
try:
# 使用 fput_object 上传文件
# content_type 对于视频流播放非常重要MP4 文件应使用 'video/mp4'
result = self.minio_client.fput_object(
bucket_name=BUCKET_NAME,
object_name=OBJECT_NAME,
file_path=LOCAL_FILE_PATH,
content_type="video/mp4" # 设置正确的内容类型
)
print(f"✅ 文件 '{LOCAL_FILE_PATH}' 已成功上传至 MinIO:")
print(f" 对象名: {result.object_name}")
print(f" Etag: {result.etag}")
except S3Error as e:
print(f"❌ MinIO 操作失败: {e}")
except FileNotFoundError:
print(f"❌ 找不到本地文件: {LOCAL_FILE_PATH}")
except Exception as e:
print(f"❌ 发生未知错误: {e}")
def upload_in_memory_file_to_comfyui(self, in_memory_file, filename):
upload_url = f"http://{COMFYUI_SERVER_ADDRESS}/upload/image"
data = {
"overwrite": "true",
"type": "input"
}
# 构建 multipart/form-data: (文件名, 内存文件对象, MIME 类型)
# MIME 类型可以根据实际图片类型修改,这里使用常见的 png/jpeg
mime_type = 'image/png' if filename.lower().endswith('.png') else 'image/jpeg'
files = {
'image': (filename, in_memory_file, mime_type)
}
print(f"⬆️ 正在上传图片 ({filename}) 到 ComfyUI...")
try:
comfyui_response = requests.post(upload_url, data=data, files=files)
comfyui_response.raise_for_status()
result = comfyui_response.json()
uploaded_name = result.get('name')
print(f"🎉 ComfyUI 上传成功! 服务器文件名: {uploaded_name}")
return uploaded_name
except requests.exceptions.RequestException as e:
print(f"❌ ComfyUI 上传失败: {e}")
print(f" 响应内容: {comfyui_response.text}")
return None
def process_and_upload_comfyui_video(
self,
filename: str,
subfolder: str,
prompt_id: str,
):
"""
完整的自动化流程:获取 ComfyUI 视频 -> 转换 GIF 并提取帧 -> 上传所有结果到 MinIO。
"""
# 1. 从 ComfyUI 获取视频二进制数据
mp4_bytes = self.get_comfyui_video_bytes(filename, subfolder)
if not mp4_bytes:
return
# 2. 准备进行视频处理
# moviepy 不支持直接使用 bytes需要将 bytes 写入一个 BytesIO 或临时文件
# 为了避免写磁盘,我们将使用 BytesIO但 MoviePy 内部依赖 FFmpeg有时需要一个可寻址的本地文件路径。
# 最可靠且避免写本地的方案是在内存中操作,然后将结果上传。
# ⚠️ 关键点:将 mp4_bytes 写入 BytesIO 以模拟文件,供 moviepy 读取
# 定义输出对象名
output_base_name = uuid.uuid4().hex
MP4_OBJECT = f"{self.user_id}/pose_transform_video/{prompt_id}/{output_base_name}.mp4"
GIF_OBJECT = f"{self.user_id}/pose_transform_gif/{prompt_id}/{output_base_name}.gif"
FRAME_OBJECT = f"{self.user_id}/pose_transform_first_img/{prompt_id}/{output_base_name}_frame.jpg"
# --- 视频处理和帧提取 ---
try:
# 1. 创建一个临时的 MP4 文件路径
# delete=False 确保文件在关闭后仍然存在,直到我们手动删除
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp_file:
tmp_file.write(mp4_bytes) # 将内存数据写入磁盘
temp_mp4_path = tmp_file.name # 记录文件路径
print(f"临时文件已写入: {temp_mp4_path}")
# 2. 使用 moviepy 打开临时文件 (传入文件路径字符串)
clip = VideoFileClip(temp_mp4_path)
# --- 在这里进行所有的视频处理和提取操作 ---
# 提取第一帧 (保持原尺寸)
frame_array = clip.get_frame(t=0.0)
image = Image.fromarray(frame_array)
frame_stream = io.BytesIO()
image.save(frame_stream, 'JPEG')
frame_bytes = frame_stream.getvalue()
print("✅ 成功提取第一帧图片。")
# 视频转 GIF (使用另一个临时文件来保存 GIF)
temp_gif_path = ""
with tempfile.NamedTemporaryFile(suffix=".gif", delete=False) as tmp_file:
temp_gif_path = tmp_file.name
target_fps = int(round(clip.fps)) if clip.fps else 24
clip.write_gif(temp_gif_path, fps=target_fps)
with open(temp_gif_path, 'rb') as f:
gif_bytes = f.read()
print("✅ 成功生成 GIF。")
# 返回结果 (例如: 上传到 MinIO)
# return mp4_bytes, gif_bytes, frame_bytes
# -----------------------------------------------
except Exception as e:
print(f"❌ 视频处理或文件操作失败: {e}")
# 在失败时,也尝试清理文件
finally:
# 3. 清理临时文件 (非常重要!)
if os.path.exists(temp_mp4_path):
os.remove(temp_mp4_path)
print(f"🗑️ 已删除临时 MP4 文件: {temp_mp4_path}")
if 'temp_gif_path' in locals() and os.path.exists(temp_gif_path):
os.remove(temp_gif_path)
print(f"🗑️ 已删除临时 GIF 文件: {temp_gif_path}")
# 3. 上传所有结果到 MinIO
# 上传原始 MP4
self.upload_stream_to_minio(mp4_bytes, MP4_OBJECT, "video/mp4")
# 上传生成的 GIF
self.upload_stream_to_minio(gif_bytes, GIF_OBJECT, "image/gif")
# 上传第一帧图片
self.upload_stream_to_minio(frame_bytes, FRAME_OBJECT, "image/jpeg")
return "\n🎉 所有任务完成!"
# --- 辅助函数:提交任务到队列 ---
def queue_prompt(self, prompt, client_id):
"""向 ComfyUI 提交工作流提示。"""
p = {"prompt": prompt, "client_id": client_id, "prompt_id": client_id}
data = json.dumps(p).encode('utf-8')
# 提交任务到 /prompt 端点
response = requests.post(f"http://{COMFYUI_SERVER_ADDRESS}/prompt", data=data)
print(f"-------------{response.text}")
print(f"------------{client_id}")
if response.status_code == 200:
return response.json()
else:
print(f"提交任务失败,状态码: {response.status_code}")
print(response.text)
return None
def get_history(self, prompt_id):
"""通过 prompt_id 获取任务的历史记录和输出。"""
# 查询 /history/{prompt_id} 端点
response = requests.get(f"http://{COMFYUI_SERVER_ADDRESS}/history/{prompt_id}")
if response.status_code == 200:
return response.json()
else:
print(f"获取历史记录失败,状态码: {response.status_code}")
return None
def poll_history(self, prompt_id, interval_seconds=5):
"""步骤 2: 轮询 /history/{prompt_id} 检查任务是否完成"""
url = f"http://{COMFYUI_SERVER_ADDRESS}/history/{prompt_id}"
print(f"⏳ 开始轮询状态 (间隔 {interval_seconds} 秒)...")
while True:
time.sleep(interval_seconds)
try:
response = requests.get(url)
# 任务未完成时ComfyUI可能会返回404或空响应我们只关注成功响应
if response.status_code == 200:
history_data = response.json()
# ComfyUI 返回的历史记录结构是 {prompt_id: {outputs: ...}}
if prompt_id in history_data:
print("🎉 任务已完成!")
return history_data[prompt_id]['outputs']
print("⏳ 任务仍在执行或等待中...")
except requests.exceptions.RequestException as e:
# 处理可能的连接错误,但通常不会在内部轮询中发生
print(f"⚠️ 轮询时发生错误: {e}")
pass
def get_comfyui_video_bytes(self, filename: str, subfolder: str, file_type: str = "output"):
"""
从 ComfyUI 的 /view 端点获取视频文件的二进制数据。
参数:
- filename: 视频文件名 (例如: 'ComfyUI_00002_.mp4')
- subfolder: 存储子文件夹 (例如: 'ComfyUI_2025-10-31')
- file_type: 文件类型 (通常是 'output')
返回:
- 视频文件的二进制内容 (bytes) 或 None。
"""
url = f"http://{COMFYUI_SERVER_ADDRESS}/view"
params = {
"filename": filename,
"subfolder": subfolder,
"type": file_type
}
print(f"📡 正在从 ComfyUI 下载视频: {filename}")
try:
# 使用 requests.get 下载文件
response = requests.get(url, params=params, stream=True)
response.raise_for_status() # 检查 HTTP 错误
# 返回文件的完整二进制内容
return response.content
except requests.exceptions.RequestException as e:
print(f"❌ 从 ComfyUI 获取视频失败: {e}")
return None
def upload_stream_to_minio(self, video_bytes: bytes, object_name: str, content_type: str):
"""从内存流上传数据到 MinIO。"""
print(f"☁️ 正在上传对象到 MinIO: {object_name}")
try:
data_stream = io.BytesIO(video_bytes)
result = self.minio_client.put_object(
bucket_name='aida-users',
object_name=object_name,
data=data_stream,
length=len(video_bytes),
content_type=content_type
)
print(f"✅ MinIO 上传成功: {result.object_name}")
return True
except S3Error as e:
print(f"❌ MinIO 上传失败: {e}")
return False
if __name__ == '__main__':
request_data = PoseTransformModel(
tasks_id="1515151123-89111",
image_url="aida-results/result_0000b606-1902-11ef-9424-0242ac180002.png",
pose_id="6"
)
server = ComfyUIServerI2V(request_data)
print(server.get_result())