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:
2026-03-27 23:26:56 +08:00
parent a1f56b1f8e
commit 8f5a86418e
17 changed files with 1517 additions and 6 deletions

View File

@@ -1,17 +1,40 @@
"""
设计相关路由
提供设计生成、查询、删除、下载接口
提供设计生成、查询、删除、下载、视频生成、3D模型生成接口
"""
import os
import logging
from fastapi import APIRouter, Depends, HTTPException, status, Query
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import User, Design
from ..models import User, Design, DesignImage
from ..schemas import DesignCreate, DesignResponse, DesignListResponse, DesignImageResponse
from ..utils.deps import get_current_user
from ..services import design_service
from ..services import ai_video_generator
from ..services import ai_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=["设计"])
@@ -29,6 +52,7 @@ def design_to_response(design: Design) -> DesignResponse:
model_used=img.model_used,
prompt_used=img.prompt_used,
sort_order=img.sort_order,
model_3d_url=img.model_3d_url,
)
for img in design.images
]
@@ -66,6 +90,7 @@ def design_to_response(design: Design) -> DesignResponse:
surface_finish=design.surface_finish,
usage_scene=design.usage_scene,
image_url=design.image_url,
video_url=design.video_url,
images=images,
status=design.status,
created_at=design.created_at,
@@ -219,3 +244,115 @@ def download_design(
filename=f"design_{design_id}.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)}"
)