Files
sionrui/frontend/app/web-gold/src/views/dh/Video.vue
2025-11-22 21:45:15 +08:00

1451 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
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 { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { createDigitalHumanTask, getDigitalHumanTask, cancelTask, retryTask } from '@/api/digitalHuman'
// 导入 voiceStore 用于获取用户音色
import { useVoiceCopyStore } from '@/stores/voiceCopy'
const voiceStore = useVoiceCopyStore()
// 状态管理
const uploadedVideo = ref('')
const uploadedVideoFile = ref(null) // 存储原始文件对象
const previewVideoUrl = ref('')
const isGenerating = ref(false)
const generationProgress = ref(0)
const currentTaskId = ref(null)
const currentTaskStatus = ref('')
const currentTaskStep = ref('')
const isSynthesizing = ref(false)
const synthesizedAudio = ref(null)
const previewLoadingVoiceId = ref('')
const isPlayingPreview = ref(false) // 是否正在播放试听音频
const isPlayingSynthesized = ref(false) // 是否正在播放已合成的音频
const pollingInterval = ref(null) // 轮询间隔ID
// Base64音频缓存
const audioBase64Cache = new Map()
const AUDIO_CACHE_MAX_SIZE = 10 // 最多缓存10个音频
// TTS 配置
const ttsText = ref('')
const selectedTtsVoice = ref('')
const speechRate = ref(1.0)
const instruction = ref('neutral') // 指令参数,用于控制音色风格
const emotion = ref('neutral') // 情感参数
const emotionActive = ref(false) // 是否激活情感tabfalse为指令true为情感
const voiceSource = ref('user') // 音色来源user 或 system
// 系统音色库使用CosyVoice v3-flash模型
const SYSTEM_VOICES = [
{ id: 'sys-pro-01', name: '星悦·知性女声', gender: 'female', category: '职业', description: '温柔专业', voiceId: 'cosyvoice-v3-flash-sys-pro-01', defaultInstruction: '请用温柔专业的语调朗读' },
{ id: 'sys-boy-01', name: '澄澄·少男音', gender: 'male', category: '少男', description: '年轻清爽', voiceId: 'cosyvoice-v3-flash-sys-boy-01', defaultInstruction: '请用年轻清爽的语调朗读' },
{ id: 'sys-girl-01', name: '沁雪·少女音', gender: 'female', category: '少女', description: '活泼甜美', voiceId: 'cosyvoice-v3-flash-sys-girl-01', defaultInstruction: '请用活泼甜美的语调朗读' },
{ id: 'sys-man-01', name: '寰宇·男青年', gender: 'male', category: '男青年', description: '磁性沉稳', voiceId: 'cosyvoice-v3-flash-sys-man-01', defaultInstruction: '请用磁性沉稳的语调朗读' }
]
// 用户音色列表
const userVoiceCards = computed(() =>
(voiceStore.profiles || []).map(profile => ({
id: `user-${profile.id}`,
rawId: profile.id,
name: profile.name || '未命名',
category: profile.gender === 'male' ? '男青年' : '女青',
gender: profile.gender || 'female',
description: profile.note || '我的配音',
fileUrl: profile.fileUrl,
transcription: profile.transcription || '',
source: 'user',
voiceId: profile.voiceId
}))
)
const displayedVoices = computed(() => {
if (voiceSource.value === 'system') {
return SYSTEM_VOICES.map(voice => ({
...voice,
source: 'system'
}))
}
return userVoiceCards.value
})
const selectedVoiceMeta = computed(() =>
displayedVoices.value.find(voice => `${voice.source}-${voice.id}` === selectedTtsVoice.value)
)
// UI 状态
const speechRateMarks = { 0.5: '0.5x', 1: '1x', 1.5: '1.5x', 2: '2x' }
const speechRateDisplay = computed(() => `${speechRate.value.toFixed(1)}x`)
// 生成数字人的条件:选中了音色 + 上传了视频 + 没有正在生成
// 注意:不需要先合成语音,可以直接使用音色配置
const canGenerate = computed(() => {
const hasText = ttsText.value.trim() // 文案必填
const hasVoice = selectedVoiceMeta.value // 必须选中音色
const hasVideo = uploadedVideo.value // 必须上传视频
const notGenerating = !isGenerating.value // 不能正在生成
return !!(hasText && hasVoice && hasVideo && notGenerating)
})
// 音色选择
const setVoiceSource = (source) => {
if (voiceSource.value === source) return
voiceSource.value = source
selectedTtsVoice.value = ''
if (source === 'user' && userVoiceCards.value.length > 0) {
selectVoiceProfile(userVoiceCards.value[0])
} else if (source === 'system' && SYSTEM_VOICES.length > 0) {
selectVoiceProfile({ ...SYSTEM_VOICES[0], source: 'system' })
}
}
const selectVoiceProfile = (voice) => {
selectedTtsVoice.value = `${voice.source}-${voice.id}`
// 选系统音色时也更新对应的instruction
if (voice.source === 'system' && voice.defaultInstruction) {
instruction.value = voice.defaultInstruction
}
}
// 音频播放
const playVoiceSample = async (voice) => {
if (!voice) return
// 防止重复点击
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
return
}
// 用户音色和系统音色都走实时试听流程
return triggerVoicePreview(voice)
}
const triggerVoicePreview = async (voice) => {
// 防止重复点击
if (previewLoadingVoiceId.value === voice.id || isPlayingPreview.value) {
return
}
previewLoadingVoiceId.value = voice.id
try {
const params = buildPreviewParams(voice)
if (!params) {
previewLoadingVoiceId.value = ''
return
}
const res = await VoiceService.preview(params)
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 {
message.error(res.msg || '试听失败')
}
} catch (error) {
console.error('preview error:', error)
message.error('试听失败')
} finally {
previewLoadingVoiceId.value = ''
}
}
const buildPreviewParams = (voice) => {
if (voice.source === 'user') {
// 用户音色使用voiceConfigId不传instruction
const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) {
message.error('配音配置无效')
return null
}
return {
voiceConfigId: configId,
inputText: ttsText.value,
speechRate: speechRate.value || 1.0,
audioFormat: 'mp3'
}
} else {
// 系统音色根据是否选择instruction或emotion来决定传递哪个参数
const params = {
voiceId: voice.voiceId,
inputText: ttsText.value,
speechRate: speechRate.value || 1.0,
audioFormat: 'mp3'
}
// instruction和emotion只能选一个传递
if (instruction.value && instruction.value !== 'neutral') {
params.instruction = instruction.value
} else if (emotion.value && emotion.value !== 'neutral') {
params.emotion = emotion.value
} else if (voice.defaultInstruction) {
params.instruction = voice.defaultInstruction
}
return params
}
}
const extractIdFromString = (idStr) => {
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
const extractedId = parseInt(idStr.replace('user-', ''))
return isNaN(extractedId) ? null : extractedId
}
// 语音合成
const handleSynthesizeVoice = async () => {
if (!ttsText.value.trim()) return message.warning('请输入文本')
const voice = selectedVoiceMeta.value
if (!voice) return message.warning('请选择音色')
isSynthesizing.value = true
try {
const params = {
inputText: ttsText.value,
speechRate: speechRate.value,
audioFormat: 'mp3'
}
if (voice.source === 'user') {
// 用户音色使用voiceConfigId不传instruction
const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) {
message.warning('音色配置无效')
return
}
params.voiceConfigId = configId
} else {
// 系统音色使用voiceId根据是否选择instruction或emotion来决定传递哪个参数
const voiceId = voice.voiceId || voice.rawId
if (!voiceId) {
message.warning('音色配置无效')
return
}
params.voiceId = voiceId
// instruction和emotion只能选一个传递
if (instruction.value && instruction.value !== 'neutral') {
params.instruction = instruction.value
} else if (emotion.value && emotion.value !== 'neutral') {
params.emotion = emotion.value
} else if (voice.defaultInstruction) {
params.instruction = voice.defaultInstruction
}
}
const res = await VoiceService.synthesize(params)
if (res.code === 0) {
synthesizedAudio.value = res.data
message.success('语音合成成功')
} else {
message.error(res.message || '合成失败')
}
} catch (error) {
console.error('synthesize error:', error)
message.error('语音合成失败')
} finally {
isSynthesizing.value = false
}
}
const playSynthesizedAudio = () => {
// 防止重复点击
if (isPlayingSynthesized.value || !synthesizedAudio.value) {
return
}
isPlayingSynthesized.value = true
// 优先使用Base64数据安全方案
if (synthesizedAudio.value.audioBase64) {
playAudioFromBase64(synthesizedAudio.value.audioBase64, synthesizedAudio.value.format, () => {
isPlayingSynthesized.value = false
})
}
// 兼容旧的audioUrl方式已废弃
else if (synthesizedAudio.value.audioUrl) {
playAudioPreview(synthesizedAudio.value.audioUrl, {
onEnded: () => {
isPlayingSynthesized.value = false
}
})
} else {
message.warning('暂无可播放的音频')
isPlayingSynthesized.value = false
}
}
// 视频处理
const handleVideoUpload = async (file) => {
if (!file.type.startsWith('video/')) {
message.error('请上传视频文件')
return false
}
try {
uploadedVideo.value = await toDataURL(file)
uploadedVideoFile.value = file // 保存文件对象
message.success('视频上传成功')
} catch (error) {
message.error('视频上传失败')
}
return false
}
const handleVideoDrop = (e) => console.log('Video drop:', e)
const clearVideo = () => {
uploadedVideo.value = ''
uploadedVideoFile.value = null
previewVideoUrl.value = ''
message.info('已清除视频')
}
const downloadPreview = () => {
if (!previewVideoUrl.value) return message.warning('没有可下载的视频')
const link = document.createElement('a')
link.href = previewVideoUrl.value
link.download = `数字人视频_${Date.now()}.mp4`
link.click()
}
// 视频生成
const generateVideo = async () => {
if (!canGenerate.value) return message.warning('请先完成配置')
if (!ttsText.value.trim()) return message.warning('请输入文本')
const voice = selectedVoiceMeta.value
if (!voice) return message.warning('请选择音色')
isGenerating.value = true
generationProgress.value = 0
currentTaskStatus.value = 'PENDING'
currentTaskStep.value = 'prepare_files'
try {
// 1. 上传视频文件(只上传视频,音频由后端实时合成)
message.loading('正在上传视频...', 0)
const videoFileId = await uploadVideoFile(uploadedVideoFile.value)
if (!videoFileId) {
throw new Error('视频上传失败')
}
message.destroy()
// 2. 创建数字人任务
const taskData = {
taskName: `数字人任务_${Date.now()}`,
videoFileId: videoFileId,
inputText: ttsText.value,
speechRate: speechRate.value,
volume: 0,
guidanceScale: 1,
seed: 8888
}
if (voice.source === 'user') {
// 用户音色使用voiceConfigId不传instruction
const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) {
message.warning('音色配置无效')
return
}
taskData.voiceConfigId = configId
} else {
// 系统音色使用voiceId根据是否选择instruction或emotion来决定传递哪个参数
taskData.voiceId = voice.voiceId
// instruction和emotion只能选一个传递
if (instruction.value && instruction.value !== 'neutral') {
taskData.instruction = instruction.value
} else if (emotion.value && emotion.value !== 'neutral') {
taskData.emotion = emotion.value
} else if (voice.defaultInstruction) {
taskData.instruction = voice.defaultInstruction
} else {
taskData.emotion = 'neutral'
}
}
message.loading('正在创建任务...', 0)
const createRes = await createDigitalHumanTask(taskData)
message.destroy()
if (createRes.code === 0) {
currentTaskId.value = createRes.data
// 保存到本地存储,方便刷新页面后恢复
localStorage.setItem('digital_human_last_task_id', createRes.data)
message.success('任务创建成功,开始处理')
// 3. 开始轮询任务状态
startPollingTask()
} else {
throw new Error(createRes.msg || '任务创建失败')
}
} catch (error) {
console.error('generateVideo error:', error)
message.destroy()
message.error(error.message || '视频生成失败')
isGenerating.value = false
currentTaskStatus.value = ''
currentTaskStep.value = ''
}
}
// 上传视频文件到后端
const uploadVideoFile = async (file) => {
try {
const res = await MaterialService.uploadFile(file, 'video')
if (res.code === 0) {
return res.data // res.data就是文件ID
} else {
throw new Error(res.message || '上传失败')
}
} catch (error) {
console.error('uploadVideoFile error:', error)
throw error
}
}
// 加载最近一次任务结果
const loadLastTask = async () => {
try {
const lastTaskId = localStorage.getItem('digital_human_last_task_id')
if (!lastTaskId) return
const res = await getDigitalHumanTask(lastTaskId)
if (res.code === 0 && res.data) {
const task = res.data
currentTaskId.value = lastTaskId
// 如果任务是成功状态,显示结果
if (task.status === 'SUCCESS' && task.resultVideoUrl) {
previewVideoUrl.value = task.resultVideoUrl
currentTaskStatus.value = 'SUCCESS'
message.success('已自动加载最近一次任务结果')
} else if (task.status === 'PROCESSING') {
// 如果任务还在处理中,继续轮询
currentTaskStatus.value = 'PROCESSING'
currentTaskStep.value = task.currentStep
generationProgress.value = task.progress || 0
startPollingTask()
}
}
} catch (error) {
console.error('loadLastTask error:', error)
// 清理无效的缓存
localStorage.removeItem('digital_human_last_task_id')
}
}
// 开始轮询任务状态
const startPollingTask = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
}
pollingInterval.value = setInterval(async () => {
if (!currentTaskId.value) {
clearInterval(pollingInterval.value)
return
}
try {
const res = await getDigitalHumanTask(currentTaskId.value)
if (res.code === 0 && res.data) {
const task = res.data
currentTaskStatus.value = task.status
currentTaskStep.value = task.currentStep
generationProgress.value = task.progress || 0
// 如果任务完成或失败,停止轮询
if (task.status === 'SUCCESS') {
clearInterval(pollingInterval.value)
pollingInterval.value = null
previewVideoUrl.value = task.resultVideoUrl
isGenerating.value = false
currentTaskStatus.value = 'SUCCESS'
// 保存成功的任务ID
localStorage.setItem('digital_human_last_task_id', currentTaskId.value)
message.success('视频生成成功!')
} else if (task.status === 'FAILED') {
clearInterval(pollingInterval.value)
pollingInterval.value = null
isGenerating.value = false
currentTaskStatus.value = 'FAILED'
// 失败时清除缓存
localStorage.removeItem('digital_human_last_task_id')
message.error(`任务失败:${task.errorMessage || '未知错误'}`)
} else if (task.status === 'CANCELED') {
clearInterval(pollingInterval.value)
pollingInterval.value = null
isGenerating.value = false
currentTaskStatus.value = 'CANCELED'
// 取消时也清除缓存
localStorage.removeItem('digital_human_last_task_id')
message.info('任务已取消')
}
}
} catch (error) {
console.error('polling error:', error)
}
}, 10000) // 每2秒轮询一次
}
// 取消任务
const handleCancelTask = async () => {
if (!currentTaskId.value) return
try {
const res = await cancelTask(currentTaskId.value)
if (res.code === 0) {
message.success('任务已取消')
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
isGenerating.value = false
} else {
message.error(res.msg || '取消失败')
}
} catch (error) {
console.error('cancelTask error:', error)
message.error('取消任务失败')
}
}
// 重试任务
const handleRetryTask = async () => {
if (!currentTaskId.value) return
try {
const res = await retryTask(currentTaskId.value)
if (res.code === 0) {
message.success('任务已重启')
currentTaskStatus.value = 'PENDING'
currentTaskStep.value = 'prepare_files'
isGenerating.value = true
startPollingTask()
} else {
message.error(res.msg || '重试失败')
}
} catch (error) {
console.error('retryTask error:', error)
message.error('重试任务失败')
}
}
// 工具函数
const toDataURL = (file) => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
// 状态描述映射
const getStatusText = (status) => {
const statusMap = {
'PENDING': '等待处理',
'PROCESSING': '处理中',
'SUCCESS': '已完成',
'FAILED': '失败',
'CANCELED': '已取消'
}
return statusMap[status] || status
}
const getStepText = (step) => {
const stepMap = {
'prepare_files': '准备文件',
'synthesize_voice': '语音合成',
'sync_lip': '口型同步',
'finishing': '完成处理',
'canceled': '已取消'
}
return stepMap[step] || step
}
const playAudioPreview = (url, options = {}) => {
if (!url) return message.warning('暂无可试听的音频')
// 暂停之前的音频
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()
}
previewAudio = null
message.error('播放失败')
})
}
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)
}
// 清理旧的previewObjectUrl
if (previewObjectUrl && previewObjectUrl !== objectUrl) {
URL.revokeObjectURL(previewObjectUrl)
}
previewObjectUrl = objectUrl
playAudioPreview(previewObjectUrl, {
revokeOnEnd: false, // 缓存模式下不立即释放
onEnded: () => {
isPlayingPreview.value = false
onEnded && onEnded()
}
})
} catch (error) {
console.error('Base64播放失败:', error)
isPlayingPreview.value = false
message.error('音频播放失败')
onEnded && onEnded()
}
}
// 生命周期
onMounted(async () => {
await voiceStore.load()
// 默认选择第一个音色
if (voiceSource.value === 'user' && userVoiceCards.value.length > 0) {
selectVoiceProfile(userVoiceCards.value[0])
} else if (voiceSource.value === 'system' && SYSTEM_VOICES.length > 0) {
selectVoiceProfile({ ...SYSTEM_VOICES[0], source: 'system' })
}
// 加载最近一次任务结果
await loadLastTask()
})
onUnmounted(() => {
previewAudio?.pause?.()
previewAudio = null
// 清理所有缓存的ObjectURL
audioBase64Cache.forEach(url => URL.revokeObjectURL(url))
audioBase64Cache.clear()
previewObjectUrl && URL.revokeObjectURL(previewObjectUrl)
// 重置播放状态
isPlayingPreview.value = false
isPlayingSynthesized.value = false
// 清理轮询
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
})
// 监听器
watch(voiceSource, () => {
selectedTtsVoice.value = ''
if (voiceSource.value === 'user' && userVoiceCards.value.length > 0) {
selectVoiceProfile(userVoiceCards.value[0])
} else if (voiceSource.value === 'system' && SYSTEM_VOICES.length > 0) {
selectVoiceProfile({ ...SYSTEM_VOICES[0], source: 'system' })
}
})
watch(() => voiceStore.profiles, () => {
if (voiceSource.value === 'user' && userVoiceCards.value.length > 0 && !selectedTtsVoice.value) {
selectVoiceProfile(userVoiceCards.value[0])
}
})
watch([ttsText, selectedTtsVoice], () => {
synthesizedAudio.value = null
})
// 音频实例
let previewAudio = null
let previewObjectUrl = ''
</script>
<template>
<div class="digital-video-page">
<div class="digital-video-grid">
<!-- 左侧配置 -->
<section class="digital-video-left">
<!-- 文本输入 -->
<div class="tts-section">
<div class="section-label">文案</div>
<a-textarea
v-model:value="ttsText"
placeholder="请输入你想让角色说话的内容"
:rows="6"
class="tts-textarea"
/>
<!-- 音色选择 -->
<div class="voice-selection">
<div class="section-label">音色</div>
<div class="voice-source-toggle">
<button
v-for="source in ['user']"
:key="source"
class="source-btn"
:class="{ active: voiceSource === source }"
@click="setVoiceSource(source)"
>
{{ source === 'system' ? '系统音色' : '我的音色' }}
</button>
</div>
<div v-if="displayedVoices.length === 0" class="empty-voices">
{{ voiceSource === 'user' ? '还没有配音可先在"配音管理"中上传' : '暂无可用的系统音色' }}
</div>
<div class="voice-list">
<div
v-for="voice in displayedVoices"
:key="voice.id"
class="voice-list-item"
:class="{ selected: selectedTtsVoice === `${voice.source}-${voice.id}` }"
@click="selectVoiceProfile(voice)"
>
<div class="voice-item-info">
<div class="voice-avatar">{{ voice.gender === 'male' ? '👨' : '👩' }}</div>
<div class="voice-item-text">
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.description }}</div>
</div>
</div>
<div class="voice-item-meta">
<span class="voice-tag">{{ voice.category }}</span>
<a-button
size="small"
type="link"
:loading="previewLoadingVoiceId === voice.id"
:disabled="previewLoadingVoiceId === voice.id || isPlayingPreview"
@click.stop="playVoiceSample(voice)"
>
{{ previewLoadingVoiceId === voice.id ? '加载中...' : (isPlayingPreview ? '播放中...' : '试听') }}
</a-button>
</div>
</div>
</div>
</div>
<!-- TTS 控制 -->
<div class="tts-controls">
<div class="control-group">
<div class="control-label">语速</div>
<div class="slider-card">
<div class="slider-info">
<div class="slider-value">{{ speechRateDisplay }}</div>
<button class="reset-btn" @click="speechRate = 1">重置</button>
</div>
<a-slider
v-model:value="speechRate"
:min="0.5"
:max="2"
:step="0.1"
:marks="speechRateMarks"
:tooltip-open="false"
/>
</div>
</div>
<div v-if="voiceSource === 'system'" class="control-group">
<div class="control-label">情感</div>
<div class="control-tabs">
<button
:class="['tab-btn', { active: !emotionActive }]"
@click="emotionActive = false"
>
语调
</button>
<button
:class="['tab-btn', { active: emotionActive }]"
@click="emotionActive = true"
>
情感
</button>
</div>
<div class="emotion-buttons">
<button
v-if="!emotionActive"
v-for="inst in [
{ value: 'neutral', label: '中性' },
{ value: '请用自然流畅的语调朗读', label: '自然' },
{ value: '请用温柔专业的语调朗读', label: '温柔' },
{ value: '请用热情洋溢的语调朗读', label: '热情' },
{ value: '请用低沉磁性的语调朗读', label: '磁性' },
{ value: '请用活泼生动的语调朗读', label: '活泼' }
]"
:key="inst.value"
class="emotion-btn"
:class="{ active: instruction === inst.value }"
@click="instruction = inst.value"
>
{{ inst.label }}
</button>
<button
v-else
v-for="emo in [
{ value: 'neutral', label: '中性' },
{ value: 'happy', label: '开心' },
{ value: 'sad', label: '悲伤' },
{ value: 'angry', label: '愤怒' },
{ value: 'excited', label: '兴奋' },
{ value: 'calm', label: '平静' }
]"
:key="emo.value"
class="emotion-btn"
:class="{ active: emotion === emo.value }"
@click="emotion = emo.value"
>
{{ emo.label }}
</button>
</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="section-label">视频</div>
<a-upload-dragger
accept="video/*"
:before-upload="handleVideoUpload"
:show-upload-list="false"
@drop="handleVideoDrop"
>
<InboxOutlined class="upload-icon" />
<p class="upload-text">点击或拖拽上传视频</p>
<p class="upload-hint">
支持格式MP4、MOV、AVI、WMV、FLV | 建议分辨率 ≥ 720p
</p>
</a-upload-dragger>
<div v-if="uploadedVideo" class="uploaded-video-preview">
<div class="preview-header">
<span>已上传视频</span>
<a-button type="link" danger size="small" @click="clearVideo">清除</a-button>
</div>
<video :src="uploadedVideo" controls />
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-section">
<div v-if="!currentTaskId || currentTaskStatus === 'SUCCESS' || currentTaskStatus === 'FAILED' || currentTaskStatus === 'CANCELED'" class="generate-actions">
<a-button
type="primary"
size="large"
block
:disabled="!canGenerate"
:loading="isGenerating"
@click="generateVideo"
>
{{ isGenerating ? '生成中...' : '生成视频' }}
</a-button>
</div>
<div v-else class="task-actions">
<div class="task-status">
<div class="status-row">
<span class="status-label">状态:</span>
<span class="status-value">{{ getStatusText(currentTaskStatus) }}</span>
</div>
<div class="status-row">
<span class="status-label">步骤:</span>
<span class="status-value">{{ getStepText(currentTaskStep) }}</span>
</div>
</div>
</div>
<a-progress
v-if="isGenerating"
:percent="generationProgress"
:show-info="true"
:status="currentTaskStatus === 'FAILED' ? 'exception' : 'active'"
/>
</div>
</section>
<!-- 右侧预览 -->
<section class="digital-video-right">
<div class="preview-title">视频预览</div>
<div class="preview-area">
<div v-if="!previewVideoUrl" class="preview-placeholder">
<div class="placeholder-icon">📹</div>
<div class="placeholder-text">生成的视频将在这里显示</div>
<div class="placeholder-hint">请先完成配置,然后点击生成</div>
</div>
<div v-else class="preview-content">
<video :src="previewVideoUrl" controls class="preview-video" />
<div class="preview-actions">
<a-button type="primary" @click="downloadPreview">下载视频</a-button>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.digital-video-page {
min-height: calc(100vh - 70px);
background: var(--color-bg);
}
.digital-video-grid {
display: grid;
grid-template-columns: 500px 1fr;
gap: 16px;
}
.digital-video-left {
background: var(--color-surface);
border: 1px solid rgba(59, 130, 246, 0.1);
border-radius: var(--radius-card);
padding: 16px;
min-height: 600px;
position: sticky;
top: 86px;
max-height: calc(100vh - 102px);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.digital-video-right {
background: var(--color-surface);
border: 1px solid rgba(59, 130, 246, 0.1);
border-radius: var(--radius-card);
padding: 16px;
min-height: 600px;
}
.tts-textarea {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: var(--radius-card);
padding: 12px;
}
.tts-hint {
margin-top: 8px;
font-size: 12px;
color: var(--color-text-secondary);
}
.voice-selection {
display: flex;
flex-direction: column;
gap: 8px;
}
.voice-source-toggle {
display: inline-flex;
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 24px;
overflow: hidden;
}
.source-btn {
padding: 6px 16px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.source-btn.active {
background: rgba(59, 130, 246, 0.2);
color: var(--color-text);
}
.empty-voices {
padding: 8px 12px;
font-size: 12px;
color: var(--color-text-secondary);
background: rgba(0, 0, 0, 0.3);
border: 1px dashed rgba(59, 130, 246, 0.3);
border-radius: var(--radius-card);
}
.voice-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.voice-list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 14px;
border: 1px solid rgba(59, 130, 246, 0.15);
border-radius: 10px;
background: rgba(0, 0, 0, 0.35);
cursor: pointer;
}
.voice-list-item:hover {
border-color: rgba(59, 130, 246, 0.5);
}
.voice-list-item.selected {
border-color: var(--color-primary);
background: rgba(59, 130, 246, 0.2);
}
.voice-item-info {
display: flex;
align-items: center;
gap: 10px;
}
.voice-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.voice-item-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.voice-name {
font-size: 13px;
color: var(--color-text);
font-weight: 600;
}
.voice-desc {
font-size: 12px;
color: var(--color-text-secondary);
}
.voice-item-meta {
display: flex;
align-items: center;
gap: 8px;
}
.voice-tag {
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 20px;
padding: 2px 10px;
font-size: 11px;
color: var(--color-text-secondary);
}
.tts-controls {
display: flex;
flex-direction: column;
gap: 12px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.control-label {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.slider-card {
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.25);
}
.slider-info {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--color-text-secondary);
margin-bottom: 8px;
}
.slider-value {
font-size: 14px;
font-weight: 600;
color: var(--color-text);
}
.reset-btn {
padding: 4px 12px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.emotion-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.control-tabs {
display: inline-flex;
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 20px;
overflow: hidden;
margin-bottom: 8px;
}
.tab-btn {
padding: 6px 20px;
border: none;
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
font-size: 12px;
}
.tab-btn.active {
background: rgba(59, 130, 246, 0.2);
color: var(--color-text);
}
.emotion-btn {
padding: 8px 16px;
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 20px;
font-size: 12px;
color: var(--color-text-secondary);
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: all 0.3s;
}
.emotion-btn:hover {
border-color: rgba(59, 130, 246, 0.5);
background: rgba(59, 130, 246, 0.1);
}
.emotion-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
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;
gap: 12px;
}
.upload-icon {
font-size: 48px;
color: var(--color-primary);
}
.upload-text {
font-size: 16px;
font-weight: 500;
margin: 16px 0 8px;
}
.upload-hint {
font-size: 12px;
color: var(--color-text-secondary);
}
.uploaded-video-preview {
padding: 12px;
background: var(--color-bg);
border-radius: var(--radius-card);
border: 1px solid var(--color-border);
}
.preview-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
font-weight: 600;
}
.uploaded-video-preview video {
width: 100%;
}
.generate-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.generate-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-status {
padding: 12px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: var(--radius-card);
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.status-row:last-child {
margin-bottom: 0;
}
.status-label {
font-size: 13px;
color: var(--color-text-secondary);
font-weight: 600;
}
.status-value {
font-size: 13px;
color: var(--color-text);
}
.action-buttons {
display: flex;
gap: 8px;
}
.action-buttons .ant-btn {
flex: 1;
}
.preview-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-secondary);
margin-bottom: 16px;
}
.preview-area {
width: 100%;
min-height: 500px;
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 500px;
background: rgba(0, 0, 0, 0.3);
border: 2px dashed rgba(59, 130, 246, 0.3);
border-radius: var(--radius-card);
}
.placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.placeholder-text {
font-size: 16px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 8px;
}
.placeholder-hint {
font-size: 12px;
color: var(--color-text-secondary);
}
.preview-content {
width: 100%;
}
.preview-video {
width: 100%;
max-height: 600px;
border-radius: var(--radius-card);
background: #000;
}
.preview-actions {
margin-top: 16px;
display: flex;
justify-content: center;
}
@media (max-width: 1400px) {
.digital-video-grid {
grid-template-columns: 1fr;
}
.digital-video-left {
position: static;
max-height: none;
}
}
</style>