feat(video): 集成可灵AI多图参考生视频生成服务
- 替换视频生成服务为可灵AI多图参考生视频API,支持1-4张多视角图片输入 - 调整图片拼接逻辑,生成横向长图传入即梦API备用 - 实现基于JWT认证的可灵API请求和轮询机制,支持高品质1:1正方形视频生成 - 在设计详情页新增视频展示区域及生成、重新生成和下载视频操作 - 更新后台系统配置,支持配置可灵AI Access Key和Secret Key - 删除即梦视频相关配置及逻辑,所有视频生成功能切换到可灵AI实现 - 优化视频生成提示词,提升视频质感和展示效果 - 增加视频文件本地存储和路径管理,保证视频可访问和下载 - 前端增加视频生成状态管理和用户界面交互提示 - 后端添加PyJWT依赖,支持JWT认证流程
This commit is contained in:
@@ -152,12 +152,11 @@ def _get_volc_keys() -> tuple:
|
||||
|
||||
async def _merge_images_to_base64(image_urls: List[str]) -> str:
|
||||
"""
|
||||
下载多张图片并拼接为一张网格图,返回 base64 编码
|
||||
下载多张图片并横向拼接为一张长图,返回 base64 编码
|
||||
|
||||
拼接策略:
|
||||
- 1张: 直接使用
|
||||
- 2张: 1×2 横向拼接
|
||||
- 3~4张: 2×2 网格拼接
|
||||
- 多张: 横向一字排开拼接(展示各角度全貌)
|
||||
"""
|
||||
# 下载所有图片
|
||||
images = []
|
||||
@@ -179,36 +178,43 @@ async def _merge_images_to_base64(image_urls: List[str]) -> str:
|
||||
if len(images) == 1:
|
||||
merged = images[0]
|
||||
else:
|
||||
# 统一尺寸到最大尺寸
|
||||
max_w = max(img.width for img in images)
|
||||
max_h = max(img.height for img in images)
|
||||
# 统一高度,横向拼接
|
||||
target_h = max(img.height for img in images)
|
||||
resized = []
|
||||
for img in images:
|
||||
if img.height != target_h:
|
||||
ratio = target_h / img.height
|
||||
new_w = int(img.width * ratio)
|
||||
img = img.resize((new_w, target_h), Image.LANCZOS)
|
||||
resized.append(img)
|
||||
|
||||
# 计算网格布局
|
||||
n = len(images)
|
||||
if n == 2:
|
||||
cols, rows = 2, 1
|
||||
else:
|
||||
cols = 2
|
||||
rows = math.ceil(n / cols)
|
||||
total_w = sum(img.width for img in resized)
|
||||
merged = Image.new("RGB", (total_w, target_h), (255, 255, 255))
|
||||
x_offset = 0
|
||||
for img in resized:
|
||||
merged.paste(img, (x_offset, 0))
|
||||
x_offset += img.width
|
||||
|
||||
# 创建拼接画布(白色背景)
|
||||
canvas_w = cols * max_w
|
||||
canvas_h = rows * max_h
|
||||
merged = Image.new("RGB", (canvas_w, canvas_h), (255, 255, 255))
|
||||
logger.info(f"图片横向拼接完成: {len(resized)}张 -> 尺寸={merged.size}")
|
||||
|
||||
for idx, img in enumerate(images):
|
||||
r = idx // cols
|
||||
c = idx % cols
|
||||
# 居中放置
|
||||
x = c * max_w + (max_w - img.width) // 2
|
||||
y = r * max_h + (max_h - img.height) // 2
|
||||
merged.paste(img, (x, y))
|
||||
# 压缩图片尺寸(即梦API对请求体大小有限制)
|
||||
max_width = 1920
|
||||
if merged.width > max_width:
|
||||
ratio = max_width / merged.width
|
||||
new_h = int(merged.height * ratio)
|
||||
merged = merged.resize((max_width, new_h), Image.LANCZOS)
|
||||
logger.info(f"拼接图已压缩至: {merged.size}")
|
||||
|
||||
logger.info(f"图片拼接完成: {n}张 -> {cols}x{rows}网格, 尺寸={merged.size}")
|
||||
|
||||
# 转 base64
|
||||
# 转 base64,控制质量确保 base64 不会过大
|
||||
buf = io.BytesIO()
|
||||
merged.save(buf, format="JPEG", quality=90)
|
||||
quality = 75
|
||||
merged.save(buf, format="JPEG", quality=quality)
|
||||
# 如果超过 5MB,进一步压缩
|
||||
while buf.tell() > 5 * 1024 * 1024 and quality > 30:
|
||||
buf = io.BytesIO()
|
||||
quality -= 10
|
||||
merged.save(buf, format="JPEG", quality=quality)
|
||||
logger.info(f"图片超过 5MB,降低质量到 {quality},大小={buf.tell()} bytes")
|
||||
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||
logger.info(f"拼接图 base64 长度: {len(b64)}")
|
||||
return b64
|
||||
@@ -222,44 +228,56 @@ async def generate_video(
|
||||
"""
|
||||
调用即梦3.0 Pro 生成 360 度旋转展示视频
|
||||
|
||||
流程:
|
||||
1. 将多视角图片横向拼接成一张长图
|
||||
2. 以 base64 方式传入即梦API
|
||||
3. 使用强化提示词描述单品展示
|
||||
|
||||
Args:
|
||||
image_urls: 多视角图片 URL 列表(取第一张作为首帧)
|
||||
image_urls: 多视角图片 URL 列表
|
||||
prompt: 视频生成提示词
|
||||
duration_seconds: 预留参数(即梦目前固定帧数)
|
||||
duration_seconds: 预留参数
|
||||
|
||||
Returns:
|
||||
生成的视频远程 URL
|
||||
|
||||
Raises:
|
||||
RuntimeError: 视频生成失败
|
||||
生成的视频本地 URL
|
||||
"""
|
||||
access_key, secret_key = _get_volc_keys()
|
||||
|
||||
logger.info(f"传入视频生成的图片数量: {len(image_urls)}")
|
||||
|
||||
# 即梦API只支持单张图片输入,取第一张(正面效果图)作为基准
|
||||
first_url = image_urls[0]
|
||||
logger.info(f"使用第一张图片生成视频: {first_url[:80]}...")
|
||||
# Step 0: 拼接多视角图片为横向长图,转 base64
|
||||
merged_b64 = await _merge_images_to_base64(image_urls)
|
||||
logger.info(f"多视角图片已拼接为长图,base64长度: {len(merged_b64)}")
|
||||
|
||||
# 从配置读取默认 prompt
|
||||
# 从配置读取默认 prompt,如果没有则使用强化提示词
|
||||
if not prompt:
|
||||
prompt = get_config_value("VIDEO_PROMPT", "")
|
||||
if not prompt:
|
||||
prompt = (
|
||||
"玉雕作品在摄影棚内缓慢旋转360度展示全貌,"
|
||||
"专业珠宝摄影灯光,纯白色背景,平稳旋转,"
|
||||
"展示正面、侧面、背面各个角度,电影级画质"
|
||||
"参考图展示的是同一件精美玉雕工艺品的多个角度,"
|
||||
"请生成这一件玉雕作品在专业珠宝摄影棚内的展示视频。"
|
||||
"纯白色背景,柔和的珠宝摄影灯光,"
|
||||
"这一件玉石作品放在旋转展台上缓慢平稳地旋转360度,"
|
||||
"展现其温润的质感、细腻的雕刻纹理和通透的光泽,"
|
||||
"电影级画质,微距的细节感,平稳流畅的转台旋转,"
|
||||
"画面中只有一件玉雕作品"
|
||||
)
|
||||
|
||||
# Step 1: 提交任务(只传第一张图片URL)
|
||||
task_id = await _submit_video_task(access_key, secret_key, first_url, prompt)
|
||||
logger.info(f"即梦视频生成任务已提交: task_id={task_id}")
|
||||
# Step 1: 提交任务(用 base64 拼接长图,失败则降级为第一张图片 URL)
|
||||
try:
|
||||
task_id = await _submit_video_task(access_key, secret_key, None, prompt, merged_b64)
|
||||
logger.info(f"即梦视频生成任务已提交(base64拼接图): task_id={task_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"base64 拼接图提交失败,降级为第一张图片URL: {e}")
|
||||
first_url = image_urls[0]
|
||||
task_id = await _submit_video_task(access_key, secret_key, first_url, prompt)
|
||||
logger.info(f"即梦视频生成任务已提交(单图URL): task_id={task_id}")
|
||||
|
||||
# Step 2: 轮询等待结果
|
||||
remote_video_url = await _poll_video_result(access_key, secret_key, task_id)
|
||||
logger.info(f"即梦视频生成完成: {remote_video_url[:80]}...")
|
||||
|
||||
# Step 3: 下载视频到本地存储(即梦URL有效期约 1 小时,必须保存到本地)
|
||||
# Step 3: 下载视频到本地存储
|
||||
local_path = await _download_video_to_local(remote_video_url)
|
||||
logger.info(f"视频已保存到本地: {local_path}")
|
||||
|
||||
@@ -269,31 +287,43 @@ async def generate_video(
|
||||
async def _submit_video_task(
|
||||
access_key: str,
|
||||
secret_key: str,
|
||||
image_url: str,
|
||||
image_url: Optional[str],
|
||||
prompt: str,
|
||||
image_base64: Optional[str] = None,
|
||||
) -> str:
|
||||
"""提交图生视频任务到即梦3.0 Pro,使用单张图片URL"""
|
||||
"""提交图生视频任务到即梦3.0 Pro,支持 URL 或 base64 输入"""
|
||||
action = "CVSync2AsyncSubmitTask"
|
||||
|
||||
logger.info(f"提交即梦视频任务,图片URL: {image_url[:80]}...")
|
||||
|
||||
payload = {
|
||||
"req_key": REQ_KEY_I2V,
|
||||
"prompt": prompt,
|
||||
"image_urls": [image_url],
|
||||
"seed": -1,
|
||||
"frames": int(get_config_value("VIDEO_FRAMES", "121")),
|
||||
"aspect_ratio": "1:1", # 玉雕展示用正方形
|
||||
"aspect_ratio": "1:1",
|
||||
}
|
||||
|
||||
# 优先使用 base64(拼接长图),否则用 URL
|
||||
if image_base64:
|
||||
payload["binary_data_base64"] = [image_base64]
|
||||
logger.info(f"使用 base64 拼接长图提交即梦视频任务,base64长度={len(image_base64)}")
|
||||
elif image_url:
|
||||
payload["image_urls"] = [image_url]
|
||||
logger.info(f"使用图片URL提交即梦视频任务: {image_url[:80]}...")
|
||||
else:
|
||||
raise RuntimeError("未提供图片输入")
|
||||
|
||||
body = json.dumps(payload, ensure_ascii=False)
|
||||
headers = _build_signed_headers(access_key, secret_key, action, body)
|
||||
url = f"{VISUAL_API_URL}?Action={action}&Version={API_VERSION}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=SUBMIT_TIMEOUT) as client:
|
||||
# base64 数据量大,需要更长的超时时间
|
||||
timeout = 120 if image_base64 else SUBMIT_TIMEOUT
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.post(url, content=body, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"即梦视频任务提交失败: status={resp.status_code}, body={resp.text[:500]}")
|
||||
error_body = resp.text[:1000]
|
||||
logger.error(f"即梦视频任务提交失败: status={resp.status_code}, body={error_body}")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user