feat(design): 添加360视频和3D模型生成功能支持
- 在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交互提示,新增生成中状态显示和错误提示,提升用户体验和操作反馈
This commit is contained in:
@@ -25,6 +25,7 @@ class Design(Base):
|
|||||||
surface_finish = Column(String(50), nullable=True, comment="表面处理")
|
surface_finish = Column(String(50), nullable=True, comment="表面处理")
|
||||||
usage_scene = Column(String(50), nullable=True, comment="用途场景")
|
usage_scene = Column(String(50), nullable=True, comment="用途场景")
|
||||||
image_url = Column(Text, nullable=True, comment="设计图URL")
|
image_url = Column(Text, nullable=True, comment="设计图URL")
|
||||||
|
video_url = Column(Text, nullable=True, comment="360度展示视频URL")
|
||||||
status = Column(String(20), default="generating", comment="状态")
|
status = Column(String(20), default="generating", comment="状态")
|
||||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class DesignImage(Base):
|
|||||||
model_used = Column(String(50), nullable=True, comment="使用的AI模型: flux-dev/seedream-4.5")
|
model_used = Column(String(50), nullable=True, comment="使用的AI模型: flux-dev/seedream-4.5")
|
||||||
prompt_used = Column(Text, nullable=True, comment="实际使用的英文prompt")
|
prompt_used = Column(Text, nullable=True, comment="实际使用的英文prompt")
|
||||||
sort_order = Column(Integer, default=0, comment="排序")
|
sort_order = Column(Integer, default=0, comment="排序")
|
||||||
|
model_3d_url = Column(Text, nullable=True, comment="3D模型URL(.glb)")
|
||||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||||
|
|
||||||
# 关联关系
|
# 关联关系
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
"""
|
"""
|
||||||
设计相关路由
|
设计相关路由
|
||||||
提供设计生成、查询、删除、下载接口
|
提供设计生成、查询、删除、下载、视频生成、3D模型生成接口
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import User, Design
|
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_3d_generator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# uploads 基础目录
|
||||||
|
UPLOADS_BASE = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_local_file(url_path: str):
|
||||||
|
"""删除本地存储的文件(如 /uploads/videos/xxx.mp4)"""
|
||||||
|
if not url_path or not url_path.startswith("/uploads/"):
|
||||||
|
return
|
||||||
|
rel_path = url_path.lstrip("/uploads/")
|
||||||
|
full_path = os.path.join(UPLOADS_BASE, rel_path)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
try:
|
||||||
|
os.remove(full_path)
|
||||||
|
logger.info(f"已删除旧文件: {full_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"删除旧文件失败: {full_path}, {e}")
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/designs", tags=["设计"])
|
router = APIRouter(prefix="/api/designs", tags=["设计"])
|
||||||
|
|
||||||
@@ -29,6 +52,7 @@ def design_to_response(design: Design) -> DesignResponse:
|
|||||||
model_used=img.model_used,
|
model_used=img.model_used,
|
||||||
prompt_used=img.prompt_used,
|
prompt_used=img.prompt_used,
|
||||||
sort_order=img.sort_order,
|
sort_order=img.sort_order,
|
||||||
|
model_3d_url=img.model_3d_url,
|
||||||
)
|
)
|
||||||
for img in design.images
|
for img in design.images
|
||||||
]
|
]
|
||||||
@@ -66,6 +90,7 @@ def design_to_response(design: Design) -> DesignResponse:
|
|||||||
surface_finish=design.surface_finish,
|
surface_finish=design.surface_finish,
|
||||||
usage_scene=design.usage_scene,
|
usage_scene=design.usage_scene,
|
||||||
image_url=design.image_url,
|
image_url=design.image_url,
|
||||||
|
video_url=design.video_url,
|
||||||
images=images,
|
images=images,
|
||||||
status=design.status,
|
status=design.status,
|
||||||
created_at=design.created_at,
|
created_at=design.created_at,
|
||||||
@@ -219,3 +244,115 @@ def download_design(
|
|||||||
filename=f"design_{design_id}.png",
|
filename=f"design_{design_id}.png",
|
||||||
media_type="image/png"
|
media_type="image/png"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{design_id}/generate-video", response_model=DesignResponse)
|
||||||
|
async def generate_video(
|
||||||
|
design_id: int,
|
||||||
|
force: bool = Query(False, description="强制重新生成"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
为设计生成 360 度旋转展示视频
|
||||||
|
取设计的多视角图片,通过火山引擎即梦 3.0 Pro 生成视频
|
||||||
|
"""
|
||||||
|
design = design_service.get_design_by_id(db=db, design_id=design_id, user_id=current_user.id)
|
||||||
|
if not design:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设计不存在")
|
||||||
|
|
||||||
|
# 已有视频且不强制重生时直接返回
|
||||||
|
if design.video_url and not force:
|
||||||
|
return design_to_response(design)
|
||||||
|
|
||||||
|
# 强制重生时删除旧视频文件
|
||||||
|
if force and design.video_url:
|
||||||
|
_delete_local_file(design.video_url)
|
||||||
|
design.video_url = None
|
||||||
|
|
||||||
|
# 收集多视角图片 URL
|
||||||
|
image_urls = []
|
||||||
|
if design.images:
|
||||||
|
for img in sorted(design.images, key=lambda x: x.sort_order):
|
||||||
|
if img.image_url:
|
||||||
|
image_urls.append(img.image_url)
|
||||||
|
if not image_urls and design.image_url:
|
||||||
|
image_urls.append(design.image_url)
|
||||||
|
|
||||||
|
if not image_urls:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="设计没有图片,无法生成视频")
|
||||||
|
|
||||||
|
logger.info(f"设计 {design_id} 生成视频,共收集到 {len(image_urls)} 张图片")
|
||||||
|
|
||||||
|
try:
|
||||||
|
video_url = await ai_video_generator.generate_video(image_urls)
|
||||||
|
design.video_url = video_url
|
||||||
|
db.commit()
|
||||||
|
db.refresh(design)
|
||||||
|
return design_to_response(design)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"视频生成失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"视频生成失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{design_id}/generate-3d", response_model=DesignResponse)
|
||||||
|
async def generate_3d_model(
|
||||||
|
design_id: int,
|
||||||
|
force: bool = Query(False, description="强制重新生成"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
为设计生成 3D 模型
|
||||||
|
收集所有多视角图片,取效果图(45度视角)生成 .glb 格式 3D 模型
|
||||||
|
"""
|
||||||
|
design = design_service.get_design_by_id(db=db, design_id=design_id, user_id=current_user.id)
|
||||||
|
if not design:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设计不存在")
|
||||||
|
|
||||||
|
# 检查是否已有 3D 模型(第一张图片)
|
||||||
|
if design.images and not force:
|
||||||
|
first_img = sorted(design.images, key=lambda x: x.sort_order)[0]
|
||||||
|
if first_img.model_3d_url:
|
||||||
|
return design_to_response(design)
|
||||||
|
|
||||||
|
# 强制重生时删除旧文件
|
||||||
|
if force and design.images:
|
||||||
|
first_img = sorted(design.images, key=lambda x: x.sort_order)[0]
|
||||||
|
if first_img.model_3d_url:
|
||||||
|
_delete_local_file(first_img.model_3d_url)
|
||||||
|
first_img.model_3d_url = None
|
||||||
|
|
||||||
|
# 收集所有多视角图片 URL 和视角名称
|
||||||
|
image_urls = []
|
||||||
|
view_names = []
|
||||||
|
if design.images:
|
||||||
|
for img in sorted(design.images, key=lambda x: x.sort_order):
|
||||||
|
if img.image_url:
|
||||||
|
image_urls.append(img.image_url)
|
||||||
|
view_names.append(img.view_name or "效果图")
|
||||||
|
if not image_urls and design.image_url:
|
||||||
|
image_urls.append(design.image_url)
|
||||||
|
view_names.append("效果图")
|
||||||
|
|
||||||
|
if not image_urls:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="设计没有图片,无法生成3D模型")
|
||||||
|
|
||||||
|
try:
|
||||||
|
model_url = await ai_3d_generator.generate_3d_model(image_urls, view_names)
|
||||||
|
# 将 3D 模型 URL 保存到第一张图片
|
||||||
|
if design.images:
|
||||||
|
first_img = sorted(design.images, key=lambda x: x.sort_order)[0]
|
||||||
|
first_img.model_3d_url = model_url
|
||||||
|
db.commit()
|
||||||
|
db.refresh(design)
|
||||||
|
return design_to_response(design)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"3D 模型生成失败: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"3D 模型生成失败: {str(e)}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class DesignImageResponse(BaseModel):
|
|||||||
model_used: Optional[str] = None
|
model_used: Optional[str] = None
|
||||||
prompt_used: Optional[str] = None
|
prompt_used: Optional[str] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
model_3d_url: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -51,6 +52,7 @@ class DesignResponse(BaseModel):
|
|||||||
surface_finish: Optional[str] = None
|
surface_finish: Optional[str] = None
|
||||||
usage_scene: Optional[str] = None
|
usage_scene: Optional[str] = None
|
||||||
image_url: Optional[str] = None
|
image_url: Optional[str] = None
|
||||||
|
video_url: Optional[str] = None
|
||||||
images: List[DesignImageResponse] = []
|
images: List[DesignImageResponse] = []
|
||||||
status: str
|
status: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
309
backend/app/services/ai_3d_generator.py
Normal file
309
backend/app/services/ai_3d_generator.py
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
"""
|
||||||
|
AI 3D 模型生成服务
|
||||||
|
使用腾讯混元3D (Hunyuan3D) API 将设计图片生成 3D 模型(.glb 格式)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config_service import get_config_value
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 腾讯混元3D API 配置
|
||||||
|
HUNYUAN3D_HOST = "ai3d.tencentcloudapi.com"
|
||||||
|
HUNYUAN3D_SERVICE = "ai3d"
|
||||||
|
HUNYUAN3D_VERSION = "2025-05-13"
|
||||||
|
HUNYUAN3D_REGION = "ap-guangzhou"
|
||||||
|
SUBMIT_TIMEOUT = 30
|
||||||
|
POLL_TIMEOUT = 15
|
||||||
|
MAX_POLL_ATTEMPTS = 120 # 约 10 分钟
|
||||||
|
POLL_INTERVAL = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tencent_credentials() -> tuple:
|
||||||
|
"""获取腾讯云 SecretId 和 SecretKey"""
|
||||||
|
secret_id = get_config_value("TENCENT_SECRET_ID", "")
|
||||||
|
secret_key = get_config_value("TENCENT_SECRET_KEY", "")
|
||||||
|
if not secret_id or not secret_key:
|
||||||
|
raise RuntimeError("未配置 TENCENT_SECRET_ID 或 TENCENT_SECRET_KEY,无法生成 3D 模型")
|
||||||
|
return secret_id, secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def _sign_tc3(secret_key: str, date: str, service: str, string_to_sign: str) -> str:
|
||||||
|
"""TC3-HMAC-SHA256 签名"""
|
||||||
|
def _hmac_sha256(key: bytes, msg: str) -> bytes:
|
||||||
|
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||||
|
|
||||||
|
secret_date = _hmac_sha256(("TC3" + secret_key).encode("utf-8"), date)
|
||||||
|
secret_service = _hmac_sha256(secret_date, service)
|
||||||
|
secret_signing = _hmac_sha256(secret_service, "tc3_request")
|
||||||
|
return hmac.new(secret_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_auth_headers(secret_id: str, secret_key: str, action: str, payload: str) -> dict:
|
||||||
|
"""构造腾讯云 API TC3-HMAC-SHA256 签名请求头"""
|
||||||
|
timestamp = int(time.time())
|
||||||
|
date = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# 1. 拼接规范请求串
|
||||||
|
http_request_method = "POST"
|
||||||
|
canonical_uri = "/"
|
||||||
|
canonical_querystring = ""
|
||||||
|
ct = "application/json; charset=utf-8"
|
||||||
|
canonical_headers = f"content-type:{ct}\nhost:{HUNYUAN3D_HOST}\nx-tc-action:{action.lower()}\n"
|
||||||
|
signed_headers = "content-type;host;x-tc-action"
|
||||||
|
hashed_payload = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||||
|
canonical_request = (
|
||||||
|
f"{http_request_method}\n{canonical_uri}\n{canonical_querystring}\n"
|
||||||
|
f"{canonical_headers}\n{signed_headers}\n{hashed_payload}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 拼接待签名字符串
|
||||||
|
algorithm = "TC3-HMAC-SHA256"
|
||||||
|
credential_scope = f"{date}/{HUNYUAN3D_SERVICE}/tc3_request"
|
||||||
|
hashed_canonical = hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()
|
||||||
|
string_to_sign = f"{algorithm}\n{timestamp}\n{credential_scope}\n{hashed_canonical}"
|
||||||
|
|
||||||
|
# 3. 计算签名
|
||||||
|
signature = _sign_tc3(secret_key, date, HUNYUAN3D_SERVICE, string_to_sign)
|
||||||
|
|
||||||
|
# 4. 拼接 Authorization
|
||||||
|
authorization = (
|
||||||
|
f"{algorithm} Credential={secret_id}/{credential_scope}, "
|
||||||
|
f"SignedHeaders={signed_headers}, Signature={signature}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Authorization": authorization,
|
||||||
|
"Content-Type": ct,
|
||||||
|
"Host": HUNYUAN3D_HOST,
|
||||||
|
"X-TC-Action": action,
|
||||||
|
"X-TC-Version": HUNYUAN3D_VERSION,
|
||||||
|
"X-TC-Region": HUNYUAN3D_REGION,
|
||||||
|
"X-TC-Timestamp": str(timestamp),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _call_hunyuan3d_api(action: str, params: dict) -> dict:
|
||||||
|
"""调用腾讯混元3D API 通用方法"""
|
||||||
|
secret_id, secret_key = _get_tencent_credentials()
|
||||||
|
payload = json.dumps(params)
|
||||||
|
headers = _build_auth_headers(secret_id, secret_key, action, payload)
|
||||||
|
url = f"https://{HUNYUAN3D_HOST}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=SUBMIT_TIMEOUT) as client:
|
||||||
|
resp = await client.post(url, content=payload, headers=headers)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# 检查腾讯云 API 错误
|
||||||
|
response = data.get("Response", {})
|
||||||
|
error = response.get("Error")
|
||||||
|
if error:
|
||||||
|
code = error.get("Code", "Unknown")
|
||||||
|
message = error.get("Message", "未知错误")
|
||||||
|
raise RuntimeError(f"混元3D API 错误 [{code}]: {message}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# 视角名称 → 混元3D MultiViewImages 视角映射
|
||||||
|
# 默认3.0版本只支持 back, left, right
|
||||||
|
_VIEW_NAME_MAP = {
|
||||||
|
"侧面图": "left",
|
||||||
|
"背面图": "back",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_3d_model(image_urls: list, view_names: Optional[list] = None) -> str:
|
||||||
|
"""
|
||||||
|
调用腾讯混元3D 专业版 API 将图片生成 3D 模型
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image_urls: 多视角图片 URL 列表
|
||||||
|
view_names: 对应的视角名称列表(效果图/正面图/侧面图/背面图)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
.glb 3D 模型文件的远程 URL
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: 3D 模型生成失败
|
||||||
|
"""
|
||||||
|
if not image_urls:
|
||||||
|
raise RuntimeError("没有可用的图片,无法生成 3D 模型")
|
||||||
|
|
||||||
|
if not view_names:
|
||||||
|
view_names = ["效果图"] + ["未知"] * (len(image_urls) - 1)
|
||||||
|
|
||||||
|
# 选择主图(正面图优先,其次效果图,否则第一张)
|
||||||
|
main_url = None
|
||||||
|
multi_views = []
|
||||||
|
|
||||||
|
for url, name in zip(image_urls, view_names):
|
||||||
|
if name == "正面图" and not main_url:
|
||||||
|
main_url = url
|
||||||
|
elif name in _VIEW_NAME_MAP:
|
||||||
|
multi_views.append({"ViewType": _VIEW_NAME_MAP[name], "ViewImageUrl": url})
|
||||||
|
elif not main_url:
|
||||||
|
main_url = url
|
||||||
|
# 其他未知视角跳过
|
||||||
|
|
||||||
|
# 如果没有找到主图,用第一张
|
||||||
|
if not main_url:
|
||||||
|
main_url = image_urls[0]
|
||||||
|
|
||||||
|
logger.info(f"3D 模型生成: 主图=1张, 多视角={len(multi_views)}张")
|
||||||
|
|
||||||
|
# Step 1: 提交图生3D任务(统一使用专业版)
|
||||||
|
job_id = await _submit_3d_task(main_url, multi_views)
|
||||||
|
logger.info(f"3D 模型生成任务已提交(专业版): job_id={job_id}")
|
||||||
|
|
||||||
|
# Step 2: 轮询等待结果
|
||||||
|
remote_url = await _poll_3d_result(job_id)
|
||||||
|
logger.info(f"3D 模型生成完成: {remote_url[:80]}...")
|
||||||
|
|
||||||
|
# Step 3: 下载模型文件到本地(解决 CORS 跨域问题)
|
||||||
|
model_url = await _download_model_to_local(remote_url)
|
||||||
|
logger.info(f"模型文件已保存到本地: {model_url}")
|
||||||
|
|
||||||
|
return model_url
|
||||||
|
|
||||||
|
|
||||||
|
async def _submit_3d_task(image_url: str, multi_views: Optional[list] = None) -> str:
|
||||||
|
"""
|
||||||
|
提交图生3D任务到腾讯混元3D 专业版
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
job_id
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"ImageUrl": image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 有多视角图片则附带
|
||||||
|
if multi_views:
|
||||||
|
params["MultiViewImages"] = multi_views
|
||||||
|
|
||||||
|
response = await _call_hunyuan3d_api("SubmitHunyuanTo3DProJob", params)
|
||||||
|
|
||||||
|
job_id = response.get("JobId")
|
||||||
|
if not job_id:
|
||||||
|
raise RuntimeError(f"混元3D 响应中未找到 JobId: {response}")
|
||||||
|
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _poll_3d_result(job_id: str) -> str:
|
||||||
|
"""
|
||||||
|
轮询 3D 模型生成结果(专业版)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
远程模型文件 URL
|
||||||
|
"""
|
||||||
|
query_action = "QueryHunyuanTo3DProJob"
|
||||||
|
|
||||||
|
for attempt in range(1, MAX_POLL_ATTEMPTS + 1):
|
||||||
|
await asyncio.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await _call_hunyuan3d_api(query_action, {"JobId": job_id})
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning(f"轮询 3D 结果失败 (attempt={attempt}): {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = response.get("Status", "")
|
||||||
|
|
||||||
|
if status == "DONE":
|
||||||
|
result_files = response.get("ResultFile3Ds", [])
|
||||||
|
logger.info(f"3D 结果文件列表: {result_files}")
|
||||||
|
# 优先找 ZIP 格式(包含所有格式:GLB/OBJ/FBX/STL等)
|
||||||
|
for f in result_files:
|
||||||
|
if f.get("Type", "").upper() == "ZIP":
|
||||||
|
return f.get("Url", "")
|
||||||
|
# 没有 ZIP,找 GLB
|
||||||
|
for f in result_files:
|
||||||
|
if f.get("Type", "").upper() == "GLB":
|
||||||
|
return f.get("Url", "")
|
||||||
|
# 都没有,取第一个有 URL 的文件
|
||||||
|
for f in result_files:
|
||||||
|
file_url = f.get("Url", "")
|
||||||
|
if file_url:
|
||||||
|
return file_url
|
||||||
|
raise RuntimeError(f"3D 模型生成成功但未找到模型 URL: {response}")
|
||||||
|
|
||||||
|
elif status == "FAIL":
|
||||||
|
error_code = response.get("ErrorCode", "")
|
||||||
|
error_msg = response.get("ErrorMessage", "未知错误")
|
||||||
|
raise RuntimeError(f"3D 模型生成失败 [{error_code}]: {error_msg}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
if attempt % 6 == 0:
|
||||||
|
logger.info(f"3D 模型生成中... (attempt={attempt}, status={status})")
|
||||||
|
|
||||||
|
raise RuntimeError(f"3D 模型生成超时: 轮询 {MAX_POLL_ATTEMPTS} 次后仍未完成")
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_model_to_local(remote_url: str) -> str:
|
||||||
|
"""
|
||||||
|
下载远程模型文件到本地 uploads/models/ 目录
|
||||||
|
支持 .zip(解压提取 .glb 并同时保留 zip)和直接的 .glb 文件
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
本地路径(/uploads/models/xxx.glb)
|
||||||
|
"""
|
||||||
|
# 确保目录存在
|
||||||
|
models_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "models")
|
||||||
|
os.makedirs(models_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# 下载远程文件
|
||||||
|
async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client:
|
||||||
|
resp = await client.get(remote_url)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"下载 3D 模型文件失败: HTTP {resp.status_code}")
|
||||||
|
file_data = resp.content
|
||||||
|
|
||||||
|
url_path = remote_url.split('?')[0].lower()
|
||||||
|
file_id = uuid.uuid4().hex
|
||||||
|
|
||||||
|
# 判断是否为 zip 文件(检查 URL 后缀 或 文件头 PK)
|
||||||
|
is_zip = url_path.endswith('.zip') or file_data[:2] == b'PK'
|
||||||
|
|
||||||
|
if is_zip:
|
||||||
|
# 保存原始 zip 包(供用户下载完整模型)
|
||||||
|
zip_path = os.path.join(models_dir, f"{file_id}.zip")
|
||||||
|
with open(zip_path, "wb") as f:
|
||||||
|
f.write(file_data)
|
||||||
|
logger.info(f"ZIP 包已保存: {zip_path} ({len(file_data)} bytes)")
|
||||||
|
|
||||||
|
# 解压提取 glb
|
||||||
|
glb_content = None
|
||||||
|
with zipfile.ZipFile(io.BytesIO(file_data)) as zf:
|
||||||
|
logger.info(f"ZIP 文件内容: {zf.namelist()}")
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.lower().endswith('.glb'):
|
||||||
|
glb_content = zf.read(name)
|
||||||
|
logger.info(f"从 zip 中提取 GLB: {name} ({len(glb_content)} bytes)")
|
||||||
|
break
|
||||||
|
|
||||||
|
if glb_content is None:
|
||||||
|
raise RuntimeError("zip 中未找到 .glb 文件")
|
||||||
|
file_data = glb_content
|
||||||
|
|
||||||
|
# 保存 glb 文件
|
||||||
|
glb_path = os.path.join(models_dir, f"{file_id}.glb")
|
||||||
|
with open(glb_path, "wb") as f:
|
||||||
|
f.write(file_data)
|
||||||
|
|
||||||
|
logger.info(f"GLB 文件已保存: {glb_path} ({len(file_data)} bytes)")
|
||||||
|
return f"/uploads/models/{file_id}.glb"
|
||||||
392
backend/app/services/ai_video_generator.py
Normal file
392
backend/app/services/ai_video_generator.py
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
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}"
|
||||||
BIN
backend/uploads/models/506909359d6c45fe9e43108ee7765a9a.glb
Normal file
BIN
backend/uploads/models/506909359d6c45fe9e43108ee7765a9a.glb
Normal file
Binary file not shown.
BIN
backend/uploads/videos/057c26e36b53432e8f169ed68e5acb2b.mp4
Normal file
BIN
backend/uploads/videos/057c26e36b53432e8f169ed68e5acb2b.mp4
Normal file
Binary file not shown.
119
frontend/package-lock.json
generated
119
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"@google/model-viewer": "^4.2.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
@@ -148,12 +149,55 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@google/model-viewer": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@google/model-viewer/-/model-viewer-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-RjpAI5cLs9CdvPcMRsOs8Bea/lNmGTTyaPyl16o9Fv6Qn8VSpgBMmXFr/11yb0hTrsojp2dOACEcY77R8hVUVA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@monogrid/gainmap-js": "^3.1.0",
|
||||||
|
"lit": "^3.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": "^0.182.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@lit-labs/ssr-dom-shim": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@lit/reactive-element": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@lit-labs/ssr-dom-shim": "^1.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@monogrid/gainmap-js": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"promise-worker-transferable": "^1.0.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"three": ">= 0.159.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
|
||||||
@@ -800,6 +844,12 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/web-bluetooth": {
|
"node_modules/@types/web-bluetooth": {
|
||||||
"version": "0.0.20",
|
"version": "0.0.20",
|
||||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||||
@@ -1457,6 +1507,12 @@
|
|||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.5",
|
"version": "5.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
|
||||||
@@ -1489,6 +1545,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-promise": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-what": {
|
"node_modules/is-what": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||||
@@ -1501,6 +1563,15 @@
|
|||||||
"url": "https://github.com/sponsors/mesqueeb"
|
"url": "https://github.com/sponsors/mesqueeb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1762,6 +1833,37 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lit": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@lit/reactive-element": "^2.1.0",
|
||||||
|
"lit-element": "^4.2.0",
|
||||||
|
"lit-html": "^3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lit-element": {
|
||||||
|
"version": "4.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz",
|
||||||
|
"integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@lit-labs/ssr-dom-shim": "^1.5.0",
|
||||||
|
"@lit/reactive-element": "^2.1.0",
|
||||||
|
"lit-html": "^3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lit-html": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
@@ -1956,6 +2058,16 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/promise-worker-transferable": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"is-promise": "^2.1.0",
|
||||||
|
"lie": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -2074,6 +2186,13 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.182.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"@google/model-viewer": "^4.2.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"element-plus": "^2.13.6",
|
"element-plus": "^2.13.6",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface DesignImage {
|
|||||||
model_used: string | null
|
model_used: string | null
|
||||||
prompt_used: string | null
|
prompt_used: string | null
|
||||||
sort_order: number
|
sort_order: number
|
||||||
|
model_3d_url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Design {
|
export interface Design {
|
||||||
@@ -34,6 +35,7 @@ export interface Design {
|
|||||||
surface_finish: string | null
|
surface_finish: string | null
|
||||||
usage_scene: string | null
|
usage_scene: string | null
|
||||||
image_url: string | null
|
image_url: string | null
|
||||||
|
video_url: string | null
|
||||||
images: DesignImage[]
|
images: DesignImage[]
|
||||||
status: string
|
status: string
|
||||||
created_at: string
|
created_at: string
|
||||||
@@ -84,3 +86,15 @@ export function deleteDesignApi(id: number) {
|
|||||||
export function getDesignDownloadUrl(id: number) {
|
export function getDesignDownloadUrl(id: number) {
|
||||||
return `/designs/${id}/download`
|
return `/designs/${id}/download`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成 360 度展示视频
|
||||||
|
export function generateVideoApi(id: number, force: boolean = false) {
|
||||||
|
const query = force ? '?force=true' : ''
|
||||||
|
return request.post<any, Design>(`/designs/${id}/generate-video${query}`, {}, { timeout: 600000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 3D 模型
|
||||||
|
export function generate3DModelApi(id: number, force: boolean = false) {
|
||||||
|
const query = force ? '?force=true' : ''
|
||||||
|
return request.post<any, Design>(`/designs/${id}/generate-3d${query}`, {}, { timeout: 600000 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 设计信息 -->
|
<!-- 360° 旋转展示区(基于3D模型) -->
|
||||||
|
<div class="model3d-section" v-if="has3DModel || generating3D">
|
||||||
|
<h4 class="section-title">360° 旋转展示</h4>
|
||||||
|
<div v-if="generating3D" class="generating-state">
|
||||||
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
|
<span>正在生成 3D 模型,请稍候...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="current3DModelUrl" class="model-viewer-wrapper">
|
||||||
|
<model-viewer
|
||||||
|
ref="modelViewerRef"
|
||||||
|
:src="current3DModelUrl"
|
||||||
|
alt="玉雕 3D 模型"
|
||||||
|
auto-rotate
|
||||||
|
auto-rotate-delay="0"
|
||||||
|
rotation-per-second="36deg"
|
||||||
|
camera-controls
|
||||||
|
shadow-intensity="1"
|
||||||
|
environment-image="neutral"
|
||||||
|
exposure="1.2"
|
||||||
|
style="width: 100%; height: 500px;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<!-- 3D 模型已生成时显示操作按钮 -->
|
||||||
|
<div v-if="current3DModelUrl && !generating3D" class="model3d-actions">
|
||||||
|
<button class="model3d-action-btn" @click="handleDownload3DModel">
|
||||||
|
<el-icon><Download /></el-icon>
|
||||||
|
<span>下载3D模型</span>
|
||||||
|
</button>
|
||||||
|
<button class="model3d-action-btn" @click="handleRecordVideo" :disabled="recording">
|
||||||
|
<el-icon><VideoCameraFilled /></el-icon>
|
||||||
|
<span>{{ recording ? `录制中... ${recordProgress}%` : '录制展示视频' }}</span>
|
||||||
|
</button>
|
||||||
|
<button class="model3d-action-btn" @click="handleRegen3D">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
<span>重新生成</span>
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
@@ -93,6 +130,16 @@
|
|||||||
<el-icon><Download /></el-icon>
|
<el-icon><Download /></el-icon>
|
||||||
<span>{{ downloading ? '下载中...' : '下载设计图' }}</span>
|
<span>{{ downloading ? '下载中...' : '下载设计图' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 生成 3D 模型按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="!has3DModel && !generating3D"
|
||||||
|
class="action-btn model3d-btn"
|
||||||
|
@click="handleGenerate3D"
|
||||||
|
:disabled="generating3D"
|
||||||
|
>
|
||||||
|
<el-icon><Platform /></el-icon>
|
||||||
|
<span>生成3D模型</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>
|
||||||
@@ -104,10 +151,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Loading, PictureFilled, ZoomIn, ZoomOut, RefreshRight, Download, User } 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 } from '@/api/design'
|
import { getDesignDownloadUrl, generate3DModelApi } from '@/api/design'
|
||||||
import request from '@/api/request'
|
import request from '@/api/request'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -116,12 +163,22 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
// model-viewer ref
|
||||||
|
const modelViewerRef = ref<any>(null)
|
||||||
|
|
||||||
|
// 录制状态
|
||||||
|
const recording = ref(false)
|
||||||
|
const recordProgress = ref(0)
|
||||||
|
|
||||||
// 当前视角索引
|
// 当前视角索引
|
||||||
const activeViewIndex = ref(0)
|
const activeViewIndex = ref(0)
|
||||||
|
|
||||||
// 缩放比例
|
// 缩放比例
|
||||||
const scale = ref(1)
|
const scale = ref(1)
|
||||||
|
|
||||||
|
// 3D 模型生成状态
|
||||||
|
const generating3D = 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
|
||||||
@@ -135,6 +192,66 @@ const activeViewName = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 是否有 3D 模型
|
||||||
|
const has3DModel = computed(() => {
|
||||||
|
if (props.design.images && props.design.images.length > 0) {
|
||||||
|
return props.design.images.some(img => !!img.model_3d_url)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前视角的 3D 模型 URL
|
||||||
|
const current3DModelUrl = computed(() => {
|
||||||
|
if (props.design.images && props.design.images.length > 0) {
|
||||||
|
const img = props.design.images[activeViewIndex.value]
|
||||||
|
if (img?.model_3d_url) return img.model_3d_url
|
||||||
|
// fallback 到第一张有 3D 模型的图
|
||||||
|
const withModel = props.design.images.find(i => !!i.model_3d_url)
|
||||||
|
return withModel?.model_3d_url || ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// 生成 3D 模型
|
||||||
|
const handleGenerate3D = async () => {
|
||||||
|
generating3D.value = true
|
||||||
|
try {
|
||||||
|
const updated = await generate3DModelApi(props.design.id)
|
||||||
|
if (updated.images) {
|
||||||
|
updated.images.forEach((img: any, idx: number) => {
|
||||||
|
if (props.design.images[idx]) {
|
||||||
|
props.design.images[idx].model_3d_url = img.model_3d_url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ElMessage.success('3D 模型生成成功!')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '3D 模型生成失败,请重试')
|
||||||
|
} finally {
|
||||||
|
generating3D.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新生成 3D 模型
|
||||||
|
const handleRegen3D = async () => {
|
||||||
|
generating3D.value = true
|
||||||
|
try {
|
||||||
|
const updated = await generate3DModelApi(props.design.id, true)
|
||||||
|
if (updated.images) {
|
||||||
|
updated.images.forEach((img: any, idx: number) => {
|
||||||
|
if (props.design.images[idx]) {
|
||||||
|
props.design.images[idx].model_3d_url = img.model_3d_url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ElMessage.success('3D 模型重新生成成功!')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.detail || '3D 模型重新生成失败')
|
||||||
|
} finally {
|
||||||
|
generating3D.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取图片URL
|
// 获取图片URL
|
||||||
const toImageUrl = (url: string | null): string => {
|
const toImageUrl = (url: string | null): string => {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
@@ -231,6 +348,168 @@ const resetZoom = () => {
|
|||||||
scale.value = 1
|
scale.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 下载 3D 模型 zip 包
|
||||||
|
const handleDownload3DModel = async () => {
|
||||||
|
const glbUrl = current3DModelUrl.value
|
||||||
|
if (!glbUrl) {
|
||||||
|
ElMessage.error('3D 模型不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const category = props.design.category?.name || '设计'
|
||||||
|
const subType = props.design.sub_type?.name || ''
|
||||||
|
|
||||||
|
// 先尝试下载 zip 包(同名不同后缀),注意不能用 request(会加 /api 前缀)
|
||||||
|
const zipUrl = glbUrl.replace(/\.glb$/, '.zip')
|
||||||
|
try {
|
||||||
|
const zipRes = await fetch(zipUrl)
|
||||||
|
if (zipRes.ok) {
|
||||||
|
const blob = await zipRes.blob()
|
||||||
|
const filename = `${category}${subType ? '-' + subType : ''}-3D模型-${props.design.id}.zip`
|
||||||
|
_downloadBlob(blob, filename)
|
||||||
|
ElMessage.success('3D 模型包下载成功')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// zip 不存在,继续尝试 glb
|
||||||
|
}
|
||||||
|
|
||||||
|
// zip 不存在,下载 glb
|
||||||
|
try {
|
||||||
|
const glbRes = await fetch(glbUrl)
|
||||||
|
if (!glbRes.ok) throw new Error('下载失败')
|
||||||
|
const blob = await glbRes.blob()
|
||||||
|
const filename = `${category}${subType ? '-' + subType : ''}-3D模型-${props.design.id}.glb`
|
||||||
|
_downloadBlob(blob, filename)
|
||||||
|
ElMessage.success('3D 模型下载成功')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('3D 模型下载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用 Blob 下载工具
|
||||||
|
const _downloadBlob = (blobData: any, filename: string) => {
|
||||||
|
const blob = new Blob([blobData])
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制 model-viewer 旋转一圈的展示视频
|
||||||
|
// 程序化控制旋转角度 + toDataURL 逐帧截图(WebGL canvas 无法直接 captureStream/drawImage)
|
||||||
|
const handleRecordVideo = async () => {
|
||||||
|
const mv = modelViewerRef.value as any
|
||||||
|
if (!mv) {
|
||||||
|
ElMessage.error('3D 模型未加载')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
recording.value = true
|
||||||
|
recordProgress.value = 0
|
||||||
|
ElMessage.info('正在录制展示视频,请稍候...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取画布尺寸
|
||||||
|
const srcCanvas = mv.shadowRoot?.querySelector('canvas') as HTMLCanvasElement
|
||||||
|
const width = srcCanvas?.width || 800
|
||||||
|
const height = srcCanvas?.height || 600
|
||||||
|
|
||||||
|
// 创建 2D 中转 canvas
|
||||||
|
const recordCanvas = document.createElement('canvas')
|
||||||
|
recordCanvas.width = width
|
||||||
|
recordCanvas.height = height
|
||||||
|
const ctx = recordCanvas.getContext('2d')!
|
||||||
|
|
||||||
|
// 视频参数
|
||||||
|
const totalFrames = 180 // 6秒 x 30fps
|
||||||
|
const fps = 30
|
||||||
|
const stream = recordCanvas.captureStream(fps)
|
||||||
|
|
||||||
|
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
|
||||||
|
? 'video/webm;codecs=vp9'
|
||||||
|
: 'video/webm'
|
||||||
|
|
||||||
|
const recorder = new MediaRecorder(stream, {
|
||||||
|
mimeType,
|
||||||
|
videoBitsPerSecond: 5000000
|
||||||
|
})
|
||||||
|
const chunks: Blob[] = []
|
||||||
|
|
||||||
|
recorder.ondataavailable = (e) => {
|
||||||
|
if (e.data.size > 0) chunks.push(e.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
// 恢复自动旋转
|
||||||
|
mv.setAttribute('auto-rotate', '')
|
||||||
|
mv.removeAttribute('interaction-prompt')
|
||||||
|
|
||||||
|
const blob = new Blob(chunks, { type: mimeType })
|
||||||
|
if (blob.size < 1000) {
|
||||||
|
ElMessage.error('视频录制失败,文件为空')
|
||||||
|
recording.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const category = props.design.category?.name || '设计'
|
||||||
|
const subType = props.design.sub_type?.name || ''
|
||||||
|
const filename = `${category}${subType ? '-' + subType : ''}-360度展示-${props.design.id}.webm`
|
||||||
|
_downloadBlob(blob, filename)
|
||||||
|
recording.value = false
|
||||||
|
recordProgress.value = 0
|
||||||
|
ElMessage.success('展示视频已下载')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暂停自动旋转,由程序控制角度
|
||||||
|
mv.removeAttribute('auto-rotate')
|
||||||
|
mv.setAttribute('interaction-prompt', 'none')
|
||||||
|
|
||||||
|
// 开始录制
|
||||||
|
recorder.start(100)
|
||||||
|
|
||||||
|
// 逐帧旋转 + 截图
|
||||||
|
for (let frame = 0; frame < totalFrames; frame++) {
|
||||||
|
const angle = (frame / totalFrames) * 360
|
||||||
|
// 设置相机轨道位置(水平角度 垂直角度 距离)
|
||||||
|
mv.cameraOrbit = `${angle}deg 75deg auto`
|
||||||
|
|
||||||
|
// 等待渲染
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 用 model-viewer 的 toDataURL 截图(内部会处理 preserveDrawingBuffer)
|
||||||
|
try {
|
||||||
|
const dataUrl = mv.toDataURL('image/jpeg', 0.85)
|
||||||
|
const img = new Image()
|
||||||
|
img.src = dataUrl
|
||||||
|
await img.decode()
|
||||||
|
ctx.drawImage(img, 0, 0, width, height)
|
||||||
|
} catch {
|
||||||
|
// 忽略单帧失败
|
||||||
|
}
|
||||||
|
|
||||||
|
recordProgress.value = Math.round(((frame + 1) / totalFrames) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制完成
|
||||||
|
recorder.stop()
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
// 恢复自动旋转
|
||||||
|
mv.setAttribute('auto-rotate', '')
|
||||||
|
mv.removeAttribute('interaction-prompt')
|
||||||
|
ElMessage.error(e.message || '录制视频失败')
|
||||||
|
recording.value = false
|
||||||
|
recordProgress.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 跳转到用户中心
|
// 跳转到用户中心
|
||||||
const goToUserCenter = () => {
|
const goToUserCenter = () => {
|
||||||
ElMessage.success('设计已自动保存到您的设计历史中')
|
ElMessage.success('设计已自动保存到您的设计历史中')
|
||||||
@@ -503,4 +782,89 @@ $text-light: #999999;
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model3d-btn {
|
||||||
|
background: #8E44AD;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #7D3C98;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(#8E44AD, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model3d-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: $text-primary;
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid $border-color;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generating-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: $text-secondary;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model3d-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model3d-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary-color;
|
||||||
|
border-color: $primary-color;
|
||||||
|
background: rgba($primary-color, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
color: $secondary-color;
|
||||||
|
border-color: $secondary-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-viewer-wrapper {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: $bg-color;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import App from './App.vue'
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import './assets/styles/theme.scss'
|
import './assets/styles/theme.scss'
|
||||||
|
|
||||||
|
// 注册 model-viewer Web Component(用于 3D 模型展示)
|
||||||
|
import '@google/model-viewer'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
|
|||||||
@@ -97,6 +97,103 @@
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 即梦视频生成配置卡片 -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title-row">
|
||||||
|
<h3 class="section-title">即梦 3.0 Pro 视频生成</h3>
|
||||||
|
<el-tag :type="volcVideoStatus" size="small">
|
||||||
|
{{ volcVideoStatusText }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<p class="card-desc">火山引擎即梦 3.0 Pro 图生视频 API,将设计图生成 360 度旋转展示视频。需要火山引擎 Access Key 和 Secret Key(非 API Key)</p>
|
||||||
|
</div>
|
||||||
|
<el-form label-width="120px" class="config-form">
|
||||||
|
<el-form-item label="Access Key">
|
||||||
|
<el-input
|
||||||
|
v-model="volcAccessKey"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入火山引擎 Access Key"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="Secret Key">
|
||||||
|
<el-input
|
||||||
|
v-model="volcSecretKey"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入火山引擎 Secret Key"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<span class="form-tip">获取方式:火山引擎控制台 → 右上角头像 → API访问密钥,或访问 https://console.volcengine.com/iam/keymanage/</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 label="视频提示词">
|
||||||
|
<el-input
|
||||||
|
v-model="videoPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="玉雕作品在摄影棚内缓慢旋转360度展示全貌..."
|
||||||
|
/>
|
||||||
|
<span class="form-tip">用于控制视频生成效果,尽情描述旋转展示方式、灯光、背景等</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 腾讯混元3D 配置卡片 -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title-row">
|
||||||
|
<h3 class="section-title">腾讯混元3D 模型生成</h3>
|
||||||
|
<el-tag :type="hunyuan3dStatus" size="small">
|
||||||
|
{{ hunyuan3dStatusText }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<p class="card-desc">腾讯混元3D 图生 3D 模型 API,将设计图转换为可交互的 3D 模型(.glb 格式)</p>
|
||||||
|
</div>
|
||||||
|
<el-form label-width="120px" class="config-form">
|
||||||
|
<el-form-item label="SecretId">
|
||||||
|
<el-input
|
||||||
|
v-model="tencentSecretId"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入腾讯云 SecretId"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="SecretKey">
|
||||||
|
<el-input
|
||||||
|
v-model="tencentSecretKey"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
placeholder="请输入腾讯云 SecretKey"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<span class="form-tip">获取方式:访问 <a href="https://console.cloud.tencent.com/cam/capi" target="_blank">https://console.cloud.tencent.com/cam/capi</a> 创建密钥,并在 <a href="https://console.cloud.tencent.com/ai3d" target="_blank">混元3D控制台</a> 开通服务</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="3D提示词">
|
||||||
|
<el-input
|
||||||
|
v-model="model3dPrompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="可选,用于控制3D模型生成效果"
|
||||||
|
/>
|
||||||
|
<span class="form-tip">可选,用于描述3D模型的生成效果要求</span>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 通用设置 -->
|
<!-- 通用设置 -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<h3 class="section-title">通用设置</h3>
|
<h3 class="section-title">通用设置</h3>
|
||||||
@@ -133,12 +230,23 @@ 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 volcSecretKey = ref('')
|
||||||
|
const tencentSecretId = ref('')
|
||||||
|
const tencentSecretKey = ref('')
|
||||||
|
const videoPrompt = ref('')
|
||||||
|
const videoFrames = ref('121')
|
||||||
|
const model3dPrompt = ref('')
|
||||||
const imageSize = ref('1024')
|
const imageSize = ref('1024')
|
||||||
const saving = ref(false)
|
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 volcSecretKeyConfigured = ref(false)
|
||||||
|
const tencentSecretIdConfigured = ref(false)
|
||||||
|
const tencentSecretKeyConfigured = ref(false)
|
||||||
|
|
||||||
// 测试状态
|
// 测试状态
|
||||||
const testingSiliconflow = ref(false)
|
const testingSiliconflow = ref(false)
|
||||||
@@ -151,6 +259,10 @@ 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 volcVideoStatusText = computed(() => ((volcAccessKey.value || volcAccessKeyConfigured.value) && (volcSecretKey.value || volcSecretKeyConfigured.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)) ? '已配置' : '未配置')
|
||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
const loadConfigs = async () => {
|
const loadConfigs = async () => {
|
||||||
@@ -176,6 +288,28 @@ 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
|
||||||
|
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']
|
||||||
|
}
|
||||||
|
if (map['VOLC_SECRET_KEY'] && !map['VOLC_SECRET_KEY'].includes('****')) {
|
||||||
|
volcSecretKey.value = map['VOLC_SECRET_KEY']
|
||||||
|
}
|
||||||
|
// 腾讯云 SecretId / SecretKey
|
||||||
|
tencentSecretIdConfigured.value = !!map['TENCENT_SECRET_ID']
|
||||||
|
tencentSecretKeyConfigured.value = !!map['TENCENT_SECRET_KEY']
|
||||||
|
if (map['TENCENT_SECRET_ID'] && !map['TENCENT_SECRET_ID'].includes('****')) {
|
||||||
|
tencentSecretId.value = map['TENCENT_SECRET_ID']
|
||||||
|
}
|
||||||
|
if (map['TENCENT_SECRET_KEY'] && !map['TENCENT_SECRET_KEY'].includes('****')) {
|
||||||
|
tencentSecretKey.value = map['TENCENT_SECRET_KEY']
|
||||||
|
}
|
||||||
|
// 提示词配置
|
||||||
|
videoPrompt.value = map['VIDEO_PROMPT'] || ''
|
||||||
|
videoFrames.value = map['VIDEO_FRAMES'] || '121'
|
||||||
|
model3dPrompt.value = map['MODEL3D_PROMPT'] || ''
|
||||||
imageSize.value = map['AI_IMAGE_SIZE'] || '1024'
|
imageSize.value = map['AI_IMAGE_SIZE'] || '1024'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载配置失败', e)
|
console.error('加载配置失败', e)
|
||||||
@@ -204,6 +338,22 @@ const handleSave = async () => {
|
|||||||
if (volcengineKey.value) {
|
if (volcengineKey.value) {
|
||||||
configs['VOLCENGINE_API_KEY'] = volcengineKey.value
|
configs['VOLCENGINE_API_KEY'] = volcengineKey.value
|
||||||
}
|
}
|
||||||
|
if (volcAccessKey.value) {
|
||||||
|
configs['VOLC_ACCESS_KEY'] = volcAccessKey.value
|
||||||
|
}
|
||||||
|
if (volcSecretKey.value) {
|
||||||
|
configs['VOLC_SECRET_KEY'] = volcSecretKey.value
|
||||||
|
}
|
||||||
|
if (tencentSecretId.value) {
|
||||||
|
configs['TENCENT_SECRET_ID'] = tencentSecretId.value
|
||||||
|
}
|
||||||
|
if (tencentSecretKey.value) {
|
||||||
|
configs['TENCENT_SECRET_KEY'] = tencentSecretKey.value
|
||||||
|
}
|
||||||
|
// 提示词始终提交(包括空值,允许清空)
|
||||||
|
configs['VIDEO_PROMPT'] = videoPrompt.value
|
||||||
|
configs['VIDEO_FRAMES'] = videoFrames.value
|
||||||
|
configs['MODEL3D_PROMPT'] = model3dPrompt.value
|
||||||
await updateConfigsBatch(configs)
|
await updateConfigsBatch(configs)
|
||||||
ElMessage.success('配置已保存')
|
ElMessage.success('配置已保存')
|
||||||
await loadConfigs()
|
await loadConfigs()
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [
|
||||||
|
vue({
|
||||||
|
template: {
|
||||||
|
compilerOptions: {
|
||||||
|
// model-viewer 是 Web Component,告知 Vue 不要尝试解析它
|
||||||
|
isCustomElement: (tag) => tag === 'model-viewer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ CREATE TABLE IF NOT EXISTS `designs` (
|
|||||||
`surface_finish` VARCHAR(50) DEFAULT NULL COMMENT '表面处理',
|
`surface_finish` VARCHAR(50) DEFAULT NULL COMMENT '表面处理',
|
||||||
`usage_scene` VARCHAR(50) DEFAULT NULL COMMENT '用途场景',
|
`usage_scene` VARCHAR(50) DEFAULT NULL COMMENT '用途场景',
|
||||||
`image_url` TEXT DEFAULT NULL COMMENT '设计图URL',
|
`image_url` TEXT DEFAULT NULL COMMENT '设计图URL',
|
||||||
|
`video_url` TEXT DEFAULT NULL COMMENT '360度展示视频URL',
|
||||||
`status` VARCHAR(20) DEFAULT 'generating' COMMENT '状态',
|
`status` VARCHAR(20) DEFAULT 'generating' COMMENT '状态',
|
||||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
@@ -113,6 +114,7 @@ CREATE TABLE IF NOT EXISTS design_images (
|
|||||||
model_used VARCHAR(50) DEFAULT NULL COMMENT '使用的AI模型',
|
model_used VARCHAR(50) DEFAULT NULL COMMENT '使用的AI模型',
|
||||||
prompt_used TEXT DEFAULT NULL COMMENT '实际使用的英文prompt',
|
prompt_used TEXT DEFAULT NULL COMMENT '实际使用的英文prompt',
|
||||||
sort_order INT DEFAULT 0 COMMENT '排序',
|
sort_order INT DEFAULT 0 COMMENT '排序',
|
||||||
|
model_3d_url TEXT DEFAULT NULL COMMENT '3D模型URL(.glb)',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
CONSTRAINT fk_design_images_design FOREIGN KEY (design_id) REFERENCES designs(id) ON DELETE CASCADE,
|
CONSTRAINT fk_design_images_design FOREIGN KEY (design_id) REFERENCES designs(id) ON DELETE CASCADE,
|
||||||
INDEX idx_design_images_design_id (design_id)
|
INDEX idx_design_images_design_id (design_id)
|
||||||
@@ -492,6 +494,13 @@ INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`,
|
|||||||
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (4, 'VOLCENGINE_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3', '火山引擎接口地址', 'ai', 'N', '2026-03-27 07:09:05');
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (4, 'VOLCENGINE_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3', '火山引擎接口地址', 'ai', 'N', '2026-03-27 07:09:05');
|
||||||
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (5, 'AI_IMAGE_MODEL', 'seedream-5.0', '默认AI生图模型 (flux-dev / seedream-4.5)', 'ai', 'N', '2026-03-27 08:20:02');
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (5, 'AI_IMAGE_MODEL', 'seedream-5.0', '默认AI生图模型 (flux-dev / seedream-4.5)', 'ai', 'N', '2026-03-27 08:20:02');
|
||||||
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (6, 'AI_IMAGE_SIZE', '1024', 'AI生图默认尺寸', 'ai', 'N', '2026-03-27 07:09:05');
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (6, 'AI_IMAGE_SIZE', '1024', 'AI生图默认尺寸', 'ai', 'N', '2026-03-27 07:09:05');
|
||||||
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (7, 'TENCENT_SECRET_ID', '', '腾讯云 SecretId (用于混元3D模型生成)', 'ai', 'Y', '2026-03-27 07:09:05');
|
||||||
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (13, 'TENCENT_SECRET_KEY', '', '腾讯云 SecretKey (用于混元3D模型生成)', 'ai', 'Y', '2026-03-27 07:09:05');
|
||||||
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (8, 'VOLC_ACCESS_KEY', '', '火山引擎 Access Key (用于即梦视频生成)', 'ai', 'Y', '2026-03-27 07:09:05');
|
||||||
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (9, 'VOLC_SECRET_KEY', '', '火山引擎 Secret Key (用于即梦视频生成)', 'ai', 'Y', '2026-03-27 07:09:05');
|
||||||
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (10, 'VIDEO_PROMPT', '玉雕作品在摄影棚内缓慢旋转360度展示全貌,专业珠宝摄影灯光,纯白色背景,平稳旋转,展示正面、侧面、背面各个角度,电影级画质', '视频生成默认提示词 (即梦 3.0 Pro 图生视频)', 'ai', 'N', '2026-03-27 07:09:05');
|
||||||
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (11, 'MODEL3D_PROMPT', '', '3D模型生成默认提示词 (混元3D备用)', 'ai', 'N', '2026-03-27 07:09:05');
|
||||||
|
INSERT INTO `system_configs` (`id`, `config_key`, `config_value`, `description`, `config_group`, `is_secret`, `updated_at`) VALUES (12, 'VIDEO_FRAMES', '121', '视频帧数: 49=2秒, 121=5秒', 'ai', 'N', '2026-03-27 07:09:05');
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user