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:
@@ -60,7 +60,44 @@
|
||||
</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">
|
||||
<h4 class="info-title">设计详情</h4>
|
||||
<div class="info-grid">
|
||||
@@ -93,6 +130,16 @@
|
||||
<el-icon><Download /></el-icon>
|
||||
<span>{{ downloading ? '下载中...' : '下载设计图' }}</span>
|
||||
</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">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>查看我的设计</span>
|
||||
@@ -104,10 +151,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
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 type { Design } from '@/stores/design'
|
||||
import { getDesignDownloadUrl } from '@/api/design'
|
||||
import { getDesignDownloadUrl, generate3DModelApi } from '@/api/design'
|
||||
import request from '@/api/request'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -116,12 +163,22 @@ const props = defineProps<{
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// model-viewer ref
|
||||
const modelViewerRef = ref<any>(null)
|
||||
|
||||
// 录制状态
|
||||
const recording = ref(false)
|
||||
const recordProgress = ref(0)
|
||||
|
||||
// 当前视角索引
|
||||
const activeViewIndex = ref(0)
|
||||
|
||||
// 缩放比例
|
||||
const scale = ref(1)
|
||||
|
||||
// 3D 模型生成状态
|
||||
const generating3D = ref(false)
|
||||
|
||||
// 是否有多视角图片
|
||||
const hasMultipleViews = computed(() => {
|
||||
return props.design.images && props.design.images.length > 1
|
||||
@@ -135,6 +192,66 @@ const activeViewName = computed(() => {
|
||||
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
|
||||
const toImageUrl = (url: string | null): string => {
|
||||
if (!url) return ''
|
||||
@@ -231,6 +348,168 @@ const resetZoom = () => {
|
||||
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 = () => {
|
||||
ElMessage.success('设计已自动保存到您的设计历史中')
|
||||
@@ -503,4 +782,89 @@ $text-light: #999999;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user