Files
YuShiSheJiShi/backend/app/services/ai_video_generator_kling.py
2ef126e445 feat: 强化多视角图片一致性 + 修复下载逻辑 + 技术文档
- 新增品类专属背面/侧面描述(BACK_VIEW_HINTS/SIDE_VIEW_HINTS)
- 强化一致性前缀策略,按视角定制相机位置描述
- 更新视角映射提示词为纯摄影术语
- 修复前端下载逻辑:改用fetch直接下载当前视角图片
- HTTPS改HTTP修复外网URL访问
- 新增多视角一致性与3D视频生成技术文档
2026-03-28 19:51:08 +08:00

281 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
AI 视频生成服务 - 可灵Kling多图参考生视频
使用可灵 AI 的多图参考生视频 API原生支持传入 1-4 张参考图片
AI 会理解为同一物体的多角度参考,生成单品旋转展示视频
API 文档: https://app.klingai.com/cn/dev/document-api/apiReference/model/multiImageToVideo
认证方式: JWT (Access Key + Secret Key)
API 端点: https://api.klingai.com
"""
import asyncio
import json
import logging
import time
import uuid
from pathlib import Path
from typing import Optional, List
import httpx
import jwt
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 配置(中国区域名)
KLING_API_BASE = "https://api-beijing.klingai.com"
# 超时与轮询配置
SUBMIT_TIMEOUT = 30
POLL_TIMEOUT = 15
MAX_POLL_ATTEMPTS = 120 # 约 10 分钟
POLL_INTERVAL = 5
# ============================================================
# JWT 认证
# ============================================================
def _generate_jwt_token(access_key: str, secret_key: str) -> str:
"""
使用 Access Key 和 Secret Key 生成 JWT Token
可灵 API 使用 JWT 认证token 有效期 30 分钟
"""
now = int(time.time())
headers = {
"alg": "HS256",
"typ": "JWT"
}
payload = {
"iss": access_key,
"exp": now + 1800, # 30 分钟过期
"nbf": now - 5, # 允许 5 秒时钟偏差
"iat": now, # 签发时间
}
token = jwt.encode(payload, secret_key, algorithm="HS256", headers=headers)
return token
def _get_kling_keys() -> tuple:
"""获取可灵 Access Key 和 Secret Key"""
access_key = get_config_value("KLING_ACCESS_KEY", "")
secret_key = get_config_value("KLING_SECRET_KEY", "")
if not access_key or not secret_key:
raise RuntimeError(
"未配置 KLING_ACCESS_KEY 或 KLING_SECRET_KEY无法使用可灵视频生成。"
"请在管理后台 系统配置 中添加可灵 AI 的 Access Key 和 Secret Key。"
)
return access_key, secret_key
def _build_headers(access_key: str, secret_key: str) -> dict:
"""构建带 JWT 认证的请求头"""
token = _generate_jwt_token(access_key, secret_key)
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
}
# ============================================================
# 视频生成核心逻辑
# ============================================================
async def generate_video(
image_urls: List[str],
prompt: str = "",
duration_seconds: int = 5,
) -> str:
"""
调用可灵多图参考生视频 API生成 360 度旋转展示视频
核心优势原生支持传入多张参考图1-4张
AI 理解为同一物体的多角度参考,生成单品视频。
Args:
image_urls: 多视角图片 URL 列表最多4张
prompt: 视频生成提示词
duration_seconds: 视频时长5 或 10 秒)
Returns:
生成的视频本地 URL
"""
access_key, secret_key = _get_kling_keys()
logger.info(f"可灵视频生成,传入图片数量: {len(image_urls)}")
# 将本地路径转换为外网可访问URL可灵API需要完整URL
from .ai_3d_generator import _to_public_url
image_urls = [_to_public_url(u) for u in image_urls]
# 可灵最多支持 4 张参考图
if len(image_urls) > 4:
image_urls = image_urls[:4]
logger.info("图片数量超过4张截取前4张")
# 构建提示词
if not prompt:
prompt = get_config_value("VIDEO_PROMPT", "")
if not prompt:
prompt = (
"精美玉雕工艺品在专业珠宝摄影棚内展示,"
"纯白色背景,柔和的珠宝摄影灯光,"
"玉石作品放在旋转展台上缓慢平稳地旋转360度"
"展示正面、侧面、背面全貌,"
"展现玉石温润的质感、细腻的雕刻纹理和通透的光泽,"
"电影级画质,微距细节感,平稳流畅的转台旋转"
)
# 视频时长,仅支持 5 或 10
duration = str(duration_seconds) if duration_seconds in (5, 10) else "5"
# Step 1: 提交任务
task_id = await _submit_video_task(
access_key, secret_key, image_urls, prompt, duration
)
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: 下载视频到本地存储
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_urls: List[str],
prompt: str,
duration: str = "5",
) -> str:
"""提交多图参考生视频任务到可灵 API"""
url = f"{KLING_API_BASE}/v1/videos/multi-image2video"
# 构建 image_list每个元素是 {"image": "url"}
image_list = [{"image": img_url} for img_url in image_urls]
payload = {
"model_name": "kling-v1-6",
"image_list": image_list,
"prompt": prompt,
"mode": "pro", # 高品质模式
"duration": duration, # 视频时长
"aspect_ratio": "1:1", # 1:1 正方形
}
headers = _build_headers(access_key, secret_key)
body = json.dumps(payload, ensure_ascii=False)
logger.info(f"提交可灵视频任务: {len(image_urls)}张参考图, 时长={duration}s, 模式=pro")
async with httpx.AsyncClient(timeout=SUBMIT_TIMEOUT) as client:
resp = await client.post(url, content=body, headers=headers)
if resp.status_code not in (200, 201):
error_body = resp.text[:1000]
logger.error(f"可灵视频任务提交失败: status={resp.status_code}, body={error_body}")
resp.raise_for_status()
data = resp.json()
# 检查响应
code = data.get("code", -1)
if code != 0:
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:
"""轮询可灵视频生成结果"""
url = f"{KLING_API_BASE}/v1/videos/multi-image2video/{task_id}"
for attempt in range(1, MAX_POLL_ATTEMPTS + 1):
await asyncio.sleep(POLL_INTERVAL)
# 每次轮询重新生成 JWT避免过期
headers = _build_headers(access_key, secret_key)
try:
async with httpx.AsyncClient(timeout=POLL_TIMEOUT) as client:
resp = await client.get(url, headers=headers)
if resp.status_code != 200:
logger.warning(
f"轮询可灵视频结果失败 (attempt={attempt}): "
f"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", -1)
if code != 0:
msg = data.get("message", "未知错误")
logger.warning(f"轮询可灵视频返回错误 (attempt={attempt}): code={code}, msg={msg}")
continue
task_data = data.get("data", {})
task_status = task_data.get("task_status", "")
if task_status == "succeed":
# 从 task_result.videos 中提取视频 URL
task_result = task_data.get("task_result", {})
videos = task_result.get("videos", [])
if videos and videos[0].get("url"):
return videos[0]["url"]
raise RuntimeError(f"可灵视频生成完成但未找到视频URL: {data}")
elif task_status == "failed":
fail_msg = task_data.get("task_status_msg", "未知原因")
raise RuntimeError(f"可灵视频生成失败: {fail_msg}")
else:
# submitted / processing
if attempt % 6 == 0:
logger.info(
f"可灵视频生成中... (attempt={attempt}, status={task_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}")
return f"/uploads/videos/{filename}"