feat: 优化功能
This commit is contained in:
@@ -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>支持 MP4、MOV</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
Reference in New Issue
Block a user