Files
sionrui/frontend/app/web-gold/src/views/kling/IdentifyFace.vue

1092 lines
28 KiB
Vue
Raw Normal View History

2025-12-01 22:27:50 +08:00
<template>
<div class="kling-page">
<div class="page-header">
<h1>可灵数字人 · 人脸识别与生成</h1>
<p class="page-desc">上传视频识别其中的人脸并生成数字人视频</p>
</div>
<div class="kling-content">
<!-- 左侧配置 -->
<div class="upload-panel">
<!-- 文案输入 -->
<div class="section">
<h3>文案</h3>
<a-textarea
v-model:value="ttsText"
placeholder="请输入你想让角色说话的内容"
:rows="4"
class="tts-textarea"
/>
</div>
<!-- 音色选择 -->
<div class="section">
<h3>音色</h3>
<div v-if="displayedVoices.length === 0" class="empty-voices">
还没有配音可先在"配音管理"中上传
</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">
<a-button
size="small"
type="link"
class="preview-btn"
:disabled="previewLoadingVoiceId === voice.id"
@click.stop="playVoiceSample(voice)"
>
<template #icon>
<SoundOutlined
v-if="!previewLoadingVoiceId && !playingPreviewVoiceId"
class="preview-icon"
/>
<LoadingOutlined
v-else-if="previewLoadingVoiceId === voice.id"
class="preview-icon spinning"
/>
<SoundOutlined
v-else-if="playingPreviewVoiceId === voice.id"
class="preview-icon playing"
/>
<SoundOutlined
v-else
class="preview-icon idle"
/>
</template>
</a-button>
</div>
</div>
</div>
</div>
<!-- TTS 控制 -->
<div class="section">
<h3>语音控制</h3>
<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>
<!-- 视频上传 -->
<div class="section">
<h3>视频</h3>
<div class="upload-zone" @drop.prevent="handleDrop" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false">
<input ref="fileInput" type="file" accept=".mp4,.mov" style="display: none" @change="handleFileSelect" />
<div v-if="!uploadedVideo" class="upload-placeholder">
<h3>上传视频文件</h3>
<p>支持 MP4MOV</p>
<a-button type="primary" size="large" @click="triggerFileSelect">
选择文件
</a-button>
</div>
<div v-else class="video-preview">
<video :src="uploadedVideo" controls class="preview-video"></video>
<p>{{ videoFile?.name }}</p>
</div>
</div>
</div>
<!-- 任务状态显示 -->
<div v-if="currentTaskId && (isGenerating || currentTaskStatus)" class="task-status-section">
<h3>任务状态</h3>
<div class="status-card">
<div class="status-row">
<span class="status-label">状态</span>
<span class="status-value">{{ getStatusText(currentTaskStatus) }}</span>
</div>
<div v-if="currentTaskStep" class="status-row">
<span class="status-label">步骤</span>
<span class="status-value">{{ getStepText(currentTaskStep) }}</span>
</div>
<div v-if="isGenerating" class="progress-row">
<a-progress :percent="generationProgress" :show-info="true" :status="currentTaskStatus === 'FAILED' ? 'exception' : 'active'" />
</div>
<div v-if="currentTaskStatus === 'FAILED' && currentTaskError" class="error-row">
<span class="error-label">错误</span>
<span class="error-value">{{ currentTaskError }}</span>
</div>
</div>
</div>
<!-- 按钮组 -->
<div class="action-buttons">
<a-button
type="primary"
size="large"
:disabled="!canIdentify || identifying || identified"
:loading="identifying"
block
@click="handleIdentify"
>
{{ identifying ? '识别中...' : (identified ? '已识别' : '人脸识别') }}
</a-button>
<a-button
type="default"
size="large"
:disabled="!canGenerate || isGenerating"
:loading="isGenerating"
block
@click="handleGenerate"
>
{{ isGenerating ? '生成中...' : '生成数字人视频' }}
</a-button>
</div>
</div>
<!-- 右侧结果 -->
<div class="result-panel">
<div v-if="!previewVideoUrl" class="result-placeholder">
<h3>生成的视频将在这里显示</h3>
</div>
<div v-else class="result-content">
<!-- 生成的视频 -->
<div class="result-section">
<h3>生成的数字人视频</h3>
<video :src="previewVideoUrl" controls class="generated-video"></video>
<div class="video-actions">
<a-button type="primary" @click="downloadVideo">下载视频</a-button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { SoundOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import { createKlingTaskAndIdentify ,createLipSyncTask , getLipSyncTask} from '@/api/kling'
import { MaterialService } from '@/api/material'
import { VoiceService } from '@/api/voice'
import { useVoiceCopyStore } from '@/stores/voiceCopy'
const voiceStore = useVoiceCopyStore()
// 状态管理
const uploadedVideo = ref('')
const videoFile = ref(null)
const identifying = ref(false)
const identified = ref(false) // 是否已识别
const previewVideoUrl = ref('')
const isGenerating = ref(false)
const currentTaskId = ref(null)
const currentTaskStatus = ref('')
const currentTaskStep = ref('')
const generationProgress = ref(0)
const currentTaskError = ref('') // 任务错误信息
// 识别结果存储
const identifySessionId = ref('') // 人脸识别会话ID
const identifyFaceId = ref('') // 选中的人脸ID
const identifyVideoFileId = ref(null) // 视频文件ID
// 音频试听缓存
const previewAudioCache = new Map()
const MAX_PREVIEW_CACHE_SIZE = 50
// TTS 配置
const ttsText = ref('')
const selectedTtsVoice = ref('')
const speechRate = ref(1.0)
// 试听状态
const previewLoadingVoiceId = ref('')
const playingPreviewVoiceId = ref('')
// 用户音色列表
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(() => userVoiceCards.value)
const selectedVoiceMeta = computed(() =>
displayedVoices.value.find(voice => `user-${voice.id}` === selectedTtsVoice.value)
)
// 生成条件
const canIdentify = computed(() => {
return !!uploadedVideo.value
})
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)
})
// UI 控制
const speechRateMarks = { 0.5: '0.5x', 1: '1x', 1.5: '1.5x', 2: '2x' }
const speechRateDisplay = computed(() => `${speechRate.value.toFixed(1)}x`)
// 音色选择
const selectVoiceProfile = (voice) => {
selectedTtsVoice.value = `user-${voice.id}`
}
// 音频试听
const playVoiceSample = async (voice) => {
if (!voice) return
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
return
}
if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) {
try {
previewAudio?.pause?.()
previewAudio = null
} catch {}
}
previewLoadingVoiceId.value = voice.id
playingPreviewVoiceId.value = voice.id
const cacheKey = generatePreviewCacheKey(voice)
const cachedAudio = previewAudioCache.get(cacheKey)
if (cachedAudio) {
console.log('使用缓存音频:', voice.name)
playCachedAudio(cachedAudio, resetPreviewState)
return
}
try {
const params = buildPreviewParams(voice)
if (!params) return resetPreviewState()
console.log(`试听音色: ${voice.name}, voiceId: ${voice.voiceId || voice.rawId}`)
const res = await VoiceService.preview(params)
if (res.code !== 0) {
message.error(res.msg || '试听失败')
return resetPreviewState()
}
if (res.data?.audioUrl) {
playAudioPreview(res.data.audioUrl, { onEnded: resetPreviewState })
} else if (res.data?.audioBase64) {
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
playCachedAudio(audioData, resetPreviewState)
} else {
message.error('试听失败')
resetPreviewState()
}
} catch (error) {
console.error('preview error:', error)
message.error('试听失败')
resetPreviewState()
}
}
const generatePreviewCacheKey = (voice) => {
const voiceId = voice.voiceId || voice.rawId || voice.id
const text = ttsText.value.trim()
const rate = speechRate.value
return `${voiceId}:${text}:${rate}`
}
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) => {
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 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',
timestamp: Date.now()
}
}
const extractIdFromString = (idStr) => {
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
const extractedId = parseInt(idStr.replace('user-', ''))
return Number.isNaN(extractedId) ? null : extractedId
}
// 视频上传
const triggerFileSelect = () => document.querySelector('input[type="file"]').click()
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) handleFile(file)
}
const handleDrop = (event) => {
const file = event.dataTransfer.files[0]
if (file) handleFile(file)
}
const handleFile = (file) => {
if (!file.name.match(/\.(mp4|mov)$/i)) {
message.error('仅支持 MP4 和 MOV')
return
}
videoFile.value = file
uploadedVideo.value = URL.createObjectURL(file)
identified.value = false // 重置识别状态
previewVideoUrl.value = ''
// 重置识别结果
identifySessionId.value = ''
identifyFaceId.value = ''
identifyVideoFileId.value = null
}
// 人脸识别
const handleIdentify = async () => {
if (!videoFile.value) return
identifying.value = true
try {
const res = await createKlingTaskAndIdentify(videoFile.value)
// 保存识别结果
identifySessionId.value = res.data.sessionId
identifyVideoFileId.value = res.data.fileId
identifyVideoFileId.value = res.data.faceId
identified.value = true
message.success('识别完成!')
console.log( '识别结果:', res.data)
// 识别成功后延迟1.5秒自动生成数字人视频
await handleGenerate()
} catch (error) {
message.error(error.message || '识别失败')
} finally {
identifying.value = false
}
}
// 生成数字人视频
const handleGenerate = async () => {
if (!canGenerate.value) {
message.warning('请先完成配置')
return
}
const voice = selectedVoiceMeta.value
if (!voice) {
message.warning('请选择音色')
return
}
isGenerating.value = true
try {
// 如果已经识别过使用识别时的视频文件ID和识别结果
let videoFileId = identifyVideoFileId.value
// 2. 创建任务
const taskData = {
taskName: `可灵数字人任务_${Date.now()}`,
videoFileId: videoFileId,
inputText: ttsText.value,
speechRate: speechRate.value,
volume: 0,
guidanceScale: 1,
seed: 8888,
klingSessionId: identifySessionId.value,
klingFaceId: identifyFaceId.value,
aiProvider: 'kling'
}
const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) {
message.warning('音色配置无效')
return
}
taskData.voiceConfigId = configId
message.loading('正在创建任务...', 0)
const res = await createLipSyncTask(taskData)
message.destroy()
if (res.code === 0) {
currentTaskId.value = res.data
message.success('任务创建成功,开始处理')
// 3. 轮询任务状态
pollTaskStatus()
} else {
throw new Error(res.msg || '任务创建失败')
}
} catch (error) {
console.error('generate error:', error)
message.destroy()
message.error(error.message || '视频生成失败')
isGenerating.value = false
}
}
// 轮询任务状态
const pollTaskStatus = async () => {
const interval = setInterval(async () => {
if (!currentTaskId.value) {
clearInterval(interval)
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(interval)
previewVideoUrl.value = task.resultVideoUrl
isGenerating.value = false
currentTaskStatus.value = 'SUCCESS'
currentTaskError.value = ''
// 保存成功的任务ID
localStorage.setItem('digital_human_last_task_id', currentTaskId.value)
message.success('视频生成成功!')
} else if (task.status === 'FAILED') {
clearInterval(interval)
isGenerating.value = false
currentTaskStatus.value = 'FAILED'
currentTaskError.value = task.errorMessage || '未知错误'
// 失败时清除缓存
localStorage.removeItem('digital_human_last_task_id')
message.error(`任务失败:${currentTaskError.value}`)
} else if (task.status === 'CANCELED') {
clearInterval(interval)
isGenerating.value = false
currentTaskStatus.value = 'CANCELED'
currentTaskError.value = ''
// 取消时也清除缓存
localStorage.removeItem('digital_human_last_task_id')
message.info('任务已取消')
} else if (task.status === 'PROCESSING') {
isGenerating.value = true
currentTaskError.value = ''
}
}
} catch (error) {
console.error('polling error:', error)
}
}, 10000)
}
// 加载最近一次任务结果
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'
currentTaskError.value = ''
message.success('已自动加载最近一次任务结果')
} else if (task.status === 'PROCESSING') {
// 如果任务还在处理中,继续轮询
currentTaskStatus.value = 'PROCESSING'
currentTaskStep.value = task.currentStep
generationProgress.value = task.progress || 0
currentTaskError.value = ''
isGenerating.value = true
pollTaskStatus()
} else if (task.status === 'FAILED') {
currentTaskStatus.value = 'FAILED'
currentTaskError.value = task.errorMessage || '未知错误'
}
// 重置识别状态,允许重新识别
identified.value = false
}
} catch (error) {
console.error('loadLastTask error:', error)
// 清理无效的缓存
localStorage.removeItem('digital_human_last_task_id')
}
}
// 工具函数
const formatTime = (ms) => {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
return String(minutes).padStart(2, '0') + ':' + String(seconds % 60).padStart(2, '0')
}
// 状态描述映射
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)
const cleanup = () => {
if (options.revokeOnEnd && url.startsWith('blob:')) {
URL.revokeObjectURL(url)
previewObjectUrl === url && (previewObjectUrl = '')
}
previewAudio = null
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('播放失败')
})
}
const downloadVideo = () => {
if (!previewVideoUrl.value) return message.warning('没有可下载的视频')
const link = document.createElement('a')
link.href = previewVideoUrl.value
link.download = `可灵数字人视频_${Date.now()}.mp4`
link.click()
}
// 生命周期
onMounted(async () => {
await voiceStore.refresh()
// 默认选择第一个音色
if (userVoiceCards.value.length > 0) {
selectVoiceProfile(userVoiceCards.value[0])
}
// 加载最近一次任务结果
await loadLastTask()
})
// 音频实例
let previewAudio = null
let previewObjectUrl = ''
</script>
<style scoped>
.kling-page { padding: 24px; min-height: 100vh; background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%); }
.page-header { text-align: center; margin-bottom: 40px; color: #fff; }
.page-header h1 { font-size: 32px; margin-bottom: 8px; background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.page-desc { color: #94a3b8; }
.kling-content { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; max-width: 1400px; margin: 0 auto; }
.upload-panel, .result-panel { background: rgba(255, 255, 255, 0.05); border-radius: 16px; padding: 24px; }
.section { margin-bottom: 24px; }
.section h3 { color: #fff; margin-bottom: 12px; font-size: 16px; }
.tts-textarea {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 8px;
padding: 12px;
color: #fff;
}
.voice-source-toggle {
display: inline-flex;
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 24px;
overflow: hidden;
margin-bottom: 12px;
}
.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;
color: #fff;
}
.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;
font-weight: 600;
}
.voice-desc {
font-size: 12px;
color: var(--color-text-secondary);
}
.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; }
}
.control-group {
margin-bottom: 16px;
}
.control-label {
font-size: 14px;
font-weight: 600;
color: #fff;
margin-bottom: 8px;
}
.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: #fff;
}
.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;
}
.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: #fff;
}
.emotion-buttons {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.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;
}
.upload-zone {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
border: 2px dashed rgba(59, 130, 246, 0.3);
border-radius: 12px;
}
.upload-placeholder {
text-align: center;
color: #94a3b8;
}
.upload-placeholder h3 {
color: #fff;
}
.video-preview {
text-align: center;
color: #fff;
}
.preview-video {
width: 100%;
max-height: 300px;
border-radius: 8px;
margin-bottom: 16px;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 24px;
}
.result-placeholder {
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
}
.result-content {
color: #fff;
}
.result-section {
margin-bottom: 24px;
}
.result-section h3 {
margin-bottom: 12px;
font-size: 18px;
}
.face-list {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.face-card {
display: flex;
gap: 16px;
padding: 16px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.generated-video {
width: 100%;
max-height: 400px;
border-radius: 8px;
margin-top: 12px;
}
.video-actions {
margin-top: 16px;
display: flex;
justify-content: center;
}
.task-status-section {
margin-bottom: 16px;
}
.task-status-section h3 {
color: #fff;
margin-bottom: 12px;
font-size: 16px;
}
.status-card {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 8px;
padding: 12px;
}
.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: #fff;
}
.progress-row {
margin: 12px 0;
}
.error-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
padding: 8px;
background: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
border-radius: 6px;
}
.error-label {
font-size: 13px;
color: #ff6b6b;
font-weight: 600;
flex-shrink: 0;
}
.error-value {
font-size: 13px;
color: #ff6b6b;
word-break: break-word;
}
@media (max-width: 1024px) {
.kling-content {
grid-template-columns: 1fr;
}
}
</style>