2025-11-03 11:52:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-02 15:37:01 +08:00
|
|
|
|
from app.core.config import settings, PS_RABBITMQ_QUEUES
|
2025-11-05 17:08:40 +08:00
|
|
|
|
from app.schemas.comfyui_i2v import ComfyuiPose2VModel
|
2025-11-03 16:37:41 +08:00
|
|
|
|
from app.service.generate_image.utils.mq import publish_status
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger()
|
|
|
|
|
|
|
2025-11-05 17:08:40 +08:00
|
|
|
|
# 图 + 骨架 = 视频 工作流
|
2025-11-03 11:52:39 +08:00
|
|
|
|
workflow_json = {
|
|
|
|
|
|
"162": {
|
|
|
|
|
|
"inputs": {
|
|
|
|
|
|
"text": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走",
|
|
|
|
|
|
"clip": [
|
|
|
|
|
|
"167",
|
|
|
|
|
|
0
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"class_type": "CLIPTextEncode",
|
|
|
|
|
|
"_meta": {
|
|
|
|
|
|
"title": "CLIP Text Encode (Negative Prompt)"
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"163": {
|
|
|
|
|
|
"inputs": {
|
2025-11-07 17:44:19 +08:00
|
|
|
|
"fps": 24,
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"images": [
|
2025-11-07 17:44:19 +08:00
|
|
|
|
"192",
|
2025-11-03 11:52:39 +08:00
|
|
|
|
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": {
|
2025-11-06 11:59:04 +08:00
|
|
|
|
"text": "The model is catwalking at the fashion show.",
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"clip": [
|
|
|
|
|
|
"167",
|
|
|
|
|
|
0
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"class_type": "CLIPTextEncode",
|
|
|
|
|
|
"_meta": {
|
|
|
|
|
|
"title": "CLIP Text Encode (Positive Prompt)"
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"180": {
|
|
|
|
|
|
"inputs": {
|
2025-11-07 17:44:19 +08:00
|
|
|
|
"width": 480,
|
|
|
|
|
|
"height": 720,
|
|
|
|
|
|
"length": 121,
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"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加载器(仅模型)"
|
|
|
|
|
|
}
|
2025-11-07 17:44:19 +08:00
|
|
|
|
},
|
|
|
|
|
|
"189": {
|
|
|
|
|
|
"inputs": {
|
|
|
|
|
|
"images": [
|
|
|
|
|
|
"171",
|
|
|
|
|
|
0
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"class_type": "PreviewImage",
|
|
|
|
|
|
"_meta": {
|
|
|
|
|
|
"title": "预览图像"
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"190": {
|
|
|
|
|
|
"inputs": {
|
|
|
|
|
|
"images": [
|
|
|
|
|
|
"192",
|
|
|
|
|
|
0
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"class_type": "PreviewImage",
|
|
|
|
|
|
"_meta": {
|
|
|
|
|
|
"title": "预览图像"
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
"192": {
|
|
|
|
|
|
"inputs": {
|
|
|
|
|
|
"batch_index": 4,
|
|
|
|
|
|
"length": 117,
|
|
|
|
|
|
"image": [
|
|
|
|
|
|
"164",
|
|
|
|
|
|
0
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
"class_type": "ImageFromBatch",
|
|
|
|
|
|
"_meta": {
|
|
|
|
|
|
"title": "从批次获取图像"
|
|
|
|
|
|
}
|
2025-11-03 11:52:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-07 17:44:19 +08:00
|
|
|
|
|
2025-11-05 17:08:40 +08:00
|
|
|
|
# 骨架映射
|
|
|
|
|
|
video_map = {
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"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"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-11-05 17:08:40 +08:00
|
|
|
|
class ComfyUIServerPose2V:
|
2025-11-03 11:52:39 +08:00
|
|
|
|
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:]
|
2025-12-30 16:49:08 +08:00
|
|
|
|
self.redis_client = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB, decode_responses=True)
|
2025-11-03 16:37:41 +08:00
|
|
|
|
self.pose_transform_data = {'tasks_id': self.tasks_id, 'status': 'PENDING', 'message': "pending", 'gif_url': '', 'video_url': '', 'image_url': ''}
|
2025-11-03 11:52:39 +08:00
|
|
|
|
self.redis_client.set(self.tasks_id, json.dumps(self.pose_transform_data))
|
|
|
|
|
|
self.redis_client.expire(self.tasks_id, 600)
|
2025-12-30 16:49:08 +08:00
|
|
|
|
self.minio_client = Minio(settings.MINIO_URL, access_key=settings.MINIO_ACCESS, secret_key=settings.MINIO_SECRET, secure=settings.MINIO_SECURE)
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
def get_result(self):
|
2025-11-05 17:08:40 +08:00
|
|
|
|
workflow_json['174']['inputs']['file'] = video_map[self.pose_num]
|
2025-11-03 11:52:39 +08:00
|
|
|
|
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)
|
2025-11-03 17:37:33 +08:00
|
|
|
|
workflow_json['178']['inputs']['image'] = uploaded_filename
|
2025-11-03 11:52:39 +08:00
|
|
|
|
# 1. 提交任务
|
|
|
|
|
|
prompt_response = self.queue_prompt(workflow_json, self.tasks_id)
|
|
|
|
|
|
if not prompt_response:
|
2025-12-30 16:49:08 +08:00
|
|
|
|
return None
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
prompt_id = prompt_response.get("prompt_id")
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f" 任务已提交,Prompt ID: {prompt_id}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
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']
|
|
|
|
|
|
}
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(file_list)
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return self.process_and_upload_comfyui_video(filename=file_list['filename'], subfolder=file_list['subfolder'], prompt_id=prompt_response['prompt_id']), prompt_id
|
2025-12-30 16:49:08 +08:00
|
|
|
|
return None
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
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:]
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print("🚀 正在连接 MinIO 客户端...")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print(f"✅ 图片已下载到内存 ({len(image_bytes)} 字节)。")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return in_memory_file, object_name.rsplit('/')[-1]
|
|
|
|
|
|
|
|
|
|
|
|
except S3Error as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ MinIO S3 错误 (例如,对象不存在): {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return None, None
|
|
|
|
|
|
except Exception as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ MinIO 下载过程中发生未知错误: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
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" # 设置正确的内容类型
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print(f"✅ 文件 '{LOCAL_FILE_PATH}' 已成功上传至 MinIO:")
|
|
|
|
|
|
# print(f" 对象名: {result.object_name}")
|
|
|
|
|
|
# print(f" Etag: {result.etag}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
except S3Error as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ MinIO 操作失败: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
except FileNotFoundError:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ 找不到本地文件: {LOCAL_FILE_PATH}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
except Exception as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ 发生未知错误: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
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" # 设置正确的内容类型
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print(f"✅ 文件 '{LOCAL_FILE_PATH}' 已成功上传至 MinIO:")
|
|
|
|
|
|
# print(f" 对象名: {result.object_name}")
|
|
|
|
|
|
# print(f" Etag: {result.etag}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
except S3Error as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ MinIO 操作失败: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
except FileNotFoundError:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ 找不到本地文件: {LOCAL_FILE_PATH}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
except Exception as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ 发生未知错误: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
2025-12-30 16:49:08 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def upload_in_memory_file_to_comfyui(in_memory_file, filename):
|
|
|
|
|
|
upload_url = f"http://{settings.COMFYUI_SERVER_ADDRESS}/upload/image"
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print(f"⬆️ 正在上传图片 ({filename}) 到 ComfyUI...")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
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')
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print(f"🎉 ComfyUI 上传成功! 服务器文件名: {uploaded_name}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return uploaded_name
|
|
|
|
|
|
|
|
|
|
|
|
except requests.exceptions.RequestException as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ ComfyUI 上传失败: {e}")
|
|
|
|
|
|
logger.error(f" 响应内容: {comfyui_response.text}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
def process_and_upload_comfyui_video(self, filename: str, subfolder: str, prompt_id: str, ):
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"""
|
|
|
|
|
|
完整的自动化流程:获取 ComfyUI 视频 -> 转换 GIF 并提取帧 -> 上传所有结果到 MinIO。
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 1. 从 ComfyUI 获取视频二进制数据
|
|
|
|
|
|
mp4_bytes = self.get_comfyui_video_bytes(filename, subfolder)
|
|
|
|
|
|
if not mp4_bytes:
|
2025-12-30 16:49:08 +08:00
|
|
|
|
return None
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 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 # 记录文件路径
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print(f"临时文件已写入: {temp_mp4_path}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 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()
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info("✅ 成功提取第一帧图片。")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 视频转 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()
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info("✅ 成功生成 GIF。")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 返回结果 (例如: 上传到 MinIO)
|
|
|
|
|
|
# return mp4_bytes, gif_bytes, frame_bytes
|
|
|
|
|
|
|
|
|
|
|
|
# -----------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ 视频处理或文件操作失败: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
# 在失败时,也尝试清理文件
|
|
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
# 3. 清理临时文件 (非常重要!)
|
|
|
|
|
|
if os.path.exists(temp_mp4_path):
|
|
|
|
|
|
os.remove(temp_mp4_path)
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f"🗑️ 已删除临时 MP4 文件: {temp_mp4_path}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
if 'temp_gif_path' in locals() and os.path.exists(temp_gif_path):
|
|
|
|
|
|
os.remove(temp_gif_path)
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f"🗑️ 已删除临时 GIF 文件: {temp_gif_path}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
# 3. 上传所有结果到 MinIO
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
# 上传原始 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")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
self.pose_transform_data = {'tasks_id': self.tasks_id, 'status': 'SUCCESS', 'message': "success", 'gif_url': f'aida-users/{GIF_OBJECT}', 'video_url': f'aida-users/{MP4_OBJECT}', 'image_url': f'aida-users/{FRAME_OBJECT}'}
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# 推送消息
|
2025-12-30 16:49:08 +08:00
|
|
|
|
if not settings.DEBUG:
|
2026-02-02 15:37:01 +08:00
|
|
|
|
publish_status(json.dumps(self.pose_transform_data), PS_RABBITMQ_QUEUES)
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(
|
2026-02-02 15:37:01 +08:00
|
|
|
|
f" [x] Sent to: {PS_RABBITMQ_QUEUES} data:@@@@ {json.dumps(self.pose_transform_data, indent=4)}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
return "\n🎉 所有任务完成!"
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(e)
|
|
|
|
|
|
return None
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
# --- 辅助函数:提交任务到队列 ---
|
2025-12-30 16:49:08 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def queue_prompt(prompt, client_id):
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"""向 ComfyUI 提交工作流提示。"""
|
|
|
|
|
|
p = {"prompt": prompt, "client_id": client_id, "prompt_id": client_id}
|
|
|
|
|
|
data = json.dumps(p).encode('utf-8')
|
|
|
|
|
|
|
|
|
|
|
|
# 提交任务到 /prompt 端点
|
2025-12-30 16:49:08 +08:00
|
|
|
|
# noinspection HttpUrlsUsage
|
|
|
|
|
|
response = requests.post(f"http://{settings.COMFYUI_SERVER_ADDRESS}/prompt", data=data)
|
2025-11-03 16:37:41 +08:00
|
|
|
|
# print(f"-------------{response.text}")
|
|
|
|
|
|
# print(f"------------{client_id}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
if response.status_code == 200:
|
|
|
|
|
|
return response.json()
|
|
|
|
|
|
else:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.warning(f"提交任务失败,状态码: {response.status_code}")
|
|
|
|
|
|
logger.warning(response.text)
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
2025-12-30 16:49:08 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def poll_history(prompt_id, interval_seconds=5):
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"""步骤 2: 轮询 /history/{prompt_id} 检查任务是否完成"""
|
2025-12-30 16:49:08 +08:00
|
|
|
|
url = f"http://{settings.COMFYUI_SERVER_ADDRESS}/history/{prompt_id}"
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f"⏳ 开始轮询状态 (间隔 {interval_seconds} 秒)...")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info("🎉 任务已完成!")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return history_data[prompt_id]['outputs']
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info("⏳ 任务仍在执行或等待中...")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
|
|
|
|
|
|
except requests.exceptions.RequestException as e:
|
|
|
|
|
|
# 处理可能的连接错误,但通常不会在内部轮询中发生
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f"⚠️ 轮询时发生错误: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
pass
|
|
|
|
|
|
|
2025-12-30 16:49:08 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def get_comfyui_video_bytes(filename: str, subfolder: str, file_type: str = "output"):
|
2025-11-03 11:52:39 +08:00
|
|
|
|
"""
|
|
|
|
|
|
从 ComfyUI 的 /view 端点获取视频文件的二进制数据。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
- filename: 视频文件名 (例如: 'ComfyUI_00002_.mp4')
|
|
|
|
|
|
- subfolder: 存储子文件夹 (例如: 'ComfyUI_2025-10-31')
|
|
|
|
|
|
- file_type: 文件类型 (通常是 'output')
|
|
|
|
|
|
|
|
|
|
|
|
返回:
|
|
|
|
|
|
- 视频文件的二进制内容 (bytes) 或 None。
|
|
|
|
|
|
"""
|
2025-12-30 16:49:08 +08:00
|
|
|
|
url = f"http://{settings.COMFYUI_SERVER_ADDRESS}/view"
|
2025-11-03 11:52:39 +08:00
|
|
|
|
params = {
|
|
|
|
|
|
"filename": filename,
|
|
|
|
|
|
"subfolder": subfolder,
|
|
|
|
|
|
"type": file_type
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f"📡 正在从 ComfyUI 下载视频: {filename}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
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:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ 从 ComfyUI 获取视频失败: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def upload_stream_to_minio(self, video_bytes: bytes, object_name: str, content_type: str):
|
|
|
|
|
|
"""从内存流上传数据到 MinIO。"""
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f"☁️ 正在上传对象到 MinIO: {object_name}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.info(f"✅ MinIO 上传成功: {result.object_name}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return True
|
|
|
|
|
|
except S3Error as e:
|
2025-11-03 16:37:41 +08:00
|
|
|
|
logger.error(f"❌ MinIO 上传失败: {e}")
|
2025-11-03 11:52:39 +08:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
2025-11-05 17:08:40 +08:00
|
|
|
|
request_data = ComfyuiPose2VModel(
|
2025-11-07 17:44:19 +08:00
|
|
|
|
tasks_id="122522251123-89111",
|
2025-11-03 17:37:33 +08:00
|
|
|
|
image_url="aida-users/89/product_image/a6949500-2393-42ac-8723-440b5d5da2b2-0-89.png",
|
2025-11-03 11:52:39 +08:00
|
|
|
|
pose_id="6"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-11-05 17:08:40 +08:00
|
|
|
|
server = ComfyUIServerPose2V(request_data)
|
2025-11-03 11:52:39 +08:00
|
|
|
|
print(server.get_result())
|