1092 lines
28 KiB
Vue
1092 lines
28 KiB
Vue
|
|
<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>支持 MP4、MOV</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>
|