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:
@@ -13,7 +13,7 @@ from ..models import User, Design, DesignImage
|
|||||||
from ..schemas import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse
|
from ..schemas import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse
|
||||||
from ..utils.deps import get_current_user
|
from ..utils.deps import get_current_user
|
||||||
from ..services import design_service
|
from ..services import design_service
|
||||||
from ..services import ai_video_generator
|
from ..services import ai_video_generator_kling
|
||||||
from ..services import ai_3d_generator
|
from ..services import ai_3d_generator
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -255,7 +255,7 @@ async def generate_video(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
为设计生成 360 度旋转展示视频
|
为设计生成 360 度旋转展示视频
|
||||||
取设计的多视角图片,通过火山引擎即梦 3.0 Pro 生成视频
|
取设计的多视角图片,通过可灵 AI 多图参考生视频 API 生成视频
|
||||||
"""
|
"""
|
||||||
design = design_service.get_design_by_id(db=db, design_id=design_id, user_id=current_user.id)
|
design = design_service.get_design_by_id(db=db, design_id=design_id, user_id=current_user.id)
|
||||||
if not design:
|
if not design:
|
||||||
@@ -285,7 +285,7 @@ async def generate_video(
|
|||||||
logger.info(f"设计 {design_id} 生成视频,共收集到 {len(image_urls)} 张图片")
|
logger.info(f"设计 {design_id} 生成视频,共收集到 {len(image_urls)} 张图片")
|
||||||
|
|
||||||
try:
|
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
|
design.video_url = video_url
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(design)
|
db.refresh(design)
|
||||||
|
|||||||
@@ -152,12 +152,11 @@ def _get_volc_keys() -> tuple:
|
|||||||
|
|
||||||
async def _merge_images_to_base64(image_urls: List[str]) -> str:
|
async def _merge_images_to_base64(image_urls: List[str]) -> str:
|
||||||
"""
|
"""
|
||||||
下载多张图片并拼接为一张网格图,返回 base64 编码
|
下载多张图片并横向拼接为一张长图,返回 base64 编码
|
||||||
|
|
||||||
拼接策略:
|
拼接策略:
|
||||||
- 1张: 直接使用
|
- 1张: 直接使用
|
||||||
- 2张: 1×2 横向拼接
|
- 多张: 横向一字排开拼接(展示各角度全貌)
|
||||||
- 3~4张: 2×2 网格拼接
|
|
||||||
"""
|
"""
|
||||||
# 下载所有图片
|
# 下载所有图片
|
||||||
images = []
|
images = []
|
||||||
@@ -179,36 +178,43 @@ async def _merge_images_to_base64(image_urls: List[str]) -> str:
|
|||||||
if len(images) == 1:
|
if len(images) == 1:
|
||||||
merged = images[0]
|
merged = images[0]
|
||||||
else:
|
else:
|
||||||
# 统一尺寸到最大尺寸
|
# 统一高度,横向拼接
|
||||||
max_w = max(img.width for img in images)
|
target_h = max(img.height for img in images)
|
||||||
max_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)
|
||||||
|
|
||||||
# 计算网格布局
|
total_w = sum(img.width for img in resized)
|
||||||
n = len(images)
|
merged = Image.new("RGB", (total_w, target_h), (255, 255, 255))
|
||||||
if n == 2:
|
x_offset = 0
|
||||||
cols, rows = 2, 1
|
for img in resized:
|
||||||
else:
|
merged.paste(img, (x_offset, 0))
|
||||||
cols = 2
|
x_offset += img.width
|
||||||
rows = math.ceil(n / cols)
|
|
||||||
|
|
||||||
# 创建拼接画布(白色背景)
|
logger.info(f"图片横向拼接完成: {len(resized)}张 -> 尺寸={merged.size}")
|
||||||
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):
|
# 压缩图片尺寸(即梦API对请求体大小有限制)
|
||||||
r = idx // cols
|
max_width = 1920
|
||||||
c = idx % cols
|
if merged.width > max_width:
|
||||||
# 居中放置
|
ratio = max_width / merged.width
|
||||||
x = c * max_w + (max_w - img.width) // 2
|
new_h = int(merged.height * ratio)
|
||||||
y = r * max_h + (max_h - img.height) // 2
|
merged = merged.resize((max_width, new_h), Image.LANCZOS)
|
||||||
merged.paste(img, (x, y))
|
logger.info(f"拼接图已压缩至: {merged.size}")
|
||||||
|
|
||||||
logger.info(f"图片拼接完成: {n}张 -> {cols}x{rows}网格, 尺寸={merged.size}")
|
# 转 base64,控制质量确保 base64 不会过大
|
||||||
|
|
||||||
# 转 base64
|
|
||||||
buf = io.BytesIO()
|
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")
|
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
|
||||||
logger.info(f"拼接图 base64 长度: {len(b64)}")
|
logger.info(f"拼接图 base64 长度: {len(b64)}")
|
||||||
return b64
|
return b64
|
||||||
@@ -222,44 +228,56 @@ async def generate_video(
|
|||||||
"""
|
"""
|
||||||
调用即梦3.0 Pro 生成 360 度旋转展示视频
|
调用即梦3.0 Pro 生成 360 度旋转展示视频
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. 将多视角图片横向拼接成一张长图
|
||||||
|
2. 以 base64 方式传入即梦API
|
||||||
|
3. 使用强化提示词描述单品展示
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
image_urls: 多视角图片 URL 列表(取第一张作为首帧)
|
image_urls: 多视角图片 URL 列表
|
||||||
prompt: 视频生成提示词
|
prompt: 视频生成提示词
|
||||||
duration_seconds: 预留参数(即梦目前固定帧数)
|
duration_seconds: 预留参数
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
生成的视频远程 URL
|
生成的视频本地 URL
|
||||||
|
|
||||||
Raises:
|
|
||||||
RuntimeError: 视频生成失败
|
|
||||||
"""
|
"""
|
||||||
access_key, secret_key = _get_volc_keys()
|
access_key, secret_key = _get_volc_keys()
|
||||||
|
|
||||||
logger.info(f"传入视频生成的图片数量: {len(image_urls)}")
|
logger.info(f"传入视频生成的图片数量: {len(image_urls)}")
|
||||||
|
|
||||||
# 即梦API只支持单张图片输入,取第一张(正面效果图)作为基准
|
# Step 0: 拼接多视角图片为横向长图,转 base64
|
||||||
first_url = image_urls[0]
|
merged_b64 = await _merge_images_to_base64(image_urls)
|
||||||
logger.info(f"使用第一张图片生成视频: {first_url[:80]}...")
|
logger.info(f"多视角图片已拼接为长图,base64长度: {len(merged_b64)}")
|
||||||
|
|
||||||
# 从配置读取默认 prompt
|
# 从配置读取默认 prompt,如果没有则使用强化提示词
|
||||||
if not prompt:
|
if not prompt:
|
||||||
prompt = get_config_value("VIDEO_PROMPT", "")
|
prompt = get_config_value("VIDEO_PROMPT", "")
|
||||||
if not prompt:
|
if not prompt:
|
||||||
prompt = (
|
prompt = (
|
||||||
"玉雕作品在摄影棚内缓慢旋转360度展示全貌,"
|
"参考图展示的是同一件精美玉雕工艺品的多个角度,"
|
||||||
"专业珠宝摄影灯光,纯白色背景,平稳旋转,"
|
"请生成这一件玉雕作品在专业珠宝摄影棚内的展示视频。"
|
||||||
"展示正面、侧面、背面各个角度,电影级画质"
|
"纯白色背景,柔和的珠宝摄影灯光,"
|
||||||
|
"这一件玉石作品放在旋转展台上缓慢平稳地旋转360度,"
|
||||||
|
"展现其温润的质感、细腻的雕刻纹理和通透的光泽,"
|
||||||
|
"电影级画质,微距的细节感,平稳流畅的转台旋转,"
|
||||||
|
"画面中只有一件玉雕作品"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1: 提交任务(只传第一张图片URL)
|
# Step 1: 提交任务(用 base64 拼接长图,失败则降级为第一张图片 URL)
|
||||||
task_id = await _submit_video_task(access_key, secret_key, first_url, prompt)
|
try:
|
||||||
logger.info(f"即梦视频生成任务已提交: task_id={task_id}")
|
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: 轮询等待结果
|
# Step 2: 轮询等待结果
|
||||||
remote_video_url = await _poll_video_result(access_key, secret_key, task_id)
|
remote_video_url = await _poll_video_result(access_key, secret_key, task_id)
|
||||||
logger.info(f"即梦视频生成完成: {remote_video_url[:80]}...")
|
logger.info(f"即梦视频生成完成: {remote_video_url[:80]}...")
|
||||||
|
|
||||||
# Step 3: 下载视频到本地存储(即梦URL有效期约 1 小时,必须保存到本地)
|
# Step 3: 下载视频到本地存储
|
||||||
local_path = await _download_video_to_local(remote_video_url)
|
local_path = await _download_video_to_local(remote_video_url)
|
||||||
logger.info(f"视频已保存到本地: {local_path}")
|
logger.info(f"视频已保存到本地: {local_path}")
|
||||||
|
|
||||||
@@ -269,31 +287,43 @@ async def generate_video(
|
|||||||
async def _submit_video_task(
|
async def _submit_video_task(
|
||||||
access_key: str,
|
access_key: str,
|
||||||
secret_key: str,
|
secret_key: str,
|
||||||
image_url: str,
|
image_url: Optional[str],
|
||||||
prompt: str,
|
prompt: str,
|
||||||
|
image_base64: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""提交图生视频任务到即梦3.0 Pro,使用单张图片URL"""
|
"""提交图生视频任务到即梦3.0 Pro,支持 URL 或 base64 输入"""
|
||||||
action = "CVSync2AsyncSubmitTask"
|
action = "CVSync2AsyncSubmitTask"
|
||||||
|
|
||||||
logger.info(f"提交即梦视频任务,图片URL: {image_url[:80]}...")
|
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"req_key": REQ_KEY_I2V,
|
"req_key": REQ_KEY_I2V,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"image_urls": [image_url],
|
|
||||||
"seed": -1,
|
"seed": -1,
|
||||||
"frames": int(get_config_value("VIDEO_FRAMES", "121")),
|
"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)
|
body = json.dumps(payload, ensure_ascii=False)
|
||||||
headers = _build_signed_headers(access_key, secret_key, action, body)
|
headers = _build_signed_headers(access_key, secret_key, action, body)
|
||||||
url = f"{VISUAL_API_URL}?Action={action}&Version={API_VERSION}"
|
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)
|
resp = await client.post(url, content=body, headers=headers)
|
||||||
if resp.status_code != 200:
|
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()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
|
||||||
|
|||||||
276
backend/app/services/ai_video_generator_kling.py
Normal file
276
backend/app/services/ai_video_generator_kling.py
Normal file
@@ -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}"
|
||||||
@@ -13,3 +13,4 @@ Pillow==10.2.0
|
|||||||
pydantic[email]==2.6.1
|
pydantic[email]==2.6.1
|
||||||
pydantic-settings==2.1.0
|
pydantic-settings==2.1.0
|
||||||
httpx==0.27.0
|
httpx==0.27.0
|
||||||
|
PyJWT==2.8.0
|
||||||
|
|||||||
Binary file not shown.
BIN
backend/uploads/videos/6ed3f33421a44876b303c7671c1597d5.mp4
Normal file
BIN
backend/uploads/videos/6ed3f33421a44876b303c7671c1597d5.mp4
Normal file
Binary file not shown.
71
backend/uvicorn.log
Normal file
71
backend/uvicorn.log
Normal file
@@ -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
|
||||||
@@ -98,6 +98,38 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 展示视频区域 -->
|
||||||
|
<div class="video-section" v-if="design.video_url || generatingVideo">
|
||||||
|
<h4 class="section-title">展示视频</h4>
|
||||||
|
<div v-if="generatingVideo" class="generating-state">
|
||||||
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
|
<span>正在生成展示视频,预计需要 2-5 分钟...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="design.video_url" class="video-wrapper">
|
||||||
|
<video
|
||||||
|
:key="design.video_url"
|
||||||
|
controls
|
||||||
|
preload="auto"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
class="preview-video"
|
||||||
|
>
|
||||||
|
<source :src="design.video_url" type="video/mp4" />
|
||||||
|
您的浏览器不支持视频播放
|
||||||
|
</video>
|
||||||
|
<div class="video-actions">
|
||||||
|
<button class="model3d-action-btn" @click="handleDownloadVideo">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
<span>下载视频</span>
|
||||||
|
</button>
|
||||||
|
<button class="model3d-action-btn" @click="handleRegenVideo">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
<span>重新生成</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="design-info">
|
<div class="design-info">
|
||||||
<h4 class="info-title">设计详情</h4>
|
<h4 class="info-title">设计详情</h4>
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
@@ -140,6 +172,15 @@
|
|||||||
<el-icon><Platform /></el-icon>
|
<el-icon><Platform /></el-icon>
|
||||||
<span>生成3D模型</span>
|
<span>生成3D模型</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 生成展示视频按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="!design.video_url && !generatingVideo"
|
||||||
|
class="action-btn video-btn"
|
||||||
|
@click="handleGenerateVideo"
|
||||||
|
>
|
||||||
|
<el-icon><VideoCameraFilled /></el-icon>
|
||||||
|
<span>生成展示视频</span>
|
||||||
|
</button>
|
||||||
<button class="action-btn secondary-btn" @click="goToUserCenter">
|
<button class="action-btn secondary-btn" @click="goToUserCenter">
|
||||||
<el-icon><User /></el-icon>
|
<el-icon><User /></el-icon>
|
||||||
<span>查看我的设计</span>
|
<span>查看我的设计</span>
|
||||||
@@ -154,7 +195,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User, Platform, VideoCameraFilled } from '@element-plus/icons-vue'
|
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User, Platform, VideoCameraFilled } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { Design } from '@/stores/design'
|
import type { Design } from '@/stores/design'
|
||||||
import { getDesignDownloadUrl, generate3DModelApi } from '@/api/design'
|
import { getDesignDownloadUrl, generate3DModelApi, generateVideoApi } from '@/api/design'
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -179,6 +220,9 @@ const scale = ref(1)
|
|||||||
// 3D 模型生成状态
|
// 3D 模型生成状态
|
||||||
const generating3D = ref(false)
|
const generating3D = ref(false)
|
||||||
|
|
||||||
|
// 视频生成状态
|
||||||
|
const generatingVideo = ref(false)
|
||||||
|
|
||||||
// 是否有多视角图片
|
// 是否有多视角图片
|
||||||
const hasMultipleViews = computed(() => {
|
const hasMultipleViews = computed(() => {
|
||||||
return props.design.images && props.design.images.length > 1
|
return props.design.images && props.design.images.length > 1
|
||||||
@@ -252,6 +296,56 @@ const handleRegen3D = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成展示视频
|
||||||
|
const handleGenerateVideo = async () => {
|
||||||
|
generatingVideo.value = true
|
||||||
|
try {
|
||||||
|
const updated = await generateVideoApi(props.design.id)
|
||||||
|
if (updated.video_url) {
|
||||||
|
props.design.video_url = updated.video_url
|
||||||
|
}
|
||||||
|
ElMessage.success('展示视频生成成功!')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || e?.detail || '视频生成失败,请重试')
|
||||||
|
} finally {
|
||||||
|
generatingVideo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成展示视频
|
||||||
|
const handleRegenVideo = async () => {
|
||||||
|
generatingVideo.value = true
|
||||||
|
try {
|
||||||
|
const updated = await generateVideoApi(props.design.id, true)
|
||||||
|
if (updated.video_url) {
|
||||||
|
props.design.video_url = updated.video_url
|
||||||
|
}
|
||||||
|
ElMessage.success('展示视频重新生成成功!')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '视频重新生成失败')
|
||||||
|
} finally {
|
||||||
|
generatingVideo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载展示视频
|
||||||
|
const handleDownloadVideo = async () => {
|
||||||
|
const videoUrl = props.design.video_url
|
||||||
|
if (!videoUrl) return
|
||||||
|
try {
|
||||||
|
const res = await fetch(videoUrl)
|
||||||
|
if (!res.ok) throw new Error('下载失败')
|
||||||
|
const blob = await res.blob()
|
||||||
|
const category = props.design.category?.name || '设计'
|
||||||
|
const subType = props.design.sub_type?.name || ''
|
||||||
|
const filename = `${category}${subType ? '-' + subType : ''}-展示视频-${props.design.id}.mp4`
|
||||||
|
_downloadBlob(blob, filename)
|
||||||
|
ElMessage.success('视频下载成功')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('视频下载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取图片URL
|
// 获取图片URL
|
||||||
const toImageUrl = (url: string | null): string => {
|
const toImageUrl = (url: string | null): string => {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
@@ -867,4 +961,43 @@ $text-light: #999999;
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: $bg-color;
|
background: $bg-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 视频区域样式
|
||||||
|
.video-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrapper {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-video {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 500px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-btn {
|
||||||
|
background: linear-gradient(135deg, #e040fb 0%, #7c4dff 100%) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
border: none !important;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -97,45 +97,38 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 即梦视频生成配置卡片 -->
|
<!-- 可灵AI 视频生成配置卡片 -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h3 class="section-title">即梦 3.0 Pro 视频生成</h3>
|
<h3 class="section-title">可灵 AI 视频生成</h3>
|
||||||
<el-tag :type="volcVideoStatus" size="small">
|
<el-tag :type="klingVideoStatus" size="small">
|
||||||
{{ volcVideoStatusText }}
|
{{ klingVideoStatusText }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="card-desc">火山引擎即梦 3.0 Pro 图生视频 API,将设计图生成 360 度旋转展示视频。需要火山引擎 Access Key 和 Secret Key(非 API Key)</p>
|
<p class="card-desc">可灵 AI 多图参考生视频 API,支持传入多张参考图(1-4张),AI 理解为同一物体多角度参考,生成单品 360 度旋转展示视频</p>
|
||||||
</div>
|
</div>
|
||||||
<el-form label-width="120px" class="config-form">
|
<el-form label-width="120px" class="config-form">
|
||||||
<el-form-item label="Access Key">
|
<el-form-item label="Access Key">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="volcAccessKey"
|
v-model="klingAccessKey"
|
||||||
type="password"
|
type="password"
|
||||||
show-password
|
show-password
|
||||||
placeholder="请输入火山引擎 Access Key"
|
placeholder="请输入可灵 AI Access Key"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="Secret Key">
|
<el-form-item label="Secret Key">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="volcSecretKey"
|
v-model="klingSecretKey"
|
||||||
type="password"
|
type="password"
|
||||||
show-password
|
show-password
|
||||||
placeholder="请输入火山引擎 Secret Key"
|
placeholder="请输入可灵 AI Secret Key"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<span class="form-tip">获取方式:火山引擎控制台 → 右上角头像 → API访问密钥,或访问 https://console.volcengine.com/iam/keymanage/</span>
|
<span class="form-tip">获取方式:访问 <a href="https://klingai.com" target="_blank">可灵AI开放平台</a> → 注册/登录 → 控制台 → API密钥管理</span>
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="视频时长">
|
|
||||||
<el-select v-model="videoFrames" style="width: 200px">
|
|
||||||
<el-option label="2 秒(快速预览)" value="49" />
|
|
||||||
<el-option label="5 秒(完整展示)" value="121" />
|
|
||||||
</el-select>
|
|
||||||
<span class="form-tip">推荐 5 秒,足够展示完整 360 度旋转</span>
|
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="视频提示词">
|
<el-form-item label="视频提示词">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -144,7 +137,7 @@
|
|||||||
:rows="3"
|
:rows="3"
|
||||||
placeholder="玉雕作品在摄影棚内缓慢旋转360度展示全貌..."
|
placeholder="玉雕作品在摄影棚内缓慢旋转360度展示全貌..."
|
||||||
/>
|
/>
|
||||||
<span class="form-tip">用于控制视频生成效果,尽情描述旋转展示方式、灯光、背景等</span>
|
<span class="form-tip">用于控制视频生成效果,留空使用默认提示词</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,12 +223,11 @@ const siliconflowKey = ref('')
|
|||||||
const siliconflowUrl = ref('https://api.siliconflow.cn/v1')
|
const siliconflowUrl = ref('https://api.siliconflow.cn/v1')
|
||||||
const volcengineKey = ref('')
|
const volcengineKey = ref('')
|
||||||
const volcengineUrl = ref('https://ark.cn-beijing.volces.com/api/v3')
|
const volcengineUrl = ref('https://ark.cn-beijing.volces.com/api/v3')
|
||||||
const volcAccessKey = ref('')
|
const klingAccessKey = ref('')
|
||||||
const volcSecretKey = ref('')
|
const klingSecretKey = ref('')
|
||||||
const tencentSecretId = ref('')
|
const tencentSecretId = ref('')
|
||||||
const tencentSecretKey = ref('')
|
const tencentSecretKey = ref('')
|
||||||
const videoPrompt = ref('')
|
const videoPrompt = ref('')
|
||||||
const videoFrames = ref('121')
|
|
||||||
const model3dPrompt = ref('')
|
const model3dPrompt = ref('')
|
||||||
const imageSize = ref('1024')
|
const imageSize = ref('1024')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -243,8 +235,8 @@ const saving = ref(false)
|
|||||||
// 后端是否已配置 API Key(脱敏值也算已配置)
|
// 后端是否已配置 API Key(脱敏值也算已配置)
|
||||||
const siliconflowConfigured = ref(false)
|
const siliconflowConfigured = ref(false)
|
||||||
const volcengineConfigured = ref(false)
|
const volcengineConfigured = ref(false)
|
||||||
const volcAccessKeyConfigured = ref(false)
|
const klingAccessKeyConfigured = ref(false)
|
||||||
const volcSecretKeyConfigured = ref(false)
|
const klingSecretKeyConfigured = ref(false)
|
||||||
const tencentSecretIdConfigured = ref(false)
|
const tencentSecretIdConfigured = ref(false)
|
||||||
const tencentSecretKeyConfigured = ref(false)
|
const tencentSecretKeyConfigured = ref(false)
|
||||||
|
|
||||||
@@ -259,8 +251,8 @@ const siliconflowStatus = computed(() => (siliconflowKey.value || siliconflowCon
|
|||||||
const siliconflowStatusText = computed(() => (siliconflowKey.value || siliconflowConfigured.value) ? '已配置' : '未配置')
|
const siliconflowStatusText = computed(() => (siliconflowKey.value || siliconflowConfigured.value) ? '已配置' : '未配置')
|
||||||
const volcengineStatus = computed(() => (volcengineKey.value || volcengineConfigured.value) ? 'success' : 'info')
|
const volcengineStatus = computed(() => (volcengineKey.value || volcengineConfigured.value) ? 'success' : 'info')
|
||||||
const volcengineStatusText = computed(() => (volcengineKey.value || volcengineConfigured.value) ? '已配置' : '未配置')
|
const volcengineStatusText = computed(() => (volcengineKey.value || volcengineConfigured.value) ? '已配置' : '未配置')
|
||||||
const volcVideoStatus = computed(() => ((volcAccessKey.value || volcAccessKeyConfigured.value) && (volcSecretKey.value || volcSecretKeyConfigured.value)) ? 'success' : 'info')
|
const klingVideoStatus = computed(() => ((klingAccessKey.value || klingAccessKeyConfigured.value) && (klingSecretKey.value || klingSecretKeyConfigured.value)) ? 'success' : 'info')
|
||||||
const volcVideoStatusText = computed(() => ((volcAccessKey.value || volcAccessKeyConfigured.value) && (volcSecretKey.value || volcSecretKeyConfigured.value)) ? '已配置' : '未配置')
|
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 hunyuan3dStatus = computed(() => ((tencentSecretId.value || tencentSecretIdConfigured.value) && (tencentSecretKey.value || tencentSecretKeyConfigured.value)) ? 'success' : 'info')
|
||||||
const hunyuan3dStatusText = computed(() => ((tencentSecretId.value || tencentSecretIdConfigured.value) && (tencentSecretKey.value || tencentSecretKeyConfigured.value)) ? '已配置' : '未配置')
|
const hunyuan3dStatusText = computed(() => ((tencentSecretId.value || tencentSecretIdConfigured.value) && (tencentSecretKey.value || tencentSecretKeyConfigured.value)) ? '已配置' : '未配置')
|
||||||
|
|
||||||
@@ -288,14 +280,14 @@ const loadConfigs = async () => {
|
|||||||
volcengineKey.value = map['VOLCENGINE_API_KEY']
|
volcengineKey.value = map['VOLCENGINE_API_KEY']
|
||||||
}
|
}
|
||||||
volcengineUrl.value = map['VOLCENGINE_BASE_URL'] || 'https://ark.cn-beijing.volces.com/api/v3'
|
volcengineUrl.value = map['VOLCENGINE_BASE_URL'] || 'https://ark.cn-beijing.volces.com/api/v3'
|
||||||
// 即梦视频 Access Key / Secret Key
|
// 可灵 AI Access Key / Secret Key
|
||||||
volcAccessKeyConfigured.value = !!map['VOLC_ACCESS_KEY']
|
klingAccessKeyConfigured.value = !!map['KLING_ACCESS_KEY']
|
||||||
volcSecretKeyConfigured.value = !!map['VOLC_SECRET_KEY']
|
klingSecretKeyConfigured.value = !!map['KLING_SECRET_KEY']
|
||||||
if (map['VOLC_ACCESS_KEY'] && !map['VOLC_ACCESS_KEY'].includes('****')) {
|
if (map['KLING_ACCESS_KEY'] && !map['KLING_ACCESS_KEY'].includes('****')) {
|
||||||
volcAccessKey.value = map['VOLC_ACCESS_KEY']
|
klingAccessKey.value = map['KLING_ACCESS_KEY']
|
||||||
}
|
}
|
||||||
if (map['VOLC_SECRET_KEY'] && !map['VOLC_SECRET_KEY'].includes('****')) {
|
if (map['KLING_SECRET_KEY'] && !map['KLING_SECRET_KEY'].includes('****')) {
|
||||||
volcSecretKey.value = map['VOLC_SECRET_KEY']
|
klingSecretKey.value = map['KLING_SECRET_KEY']
|
||||||
}
|
}
|
||||||
// 腾讯云 SecretId / SecretKey
|
// 腾讯云 SecretId / SecretKey
|
||||||
tencentSecretIdConfigured.value = !!map['TENCENT_SECRET_ID']
|
tencentSecretIdConfigured.value = !!map['TENCENT_SECRET_ID']
|
||||||
@@ -308,7 +300,6 @@ const loadConfigs = async () => {
|
|||||||
}
|
}
|
||||||
// 提示词配置
|
// 提示词配置
|
||||||
videoPrompt.value = map['VIDEO_PROMPT'] || ''
|
videoPrompt.value = map['VIDEO_PROMPT'] || ''
|
||||||
videoFrames.value = map['VIDEO_FRAMES'] || '121'
|
|
||||||
model3dPrompt.value = map['MODEL3D_PROMPT'] || ''
|
model3dPrompt.value = map['MODEL3D_PROMPT'] || ''
|
||||||
imageSize.value = map['AI_IMAGE_SIZE'] || '1024'
|
imageSize.value = map['AI_IMAGE_SIZE'] || '1024'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -338,11 +329,11 @@ const handleSave = async () => {
|
|||||||
if (volcengineKey.value) {
|
if (volcengineKey.value) {
|
||||||
configs['VOLCENGINE_API_KEY'] = volcengineKey.value
|
configs['VOLCENGINE_API_KEY'] = volcengineKey.value
|
||||||
}
|
}
|
||||||
if (volcAccessKey.value) {
|
if (klingAccessKey.value) {
|
||||||
configs['VOLC_ACCESS_KEY'] = volcAccessKey.value
|
configs['KLING_ACCESS_KEY'] = klingAccessKey.value
|
||||||
}
|
}
|
||||||
if (volcSecretKey.value) {
|
if (klingSecretKey.value) {
|
||||||
configs['VOLC_SECRET_KEY'] = volcSecretKey.value
|
configs['KLING_SECRET_KEY'] = klingSecretKey.value
|
||||||
}
|
}
|
||||||
if (tencentSecretId.value) {
|
if (tencentSecretId.value) {
|
||||||
configs['TENCENT_SECRET_ID'] = tencentSecretId.value
|
configs['TENCENT_SECRET_ID'] = tencentSecretId.value
|
||||||
@@ -352,7 +343,6 @@ const handleSave = async () => {
|
|||||||
}
|
}
|
||||||
// 提示词始终提交(包括空值,允许清空)
|
// 提示词始终提交(包括空值,允许清空)
|
||||||
configs['VIDEO_PROMPT'] = videoPrompt.value
|
configs['VIDEO_PROMPT'] = videoPrompt.value
|
||||||
configs['VIDEO_FRAMES'] = videoFrames.value
|
|
||||||
configs['MODEL3D_PROMPT'] = model3dPrompt.value
|
configs['MODEL3D_PROMPT'] = model3dPrompt.value
|
||||||
await updateConfigsBatch(configs)
|
await updateConfigsBatch(configs)
|
||||||
ElMessage.success('配置已保存')
|
ElMessage.success('配置已保存')
|
||||||
|
|||||||
Reference in New Issue
Block a user