feat: 优化

This commit is contained in:
2025-11-22 23:20:03 +08:00
parent c35a2219c9
commit 4377dc8dc8

View File

@@ -1,8 +1,14 @@
<script setup>
/**
* 数字人视频生成页面
* 功能:用户上传视频,选择音色和文案,生成数字人视频
* 优化:移除冗余状态、统一试听逻辑、优化错误处理、清理废弃代码
* 试听优化添加缓存机制同一参数下第二次试听直接播放缓存无需重复调用API
*/
defineOptions({ name: 'DigitalVideoPage' })
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import { InboxOutlined } from '@ant-design/icons-vue'
import { InboxOutlined, SoundOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
@@ -23,18 +29,18 @@ const currentTaskStep = ref('')
const isSynthesizing = ref(false)
const synthesizedAudio = ref(null)
const previewLoadingVoiceId = ref('')
const isPlayingPreview = ref(false) // 是否正在播放试听音频
const playingPreviewVoiceId = ref('') // 当前正在试听的音色ID
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
const pollingInterval = ref(null) // 轮询间隔ID
// Base64音频缓存
const audioBase64Cache = new Map()
const AUDIO_CACHE_MAX_SIZE = 10 // 最多缓存10个音频
// 试听音频缓存 - 按音色ID缓存避免重复API调用
const previewAudioCache = new Map()
const MAX_PREVIEW_CACHE_SIZE = 50 // 最多缓存50个音色的试听音
// TTS 配置
const ttsText = ref('')
const selectedTtsVoice = ref('')
const speechRate = ref(1.0)
const speechRate = ref(1.5)
const instruction = ref('neutral') // 指令参数,用于控制音色风格
const emotion = ref('neutral') // 情感参数
const emotionActive = ref(false) // 是否激活情感tabfalse为指令true为情感
@@ -112,54 +118,131 @@ const selectVoiceProfile = (voice) => {
}
}
// 音频播放
// 音频试听 - 统一入口
const playVoiceSample = async (voice) => {
if (!voice) return
// 防止重复点击
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
// 防止重复点击同一个音色
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
return
}
// 用户音色和系统音色都走实时试听流程
return triggerVoicePreview(voice)
}
// 如果正在播放其他音色,先停止
if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) {
try {
previewAudio?.pause?.()
previewAudio = null
} catch {}
}
const triggerVoicePreview = async (voice) => {
// 防止重复点击
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
previewLoadingVoiceId.value = voice.id
playingPreviewVoiceId.value = voice.id
// 生成缓存key
const cacheKey = generatePreviewCacheKey(voice)
const cachedAudio = previewAudioCache.get(cacheKey)
// 如果有缓存,直接播放
if (cachedAudio) {
console.log('使用缓存音频:', voice.name)
playCachedAudio(cachedAudio, resetPreviewState)
return
}
previewLoadingVoiceId.value = voice.id
// 没有缓存调用API
try {
const params = buildPreviewParams(voice)
if (!params) {
previewLoadingVoiceId.value = ''
return
}
const res = await VoiceService.preview(params)
if (!params) return resetPreviewState()
if (res.code === 0 && res.data?.audioUrl) {
isPlayingPreview.value = true
playAudioPreview(res.data.audioUrl, {
onEnded: () => {
isPlayingPreview.value = false
}
})
} else if (res.code === 0 && res.data?.audioBase64) {
isPlayingPreview.value = true
playAudioFromBase64(res.data.audioBase64, res.data.format)
} else {
// 调试:打印试听参数
console.log(`试听音色: ${voice.name}, voiceId: ${voice.voiceId || voice.rawId}`)
const res = await VoiceService.preview(params)
if (res.code !== 0) {
message.error(res.msg || '试听失败')
return resetPreviewState()
}
// 调试:打印返回的音频信息
if (res.data?.audioBase64) {
console.log(`返回音频base64前32位: ${res.data.audioBase64.substring(0, 32)}...`)
}
// 根据返回类型播放音频并缓存
if (res.data?.audioUrl) {
// URL方式暂不缓存
playAudioPreview(res.data.audioUrl, { onEnded: resetPreviewState })
} else if (res.data?.audioBase64) {
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
playCachedAudio(audioData, resetPreviewState)
} else {
message.error('试听失败')
resetPreviewState()
}
} catch (error) {
console.error('preview error:', error)
message.error('试听失败')
} finally {
previewLoadingVoiceId.value = ''
resetPreviewState()
}
}
// 生成试听缓存key
const generatePreviewCacheKey = (voice) => {
const voiceId = voice.voiceId || voice.rawId || voice.id
const text = ttsText.value.trim()
const rate = speechRate.value
const inst = instruction.value
const emo = emotion.value
return `${voiceId}:${text}:${rate}:${inst}:${emo}`
}
// 解码base64并缓存
const decodeAndCacheBase64 = async (audioBase64, format = 'mp3', cacheKey) => {
const byteCharacters = window.atob(audioBase64)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime })
const objectUrl = URL.createObjectURL(blob)
const audioData = { blob, objectUrl, format }
// 缓存音频数据
previewAudioCache.set(cacheKey, audioData)
// 管理缓存大小
if (previewAudioCache.size > MAX_PREVIEW_CACHE_SIZE) {
const firstKey = previewAudioCache.keys().next().value
const oldData = previewAudioCache.get(firstKey)
URL.revokeObjectURL(oldData.objectUrl)
previewAudioCache.delete(firstKey)
}
return audioData
}
// 播放缓存的音频
const playCachedAudio = (audioData, onEnded) => {
// 清理旧的previewObjectUrl
if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) {
URL.revokeObjectURL(previewObjectUrl)
}
previewObjectUrl = audioData.objectUrl
playAudioPreview(previewObjectUrl, {
revokeOnEnd: false, // 缓存音频不释放,保留给下次播放
onEnded: () => {
onEnded && onEnded()
}
})
}
// 重置试听状态
const resetPreviewState = () => {
previewLoadingVoiceId.value = ''
playingPreviewVoiceId.value = ''
}
const buildPreviewParams = (voice) => {
if (voice.source === 'user') {
// 用户音色使用voiceConfigId不传instruction
@@ -172,7 +255,8 @@ const buildPreviewParams = (voice) => {
voiceConfigId: configId,
inputText: ttsText.value,
speechRate: speechRate.value || 1.0,
audioFormat: 'mp3'
audioFormat: 'mp3',
timestamp: Date.now() // 添加时间戳确保每次请求不同
}
} else {
// 系统音色根据是否选择instruction或emotion来决定传递哪个参数
@@ -180,7 +264,8 @@ const buildPreviewParams = (voice) => {
voiceId: voice.voiceId,
inputText: ttsText.value,
speechRate: speechRate.value || 1.0,
audioFormat: 'mp3'
audioFormat: 'mp3',
timestamp: Date.now() // 添加时间戳确保每次请求不同
}
// instruction和emotion只能选一个传递
@@ -199,7 +284,7 @@ const buildPreviewParams = (voice) => {
const extractIdFromString = (idStr) => {
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
const extractedId = parseInt(idStr.replace('user-', ''))
return isNaN(extractedId) ? null : extractedId
return Number.isNaN(extractedId) ? null : extractedId
}
// 语音合成
@@ -498,7 +583,7 @@ const startPollingTask = () => {
} catch (error) {
console.error('polling error:', error)
}
}, 10000) // 每2秒轮询一次
}, 10000) // 每10秒轮询一次
}
// 取消任务
@@ -577,93 +662,67 @@ const getStepText = (step) => {
const playAudioPreview = (url, options = {}) => {
if (!url) return message.warning('暂无可试听的音频')
// 暂停之前的音频
try {
previewAudio?.pause?.()
try {
previewAudio?.pause?.()
previewAudio = null
} catch {}
const audio = new Audio(url)
audio.play().then(() => {
previewAudio = audio
audio.onended = () => {
// 调用自定义结束回调
if (options.onEnded) {
options.onEnded()
}
// 清理blob URL
if (options.revokeOnEnd && url.startsWith('blob:')) {
URL.revokeObjectURL(url)
previewObjectUrl === url && (previewObjectUrl = '')
}
previewAudio = null
}
audio.onerror = () => {
// 播放失败时也要重置状态
if (options.onEnded) {
options.onEnded()
}
previewAudio = null
message.error('播放失败')
}
}).catch(err => {
console.error('播放失败:', err)
// 播放失败时重置状态
if (options.onEnded) {
options.onEnded()
const cleanup = () => {
if (options.revokeOnEnd && url.startsWith('blob:')) {
URL.revokeObjectURL(url)
previewObjectUrl === url && (previewObjectUrl = '')
}
previewAudio = null
message.error('播放失败')
})
options.onEnded && options.onEnded()
}
audio.play()
.then(() => {
previewAudio = audio
audio.onended = cleanup
audio.onerror = () => {
console.error('播放失败')
cleanup()
message.error('播放失败')
}
})
.catch(err => {
console.error('播放失败:', err)
cleanup()
message.error('播放失败')
})
}
// 播放Base64音频用于合成音频播放
const playAudioFromBase64 = (audioBase64, format = 'mp3', onEnded = null) => {
try {
// 检查缓存
const cacheKey = `${audioBase64.substring(0, 32)}_${format}` // 使用base64前32位作为缓存键
let objectUrl = audioBase64Cache.get(cacheKey)
if (!objectUrl) {
// 解码base64并创建blob
const byteCharacters = window.atob(audioBase64)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime })
objectUrl = URL.createObjectURL(blob)
// 管理缓存大小
if (audioBase64Cache.size >= AUDIO_CACHE_MAX_SIZE) {
// 清理最早的缓存
const firstKey = audioBase64Cache.keys().next().value
const oldUrl = audioBase64Cache.get(firstKey)
URL.revokeObjectURL(oldUrl)
audioBase64Cache.delete(firstKey)
}
// 存储到缓存
audioBase64Cache.set(cacheKey, objectUrl)
// 解码base64并创建blob
const byteCharacters = window.atob(audioBase64)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime })
const objectUrl = URL.createObjectURL(blob)
// 清理旧的previewObjectUrl
if (previewObjectUrl && previewObjectUrl !== objectUrl) {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl)
}
previewObjectUrl = objectUrl
playAudioPreview(previewObjectUrl, {
revokeOnEnd: false, // 缓存模式下不立即释放
revokeOnEnd: true, // 合成音频播放结束后释放URL
onEnded: () => {
isPlayingPreview.value = false
onEnded && onEnded()
}
})
} catch (error) {
console.error('Base64播放失败:', error)
isPlayingPreview.value = false
message.error('音频播放失败')
onEnded && onEnded()
}
@@ -684,15 +743,23 @@ onMounted(async () => {
})
onUnmounted(() => {
// 停止音频播放
previewAudio?.pause?.()
previewAudio = null
// 清理所有缓存的ObjectURL
audioBase64Cache.forEach(url => URL.revokeObjectURL(url))
audioBase64Cache.clear()
// 清理音频URL
previewObjectUrl && URL.revokeObjectURL(previewObjectUrl)
// 清理试听缓存
previewAudioCache.forEach(audioData => {
URL.revokeObjectURL(audioData.objectUrl)
})
previewAudioCache.clear()
// 重置播放状态
isPlayingPreview.value = false
resetPreviewState()
isPlayingSynthesized.value = false
// 清理轮询
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
@@ -720,6 +787,16 @@ watch([ttsText, selectedTtsVoice], () => {
synthesizedAudio.value = null
})
// 监听试听相关参数变化,清除缓存
watch([ttsText, speechRate, instruction, emotion], () => {
// 清除试听缓存
previewAudioCache.forEach(audioData => {
URL.revokeObjectURL(audioData.objectUrl)
})
previewAudioCache.clear()
console.log('试听参数已变化,清除缓存')
})
// 音频实例
let previewAudio = null
let previewObjectUrl = ''
@@ -780,11 +857,28 @@ let previewObjectUrl = ''
<a-button
size="small"
type="link"
:loading="previewLoadingVoiceId === voice.id"
:disabled="previewLoadingVoiceId === voice.id || isPlayingPreview"
class="preview-btn"
:disabled="previewLoadingVoiceId === voice.id"
@click.stop="playVoiceSample(voice)"
>
{{ previewLoadingVoiceId === voice.id ? '加载中...' : (isPlayingPreview ? '播放中...' : '试听') }}
<template #icon>
<SoundOutlined
v-if="!previewLoadingVoiceId && !playingPreviewVoiceId"
class="preview-icon"
/>
<LoadingOutlined
v-else-if="previewLoadingVoiceId === voice.id"
class="preview-icon spinning"
/>
<SoundOutlined
v-else-if="playingPreviewVoiceId === voice.id"
class="preview-icon playing"
/>
<SoundOutlined
v-else
class="preview-icon idle"
/>
</template>
</a-button>
</div>
</div>
@@ -867,40 +961,6 @@ let previewObjectUrl = ''
</div>
</div>
<!-- 语音合成 -->
<!-- <div class="tts-actions">
<a-button
type="primary"
size="large"
block
:loading="isSynthesizing"
@click="handleSynthesizeVoice"
>
{{ isSynthesizing ? '语音合成中...' : '生成语音' }}
</a-button>
<div v-if="synthesizedAudio" class="synth-audio-card">
<div class="synth-audio-title">已生成语音</div>
<div class="synth-audio-meta">
<span>格式:{{ (synthesizedAudio.format || 'mp3').toUpperCase() }}</span>
<span v-if="synthesizedAudio.audioBase64">Base64编码</span>
</div>
<div class="synth-audio-actions">
<a-button
size="small"
:loading="isPlayingSynthesized"
:disabled="isPlayingSynthesized"
@click="playSynthesizedAudio"
>
{{ isPlayingSynthesized ? '播放中...' : '试听' }}
</a-button>
</div>
</div>
<div v-else class="synth-audio-hint">
先生成语音,再上传视频,即可开始混剪
</div>
</div> -->
<!-- 视频上传 -->
<div class="video-section">
<div class="section-label">视频</div>
@@ -1136,6 +1196,56 @@ let previewObjectUrl = ''
gap: 8px;
}
.preview-btn {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.preview-icon {
font-size: 18px;
color: var(--color-text);
transition: all 0.3s;
}
/* 空闲状态(当前没有试听但有其他试听在进行) */
.preview-icon.idle {
opacity: 0.5;
}
/* 加载中旋转动画 */
.preview-icon.spinning {
animation: spin 1s linear infinite;
color: var(--color-text);
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 播放中脉冲动画 */
.preview-icon.playing {
animation: pulse 1.5s ease-in-out infinite;
color: var(--color-text);
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.7;
}
}
.voice-tag {
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 20px;
@@ -1244,43 +1354,6 @@ let previewObjectUrl = ''
color: white;
}
.tts-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.synth-audio-card {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: var(--radius-card);
padding: 12px;
}
.synth-audio-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.synth-audio-meta {
font-size: 12px;
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.synth-audio-actions {
display: flex;
justify-content: flex-end;
}
.synth-audio-hint {
font-size: 12px;
color: var(--color-text-secondary);
text-align: center;
}
.video-section {
display: flex;
flex-direction: column;