feat: 优化功能

This commit is contained in:
2025-12-22 00:15:02 +08:00
parent b80de78d7c
commit 8d7bc0d47f
12 changed files with 2513 additions and 595 deletions

View File

@@ -19,7 +19,7 @@
:show-count="true"
class="tts-textarea"
/>
<div v-if="identified && faceDuration > 0" class="text-hint">
<div v-if="identifyState.identified && faceDuration > 0" class="text-hint">
<span class="hint-icon">💡</span>
<span>视频中人脸出现时长约 {{ (faceDuration / 1000).toFixed(1) }} 建议文案不超过 {{ suggestedMaxChars }} </span>
</div>
@@ -108,7 +108,7 @@
<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">
<div v-if="!videoState.uploadedVideo" class="upload-placeholder">
<h3>上传视频文件</h3>
<p>支持 MP4MOV</p>
<a-button type="primary" size="large" @click="triggerFileSelect">
@@ -117,36 +117,15 @@
</div>
<div v-else class="video-preview">
<video :src="uploadedVideo" controls class="preview-video"></video>
<p>{{ videoFile?.name }}</p>
<video :src="videoState.uploadedVideo" controls class="preview-video"></video>
<p>{{ videoState.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 v-if="identified" class="section audio-generation-section">
<div v-if="identifyState.identified" class="section audio-generation-section">
<h3>配音生成与校验</h3>
<!-- 生成配音按钮 -->
@@ -155,30 +134,30 @@
type="default"
size="large"
:disabled="!canGenerateAudio"
:loading="generatingAudio"
:loading="audioState.generating"
block
@click="handleGenerateAudio"
>
{{ generatingAudio ? '生成中...' : '生成配音(用于校验时长)' }}
{{ audioState.generating ? '生成中...' : '生成配音(用于校验时长)' }}
</a-button>
</div>
<!-- 音频预览生成后显示 -->
<div v-if="generatedAudio" class="audio-preview">
<div v-if="audioState.generated" class="audio-preview">
<div class="audio-info">
<h4>生成的配音</h4>
<div class="duration-info">
<span class="label">音频时长</span>
<span class="value">{{ (audioDurationMs / 1000).toFixed(1) }} </span>
<span class="value">{{ (audioState.durationMs / 1000).toFixed(1) }} </span>
</div>
<div class="duration-info">
<span class="label">人脸区间</span>
<span class="value">{{ (faceDuration / 1000).toFixed(1) }} </span>
</div>
<div class="duration-info" :class="{ 'validation-passed': audioValidationPassed, 'validation-failed': !audioValidationPassed }">
<div class="duration-info" :class="{ 'validation-passed': audioState.validationPassed, 'validation-failed': !audioState.validationPassed }">
<span class="label">校验结果</span>
<span class="value">
{{ audioValidationPassed ? '✅ 通过' : '❌ 不通过需至少2秒重合' }}
{{ audioState.validationPassed ? '✅ 通过' : '❌ 不通过需至少2秒重合' }}
</span>
</div>
</div>
@@ -186,14 +165,14 @@
<!-- 音频播放器 -->
<div class="audio-player">
<audio
v-if="generatedAudio.audioBase64"
:src="`data:audio/mp3;base64,${generatedAudio.audioBase64}`"
v-if="audioState.generated.audioBase64"
:src="`data:audio/mp3;base64,${audioState.generated.audioBase64}`"
controls
class="audio-element"
/>
<audio
v-else-if="generatedAudio.audioUrl"
:src="generatedAudio.audioUrl"
v-else-if="audioState.generated.audioUrl"
:src="audioState.generated.audioUrl"
controls
class="audio-element"
/>
@@ -205,7 +184,7 @@
type="link"
size="small"
@click="handleGenerateAudio"
:loading="generatingAudio"
:loading="audioState.generating"
>
重新生成
</a-button>
@@ -218,27 +197,26 @@
<a-button
type="primary"
size="large"
:disabled="!canIdentify || identifying || identified"
:loading="identifying"
:disabled="!canIdentify || identifyState.identifying || identifyState.identified"
:loading="identifyState.identifying"
block
@click="handleIdentify"
>
{{ identifying ? '识别中...' : (identified ? '已识别' : '人脸识别') }}
{{ identifyState.identifying ? '识别中...' : (identifyState.identified ? '已识别' : '人脸识别') }}
</a-button>
<a-button
type="default"
size="large"
:disabled="!canGenerate || isGenerating"
:loading="isGenerating"
:disabled="!canGenerate"
block
@click="handleGenerate"
>
{{ isGenerating ? '生成中...' : '生成数字人视频' }}
生成数字人视频
</a-button>
<!-- 添加提示信息 -->
<div v-if="canGenerate && !audioValidationPassed" class="generate-hint">
<div v-if="canGenerate && !audioState.validationPassed" class="generate-hint">
<span class="hint-icon"></span>
<span>请先生成配音并通过时长校验</span>
</div>
@@ -247,7 +225,7 @@
<!-- 右侧结果 -->
<div class="result-panel">
<div v-if="!previewVideoUrl" class="result-placeholder">
<div v-if="!videoState.previewVideoUrl" class="result-placeholder">
<h3>生成的视频将在这里显示</h3>
</div>
@@ -255,7 +233,7 @@
<!-- 生成的视频 -->
<div class="result-section">
<h3>生成的数字人视频</h3>
<video :src="previewVideoUrl" controls class="generated-video"></video>
<video :src="videoState.previewVideoUrl" controls class="generated-video"></video>
<div class="video-actions">
<a-button type="primary" @click="downloadVideo">下载视频</a-button>
</div>
@@ -278,55 +256,74 @@ 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 identifyFaceStartTime = ref(0) // 人脸可对口型区间起点时间ms
const identifyFaceEndTime = ref(0) // 人脸可对口型区间终点时间ms
const identifyVideoFileId = ref(null) // 视频文件ID
/**
* 视频相关状态
*/
const videoState = ref({
uploadedVideo: '',
videoFile: null,
previewVideoUrl: ''
})
// 配音预生成状态
const generatedAudio = ref(null) // 生成的音频数据
const audioDurationMs = ref(0) // 音频时长(毫秒)
const audioValidationPassed = ref(false) // 时长校验是否通过
const generatingAudio = ref(false) // 是否正在生成配音
/**
* 人脸识别相关状态
*/
const identifyState = ref({
identifying: false,
identified: false,
sessionId: '',
faceId: '',
faceStartTime: 0,
faceEndTime: 0,
videoFileId: null
})
// 人脸区间时长(毫秒)
const faceDuration = computed(() => identifyFaceEndTime.value - identifyFaceStartTime.value)
/**
* 音频相关状态
*/
const audioState = ref({
generated: null,
durationMs: 0,
validationPassed: false,
generating: false
})
// 基于人脸时长计算建议的最大文案字数中文约3.5字/秒)
// UI 状态
const dragOver = ref(false)
// ==================== 计算属性 ====================
/**
* 人脸区间时长(毫秒)
*/
const faceDuration = computed(() => identifyState.value.faceEndTime - identifyState.value.faceStartTime)
/**
* 基于人脸时长计算建议的最大文案字数中文约3.5字/秒)
*/
const suggestedMaxChars = computed(() => {
const durationSec = faceDuration.value / 1000
// 语速影响:语速越高,单位时间可说更多字
const adjustedRate = speechRate.value || 1.0
return Math.floor(durationSec * 3.5 * adjustedRate)
})
// 最大文案长度限制(略大于建议值,留有余地)
/**
* 最大文案长度限制(略大于建议值,留有余地)
*/
const maxTextLength = computed(() => {
if (!identified.value || faceDuration.value <= 0) {
return 4000 // 未识别时使用默认限制
if (!identifyState.value.identified || faceDuration.value <= 0) {
return 4000
}
// 最大字数 = 建议字数 * 1.2但不超过4000
return Math.min(4000, Math.floor(suggestedMaxChars.value * 1.2))
})
// 文案输入框提示文字
/**
* 文案输入框提示文字
*/
const textareaPlaceholder = computed(() => {
if (identified.value && faceDuration.value > 0) {
if (identifyState.value.identified && faceDuration.value > 0) {
return `请输入文案,建议不超过${suggestedMaxChars.value}字以确保与视频匹配`
}
return '请输入你想让角色说话的内容'
@@ -368,25 +365,32 @@ const selectedVoiceMeta = computed(() =>
)
// 生成条件
const canIdentify = computed(() => {
return !!uploadedVideo.value
})
// ==================== 条件计算 ====================
/**
* 是否可以进行人脸识别
*/
const canIdentify = computed(() => !!videoState.value.uploadedVideo)
/**
* 是否可以生成视频
*/
const canGenerate = computed(() => {
const hasText = ttsText.value.trim()
const hasVoice = selectedVoiceMeta.value
const hasVideo = uploadedVideo.value
const isIdentified = identified.value // 必须先识别
const notGenerating = !isGenerating.value
const audioValidated = audioValidationPassed.value // 必须通过音频时长校验
return !!(hasText && hasVoice && hasVideo && isIdentified && notGenerating && audioValidated)
const hasVideo = videoState.value.uploadedVideo
const isIdentified = identifyState.value.identified
const audioValidated = audioState.value.validationPassed
return !!(hasText && hasVoice && hasVideo && isIdentified && audioValidated)
})
// 新增:生成配音的条件(不需要通过校验,只需要基本的文案和音色)
/**
* 是否可以生成配音(不需要通过校验)
*/
const canGenerateAudio = computed(() => {
const hasText = ttsText.value.trim()
const hasVoice = selectedVoiceMeta.value
return !!(hasText && hasVoice && !generatingAudio.value)
return !!(hasText && hasVoice && !audioState.value.generating)
})
// UI 控制
@@ -460,12 +464,12 @@ const generatePreviewCacheKey = (voice) => {
const decodeAndCacheBase64 = async (audioBase64, format = 'mp3', cacheKey) => {
const byteCharacters = window.atob(audioBase64)
const byteNumbers = new Array(byteCharacters.length)
const byteNumbers = new Uint8Array(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 blob = new Blob([byteNumbers], { type: mime })
const objectUrl = URL.createObjectURL(blob)
const audioData = { blob, objectUrl, format }
@@ -540,41 +544,41 @@ const handleFile = (file) => {
message.error('仅支持 MP4 和 MOV')
return
}
videoFile.value = file
uploadedVideo.value = URL.createObjectURL(file)
identified.value = false // 重置识别状态
previewVideoUrl.value = ''
videoState.value.videoFile = file
videoState.value.uploadedVideo = URL.createObjectURL(file)
identifyState.value.identified = false // 重置识别状态
videoState.value.previewVideoUrl = ''
// 重置识别结果
identifySessionId.value = ''
identifyFaceId.value = ''
identifyVideoFileId.value = null
identifyState.value.sessionId = ''
identifyState.value.faceId = ''
identifyState.value.videoFileId = null
}
// 人脸识别
const handleIdentify = async () => {
if (!videoFile.value) return
if (!videoState.value.videoFile) return
identifying.value = true
identifyState.value.identifying = true
try {
const res = await createKlingTaskAndIdentify(videoFile.value)
const res = await createKlingTaskAndIdentify(videoState.value.videoFile)
// 保存识别结果
identifySessionId.value = res.data.sessionId
identifyVideoFileId.value = res.data.fileId
identifyFaceId.value = res.data.faceId
identifyState.value.sessionId = res.data.sessionId
identifyState.value.videoFileId = res.data.fileId
identifyState.value.faceId = res.data.faceId
// 保存人脸时间信息,用于音频插入时间
identifyFaceStartTime.value = res.data.startTime || 0
identifyFaceEndTime.value = res.data.endTime || 0
identified.value = true
identifyState.value.faceStartTime = res.data.startTime || 0
identifyState.value.faceEndTime = res.data.endTime || 0
identifyState.value.identified = true
// 显示识别成功提示,包含人脸区间信息
const durationSec = (identifyFaceEndTime.value - identifyFaceStartTime.value) / 1000
const durationSec = (identifyState.value.faceEndTime - identifyState.value.faceStartTime) / 1000
message.success(`识别完成!人脸出现时长约 ${durationSec.toFixed(1)} 秒,建议文案不超过 ${suggestedMaxChars.value}`)
console.log('识别结果:', res.data)
// 不再自动触发生成,让用户先调整文案
} catch (error) {
message.error(error.message || '识别失败')
} finally {
identifying.value = false
identifyState.value.identifying = false
}
}
@@ -591,7 +595,7 @@ const handleGenerateAudio = async () => {
return
}
generatingAudio.value = true
audioState.value.generating = true
try {
const params = {
inputText: ttsText.value,
@@ -602,7 +606,7 @@ const handleGenerateAudio = async () => {
const res = await VoiceService.synthesize(params)
if (res.code === 0) {
generatedAudio.value = res.data
audioState.value.generated = res.data
// ✅ 严格依赖前端解析的真实时长TTS API的durationMs不可靠
if (!res.data.audioBase64) {
@@ -610,7 +614,7 @@ const handleGenerateAudio = async () => {
}
try {
audioDurationMs.value = await parseAudioDuration(res.data.audioBase64)
audioState.value.durationMs = await parseAudioDuration(res.data.audioBase64)
// 自动校验时长
validateAudioDuration()
message.success('配音生成成功!')
@@ -618,9 +622,9 @@ const handleGenerateAudio = async () => {
// 解析失败则终止流程,要求用户重新生成
console.error('❌ 音频解析失败:', error)
message.error('音频解析失败,请重新生成配音')
audioDurationMs.value = 0
generatedAudio.value = null
audioValidationPassed.value = false
audioState.value.durationMs = 0
audioState.value.generated = null
audioState.value.validationPassed = false
}
} else {
throw new Error(res.msg || '配音生成失败')
@@ -629,7 +633,7 @@ const handleGenerateAudio = async () => {
console.error('generateAudio error:', error)
message.error(error.message || '配音生成失败')
} finally {
generatingAudio.value = false
audioState.value.generating = false
}
}
@@ -687,15 +691,15 @@ const parseAudioDuration = (base64Data) => {
// 校验音频时长与人脸时长的匹配性
// 要求音频与人脸区间至少重合2秒
const validateAudioDuration = () => {
if (!identified.value || faceDuration.value <= 0) {
audioValidationPassed.value = false
if (!identifyState.value.identified || faceDuration.value <= 0) {
audioState.value.validationPassed = false
return false
}
const faceStart = identifyFaceStartTime.value
const faceEnd = identifyFaceEndTime.value
const faceStart = identifyState.value.faceStartTime
const faceEnd = identifyState.value.faceEndTime
const faceDurationMs = faceEnd - faceStart
const audioDuration = audioDurationMs.value
const audioDuration = audioState.value.durationMs
// 计算重合区间(简化:假设音频从人脸起点开始插入)
const overlapStart = faceStart
@@ -705,7 +709,7 @@ const validateAudioDuration = () => {
// 校验重合区间至少2秒
const isValid = overlapDuration >= 2000
audioValidationPassed.value = isValid
audioState.value.validationPassed = isValid
if (!isValid) {
const overlapSec = (overlapDuration / 1000).toFixed(1)
@@ -739,11 +743,10 @@ const handleGenerate = async () => {
return
}
isGenerating.value = true
try {
// 如果已经识别过使用识别时的视频文件ID和识别结果
let videoFileId = identifyVideoFileId.value
let videoFileId = identifyState.value.videoFileId
// 2. 创建任务
const taskData = {
taskName: `可灵数字人任务_${Date.now()}`,
@@ -753,11 +756,11 @@ const handleGenerate = async () => {
volume: 0,
guidanceScale: 1,
seed: 8888,
kling_session_id: identifySessionId.value,
kling_face_id: identifyFaceId.value,
kling_session_id: identifyState.value.sessionId,
kling_face_id: identifyState.value.faceId,
// 人脸可对口型时间区间,用于音频插入时间
kling_face_start_time: identifyFaceStartTime.value,
kling_face_end_time: identifyFaceEndTime.value,
kling_face_start_time: identifyState.value.faceStartTime,
kling_face_end_time: identifyState.value.faceEndTime,
ai_provider: 'kling'
}
@@ -769,97 +772,40 @@ const handleGenerate = async () => {
taskData.voiceConfigId = configId
// ✅ 新增传递预生成的音频给后端复用而不重复TTS
if (generatedAudio.value && audioDurationMs.value > 0) {
if (audioState.value.generated && audioState.value.durationMs > 0) {
taskData.pre_generated_audio = {
audioBase64: generatedAudio.value.audioBase64,
format: generatedAudio.value.format || 'mp3'
audioBase64: audioState.value.generated.audioBase64,
format: audioState.value.generated.format || 'mp3'
}
// ✅ 新增:传递 sound_end_time 给可灵API音频结束时间
// 可灵API要求音频从0开始所以结束时间 = 0 + 音频时长
taskData.sound_end_time = audioDurationMs.value
taskData.sound_end_time = audioState.value.durationMs
console.log('传递预生成音频给后端:', {
soundEndTime: taskData.sound_end_time,
hasAudioData: !!generatedAudio.value.audioBase64
hasAudioData: !!audioState.value.generated.audioBase64
})
} else {
console.warn('⚠️ 未找到预生成音频将在后端重新TTS')
}
message.loading('正在创建任务...', 0)
const res = await createLipSyncTask(taskData)
message.destroy()
if (res.code === 0) {
currentTaskId.value = res.data
message.success('任务创建成功,开始处理')
// 3. 轮询任务状态
pollTaskStatus()
message.success('任务已提交到任务中心,请前往查看')
// 不再等待任务完成,用户可以继续操作
} else {
throw new Error(res.msg || '任务创建失败')
}
} catch (error) {
console.error('generate error:', error)
message.destroy()
message.error(error.message || '视频生成失败')
isGenerating.value = false
message.error(error.message || '任务提交失败')
}
}
// 轮询任务状态
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 () => {
@@ -870,28 +816,14 @@ const loadLastTask = async () => {
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 = ''
} 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 || '未知错误'
videoState.value.previewVideoUrl = task.resultVideoUrl
}
// 重置识别状态,允许重新识别
identified.value = false
identifyState.value.identified = false
}
} catch (error) {
console.error('loadLastTask error:', error)
@@ -907,28 +839,6 @@ const formatTime = (ms) => {
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('暂无可试听的音频')
@@ -966,9 +876,9 @@ const playAudioPreview = (url, options = {}) => {
}
const downloadVideo = () => {
if (!previewVideoUrl.value) return message.warning('没有可下载的视频')
if (!videoState.value.previewVideoUrl) return message.warning('没有可下载的视频')
const link = document.createElement('a')
link.href = previewVideoUrl.value
link.href = videoState.value.previewVideoUrl
link.download = `可灵数字人视频_${Date.now()}.mp4`
link.click()
}
@@ -994,7 +904,7 @@ let previewObjectUrl = ''
<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-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; background-clip: text; 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; }
@@ -1333,73 +1243,6 @@ let previewObjectUrl = ''
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;

File diff suppressed because it is too large Load Diff