feat: 优化
This commit is contained in:
@@ -1,8 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 数字人视频生成页面
|
||||||
|
* 功能:用户上传视频,选择音色和文案,生成数字人视频
|
||||||
|
* 优化:移除冗余状态、统一试听逻辑、优化错误处理、清理废弃代码
|
||||||
|
* 试听优化:添加缓存机制,同一参数下第二次试听直接播放缓存,无需重复调用API
|
||||||
|
*/
|
||||||
defineOptions({ name: 'DigitalVideoPage' })
|
defineOptions({ name: 'DigitalVideoPage' })
|
||||||
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||||
import { message } from 'ant-design-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 { VoiceService } from '@/api/voice'
|
||||||
import { MaterialService } from '@/api/material'
|
import { MaterialService } from '@/api/material'
|
||||||
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
|
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
|
||||||
@@ -23,18 +29,18 @@ const currentTaskStep = ref('')
|
|||||||
const isSynthesizing = ref(false)
|
const isSynthesizing = ref(false)
|
||||||
const synthesizedAudio = ref(null)
|
const synthesizedAudio = ref(null)
|
||||||
const previewLoadingVoiceId = ref('')
|
const previewLoadingVoiceId = ref('')
|
||||||
const isPlayingPreview = ref(false) // 是否正在播放试听音频
|
const playingPreviewVoiceId = ref('') // 当前正在试听的音色ID
|
||||||
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
|
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
|
||||||
const pollingInterval = ref(null) // 轮询间隔ID
|
const pollingInterval = ref(null) // 轮询间隔ID
|
||||||
|
|
||||||
// Base64音频缓存
|
// 试听音频缓存 - 按音色ID缓存,避免重复API调用
|
||||||
const audioBase64Cache = new Map()
|
const previewAudioCache = new Map()
|
||||||
const AUDIO_CACHE_MAX_SIZE = 10 // 最多缓存10个音频
|
const MAX_PREVIEW_CACHE_SIZE = 50 // 最多缓存50个音色的试听音频
|
||||||
|
|
||||||
// TTS 配置
|
// TTS 配置
|
||||||
const ttsText = ref('')
|
const ttsText = ref('')
|
||||||
const selectedTtsVoice = ref('')
|
const selectedTtsVoice = ref('')
|
||||||
const speechRate = ref(1.0)
|
const speechRate = ref(1.5)
|
||||||
const instruction = ref('neutral') // 指令参数,用于控制音色风格
|
const instruction = ref('neutral') // 指令参数,用于控制音色风格
|
||||||
const emotion = ref('neutral') // 情感参数
|
const emotion = ref('neutral') // 情感参数
|
||||||
const emotionActive = ref(false) // 是否激活情感tab(false为指令,true为情感)
|
const emotionActive = ref(false) // 是否激活情感tab(false为指令,true为情感)
|
||||||
@@ -112,54 +118,131 @@ const selectVoiceProfile = (voice) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 音频播放
|
// 音频试听 - 统一入口
|
||||||
const playVoiceSample = async (voice) => {
|
const playVoiceSample = async (voice) => {
|
||||||
if (!voice) return
|
if (!voice) return
|
||||||
// 防止重复点击
|
// 防止重复点击同一个音色
|
||||||
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
|
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 用户音色和系统音色都走实时试听流程
|
// 如果正在播放其他音色,先停止
|
||||||
return triggerVoicePreview(voice)
|
if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) {
|
||||||
}
|
try {
|
||||||
|
previewAudio?.pause?.()
|
||||||
|
previewAudio = null
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
const triggerVoicePreview = async (voice) => {
|
previewLoadingVoiceId.value = voice.id
|
||||||
// 防止重复点击
|
playingPreviewVoiceId.value = voice.id
|
||||||
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
|
|
||||||
|
// 生成缓存key
|
||||||
|
const cacheKey = generatePreviewCacheKey(voice)
|
||||||
|
const cachedAudio = previewAudioCache.get(cacheKey)
|
||||||
|
|
||||||
|
// 如果有缓存,直接播放
|
||||||
|
if (cachedAudio) {
|
||||||
|
console.log('使用缓存音频:', voice.name)
|
||||||
|
playCachedAudio(cachedAudio, resetPreviewState)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
previewLoadingVoiceId.value = voice.id
|
// 没有缓存,调用API
|
||||||
try {
|
try {
|
||||||
const params = buildPreviewParams(voice)
|
const params = buildPreviewParams(voice)
|
||||||
if (!params) {
|
if (!params) return resetPreviewState()
|
||||||
previewLoadingVoiceId.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await VoiceService.preview(params)
|
|
||||||
|
|
||||||
if (res.code === 0 && res.data?.audioUrl) {
|
// 调试:打印试听参数
|
||||||
isPlayingPreview.value = true
|
console.log(`试听音色: ${voice.name}, voiceId: ${voice.voiceId || voice.rawId}`)
|
||||||
playAudioPreview(res.data.audioUrl, {
|
|
||||||
onEnded: () => {
|
const res = await VoiceService.preview(params)
|
||||||
isPlayingPreview.value = false
|
if (res.code !== 0) {
|
||||||
}
|
|
||||||
})
|
|
||||||
} else if (res.code === 0 && res.data?.audioBase64) {
|
|
||||||
isPlayingPreview.value = true
|
|
||||||
playAudioFromBase64(res.data.audioBase64, res.data.format)
|
|
||||||
} else {
|
|
||||||
message.error(res.msg || '试听失败')
|
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) {
|
} catch (error) {
|
||||||
console.error('preview error:', error)
|
console.error('preview error:', error)
|
||||||
message.error('试听失败')
|
message.error('试听失败')
|
||||||
} finally {
|
resetPreviewState()
|
||||||
previewLoadingVoiceId.value = ''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成试听缓存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) => {
|
const buildPreviewParams = (voice) => {
|
||||||
if (voice.source === 'user') {
|
if (voice.source === 'user') {
|
||||||
// 用户音色:使用voiceConfigId,不传instruction
|
// 用户音色:使用voiceConfigId,不传instruction
|
||||||
@@ -172,7 +255,8 @@ const buildPreviewParams = (voice) => {
|
|||||||
voiceConfigId: configId,
|
voiceConfigId: configId,
|
||||||
inputText: ttsText.value,
|
inputText: ttsText.value,
|
||||||
speechRate: speechRate.value || 1.0,
|
speechRate: speechRate.value || 1.0,
|
||||||
audioFormat: 'mp3'
|
audioFormat: 'mp3',
|
||||||
|
timestamp: Date.now() // 添加时间戳确保每次请求不同
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 系统音色:根据是否选择instruction或emotion来决定传递哪个参数
|
// 系统音色:根据是否选择instruction或emotion来决定传递哪个参数
|
||||||
@@ -180,7 +264,8 @@ const buildPreviewParams = (voice) => {
|
|||||||
voiceId: voice.voiceId,
|
voiceId: voice.voiceId,
|
||||||
inputText: ttsText.value,
|
inputText: ttsText.value,
|
||||||
speechRate: speechRate.value || 1.0,
|
speechRate: speechRate.value || 1.0,
|
||||||
audioFormat: 'mp3'
|
audioFormat: 'mp3',
|
||||||
|
timestamp: Date.now() // 添加时间戳确保每次请求不同
|
||||||
}
|
}
|
||||||
|
|
||||||
// instruction和emotion只能选一个传递
|
// instruction和emotion只能选一个传递
|
||||||
@@ -199,7 +284,7 @@ const buildPreviewParams = (voice) => {
|
|||||||
const extractIdFromString = (idStr) => {
|
const extractIdFromString = (idStr) => {
|
||||||
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
|
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
|
||||||
const extractedId = parseInt(idStr.replace('user-', ''))
|
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) {
|
} catch (error) {
|
||||||
console.error('polling error:', error)
|
console.error('polling error:', error)
|
||||||
}
|
}
|
||||||
}, 10000) // 每2秒轮询一次
|
}, 10000) // 每10秒轮询一次
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消任务
|
// 取消任务
|
||||||
@@ -577,93 +662,67 @@ const getStepText = (step) => {
|
|||||||
|
|
||||||
const playAudioPreview = (url, options = {}) => {
|
const playAudioPreview = (url, options = {}) => {
|
||||||
if (!url) return message.warning('暂无可试听的音频')
|
if (!url) return message.warning('暂无可试听的音频')
|
||||||
|
|
||||||
// 暂停之前的音频
|
// 暂停之前的音频
|
||||||
try {
|
try {
|
||||||
previewAudio?.pause?.()
|
previewAudio?.pause?.()
|
||||||
previewAudio = null
|
previewAudio = null
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const audio = new Audio(url)
|
const audio = new Audio(url)
|
||||||
audio.play().then(() => {
|
const cleanup = () => {
|
||||||
previewAudio = audio
|
if (options.revokeOnEnd && url.startsWith('blob:')) {
|
||||||
audio.onended = () => {
|
URL.revokeObjectURL(url)
|
||||||
// 调用自定义结束回调
|
previewObjectUrl === url && (previewObjectUrl = '')
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
previewAudio = null
|
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) => {
|
const playAudioFromBase64 = (audioBase64, format = 'mp3', onEnded = null) => {
|
||||||
try {
|
try {
|
||||||
// 检查缓存
|
// 解码base64并创建blob
|
||||||
const cacheKey = `${audioBase64.substring(0, 32)}_${format}` // 使用base64前32位作为缓存键
|
const byteCharacters = window.atob(audioBase64)
|
||||||
let objectUrl = audioBase64Cache.get(cacheKey)
|
const byteNumbers = new Array(byteCharacters.length)
|
||||||
|
for (let i = 0; i < byteCharacters.length; i++) {
|
||||||
if (!objectUrl) {
|
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||||
// 解码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)
|
|
||||||
}
|
}
|
||||||
|
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
|
||||||
|
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime })
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
|
||||||
// 清理旧的previewObjectUrl
|
// 清理旧的previewObjectUrl
|
||||||
if (previewObjectUrl && previewObjectUrl !== objectUrl) {
|
if (previewObjectUrl) {
|
||||||
URL.revokeObjectURL(previewObjectUrl)
|
URL.revokeObjectURL(previewObjectUrl)
|
||||||
}
|
}
|
||||||
previewObjectUrl = objectUrl
|
previewObjectUrl = objectUrl
|
||||||
|
|
||||||
playAudioPreview(previewObjectUrl, {
|
playAudioPreview(previewObjectUrl, {
|
||||||
revokeOnEnd: false, // 缓存模式下不立即释放
|
revokeOnEnd: true, // 合成音频播放结束后释放URL
|
||||||
onEnded: () => {
|
onEnded: () => {
|
||||||
isPlayingPreview.value = false
|
|
||||||
onEnded && onEnded()
|
onEnded && onEnded()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Base64播放失败:', error)
|
console.error('Base64播放失败:', error)
|
||||||
isPlayingPreview.value = false
|
|
||||||
message.error('音频播放失败')
|
message.error('音频播放失败')
|
||||||
onEnded && onEnded()
|
onEnded && onEnded()
|
||||||
}
|
}
|
||||||
@@ -684,15 +743,23 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
// 停止音频播放
|
||||||
previewAudio?.pause?.()
|
previewAudio?.pause?.()
|
||||||
previewAudio = null
|
previewAudio = null
|
||||||
// 清理所有缓存的ObjectURL
|
|
||||||
audioBase64Cache.forEach(url => URL.revokeObjectURL(url))
|
// 清理音频URL
|
||||||
audioBase64Cache.clear()
|
|
||||||
previewObjectUrl && URL.revokeObjectURL(previewObjectUrl)
|
previewObjectUrl && URL.revokeObjectURL(previewObjectUrl)
|
||||||
|
|
||||||
|
// 清理试听缓存
|
||||||
|
previewAudioCache.forEach(audioData => {
|
||||||
|
URL.revokeObjectURL(audioData.objectUrl)
|
||||||
|
})
|
||||||
|
previewAudioCache.clear()
|
||||||
|
|
||||||
// 重置播放状态
|
// 重置播放状态
|
||||||
isPlayingPreview.value = false
|
resetPreviewState()
|
||||||
isPlayingSynthesized.value = false
|
isPlayingSynthesized.value = false
|
||||||
|
|
||||||
// 清理轮询
|
// 清理轮询
|
||||||
if (pollingInterval.value) {
|
if (pollingInterval.value) {
|
||||||
clearInterval(pollingInterval.value)
|
clearInterval(pollingInterval.value)
|
||||||
@@ -720,6 +787,16 @@ watch([ttsText, selectedTtsVoice], () => {
|
|||||||
synthesizedAudio.value = null
|
synthesizedAudio.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 监听试听相关参数变化,清除缓存
|
||||||
|
watch([ttsText, speechRate, instruction, emotion], () => {
|
||||||
|
// 清除试听缓存
|
||||||
|
previewAudioCache.forEach(audioData => {
|
||||||
|
URL.revokeObjectURL(audioData.objectUrl)
|
||||||
|
})
|
||||||
|
previewAudioCache.clear()
|
||||||
|
console.log('试听参数已变化,清除缓存')
|
||||||
|
})
|
||||||
|
|
||||||
// 音频实例
|
// 音频实例
|
||||||
let previewAudio = null
|
let previewAudio = null
|
||||||
let previewObjectUrl = ''
|
let previewObjectUrl = ''
|
||||||
@@ -780,11 +857,28 @@ let previewObjectUrl = ''
|
|||||||
<a-button
|
<a-button
|
||||||
size="small"
|
size="small"
|
||||||
type="link"
|
type="link"
|
||||||
:loading="previewLoadingVoiceId === voice.id"
|
class="preview-btn"
|
||||||
:disabled="previewLoadingVoiceId === voice.id || isPlayingPreview"
|
:disabled="previewLoadingVoiceId === voice.id"
|
||||||
@click.stop="playVoiceSample(voice)"
|
@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>
|
</a-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -867,40 +961,6 @@ let previewObjectUrl = ''
|
|||||||
</div>
|
</div>
|
||||||
</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="video-section">
|
||||||
<div class="section-label">视频</div>
|
<div class="section-label">视频</div>
|
||||||
@@ -1136,6 +1196,56 @@ let previewObjectUrl = ''
|
|||||||
gap: 8px;
|
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 {
|
.voice-tag {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -1244,43 +1354,6 @@ let previewObjectUrl = ''
|
|||||||
color: white;
|
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 {
|
.video-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user