1451 lines
40 KiB
Vue
1451 lines
40 KiB
Vue
<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) // 是否激活情感tab(false为指令,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>
|