- 在Design模型中新增video_url字段用于存储360度展示视频URL - 在DesignImage模型中新增model_3d_url字段用于存储3D模型URL - 设计路由新增生成视频接口,调用火山引擎即梦3.0 Pro API生成展示视频 - 设计路由新增生成3D模型接口,调用腾讯混元3D服务生成.glb格式3D模型 - 新增本地文件删除工具,支持强制重新生成时清理旧文件 - 设计响应Schema中添加video_url和model_3d_url字段支持前后端数据传递 - 前端设计详情页新增360度旋转3D模型展示区,支持生成、重新生成和下载3D模型 - 实现录制3D模型展示视频功能,支持捕获model-viewer旋转画面逐帧合成WebM文件下载 - 引入@google/model-viewer库作为3D模型Web组件展示支持 - 管理后台新增即梦视频生成和腾讯混元3D模型生成配置界面,方便服务密钥管理 - 前端API增加生成视频和生成3D模型接口请求方法,超时设置为10分钟以支持长时间处理 - 优化UI交互提示,新增生成中状态显示和错误提示,提升用户体验和操作反馈
393 lines
12 KiB
Python
393 lines
12 KiB
Python
"""
|
||
AI 视频生成服务
|
||
使用火山引擎 即梦3.0 Pro (Jimeng Video 3.0 Pro) 将设计图生成 360 度旋转展示视频
|
||
|
||
API 文档: https://www.volcengine.com/docs/85621/1777001
|
||
认证方式: Volcengine V4 签名 (Access Key + Secret Key)
|
||
API 端点: https://visual.volcengineapi.com
|
||
"""
|
||
import asyncio
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import io
|
||
import json
|
||
import logging
|
||
import math
|
||
import os
|
||
import uuid
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Optional, List
|
||
from urllib.parse import quote
|
||
|
||
import httpx
|
||
from PIL import Image
|
||
|
||
from .config_service import get_config_value
|
||
|
||
# 视频本地存储目录
|
||
VIDEO_UPLOAD_DIR = Path(__file__).resolve().parent.parent.parent / "uploads" / "videos"
|
||
VIDEO_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 火山引擎视觉 API 配置
|
||
VISUAL_API_HOST = "visual.volcengineapi.com"
|
||
VISUAL_API_URL = f"https://{VISUAL_API_HOST}"
|
||
REGION = "cn-north-1"
|
||
SERVICE = "cv"
|
||
API_VERSION = "2022-08-31"
|
||
|
||
# 即梦3.0 Pro req_key
|
||
REQ_KEY_I2V = "jimeng_ti2v_v30_pro" # 支持传 image_urls 做图生视频
|
||
|
||
# 超时与轮询配置
|
||
SUBMIT_TIMEOUT = 30
|
||
POLL_TIMEOUT = 15
|
||
MAX_POLL_ATTEMPTS = 120 # 约 10 分钟
|
||
POLL_INTERVAL = 5
|
||
|
||
|
||
# ============================================================
|
||
# Volcengine V4 签名实现
|
||
# ============================================================
|
||
|
||
def _sign(key: bytes, msg: str) -> bytes:
|
||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||
|
||
|
||
def _get_signature_key(secret_key: str, date_stamp: str, region: str, service: str) -> bytes:
|
||
k_date = _sign(secret_key.encode("utf-8"), date_stamp)
|
||
k_region = _sign(k_date, region)
|
||
k_service = _sign(k_region, service)
|
||
k_signing = _sign(k_service, "request")
|
||
return k_signing
|
||
|
||
|
||
def _build_signed_headers(
|
||
access_key: str,
|
||
secret_key: str,
|
||
action: str,
|
||
body: str,
|
||
) -> dict:
|
||
"""
|
||
构建带 V4 签名的请求头
|
||
|
||
参考: https://www.volcengine.com/docs/6369/67269
|
||
"""
|
||
now = datetime.now(timezone.utc)
|
||
date_stamp = now.strftime("%Y%m%d")
|
||
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
|
||
|
||
# 请求参数
|
||
canonical_querystring = f"Action={quote(action, safe='')}&Version={quote(API_VERSION, safe='')}"
|
||
|
||
# 规范请求头
|
||
content_type = "application/json"
|
||
canonical_headers = (
|
||
f"content-type:{content_type}\n"
|
||
f"host:{VISUAL_API_HOST}\n"
|
||
f"x-date:{amz_date}\n"
|
||
)
|
||
signed_headers = "content-type;host;x-date"
|
||
|
||
# Payload hash
|
||
payload_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
||
|
||
# 规范请求
|
||
canonical_request = (
|
||
f"POST\n"
|
||
f"/\n"
|
||
f"{canonical_querystring}\n"
|
||
f"{canonical_headers}\n"
|
||
f"{signed_headers}\n"
|
||
f"{payload_hash}"
|
||
)
|
||
|
||
# 待签字符串
|
||
credential_scope = f"{date_stamp}/{REGION}/{SERVICE}/request"
|
||
string_to_sign = (
|
||
f"HMAC-SHA256\n"
|
||
f"{amz_date}\n"
|
||
f"{credential_scope}\n"
|
||
f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
|
||
)
|
||
|
||
# 签名
|
||
signing_key = _get_signature_key(secret_key, date_stamp, REGION, SERVICE)
|
||
signature = hmac.new(signing_key, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
|
||
# Authorization 头
|
||
authorization = (
|
||
f"HMAC-SHA256 "
|
||
f"Credential={access_key}/{credential_scope}, "
|
||
f"SignedHeaders={signed_headers}, "
|
||
f"Signature={signature}"
|
||
)
|
||
|
||
return {
|
||
"Content-Type": content_type,
|
||
"Host": VISUAL_API_HOST,
|
||
"X-Date": amz_date,
|
||
"Authorization": authorization,
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 视频生成核心逻辑
|
||
# ============================================================
|
||
|
||
def _get_volc_keys() -> tuple:
|
||
"""获取火山引擎 Access Key 和 Secret Key"""
|
||
access_key = get_config_value("VOLC_ACCESS_KEY", "")
|
||
secret_key = get_config_value("VOLC_SECRET_KEY", "")
|
||
if not access_key or not secret_key:
|
||
raise RuntimeError(
|
||
"未配置 VOLC_ACCESS_KEY 或 VOLC_SECRET_KEY,无法使用即梦视频生成。"
|
||
"请在管理后台 系统配置 中添加火山引擎 Access Key 和 Secret Key。"
|
||
)
|
||
return access_key, secret_key
|
||
|
||
|
||
async def _merge_images_to_base64(image_urls: List[str]) -> str:
|
||
"""
|
||
下载多张图片并拼接为一张网格图,返回 base64 编码
|
||
|
||
拼接策略:
|
||
- 1张: 直接使用
|
||
- 2张: 1×2 横向拼接
|
||
- 3~4张: 2×2 网格拼接
|
||
"""
|
||
# 下载所有图片
|
||
images = []
|
||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||
for url in image_urls:
|
||
try:
|
||
resp = await client.get(url)
|
||
resp.raise_for_status()
|
||
img = Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||
images.append(img)
|
||
logger.info(f"下载图片成功: {url[:60]}... 尺寸={img.size}")
|
||
except Exception as e:
|
||
logger.warning(f"下载图片失败: {url[:60]}... {e}")
|
||
|
||
if not images:
|
||
raise RuntimeError("没有成功下载任何图片")
|
||
|
||
# 只有1张时直接使用
|
||
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)
|
||
|
||
# 计算网格布局
|
||
n = len(images)
|
||
if n == 2:
|
||
cols, rows = 2, 1
|
||
else:
|
||
cols = 2
|
||
rows = math.ceil(n / cols)
|
||
|
||
# 创建拼接画布(白色背景)
|
||
canvas_w = cols * max_w
|
||
canvas_h = rows * max_h
|
||
merged = Image.new("RGB", (canvas_w, canvas_h), (255, 255, 255))
|
||
|
||
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))
|
||
|
||
logger.info(f"图片拼接完成: {n}张 -> {cols}x{rows}网格, 尺寸={merged.size}")
|
||
|
||
# 转 base64
|
||
buf = io.BytesIO()
|
||
merged.save(buf, format="JPEG", quality=90)
|
||
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||
logger.info(f"拼接图 base64 长度: {len(b64)}")
|
||
return b64
|
||
|
||
|
||
async def generate_video(
|
||
image_urls: List[str],
|
||
prompt: str = "",
|
||
duration_seconds: int = 5,
|
||
) -> str:
|
||
"""
|
||
调用即梦3.0 Pro 生成 360 度旋转展示视频
|
||
|
||
Args:
|
||
image_urls: 多视角图片 URL 列表(取第一张作为首帧)
|
||
prompt: 视频生成提示词
|
||
duration_seconds: 预留参数(即梦目前固定帧数)
|
||
|
||
Returns:
|
||
生成的视频远程 URL
|
||
|
||
Raises:
|
||
RuntimeError: 视频生成失败
|
||
"""
|
||
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]}...")
|
||
|
||
# 从配置读取默认 prompt
|
||
if not prompt:
|
||
prompt = get_config_value("VIDEO_PROMPT", "")
|
||
if not prompt:
|
||
prompt = (
|
||
"玉雕作品在摄影棚内缓慢旋转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 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 小时,必须保存到本地)
|
||
local_path = await _download_video_to_local(remote_video_url)
|
||
logger.info(f"视频已保存到本地: {local_path}")
|
||
|
||
return local_path
|
||
|
||
|
||
async def _submit_video_task(
|
||
access_key: str,
|
||
secret_key: str,
|
||
image_url: str,
|
||
prompt: str,
|
||
) -> str:
|
||
"""提交图生视频任务到即梦3.0 Pro,使用单张图片URL"""
|
||
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", # 玉雕展示用正方形
|
||
}
|
||
|
||
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:
|
||
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]}")
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
# 检查响应
|
||
code = data.get("code", 0)
|
||
if code != 10000:
|
||
msg = data.get("message", "未知错误")
|
||
raise RuntimeError(f"即梦视频任务提交失败 (code={code}): {msg}")
|
||
|
||
task_id = data.get("data", {}).get("task_id")
|
||
if not task_id:
|
||
raise RuntimeError(f"即梦响应中未找到 task_id: {data}")
|
||
|
||
return task_id
|
||
|
||
|
||
async def _poll_video_result(
|
||
access_key: str,
|
||
secret_key: str,
|
||
task_id: str,
|
||
) -> str:
|
||
"""轮询视频生成结果"""
|
||
action = "CVSync2AsyncGetResult"
|
||
|
||
payload = {
|
||
"req_key": REQ_KEY_I2V,
|
||
"task_id": task_id,
|
||
}
|
||
|
||
body = json.dumps(payload, ensure_ascii=False)
|
||
|
||
for attempt in range(1, MAX_POLL_ATTEMPTS + 1):
|
||
await asyncio.sleep(POLL_INTERVAL)
|
||
|
||
# 每次轮询需要重新签名(时间戳不同)
|
||
headers = _build_signed_headers(access_key, secret_key, action, body)
|
||
url = f"{VISUAL_API_URL}?Action={action}&Version={API_VERSION}"
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=POLL_TIMEOUT) as client:
|
||
resp = await client.post(url, content=body, headers=headers)
|
||
if resp.status_code != 200:
|
||
logger.warning(f"轮询即梦视频结果失败 (attempt={attempt}): status={resp.status_code}, body={resp.text[:300]}")
|
||
continue
|
||
data = resp.json()
|
||
except Exception as e:
|
||
logger.warning(f"轮询即梦视频异常 (attempt={attempt}): {e}")
|
||
continue
|
||
|
||
code = data.get("code", 0)
|
||
task_data = data.get("data", {})
|
||
status = task_data.get("status", "")
|
||
|
||
if status == "done" and code == 10000:
|
||
video_url = task_data.get("video_url", "")
|
||
if video_url:
|
||
return video_url
|
||
raise RuntimeError(f"即梦视频生成完成但未找到 video_url: {data}")
|
||
|
||
elif status == "done" and code != 10000:
|
||
msg = data.get("message", "未知错误")
|
||
raise RuntimeError(f"即梦视频生成失败 (code={code}): {msg}")
|
||
|
||
elif status in ("not_found", "expired"):
|
||
raise RuntimeError(f"即梦视频任务状态异常: {status}")
|
||
|
||
else:
|
||
# in_queue / generating
|
||
if attempt % 6 == 0:
|
||
logger.info(f"即梦视频生成中... (attempt={attempt}, status={status})")
|
||
|
||
raise RuntimeError(f"即梦视频生成超时: 轮询 {MAX_POLL_ATTEMPTS} 次后仍未完成")
|
||
|
||
|
||
async def _download_video_to_local(remote_url: str) -> str:
|
||
"""
|
||
下载远程视频到本地 uploads/videos/ 目录
|
||
|
||
Returns:
|
||
本地视频的 URL 路径,如 /uploads/videos/xxx.mp4
|
||
"""
|
||
filename = f"{uuid.uuid4().hex}.mp4"
|
||
local_file = VIDEO_UPLOAD_DIR / filename
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client:
|
||
resp = await client.get(remote_url)
|
||
resp.raise_for_status()
|
||
local_file.write_bytes(resp.content)
|
||
logger.info(f"视频下载完成: {len(resp.content)} 字节 -> {local_file}")
|
||
except Exception as e:
|
||
logger.error(f"视频下载失败: {e}")
|
||
raise RuntimeError(f"视频下载失败: {e}")
|
||
|
||
# 返回相对 URL 路径(和图片一样通过 /uploads/ 静态服务访问)
|
||
return f"/uploads/videos/{filename}"
|