diff --git a/backend/app/routers/designs.py b/backend/app/routers/designs.py index 85ed419..fb96b1c 100644 --- a/backend/app/routers/designs.py +++ b/backend/app/routers/designs.py @@ -13,7 +13,7 @@ from ..models import User, Design, DesignImage from ..schemas import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse from ..utils.deps import get_current_user from ..services import design_service -from ..services import ai_video_generator +from ..services import ai_video_generator_kling from ..services import ai_3d_generator logger = logging.getLogger(__name__) @@ -255,7 +255,7 @@ async def generate_video( ): """ 为设计生成 360 度旋转展示视频 - 取设计的多视角图片,通过火山引擎即梦 3.0 Pro 生成视频 + 取设计的多视角图片,通过可灵 AI 多图参考生视频 API 生成视频 """ design = design_service.get_design_by_id(db=db, design_id=design_id, user_id=current_user.id) if not design: @@ -285,7 +285,7 @@ async def generate_video( logger.info(f"设计 {design_id} 生成视频,共收集到 {len(image_urls)} 张图片") try: - video_url = await ai_video_generator.generate_video(image_urls) + video_url = await ai_video_generator_kling.generate_video(image_urls) design.video_url = video_url db.commit() db.refresh(design) diff --git a/backend/app/services/ai_video_generator.py b/backend/app/services/ai_video_generator.py index 26346c7..4f59830 100644 --- a/backend/app/services/ai_video_generator.py +++ b/backend/app/services/ai_video_generator.py @@ -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() diff --git a/backend/app/services/ai_video_generator_kling.py b/backend/app/services/ai_video_generator_kling.py new file mode 100644 index 0000000..56435ef --- /dev/null +++ b/backend/app/services/ai_video_generator_kling.py @@ -0,0 +1,276 @@ +""" +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}" diff --git a/backend/requirements.txt b/backend/requirements.txt index 2e23b90..08c79e7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ Pillow==10.2.0 pydantic[email]==2.6.1 pydantic-settings==2.1.0 httpx==0.27.0 +PyJWT==2.8.0 diff --git a/backend/uploads/videos/057c26e36b53432e8f169ed68e5acb2b.mp4 b/backend/uploads/videos/057c26e36b53432e8f169ed68e5acb2b.mp4 deleted file mode 100644 index 993bc20..0000000 Binary files a/backend/uploads/videos/057c26e36b53432e8f169ed68e5acb2b.mp4 and /dev/null differ diff --git a/backend/uploads/videos/6ed3f33421a44876b303c7671c1597d5.mp4 b/backend/uploads/videos/6ed3f33421a44876b303c7671c1597d5.mp4 new file mode 100644 index 0000000..d92e6ff Binary files /dev/null and b/backend/uploads/videos/6ed3f33421a44876b303c7671c1597d5.mp4 differ diff --git a/backend/uvicorn.log b/backend/uvicorn.log new file mode 100644 index 0000000..d440494 --- /dev/null +++ b/backend/uvicorn.log @@ -0,0 +1,71 @@ +INFO: Will watch for changes in these directories: ['/Users/changyoutongxue/开发-qoder/YuShiSheJi/backend'] +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +INFO: Started reloader process [63566] using StatReload +INFO: Started server process [63686] +INFO: Waiting for application startup. +INFO: Application startup complete. +✅ 上传目录已准备: uploads +INFO: 127.0.0.1:62589 - "POST /api/auth/login HTTP/1.1" 200 OK +INFO: 127.0.0.1:62593 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:62599 - "GET /api/categories HTTP/1.1" 200 OK +INFO: 127.0.0.1:62605 - "GET /api/admin/dashboard HTTP/1.1" 200 OK +INFO: 127.0.0.1:62608 - "GET /api/admin/configs HTTP/1.1" 200 OK +INFO: 127.0.0.1:62786 - "GET / HTTP/1.1" 200 OK +INFO: 127.0.0.1:62786 - "GET /favicon.ico HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:62835 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:62838 - "GET /api/admin/configs HTTP/1.1" 200 OK +INFO: 127.0.0.1:62913 - "GET /api/admin/configs HTTP/1.1" 200 OK +INFO: 127.0.0.1:62982 - "GET /api/admin/configs HTTP/1.1" 200 OK +INFO: 127.0.0.1:63026 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:63030 - "GET /api/admin/configs HTTP/1.1" 200 OK +INFO: 127.0.0.1:63052 - "PUT /api/admin/configs HTTP/1.1" 200 OK +INFO: 127.0.0.1:63055 - "GET /api/admin/configs HTTP/1.1" 200 OK +INFO: 127.0.0.1:63063 - "GET /api/admin/users?page=1&page_size=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63072 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:63075 - "GET /api/categories HTTP/1.1" 200 OK +INFO: 127.0.0.1:63079 - "GET /api/designs?page=1&page_size=20 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63081 - "GET /uploads/designs/26.png HTTP/1.1" 304 Not Modified +INFO: 127.0.0.1:63086 - "GET /api/designs/27 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63088 - "GET /uploads/videos/f2e6c3eac03b485f8115e72a1d33b491.mp4 HTTP/1.1" 304 Not Modified +INFO: 127.0.0.1:63091 - "GET /uploads/models/506909359d6c45fe9e43108ee7765a9a.glb HTTP/1.1" 304 Not Modified +可灵视频任务提交失败: status=401, body={"code":1002,"message":"Auth failed","request_id":"CjyhVGikJMQAAAAAGOrdCQ","data":null} +视频生成失败: Client error '401 ' for url 'https://api.klingai.com/v1/videos/multi-image2video' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 +INFO: 127.0.0.1:63096 - "POST /api/designs/27/generate-video?force=true HTTP/1.1" 500 Internal Server Error +WARNING: StatReload detected changes in 'app/services/ai_video_generator_kling.py'. Reloading... +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [63686] +👋 应用已关闭 +INFO: Started server process [64167] +INFO: Waiting for application startup. +INFO: Application startup complete. +✅ 上传目录已准备: uploads +INFO: 127.0.0.1:63400 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:63405 - "GET /api/categories HTTP/1.1" 200 OK +INFO: 127.0.0.1:63407 - "GET /api/designs/27 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63409 - "GET /uploads/videos/f2e6c3eac03b485f8115e72a1d33b491.mp4 HTTP/1.1" 404 Not Found +可灵视频任务提交失败: status=401, body={"code":1002,"message":"Auth failed","request_id":"CjMkpGikJkcAAAAAGOrbdA","data":null} +视频生成失败: Client error '401 ' for url 'https://api.klingai.com/v1/videos/multi-image2video' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 +INFO: 127.0.0.1:63412 - "POST /api/designs/27/generate-video?force=true HTTP/1.1" 500 Internal Server Error +INFO: 127.0.0.1:63418 - "GET /uploads/videos/f2e6c3eac03b485f8115e72a1d33b491.mp4 HTTP/1.1" 404 Not Found +WARNING: StatReload detected changes in 'app/services/ai_video_generator_kling.py'. Reloading... +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [64167] +👋 应用已关闭 +INFO: Started server process [64861] +INFO: Waiting for application startup. +INFO: Application startup complete. +✅ 上传目录已准备: uploads +INFO: 127.0.0.1:63556 - "GET /api/auth/me HTTP/1.1" 200 OK +INFO: 127.0.0.1:63563 - "GET /api/categories HTTP/1.1" 200 OK +INFO: 127.0.0.1:63565 - "GET /api/designs/27 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63567 - "GET /uploads/videos/f2e6c3eac03b485f8115e72a1d33b491.mp4 HTTP/1.1" 404 Not Found +INFO: 127.0.0.1:63569 - "GET /uploads/models/506909359d6c45fe9e43108ee7765a9a.glb HTTP/1.1" 304 Not Modified +INFO: 127.0.0.1:63575 - "POST /api/designs/27/generate-video?force=true HTTP/1.1" 200 OK +INFO: 127.0.0.1:63785 - "GET /uploads/videos/6ed3f33421a44876b303c7671c1597d5.mp4 HTTP/1.1" 200 OK +INFO: 127.0.0.1:63846 - "GET /uploads/videos/6ed3f33421a44876b303c7671c1597d5.mp4 HTTP/1.1" 200 OK diff --git a/frontend/src/components/DesignPreview.vue b/frontend/src/components/DesignPreview.vue index 3b14aa7..91cdb90 100644 --- a/frontend/src/components/DesignPreview.vue +++ b/frontend/src/components/DesignPreview.vue @@ -98,6 +98,38 @@ + + +
+

展示视频

+
+ + 正在生成展示视频,预计需要 2-5 分钟... +
+
+ +
+ + +
+
+
+

设计详情

@@ -140,6 +172,15 @@ 生成3D模型 + +
- +
-

即梦 3.0 Pro 视频生成

- - {{ volcVideoStatusText }} +

可灵 AI 视频生成

+ + {{ klingVideoStatusText }}
-

火山引擎即梦 3.0 Pro 图生视频 API,将设计图生成 360 度旋转展示视频。需要火山引擎 Access Key 和 Secret Key(非 API Key)

+

可灵 AI 多图参考生视频 API,支持传入多张参考图(1-4张),AI 理解为同一物体多角度参考,生成单品 360 度旋转展示视频

- 获取方式:火山引擎控制台 → 右上角头像 → API访问密钥,或访问 https://console.volcengine.com/iam/keymanage/ - - - - - - - 推荐 5 秒,足够展示完整 360 度旋转 + 获取方式:访问 可灵AI开放平台 → 注册/登录 → 控制台 → API密钥管理 - 用于控制视频生成效果,尽情描述旋转展示方式、灯光、背景等 + 用于控制视频生成效果,留空使用默认提示词
@@ -230,12 +223,11 @@ const siliconflowKey = ref('') const siliconflowUrl = ref('https://api.siliconflow.cn/v1') const volcengineKey = ref('') const volcengineUrl = ref('https://ark.cn-beijing.volces.com/api/v3') -const volcAccessKey = ref('') -const volcSecretKey = ref('') +const klingAccessKey = ref('') +const klingSecretKey = ref('') const tencentSecretId = ref('') const tencentSecretKey = ref('') const videoPrompt = ref('') -const videoFrames = ref('121') const model3dPrompt = ref('') const imageSize = ref('1024') const saving = ref(false) @@ -243,8 +235,8 @@ const saving = ref(false) // 后端是否已配置 API Key(脱敏值也算已配置) const siliconflowConfigured = ref(false) const volcengineConfigured = ref(false) -const volcAccessKeyConfigured = ref(false) -const volcSecretKeyConfigured = ref(false) +const klingAccessKeyConfigured = ref(false) +const klingSecretKeyConfigured = ref(false) const tencentSecretIdConfigured = ref(false) const tencentSecretKeyConfigured = ref(false) @@ -259,8 +251,8 @@ const siliconflowStatus = computed(() => (siliconflowKey.value || siliconflowCon const siliconflowStatusText = computed(() => (siliconflowKey.value || siliconflowConfigured.value) ? '已配置' : '未配置') const volcengineStatus = computed(() => (volcengineKey.value || volcengineConfigured.value) ? 'success' : 'info') const volcengineStatusText = computed(() => (volcengineKey.value || volcengineConfigured.value) ? '已配置' : '未配置') -const volcVideoStatus = computed(() => ((volcAccessKey.value || volcAccessKeyConfigured.value) && (volcSecretKey.value || volcSecretKeyConfigured.value)) ? 'success' : 'info') -const volcVideoStatusText = computed(() => ((volcAccessKey.value || volcAccessKeyConfigured.value) && (volcSecretKey.value || volcSecretKeyConfigured.value)) ? '已配置' : '未配置') +const klingVideoStatus = computed(() => ((klingAccessKey.value || klingAccessKeyConfigured.value) && (klingSecretKey.value || klingSecretKeyConfigured.value)) ? 'success' : 'info') +const klingVideoStatusText = computed(() => ((klingAccessKey.value || klingAccessKeyConfigured.value) && (klingSecretKey.value || klingSecretKeyConfigured.value)) ? '已配置' : '未配置') const hunyuan3dStatus = computed(() => ((tencentSecretId.value || tencentSecretIdConfigured.value) && (tencentSecretKey.value || tencentSecretKeyConfigured.value)) ? 'success' : 'info') const hunyuan3dStatusText = computed(() => ((tencentSecretId.value || tencentSecretIdConfigured.value) && (tencentSecretKey.value || tencentSecretKeyConfigured.value)) ? '已配置' : '未配置') @@ -288,14 +280,14 @@ const loadConfigs = async () => { volcengineKey.value = map['VOLCENGINE_API_KEY'] } volcengineUrl.value = map['VOLCENGINE_BASE_URL'] || 'https://ark.cn-beijing.volces.com/api/v3' - // 即梦视频 Access Key / Secret Key - volcAccessKeyConfigured.value = !!map['VOLC_ACCESS_KEY'] - volcSecretKeyConfigured.value = !!map['VOLC_SECRET_KEY'] - if (map['VOLC_ACCESS_KEY'] && !map['VOLC_ACCESS_KEY'].includes('****')) { - volcAccessKey.value = map['VOLC_ACCESS_KEY'] + // 可灵 AI Access Key / Secret Key + klingAccessKeyConfigured.value = !!map['KLING_ACCESS_KEY'] + klingSecretKeyConfigured.value = !!map['KLING_SECRET_KEY'] + if (map['KLING_ACCESS_KEY'] && !map['KLING_ACCESS_KEY'].includes('****')) { + klingAccessKey.value = map['KLING_ACCESS_KEY'] } - if (map['VOLC_SECRET_KEY'] && !map['VOLC_SECRET_KEY'].includes('****')) { - volcSecretKey.value = map['VOLC_SECRET_KEY'] + if (map['KLING_SECRET_KEY'] && !map['KLING_SECRET_KEY'].includes('****')) { + klingSecretKey.value = map['KLING_SECRET_KEY'] } // 腾讯云 SecretId / SecretKey tencentSecretIdConfigured.value = !!map['TENCENT_SECRET_ID'] @@ -308,7 +300,6 @@ const loadConfigs = async () => { } // 提示词配置 videoPrompt.value = map['VIDEO_PROMPT'] || '' - videoFrames.value = map['VIDEO_FRAMES'] || '121' model3dPrompt.value = map['MODEL3D_PROMPT'] || '' imageSize.value = map['AI_IMAGE_SIZE'] || '1024' } catch (e) { @@ -338,11 +329,11 @@ const handleSave = async () => { if (volcengineKey.value) { configs['VOLCENGINE_API_KEY'] = volcengineKey.value } - if (volcAccessKey.value) { - configs['VOLC_ACCESS_KEY'] = volcAccessKey.value + if (klingAccessKey.value) { + configs['KLING_ACCESS_KEY'] = klingAccessKey.value } - if (volcSecretKey.value) { - configs['VOLC_SECRET_KEY'] = volcSecretKey.value + if (klingSecretKey.value) { + configs['KLING_SECRET_KEY'] = klingSecretKey.value } if (tencentSecretId.value) { configs['TENCENT_SECRET_ID'] = tencentSecretId.value @@ -352,7 +343,6 @@ const handleSave = async () => { } // 提示词始终提交(包括空值,允许清空) configs['VIDEO_PROMPT'] = videoPrompt.value - configs['VIDEO_FRAMES'] = videoFrames.value configs['MODEL3D_PROMPT'] = model3dPrompt.value await updateConfigsBatch(configs) ElMessage.success('配置已保存')