""" 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)}") # 可灵最多支持 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}"