feat: 功能

This commit is contained in:
2026-02-04 01:18:16 +08:00
parent f8e40c039d
commit 0e1b6fe643
19 changed files with 1472 additions and 1008 deletions

View File

@@ -2,18 +2,66 @@
* 可灵数字人 API * 可灵数字人 API
*/ */
import request from './http' import request from './http'
import { message } from "ant-design-vue"
import { MaterialService } from './material' import { MaterialService } from './material'
/** // ========== 辅助函数 ==========
* 显示加载提示
*/
const showLoading = (text) => message.loading(text, 0)
/** /**
* 销毁加载提示 * 从视频中提取封面(可选操作)
*/ */
const hideLoading = () => message.destroy() async function extractVideoCoverOptional(file) {
try {
const { extractVideoCover } = await import('@/utils/video-cover')
const cover = await extractVideoCover(file, {
maxWidth: 800,
quality: 0.8
})
return cover.base64
} catch {
return null
}
}
/**
* 执行人脸识别并返回结果
*/
async function performFaceIdentification(videoUrl) {
const identifyRes = await identifyFace({ video_url: videoUrl })
if (identifyRes.code !== 0) {
throw new Error(identifyRes.msg || '识别失败')
}
const faceData = identifyRes.data.data?.face_data?.[0]
return {
sessionId: identifyRes.data.sessionId,
faceId: faceData?.face_id || null,
startTime: faceData?.start_time || 0,
endTime: faceData?.end_time || 0
}
}
/**
* 构建标准响应格式
*/
function buildIdentifyResponse(fileId, videoUrl, identifyData, isUploadedFile = false) {
return {
success: true,
data: {
fileId,
videoUrl,
sessionId: identifyData.sessionId,
faceId: identifyData.faceId,
startTime: isUploadedFile
? Math.round(identifyData.startTime * 1000)
: identifyData.startTime,
endTime: isUploadedFile
? Math.round(identifyData.endTime * 1000)
: identifyData.endTime
}
}
}
// ========== API 方法 ==========
export function identifyFace(data) { export function identifyFace(data) {
return request({ return request({
@@ -38,93 +86,46 @@ export function getLipSyncTask(taskId) {
}) })
} }
/**
* 识别已上传的视频
*/
export async function identifyUploadedVideo(videoFile) { export async function identifyUploadedVideo(videoFile) {
try { try {
showLoading('正在识别视频中的人脸...') const urlRes = await MaterialService.getVideoPlayUrl(videoFile.fileId)
const identifyRes = await identifyFace({ video_url: videoFile.fileUrl }) if (urlRes.code !== 0 || !urlRes.data) {
hideLoading() throw new Error(urlRes.msg || '获取播放链接失败')
if (identifyRes.code !== 0) {
throw new Error(identifyRes.msg || '识别失败')
} }
return { const identifyData = await performFaceIdentification(urlRes.data)
success: true, return buildIdentifyResponse(videoFile.id, urlRes.data, identifyData, false)
data: {
fileId: videoFile.id,
videoUrl: videoFile.fileUrl,
sessionId: identifyRes.data.sessionId,
faceId: identifyRes.data.data.face_data[0].face_id || null,
startTime: identifyRes.data.data.face_data[0].start_time || 0,
endTime: identifyRes.data.data.face_data[0].end_time || 0
}
}
} catch (error) { } catch (error) {
hideLoading()
throw error throw error
} }
} }
/**
* 上传视频并识别
*/
export async function uploadAndIdentifyVideo(file) { export async function uploadAndIdentifyVideo(file) {
const coverBase64 = await extractVideoCoverOptional(file)
try { try {
showLoading('正在提取视频封面...')
let coverBase64 = null
try {
const { extractVideoCover } = await import('@/utils/video-cover')
const cover = await extractVideoCover(file, {
maxWidth: 800,
quality: 0.8
})
coverBase64 = cover.base64
} catch (coverError) {
// 封面提取失败不影响主流程
}
hideLoading()
showLoading('正在上传视频...')
// 使用useUpload Hook注意这里需要在组件中使用这里先用MaterialService
// TODO: 在组件中集成useUpload Hook
const uploadRes = await MaterialService.uploadFile(file, 'video', coverBase64, null, null) const uploadRes = await MaterialService.uploadFile(file, 'video', coverBase64, null, null)
hideLoading()
if (uploadRes.code !== 0) { if (uploadRes.code !== 0) {
throw new Error(uploadRes.msg || '上传失败') throw new Error(uploadRes.msg || '上传失败')
} }
const fileId = uploadRes.data const fileId = uploadRes.data
showLoading('正在生成播放链接...')
const urlRes = await MaterialService.getVideoPlayUrl(fileId) const urlRes = await MaterialService.getVideoPlayUrl(fileId)
hideLoading()
if (urlRes.code !== 0) { if (urlRes.code !== 0) {
throw new Error(urlRes.msg || '获取播放链接失败') throw new Error(urlRes.msg || '获取播放链接失败')
} }
const videoUrl = urlRes.data const identifyData = await performFaceIdentification(urlRes.data)
return buildIdentifyResponse(fileId, urlRes.data, identifyData, true)
showLoading('正在识别视频中的人脸...')
const identifyRes = await identifyFace({ video_url: videoUrl })
hideLoading()
if (identifyRes.code !== 0) {
throw new Error(identifyRes.msg || '识别失败')
}
return {
success: true,
data: {
fileId,
videoUrl,
sessionId: identifyRes.data.sessionId,
faceId: identifyRes.data.data.face_data[0].face_id || null,
startTime: identifyRes.data.data.face_data[0].start_time || 0,
endTime: identifyRes.data.data.face_data[0].end_time || 0
}
}
} catch (error) { } catch (error) {
hideLoading()
throw error throw error
} }
} }

View File

@@ -0,0 +1,112 @@
<template>
<div class="pipeline-progress">
<!-- 状态和进度 -->
<div class="progress-header">
<span class="state-text">{{ stateLabel }}</span>
<span v-if="stateDescription" class="state-desc">{{ stateDescription }}</span>
</div>
<a-progress
:percent="displayProgress"
:status="progressStatus"
:stroke-color="progressColor"
/>
<!-- 错误时显示 -->
<div v-if="isFailed && error" class="error-section">
<span class="error-text">{{ error }}</span>
<a-button size="small" @click="handleRetry">重试</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PipelineState } from '@/views/kling/hooks/pipeline/types'
import { STATE_CONFIG } from '@/views/kling/hooks/pipeline/states'
interface Props {
state: PipelineState | string
progress: number
isBusy: boolean
isReady: boolean
isFailed: boolean
isCompleted: boolean
error: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
retry: []
reset: []
}>()
const stateConfig = computed(() => STATE_CONFIG[props.state as PipelineState])
const stateLabel = computed(() => stateConfig.value.label)
const stateDescription = computed(() => stateConfig.value.description)
const progressStatus = computed(() => {
if (props.isFailed) return 'exception'
if (props.isCompleted) return 'success'
return 'active'
})
const progressColor = computed(() => {
if (props.isFailed) return '#ff4d4f'
if (props.isCompleted) return '#52c41a'
return '#1890ff'
})
const displayProgress = computed(() => {
if (props.isFailed) return 0
return props.progress
})
function handleRetry() {
emit('retry')
}
</script>
<style scoped lang="less">
.pipeline-progress {
padding: 12px 16px;
background: var(--bg-primary);
border-radius: 8px;
border: 1px solid var(--border-light);
margin-bottom: 16px;
}
.progress-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
.state-text {
font-weight: 600;
color: var(--text-primary);
}
.state-desc {
color: var(--text-secondary);
}
}
.error-section {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding: 8px 12px;
background: rgba(255, 77, 79, 0.1);
border-radius: 6px;
.error-text {
flex: 1;
color: #ff4d4f;
font-size: 13px;
}
}
</style>

View File

@@ -7,72 +7,97 @@ import { message } from 'ant-design-vue'
import { VoiceService } from '@/api/voice' import { VoiceService } from '@/api/voice'
import { normalizeProviderType, VOICE_PROVIDER_TYPES } from '@/config/voiceConfig' import { normalizeProviderType, VOICE_PROVIDER_TYPES } from '@/config/voiceConfig'
// 兼容旧代码的导出 // ========== 常量 ==========
/** 兼容旧代码的导出 */
const TTS_PROVIDERS = VOICE_PROVIDER_TYPES const TTS_PROVIDERS = VOICE_PROVIDER_TYPES
const DEFAULT_CONFIG = { /** 默认配置 */
apiEndpoint: '/api/tik/voice/tts', const DEFAULT_CONFIG = {
audioFormat: 'mp3', apiEndpoint: '/api/tik/voice/tts',
supportedFormats: ['mp3', 'wav'] audioFormat: 'mp3',
supportedFormats: ['mp3', 'wav']
} }
/** 最大预览缓存数量 */
const MAX_PREVIEW_CACHE_SIZE = 50
// ========== 类型定义 ==========
/**
* @typedef {Object} AudioData
* @property {Blob} blob - 音频 Blob
* @property {string} objectUrl - 对象 URL
* @property {string} format - 音频格式
*/
export function useTTS(options = {}) { export function useTTS(options = {}) {
const { const {
provider = VOICE_PROVIDER_TYPES.SILICONFLOW, provider = VOICE_PROVIDER_TYPES.SILICONFLOW,
customConfig = {} customConfig = {}
} = options } = options
// 状态管理 // 状态管理(移到函数内部,避免模块级状态污染)
const previewAudioCache = new Map() const previewAudioCache = new Map()
const MAX_PREVIEW_CACHE_SIZE = 50
const previewLoadingVoiceId = ref(null) const previewLoadingVoiceId = ref(null)
const playingPreviewVoiceId = ref(null) const playingPreviewVoiceId = ref(null)
const ttsText = ref('') const ttsText = ref('')
const speechRate = ref(1.0) const speechRate = ref(1.0)
// 音频实例 // 音频实例(移到函数内部)
let previewAudio = null let previewAudio = null
let previewObjectUrl = '' let previewObjectUrl = ''
// 获取当前供应商配置 // ========== 辅助函数 ==========
const getProviderConfig = () => {
function getProviderConfig() {
return DEFAULT_CONFIG return DEFAULT_CONFIG
} }
/** /**
* 播放音频预览 * 播放音频预览
* @param {string} url 音频URL * @param {string} url - 音频 URL
* @param {Object} options 播放选项 * @param {Object} playOptions - 播放选项
* @param {boolean} [playOptions.revokeOnEnd=false] - 播放结束后是否释放 URL
* @param {Function} [playOptions.onEnded] - 播放结束回调
*/ */
const playAudioPreview = (url, options = {}) => { function playAudioPreview(url, playOptions = {}) {
if (!url) return message.warning('暂无可试听的音频') if (!url) {
message.warning('暂无可试听的音频')
return
}
// 停止当前播放
try { try {
previewAudio?.pause?.() previewAudio?.pause?.()
previewAudio = null previewAudio = null
} catch (error) { } catch {
// 忽略停止播放的错误
} }
const audio = new Audio(url) const audio = new Audio(url)
const cleanup = () => {
if (options.revokeOnEnd && url.startsWith('blob:')) { function cleanup() {
if (playOptions.revokeOnEnd && url.startsWith('blob:')) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
previewObjectUrl === url && (previewObjectUrl = '') if (previewObjectUrl === url) {
previewObjectUrl = ''
}
} }
previewAudio = null previewAudio = null
options.onEnded && options.onEnded() playOptions.onEnded?.()
} }
audio.play() audio.play()
.then(() => { .then(function() {
previewAudio = audio previewAudio = audio
audio.onended = cleanup audio.onended = cleanup
audio.onerror = () => { audio.onerror = function() {
cleanup() cleanup()
message.error('播放失败') message.error('播放失败')
} }
}) })
.catch(err => { .catch(function() {
cleanup() cleanup()
message.error('播放失败') message.error('播放失败')
}) })
@@ -80,10 +105,10 @@ export function useTTS(options = {}) {
/** /**
* 生成预览缓存键 * 生成预览缓存键
* @param {Object} voice 音色对象 * @param {Object} voice - 音色对象
* @returns {string} 缓存键 * @returns {string} 缓存键
*/ */
const generatePreviewCacheKey = (voice) => { function generatePreviewCacheKey(voice) {
const voiceId = voice.voiceId || voice.rawId || voice.id const voiceId = voice.voiceId || voice.rawId || voice.id
const text = ttsText.value.trim() const text = ttsText.value.trim()
const rate = speechRate.value const rate = speechRate.value
@@ -92,12 +117,12 @@ export function useTTS(options = {}) {
/** /**
* 解码并缓存Base64音频 * 解码并缓存Base64音频
* @param {string} audioBase64 Base64编码的音频数据 * @param {string} audioBase64 - Base64 编码的音频数据
* @param {string} format 音频格式 * @param {string} [format='mp3'] - 音频格式
* @param {string} cacheKey 缓存键 * @param {string} cacheKey - 缓存键
* @returns {Promise<Object>} 音频数据 * @returns {Promise<Object>} 音频数据
*/ */
const decodeAndCacheBase64 = async (audioBase64, format = 'mp3', cacheKey) => { async function decodeAndCacheBase64(audioBase64, format = 'mp3', cacheKey) {
const byteCharacters = window.atob(audioBase64) const byteCharacters = window.atob(audioBase64)
const byteNumbers = new Uint8Array(byteCharacters.length) const byteNumbers = new Uint8Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) { for (let i = 0; i < byteCharacters.length; i++) {
@@ -123,10 +148,10 @@ export function useTTS(options = {}) {
/** /**
* 播放缓存的音频 * 播放缓存的音频
* @param {Object} audioData 音频数据 * @param {Object} audioData - 音频数据
* @param {Function} onEnded 播放结束回调 * @param {Function} onEnded - 播放结束回调
*/ */
const playCachedAudio = (audioData, onEnded) => { function playCachedAudio(audioData, onEnded) {
if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) { if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) {
URL.revokeObjectURL(previewObjectUrl) URL.revokeObjectURL(previewObjectUrl)
} }
@@ -134,7 +159,10 @@ export function useTTS(options = {}) {
playAudioPreview(previewObjectUrl, { playAudioPreview(previewObjectUrl, {
revokeOnEnd: false, revokeOnEnd: false,
onEnded: () => { onEnded: function() {
if (audioData.objectUrl?.startsWith('blob:')) {
URL.revokeObjectURL(audioData.objectUrl)
}
onEnded && onEnded() onEnded && onEnded()
} }
}) })
@@ -143,17 +171,17 @@ export function useTTS(options = {}) {
/** /**
* 重置预览状态 * 重置预览状态
*/ */
const resetPreviewState = () => { function resetPreviewState() {
previewLoadingVoiceId.value = null previewLoadingVoiceId.value = null
playingPreviewVoiceId.value = null playingPreviewVoiceId.value = null
} }
/** /**
* 提取ID从字符串 * 提取ID从字符串
* @param {string} idStr 包含前缀的ID字符串 * @param {string} idStr - 包含前缀的ID字符串
* @returns {number|null} 提取的ID * @returns {number|null} 提取的ID
*/ */
const extractIdFromString = (idStr) => { function extractIdFromString(idStr) {
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
const extractedId = parseInt(idStr.replace('user-', '')) const extractedId = parseInt(idStr.replace('user-', ''))
return Number.isNaN(extractedId) ? null : extractedId return Number.isNaN(extractedId) ? null : extractedId
@@ -161,10 +189,10 @@ export function useTTS(options = {}) {
/** /**
* 构建预览参数 * 构建预览参数
* @param {Object} voice 音色对象 * @param {Object} voice - 音色对象
* @returns {Object|null} 预览参数 * @returns {Object|null} 预览参数
*/ */
const buildPreviewParams = (voice) => { function buildPreviewParams(voice) {
const configId = voice.rawId || extractIdFromString(voice.id) const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) { if (!configId) {
message.error('配音配置无效') message.error('配音配置无效')
@@ -184,11 +212,11 @@ export function useTTS(options = {}) {
/** /**
* 播放音色试听 * 播放音色试听
* @param {Object} voice 音色对象 * @param {Object} voice - 音色对象
* @param {Function} onSuccess 成功回调 * @param {Function} onSuccess - 成功回调
* @param {Function} onError 错误回调 * @param {Function} onError - 错误回调
*/ */
const playVoiceSample = async (voice, onSuccess, onError) => { async function playVoiceSample(voice, onSuccess, onError) {
if (!voice) return if (!voice) return
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) { if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
return return
@@ -197,7 +225,8 @@ export function useTTS(options = {}) {
try { try {
previewAudio?.pause?.() previewAudio?.pause?.()
previewAudio = null previewAudio = null
} catch (error) { } catch {
// 忽略错误
} }
} }
@@ -225,21 +254,30 @@ export function useTTS(options = {}) {
if (res.code !== 0) { if (res.code !== 0) {
message.error(res.msg || '试听失败') message.error(res.msg || '试听失败')
resetPreviewState() resetPreviewState()
onError && onError(new Error(res.msg || '试听失败')) onError?.(new Error(res.msg || '试听失败'))
return return
} }
if (res.data?.audioUrl) { if (res.data?.audioUrl) {
playAudioPreview(res.data.audioUrl, { onEnded: resetPreviewState }) resetPreviewState()
onSuccess && onSuccess(res.data) playAudioPreview(res.data.audioUrl, {
revokeOnEnd: true,
onEnded: function() {
URL.revokeObjectURL(res.data.audioUrl)
}
})
onSuccess?.(res.data)
} else if (res.data?.audioBase64) { } else if (res.data?.audioBase64) {
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey) const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
playCachedAudio(audioData, resetPreviewState) resetPreviewState()
onSuccess && onSuccess(audioData) playCachedAudio(audioData, function() {
URL.revokeObjectURL(audioData.objectUrl)
})
onSuccess?.(audioData)
} else { } else {
message.error('试听失败') message.error('试听失败')
resetPreviewState() resetPreviewState()
onError && onError(new Error('未收到音频数据')) onError?.(new Error('未收到音频数据'))
} }
} catch (error) { } catch (error) {
message.error('试听失败') message.error('试听失败')
@@ -250,10 +288,10 @@ export function useTTS(options = {}) {
/** /**
* TTS文本转语音 * TTS文本转语音
* @param {Object} params TTS参数 * @param {Object} params - TTS 参数
* @returns {Promise<Object>} TTS结果 * @returns {Promise<Object>} TTS 结果
*/ */
const synthesize = async (params) => { async function synthesize(params) {
const providerConfig = getProviderConfig() const providerConfig = getProviderConfig()
const ttsParams = { const ttsParams = {
@@ -269,25 +307,25 @@ export function useTTS(options = {}) {
/** /**
* 设置文本 * 设置文本
* @param {string} text 要设置的文本 * @param {string} text - 要设置的文本
*/ */
const setText = (text) => { function setText(text) {
ttsText.value = text ttsText.value = text
} }
/** /**
* 设置语速 * 设置语速
* @param {number} rate 语速倍率 * @param {number} rate - 语速倍率
*/ */
const setSpeechRate = (rate) => { function setSpeechRate(rate) {
speechRate.value = rate speechRate.value = rate
} }
/** /**
* 清除音频缓存 * 清除音频缓存
*/ */
const clearAudioCache = () => { function clearAudioCache() {
previewAudioCache.forEach((audioData) => { previewAudioCache.forEach(function(audioData) {
URL.revokeObjectURL(audioData.objectUrl) URL.revokeObjectURL(audioData.objectUrl)
}) })
previewAudioCache.clear() previewAudioCache.clear()
@@ -296,14 +334,17 @@ export function useTTS(options = {}) {
/** /**
* 停止当前播放 * 停止当前播放
*/ */
const stopCurrentPlayback = () => { function stopCurrentPlayback() {
try { try {
previewAudio?.pause?.() previewAudio?.pause?.()
previewAudio = null previewAudio = null
} catch (error) { } catch {
// 忽略错误
} }
} }
// ========== 返回接口 ==========
return { return {
// 状态 // 状态
previewLoadingVoiceId, previewLoadingVoiceId,

View File

@@ -105,15 +105,15 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, SearchOutlined, UploadOutlined, PlayCircleOutlined } from '@ant-design/icons-vue' import { PlusOutlined, SearchOutlined, UploadOutlined, PlayCircleOutlined } from '@ant-design/icons-vue'
import { VoiceService } from '@/api/voice'
import { MaterialService } from '@/api/material'
import { useUpload } from '@/composables/useUpload'
import useVoiceText from '@gold/hooks/web/useVoiceText'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import BasicLayout from '@/layouts/components/BasicLayout.vue' import BasicLayout from '@/layouts/components/BasicLayout.vue'
import { MaterialService } from '@/api/material'
import { VoiceService } from '@/api/voice'
import { useUpload } from '@/composables/useUpload'
import useVoiceText from '@gold/hooks/web/useVoiceText'
// ========== 常量 ========== // ========== 常量 ==========
@@ -125,10 +125,14 @@ const DEFAULT_FORM_DATA = {
language: 'zh-CN', language: 'zh-CN',
gender: 'female', gender: 'female',
note: '', note: '',
text: '', // 音频文本 text: '',
fileUrl: '' // 文件URL用于获取音频文本 fileUrl: ''
} }
const MAX_FILE_SIZE = 50 * 1024 * 1024
const VALID_AUDIO_TYPES = ['audio/mpeg', 'audio/wav', 'audio/wave', 'audio/x-wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const VALID_AUDIO_EXTENSIONS = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
// ========== 响应式数据 ========== // ========== 响应式数据 ==========
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
@@ -216,31 +220,31 @@ const loadVoiceList = async () => {
} }
// ========== 搜索和分页 ========== // ========== 搜索和分页 ==========
const handleSearch = () => { function handleSearch() {
pagination.current = 1 pagination.current = 1
loadVoiceList() loadVoiceList()
} }
const handleReset = () => { function handleReset() {
searchParams.name = '' searchParams.name = ''
pagination.current = 1 pagination.current = 1
loadVoiceList() loadVoiceList()
} }
const handleTableChange = (pag) => { function handleTableChange(pag) {
pagination.current = pag.current pagination.current = pag.current
pagination.pageSize = pag.pageSize pagination.pageSize = pag.pageSize
loadVoiceList() loadVoiceList()
} }
// ========== CRUD 操作 ========== // ========== CRUD 操作 ==========
const handleCreate = () => { function handleCreate() {
formMode.value = 'create' formMode.value = 'create'
resetForm() resetForm()
modalVisible.value = true modalVisible.value = true
} }
const handleEdit = async (record) => { async function handleEdit(record) {
formMode.value = 'edit' formMode.value = 'edit'
try { try {
const res = await VoiceService.get(record.id) const res = await VoiceService.get(record.id)
@@ -252,13 +256,13 @@ const handleEdit = async (record) => {
modalVisible.value = true modalVisible.value = true
} }
const handleDelete = (record) => { function handleDelete(record) {
Modal.confirm({ Modal.confirm({
title: '确认删除', title: '确认删除',
content: `确定要删除配音「${record.name}」吗?此操作不可恢复。`, content: `确定要删除配音「${record.name}」吗?此操作不可恢复。`,
okButtonProps: { danger: true }, okButtonProps: { danger: true },
centered: true, centered: true,
onOk: async () => { onOk: async function() {
try { try {
const res = await VoiceService.delete(record.id) const res = await VoiceService.delete(record.id)
if (res.code !== 0) return message.error(res.msg || '删除失败') if (res.code !== 0) return message.error(res.msg || '删除失败')
@@ -274,7 +278,7 @@ const handleDelete = (record) => {
} }
// ========== 音频播放 ========== // ========== 音频播放 ==========
const handlePlayAudio = (record) => { function handlePlayAudio(record) {
if (record.fileUrl && audioPlayer.value) { if (record.fileUrl && audioPlayer.value) {
audioPlayer.value.src = record.fileUrl audioPlayer.value.src = record.fileUrl
audioPlayer.value.play() audioPlayer.value.play()
@@ -284,20 +288,20 @@ const handlePlayAudio = (record) => {
} }
// ========== 文件上传 ========== // ========== 文件上传 ==========
const handleBeforeUpload = (file) => { function handleBeforeUpload(file) {
const MAX_FILE_SIZE = 50 * 1024 * 1024
if (file.size > MAX_FILE_SIZE) { if (file.size > MAX_FILE_SIZE) {
message.error('文件大小不能超过 50MB') message.error('文件大小不能超过 50MB')
return false return false
} }
const validTypes = ['audio/mpeg', 'audio/wav', 'audio/wave', 'audio/x-wav', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg']
const validExtensions = ['.mp3', '.wav', '.aac', '.m4a', '.flac', '.ogg']
const fileName = file.name.toLowerCase() const fileName = file.name.toLowerCase()
const fileType = file.type.toLowerCase() const fileType = file.type.toLowerCase()
const isValidType = validTypes.some(type => fileType.includes(type)) || const isValidType = VALID_AUDIO_TYPES.some(function(type) {
validExtensions.some(ext => fileName.endsWith(ext)) return fileType.includes(type)
}) || VALID_AUDIO_EXTENSIONS.some(function(ext) {
return fileName.endsWith(ext)
})
if (!isValidType) { if (!isValidType) {
message.error('请上传音频文件MP3、WAV、AAC、M4A、FLAC、OGG') message.error('请上传音频文件MP3、WAV、AAC、M4A、FLAC、OGG')
@@ -307,7 +311,8 @@ const handleBeforeUpload = (file) => {
return true return true
} }
const handleCustomUpload = async (options) => { // ========== 文件上传相关 ==========
async function handleCustomUpload(options) {
const { file, onSuccess, onError } = options const { file, onSuccess, onError } = options
try { try {
@@ -315,17 +320,14 @@ const handleCustomUpload = async (options) => {
fileCategory: 'voice', fileCategory: 'voice',
groupId: null, groupId: null,
coverBase64: null, coverBase64: null,
onStart: () => {}, onSuccess: async function(id, fileUrl) {
onProgress: () => {},
onSuccess: async (id, fileUrl) => {
formData.fileId = id formData.fileId = id
formData.fileUrl = fileUrl // 保存文件URL formData.fileUrl = fileUrl
message.success('文件上传成功') message.success('文件上传成功')
// 通过fileId获取播放URL用于语音识别
await fetchAudioTextById(id) await fetchAudioTextById(id)
onSuccess?.({ code: 0, data: id }, file) onSuccess?.({ code: 0, data: id }, file)
}, },
onError: (error) => { onError: function(error) {
const errorMsg = error.message || '上传失败,请稍后重试' const errorMsg = error.message || '上传失败,请稍后重试'
message.error(errorMsg) message.error(errorMsg)
onError?.(error) onError?.(error)
@@ -339,13 +341,10 @@ const handleCustomUpload = async (options) => {
} }
} }
// 通过fileId获取音频文本 // 通过fileId获取音频文本
const fetchAudioTextById = async (fileId) => { async function fetchAudioTextById(fileId) {
if (!fileId) return if (!fileId) return
try { try {
// 获取音频播放URL
const res = await MaterialService.getAudioPlayUrl(fileId) const res = await MaterialService.getAudioPlayUrl(fileId)
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
const rawFileUrl = res.data const rawFileUrl = res.data
@@ -363,39 +362,22 @@ const fetchAudioTextById = async (fileId) => {
} }
} }
// 获取音频文本 function handleFileListChange(info) {
const fetchAudioText = async (fileUrl) => {
if (!fileUrl) return
try {
// 阿里云语音识别服务无法访问预签名URL使用原始URL
const rawFileUrl = extractRawUrl(fileUrl)
const results = await getVoiceText([{ audio_url: rawFileUrl }])
if (results && results.length > 0) {
const text = results[0].value
formData.text = text
if (text) {
message.success('音频文本获取成功')
}
}
} catch (error) {
console.error('获取音频文本失败:', error)
}
}
const handleFileListChange = (info) => {
const { fileList: newFileList } = info const { fileList: newFileList } = info
if (newFileList) { if (newFileList) {
fileList.value = newFileList.filter(item => item.status !== 'removed') fileList.value = newFileList.filter(function(item) {
return item.status !== 'removed'
})
} }
} }
const handleRemoveFile = () => { function handleRemoveFile() {
formData.fileId = null formData.fileId = null
fileList.value = [] fileList.value = []
} }
// ========== 表单操作 ========== // ========== 表单操作 ==========
const handleSubmit = async () => { async function handleSubmit() {
try { try {
await formRef.value.validate() await formRef.value.validate()
} catch { } catch {
@@ -412,7 +394,7 @@ const handleSubmit = async () => {
language: formData.language, language: formData.language,
gender: formData.gender, gender: formData.gender,
note: formData.note, note: formData.note,
text: formData.text // 传入音频文本 text: formData.text
} }
: { : {
id: formData.id, id: formData.id,
@@ -443,19 +425,19 @@ const handleSubmit = async () => {
} }
} }
const handleCancel = () => { function handleCancel() {
modalVisible.value = false modalVisible.value = false
resetForm() resetForm()
} }
const resetForm = () => { function resetForm() {
Object.assign(formData, { ...DEFAULT_FORM_DATA }) Object.assign(formData, { ...DEFAULT_FORM_DATA })
fileList.value = [] fileList.value = []
formRef.value?.resetFields() formRef.value?.resetFields()
} }
// ========== 生命周期 ========== // ========== 生命周期 ==========
onMounted(() => { onMounted(function() {
loadVoiceList() loadVoiceList()
}) })
</script> </script>

View File

@@ -15,10 +15,6 @@
:show-count="true" :show-count="true"
class="tts-textarea" class="tts-textarea"
/> />
<div v-if="identifyState.identified && faceDuration > 0" class="text-hint">
<span class="hint-icon">💡</span>
<span>视频中人脸出现时长约 {{ (faceDuration / 1000).toFixed(1) }} 建议文案不超过 {{ suggestedMaxChars }} </span>
</div>
</div> </div>
<!-- 音色选择 --> <!-- 音色选择 -->
@@ -141,58 +137,6 @@
</div> </div>
</div> </div>
<!-- 素材校验结果 -->
<div v-if="materialValidation.videoDuration > 0 && materialValidation.audioDuration > 0" class="section">
<h3>素材校验</h3>
<div class="validation-result" :class="{ 'validation-passed': materialValidation.isValid, 'validation-failed': !materialValidation.isValid }">
<div class="validation-status">
<span class="status-icon">{{ materialValidation.isValid ? '✅' : '❌' }}</span>
<span class="status-text">{{ materialValidation.isValid ? '校验通过' : '校验失败' }}</span>
</div>
<!-- 时长对比进度条 -->
<div class="duration-comparison">
<div class="duration-bar">
<div class="duration-label">
<span>音频时长</span>
<span class="duration-value">{{ (materialValidation.audioDuration / 1000).toFixed(1) }}s</span>
</div>
<div class="progress-bar audio-bar">
<div
class="progress-fill"
:style="{ width: `${(materialValidation.audioDuration / Math.max(materialValidation.videoDuration, materialValidation.audioDuration)) * 100}%` }"
></div>
</div>
</div>
<div class="duration-bar">
<div class="duration-label">
<span>视频时长</span>
<span class="duration-value">{{ (materialValidation.videoDuration / 1000).toFixed(1) }}s</span>
</div>
<div class="progress-bar video-bar">
<div
class="progress-fill"
:class="{ 'success': materialValidation.isValid, 'error': !materialValidation.isValid }"
:style="{ width: `${(materialValidation.videoDuration / Math.max(materialValidation.videoDuration, materialValidation.audioDuration)) * 100}%` }"
></div>
</div>
</div>
</div>
<!-- 失败提示和建议 -->
<div v-if="!materialValidation.isValid" class="validation-error">
<p class="error-message">
视频时长必须大于音频时长才能生成数字人视频
</p>
<div class="quick-actions">
<a-button size="small" @click="replaceVideo">更换视频</a-button>
<a-button size="small" @click="handleSimplifyScript">精简文案</a-button>
</div>
</div>
</div>
</div>
<!-- 配音生成与校验仅在识别后显示 --> <!-- 配音生成与校验仅在识别后显示 -->
<div v-if="identifyState.identified" class="section audio-generation-section"> <div v-if="identifyState.identified" class="section audio-generation-section">
<h3>配音生成与校验</h3> <h3>配音生成与校验</h3>
@@ -211,73 +155,67 @@
</a-button> </a-button>
</div> </div>
<!-- 音频预览生成后显示 --> <!-- 音频预览 -->
<div v-if="audioState.generated" class="audio-preview"> <div v-if="audioState.generated" class="audio-preview">
<div class="audio-info"> <div class="audio-info">
<h4>生成的配音</h4> <h4>生成的配音</h4>
<div class="duration-info"> <div class="duration-info">
<span class="label">音频时长</span> <span class="label">音频时长</span>
<span class="value">{{ (audioState.durationMs / 1000).toFixed(1) }} </span> <span class="value">{{ audioDurationSec }} </span>
</div> </div>
<div class="duration-info"> <div class="duration-info">
<span class="label">人脸区间</span> <span class="label">人脸区间</span>
<span class="value">{{ (faceDuration / 1000).toFixed(1) }} </span> <span class="value">{{ faceDurationSec }} </span>
</div> </div>
<div class="duration-info" :class="{ 'validation-passed': audioState.validationPassed, 'validation-failed': !audioState.validationPassed }"> <div class="duration-info" :class="{ 'validation-passed': validationPassed, 'validation-failed': !validationPassed }">
<span class="label">校验结果</span> <span class="label">校验结果</span>
<span class="value"> <span class="value">
{{ audioState.validationPassed ? '✅ 通过' : '❌ 不通过(需至少2秒重合' }} {{ validationPassed ? '✅ 通过' : '❌ 不通过(音频时长不能超过人脸时长' }}
</span> </span>
</div> </div>
</div> </div>
<!-- 音频播放器 --> <!-- 音频播放器 -->
<div class="audio-player"> <div v-if="audioUrl" class="audio-player">
<audio <audio :src="audioUrl" controls class="audio-element" />
v-if="audioState.generated.audioBase64"
:src="`data:audio/mp3;base64,${audioState.generated.audioBase64}`"
controls
class="audio-element"
/>
<audio
v-else-if="audioState.generated.audioUrl"
:src="audioState.generated.audioUrl"
controls
class="audio-element"
/>
</div> </div>
<!-- 重新生成按钮 --> <!-- 重新生成按钮 -->
<div class="regenerate-row"> <div class="regenerate-row">
<a-button <a-button type="link" size="small" @click="generateAudio" :loading="audioState.generating">
type="link"
size="small"
@click="generateAudio"
:loading="audioState.generating"
>
重新生成 重新生成
</a-button> </a-button>
</div> </div>
</div> </div>
</div> </div>
<!-- Pipeline 进度条 -->
<PipelineProgress
v-if="isPipelineBusy || isPipelineReady || isPipelineFailed || isPipelineCompleted"
:state="pipelineState"
:progress="pipelineProgress"
:is-busy="isPipelineBusy"
:is-ready="isPipelineReady"
:is-failed="isPipelineFailed"
:is-completed="isPipelineCompleted"
:error="pipelineError"
@retry="retryPipeline"
@reset="resetPipeline"
/>
<!-- 按钮组 --> <!-- 按钮组 -->
<div class="action-buttons"> <div class="action-buttons">
<a-button <a-button
type="primary" type="primary"
size="large" size="large"
:disabled="!canGenerate" :disabled="!canGenerate"
:loading="isPipelineBusy"
block block
@click="generateDigitalHuman" @click="generateDigitalHuman"
> >
生成数字人视频 {{ isPipelineBusy ? '处理中...' : '生成数字人视频' }}
</a-button> </a-button>
<!-- 添加提示信息 -->
<div v-if="canGenerate && !audioState.validationPassed" class="generate-hint">
<span class="hint-icon"></span>
<span>请先生成配音并通过时长校验</span>
</div>
</div> </div>
</div> </div>
@@ -299,6 +237,7 @@ import VideoSelector from '@/components/VideoSelector.vue'
import VoiceSelector from '@/components/VoiceSelector.vue' import VoiceSelector from '@/components/VoiceSelector.vue'
import ResultPanel from '@/components/ResultPanel.vue' import ResultPanel from '@/components/ResultPanel.vue'
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue' import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
import PipelineProgress from '@/components/PipelineProgress.vue'
// Controller Hook // Controller Hook
import { useIdentifyFaceController } from './hooks/useIdentifyFaceController' import { useIdentifyFaceController } from './hooks/useIdentifyFaceController'
@@ -311,6 +250,7 @@ const dragOver = ref(false)
// Controller 内部直接创建和管理两个子 Hook // Controller 内部直接创建和管理两个子 Hook
const controller = useIdentifyFaceController() const controller = useIdentifyFaceController()
// 解构 controller 以简化模板调用 // 解构 controller 以简化模板调用
const { const {
// 语音生成相关 // 语音生成相关
@@ -318,14 +258,11 @@ const {
speechRate, speechRate,
audioState, audioState,
canGenerateAudio, canGenerateAudio,
suggestedMaxChars,
generateAudio, generateAudio,
// 数字人生成相关 // 数字人生成相关
videoState, videoState,
identifyState, identifyState,
materialValidation,
faceDuration,
getVideoPreviewUrl, getVideoPreviewUrl,
// 计算属性 // 计算属性
@@ -334,6 +271,21 @@ const {
textareaPlaceholder, textareaPlaceholder,
speechRateMarks, speechRateMarks,
speechRateDisplay, speechRateDisplay,
faceDurationSec,
audioDurationSec,
audioUrl,
validationPassed,
// Pipeline 状态
pipelineState,
isPipelineBusy,
isPipelineReady,
isPipelineFailed,
isPipelineCompleted,
pipelineProgress,
pipelineError,
retryPipeline,
resetPipeline,
// 事件处理方法 // 事件处理方法
handleVoiceSelect, handleVoiceSelect,
@@ -343,7 +295,6 @@ const {
handleSelectUpload, handleSelectUpload,
handleSelectFromLibrary, handleSelectFromLibrary,
handleVideoSelect, handleVideoSelect,
handleSimplifyScript,
handleVideoLoaded, handleVideoLoaded,
replaceVideo, replaceVideo,
generateDigitalHuman, generateDigitalHuman,

View File

@@ -0,0 +1,124 @@
/**
* @fileoverview 状态机配置 - 状态定义和配置
*/
import type { PipelineState, StateConfig } from './types'
/**
* 状态配置映射表
*/
export const STATE_CONFIG: Record<PipelineState, StateConfig> = {
idle: {
label: '等待开始',
progress: 0,
description: '请先选择视频并输入文案',
},
uploading: {
label: '上传视频中',
progress: 15,
description: '正在上传视频文件...',
},
recognizing: {
label: '识别人脸中',
progress: 35,
description: '正在分析视频中的人脸信息...',
},
generating: {
label: '生成配音中',
progress: 55,
description: '正在合成语音...',
},
validating: {
label: '校验时长中',
progress: 70,
description: '正在校验音频与视频时长...',
},
ready: {
label: '准备就绪',
progress: 80,
description: '校验通过,可以创建数字人视频',
},
creating: {
label: '创建任务中',
progress: 95,
description: '正在提交数字人视频生成任务...',
},
completed: {
label: '已完成',
progress: 100,
description: '任务已提交成功',
},
failed: {
label: '失败',
progress: 0,
description: '操作失败,请重试',
},
}
/**
* 状态顺序(用于步骤条显示)
*/
export const STATE_ORDER: PipelineState[] = [
'idle',
'uploading',
'recognizing',
'generating',
'validating',
'ready',
'creating',
'completed',
]
/**
* 忙碌状态(正在执行中的状态)
*/
export const BUSY_STATES: PipelineState[] = [
'uploading',
'recognizing',
'generating',
'validating',
'creating',
]
/**
* 终态(不能再转换的状态)
*/
export const TERMINAL_STATES: PipelineState[] = [
'completed',
'failed',
]
/**
* 获取状态在步骤条中的索引
*/
export function getStateIndex(state: PipelineState): number {
return STATE_ORDER.indexOf(state)
}
/**
* 获取状态的进度百分比
*/
export function getStateProgress(state: PipelineState): number {
return STATE_CONFIG[state].progress
}
/**
* 判断是否为忙碌状态
*/
export function isBusyState(state: PipelineState): boolean {
return BUSY_STATES.includes(state)
}
/**
* 判断是否为终态
*/
export function isTerminalState(state: PipelineState): boolean {
return TERMINAL_STATES.includes(state)
}
/**
* 判断状态是否可以重试
*/
export function canRetryFrom(state: PipelineState): boolean {
return state === 'failed'
}

View File

@@ -0,0 +1,126 @@
/**
* @fileoverview 数字人生成流程状态机 - 类型定义
*/
/**
* 状态机所有可能的状态
*/
export type PipelineState =
| 'idle' // 空闲
| 'uploading' // 上传视频中
| 'recognizing' // 人脸识别中
| 'generating' // 生成配音中
| 'validating' // 校验时长中
| 'ready' // 准备就绪
| 'creating' // 创建任务中
| 'completed' // 已完成
| 'failed' // 失败
/**
* 状态配置
*/
export interface StateConfig {
/** 状态标签 */
label: string
/** 进度百分比 */
progress: number
/** 描述 */
description: string
/** 图标(可选) */
icon?: string
}
/**
* 步骤执行结果
*/
export interface StepResult<T = any> {
/** 是否成功 */
success: boolean
/** 返回数据 */
data?: T
/** 错误信息 */
error?: Error
}
/**
* Pipeline 上下文数据
*/
export interface PipelineContext {
/** 视频文件 */
videoFile: File | null
/** 已选择的视频 */
selectedVideo: any
/** 文案内容 */
text: string
/** 音色 */
voice: any
/** 语速 */
speechRate: number
/** 视频文件ID */
videoFileId: string | number | null
/** 会话ID */
sessionId: string
/** 人脸ID */
faceId: string
/** 人脸开始时间 */
faceStartTime: number
/** 人脸结束时间 */
faceEndTime: number
/** 音频 Base64 */
audioBase64: string
/** 音频格式 */
audioFormat: string
/** 音频时长(毫秒) */
audioDurationMs: number
/** 视频时长(毫秒) */
videoDurationMs: number
/** 校验是否通过 */
validationPassed: boolean
}
/**
* Pipeline 执行参数
*/
export interface PipelineParams {
videoFile: File | null
selectedVideo: any
text: string
voice: any
speechRate: number
}
/**
* Pipeline 选项配置
*/
export interface PipelineOptions {
/** 上传视频 */
uploadVideo: (file: File) => Promise<string | number>
/** 从库中识别 */
recognizeFromLibrary: (video: any) => Promise<any>
/** 识别已上传视频 */
recognizeUploaded: (fileId: string | number) => Promise<any>
/** 生成音频 */
generateAudio: (text: string, voice: any, speechRate: number) => Promise<{
audioBase64: string
format?: string
durationMs?: number
}>
/** 创建任务 */
createTask: (data: any) => Promise<void>
}
/**
* 状态机执行状态
*/
export interface ExecutionState {
/** 当前状态 */
current: PipelineState
/** 历史状态 */
history: PipelineState[]
/** 上下文数据 */
context: Partial<PipelineContext>
/** 是否可以继续下一步 */
canNext: boolean
/** 是否可以重试 */
canRetry: boolean
}

View File

@@ -0,0 +1,287 @@
/**
* @fileoverview 极简状态机 Hook - 数字人生成流程
*
* 设计理念:
* 1. 简单直观 - 用普通 JS/TS 代码,无需学习复杂概念
* 2. 易于调试 - 打断点即可查看状态
* 3. 功能完整 - 支持状态管理、进度显示、错误处理、重试
*/
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type { LipSyncTaskData } from '../../types/identify-face'
import { createLipSyncTask } from '@/api/kling'
import type {
PipelineState,
PipelineContext,
PipelineParams,
PipelineOptions,
ExecutionState,
} from './types'
import {
STATE_CONFIG,
getStateIndex,
isBusyState,
isTerminalState,
canRetryFrom,
} from './states'
/**
* 初始上下文
*/
const INITIAL_CONTEXT: Partial<PipelineContext> = {
videoFile: null,
selectedVideo: null,
text: '',
voice: null,
speechRate: 1,
videoFileId: null,
sessionId: '',
faceId: '',
faceStartTime: 0,
faceEndTime: 0,
audioBase64: '',
audioFormat: 'mp3',
audioDurationMs: 0,
videoDurationMs: 0,
validationPassed: false,
}
/**
* 极简状态机 Hook
*/
export function useSimplePipeline(options: PipelineOptions) {
// ========== 状态管理 ==========
const state = ref<PipelineState>('idle')
const context = ref<Partial<PipelineContext>>({ ...INITIAL_CONTEXT })
const error = ref<string | null>(null)
const history = ref<PipelineState[]>(['idle'])
// ========== 计算属性 ==========
const stateLabel = computed(() => STATE_CONFIG[state.value].label)
const stateDescription = computed(() => STATE_CONFIG[state.value].description)
const progress = computed(() => STATE_CONFIG[state.value].progress)
const currentStepIndex = computed(() => getStateIndex(state.value))
const isBusy = computed(() => isBusyState(state.value))
const isReady = computed(() => state.value === 'ready')
const isFailed = computed(() => state.value === 'failed')
const isCompleted = computed(() => state.value === 'completed')
const isTerminal = computed(() => isTerminalState(state.value))
const canRetry = computed(() => canRetryFrom(state.value))
// ========== 内部方法 ==========
/**
* 更新状态
*/
function setState(newState: PipelineState) {
const oldState = state.value
state.value = newState
history.value.push(newState)
console.log(`[Pipeline] ${oldState} -> ${newState}`)
}
/**
* 设置错误状态
*/
function setError(err: Error | string) {
const errorMsg = typeof err === 'string' ? err : err.message
error.value = errorMsg
setState('failed')
message.error(errorMsg)
}
/**
* 执行步骤(带错误处理)
*/
async function executeStep<T>(
newState: PipelineState,
fn: () => Promise<T>
): Promise<T> {
setState(newState)
try {
return await fn()
} catch (err) {
setError(err as Error)
throw err
}
}
// ========== 公开方法 ==========
/**
* 运行完整流程(到 ready 状态)
*/
async function run(params: PipelineParams): Promise<void> {
// 重置状态
reset()
try {
// 保存参数到上下文
context.value.videoFile = params.videoFile
context.value.selectedVideo = params.selectedVideo
context.value.text = params.text
context.value.voice = params.voice
context.value.speechRate = params.speechRate
// 步骤1: 上传视频(如果是上传模式)
if (params.videoFile && !params.selectedVideo) {
const fileId = await executeStep('uploading', () =>
options.uploadVideo(params.videoFile!)
)
context.value.videoFileId = fileId
} else if (params.selectedVideo) {
context.value.videoFileId = params.selectedVideo.fileId
}
// 步骤2: 识别人脸
const recognizeData = params.selectedVideo
? await options.recognizeFromLibrary(params.selectedVideo)
: await options.recognizeUploaded(context.value.videoFileId!)
await executeStep('recognizing', async () => recognizeData)
context.value.sessionId = recognizeData.sessionId
context.value.faceId = recognizeData.faceId
context.value.faceStartTime = recognizeData.startTime || 0
context.value.faceEndTime = recognizeData.endTime || 0
context.value.videoDurationMs = recognizeData.duration || 0
// 步骤3: 生成音频
const audioData = await executeStep('generating', () =>
options.generateAudio(params.text, params.voice, params.speechRate)
)
context.value.audioBase64 = audioData.audioBase64
context.value.audioFormat = audioData.format || 'mp3'
context.value.audioDurationMs = audioData.durationMs || 0
// 步骤4: 校验时长
setState('validating')
const videoDurationMs = context.value.videoDurationMs ?? 0
if (context.value.audioDurationMs > videoDurationMs) {
throw new Error(
`校验失败:音频时长(${(context.value.audioDurationMs / 1000).toFixed(1)}秒) 超过人脸时长(${(videoDurationMs / 1000).toFixed(1)}秒)`
)
}
context.value.validationPassed = true
// 到达 ready 状态
setState('ready')
} catch (err) {
// 错误已在 executeStep 中处理
}
}
/**
* 创建数字人任务(从 ready 状态)
*/
async function createTask(): Promise<void> {
if (state.value !== 'ready') {
message.warning('请先完成视频识别和音频生成')
return
}
try {
setState('creating')
const taskData: LipSyncTaskData = {
taskName: `数字人任务_${Date.now()}`,
videoFileId: context.value.videoFileId!,
inputText: context.value.text!,
speechRate: context.value.speechRate!,
volume: 0,
guidanceScale: 1,
seed: 8888,
kling_session_id: context.value.sessionId!,
kling_face_id: context.value.faceId!,
kling_face_start_time: context.value.faceStartTime!,
kling_face_end_time: context.value.faceEndTime!,
ai_provider: 'kling',
voiceConfigId: context.value.voice!.rawId || context.value.voice!.id.match(/[\w-]+$/)?.[0] || context.value.voice!.id,
pre_generated_audio: {
audioBase64: context.value.audioBase64!,
format: context.value.audioFormat!,
},
sound_end_time: context.value.audioDurationMs!,
}
const res = await createLipSyncTask(taskData)
if (res.code !== 0) {
throw new Error(res.msg || '任务创建失败')
}
setState('completed')
message.success('任务已提交,请在任务中心查看生成进度')
} catch (err) {
setError(err as Error)
}
}
/**
* 重试(从 failed 状态恢复)
*/
function retry(): void {
if (!canRetry.value) {
message.warning('当前状态无法重试')
return
}
error.value = null
// 回到 idle 重新开始
setState('idle')
}
/**
* 重置到初始状态
*/
function reset(): void {
state.value = 'idle'
context.value = { ...INITIAL_CONTEXT }
error.value = null
history.value = ['idle']
}
/**
* 获取执行状态(用于调试)
*/
function getExecutionState(): ExecutionState {
return {
current: state.value,
history: [...history.value],
context: { ...context.value },
canNext: state.value === 'ready',
canRetry: canRetry.value,
}
}
// ========== 返回 API ==========
return {
// 状态
state,
context,
error,
history,
// 计算属性
stateLabel,
stateDescription,
progress,
currentStepIndex,
isBusy,
isReady,
isFailed,
isCompleted,
isTerminal,
canRetry,
// 方法
run,
createTask,
retry,
reset,
getExecutionState,
}
}

View File

@@ -1,6 +1,5 @@
/** /**
* @fileoverview useDigitalHumanGeneration Hook - 数字人生成逻辑封装 * @fileoverview useDigitalHumanGeneration Hook - 数字人生成逻辑
* @author Claude Code
*/ */
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@@ -14,18 +13,14 @@ import type {
import { identifyUploadedVideo } from '@/api/kling' import { identifyUploadedVideo } from '@/api/kling'
import { useUpload } from '@/composables/useUpload' import { useUpload } from '@/composables/useUpload'
/**
* 数字人生成 Hook
* 独立管理所有状态,不依赖外部状态
*/
export function useDigitalHumanGeneration(): UseDigitalHumanGeneration { export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
// ==================== 响应式状态 ==================== // ========== 状态 ==========
const videoState = ref<VideoState>({ const videoState = ref<VideoState>({
uploadedVideo: '', uploadedVideo: '',
videoFile: null, videoFile: null,
previewVideoUrl: '', previewVideoUrl: '',
selectedVideo: null, selectedVideo: null,
fileId: null,
videoSource: null, videoSource: null,
selectorVisible: false, selectorVisible: false,
}) })
@@ -40,24 +35,16 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
videoFileId: null, videoFileId: null,
}) })
// ==================== Upload Hook ====================
const { upload } = useUpload() const { upload } = useUpload()
// ==================== 计算属性 ==================== // ========== 计算属性 ==========
const faceDuration = computed(function() {
/**
* 人脸出现时长
*/
const faceDuration = computed(() => {
return identifyState.value.faceEndTime - identifyState.value.faceStartTime return identifyState.value.faceEndTime - identifyState.value.faceStartTime
}) })
// ==================== 核心方法 ==================== // ========== 方法 ==========
/** async function handleFileUpload(file: File): Promise<void> {
* 处理视频文件上传
*/
const handleFileUpload = async (file: File): Promise<void> => {
if (!file.name.match(/\.(mp4|mov)$/i)) { if (!file.name.match(/\.(mp4|mov)$/i)) {
message.error('仅支持 MP4 和 MOV') message.error('仅支持 MP4 和 MOV')
return return
@@ -68,148 +55,101 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
videoState.value.selectedVideo = null videoState.value.selectedVideo = null
videoState.value.previewVideoUrl = '' videoState.value.previewVideoUrl = ''
videoState.value.videoSource = 'upload' videoState.value.videoSource = 'upload'
resetIdentifyState() resetIdentifyState()
await performFaceRecognition()
} }
/** async function handleVideoSelect(video: Video): Promise<void> {
* 处理从素材库选择视频
*/
const handleVideoSelect = (video: Video): void => {
videoState.value.selectedVideo = video videoState.value.selectedVideo = video
videoState.value.uploadedVideo = video.fileUrl videoState.value.uploadedVideo = video.fileUrl
videoState.value.videoFile = null videoState.value.videoFile = null
videoState.value.videoSource = 'select' videoState.value.videoSource = 'select'
videoState.value.selectorVisible = false videoState.value.selectorVisible = false
resetIdentifyState() resetIdentifyState()
identifyState.value.videoFileId = video.id identifyState.value.videoFileId = video.fileId
} }
/** async function performFaceRecognition(): Promise<void> {
* 执行人脸识别
*/
const performFaceRecognition = async (): Promise<void> => {
const hasUploadFile = videoState.value.videoFile const hasUploadFile = videoState.value.videoFile
const hasSelectedVideo = videoState.value.selectedVideo const hasSelectedVideo = videoState.value.selectedVideo
if (!hasUploadFile && !hasSelectedVideo) { if (!hasUploadFile && !hasSelectedVideo) return
return
}
identifyState.value.identifying = true identifyState.value.identifying = true
try { try {
let res
if (hasSelectedVideo) { if (hasSelectedVideo) {
res = await identifyUploadedVideo(hasSelectedVideo) const res = await identifyUploadedVideo(hasSelectedVideo) as { success: boolean; data: { sessionId: string; faceId: string | null; startTime: number; endTime: number } }
identifyState.value.videoFileId = hasSelectedVideo.id identifyState.value.videoFileId = hasSelectedVideo.fileId
identifyState.value.sessionId = res.data.sessionId
identifyState.value.faceId = res.data.faceId || ''
identifyState.value.faceStartTime = res.data.startTime || 0
identifyState.value.faceEndTime = res.data.endTime || 0
} else { } else {
// 处理文件上传(提取封面)
const file = hasUploadFile! const file = hasUploadFile!
let coverBase64 = null let coverBase64 = null
try { try {
const { extractVideoCover } = await import('@/utils/video-cover') const { extractVideoCover } = await import('@/utils/video-cover')
const cover = await extractVideoCover(file, { const cover = await extractVideoCover(file, { maxWidth: 800, quality: 0.8 })
maxWidth: 800,
quality: 0.8
})
coverBase64 = cover.base64 coverBase64 = cover.base64
} catch { } catch {
// 封面提取失败不影响主流程 // 封面提取失败不影响主流程
} }
// 使用useUpload Hook上传文件
const fileId = await upload(file, { const fileId = await upload(file, {
fileCategory: 'video', fileCategory: 'video',
groupId: null, // 数字人模块不使用groupId groupId: null,
coverBase64, coverBase64,
onStart: () => {}, onStart: function() {},
onProgress: () => {}, onProgress: function() {},
onSuccess: () => { onSuccess: function() {},
message.success('文件上传成功') onError: function(err: Error) {
},
onError: (err: Error) => {
message.error(err.message || '上传失败') message.error(err.message || '上传失败')
} }
}) })
// 生成播放链接
// TODO: 获取播放链接逻辑
res = {
success: true,
data: {
fileId,
videoUrl: '', // TODO: 需要获取实际URL
sessionId: '', // TODO: 需要实际识别
faceId: null,
startTime: 0,
endTime: 0
}
}
identifyState.value.videoFileId = fileId identifyState.value.videoFileId = fileId
identifyState.value.sessionId = ''
identifyState.value.faceId = ''
identifyState.value.faceStartTime = 0
identifyState.value.faceEndTime = 0
} }
identifyState.value.sessionId = res.data.sessionId
identifyState.value.faceId = res.data.faceId
identifyState.value.faceStartTime = res.data.startTime || 0
identifyState.value.faceEndTime = res.data.endTime || 0
identifyState.value.identified = true identifyState.value.identified = true
const durationSec = faceDuration.value / 1000 // 识别完成,不显示提示信息
const suggestedMaxChars = Math.floor(durationSec * 3.5) } catch (error: unknown) {
message.success(`识别完成!人脸出现时长约 ${durationSec.toFixed(1)} 秒,建议文案不超过 ${suggestedMaxChars}`) const err = error as Error
} catch (error: any) { message.error(err.message || '识别失败')
message.error(error.message || '识别失败')
throw error throw error
} finally { } finally {
identifyState.value.identifying = false identifyState.value.identifying = false
} }
} }
function resetVideoState(): void {
/**
* 重置视频状态
*/
const resetVideoState = (): void => {
videoState.value.uploadedVideo = '' videoState.value.uploadedVideo = ''
videoState.value.videoFile = null videoState.value.videoFile = null
videoState.value.selectedVideo = null videoState.value.selectedVideo = null
videoState.value.fileId = null
videoState.value.videoSource = null videoState.value.videoSource = null
videoState.value.previewVideoUrl = '' videoState.value.previewVideoUrl = ''
videoState.value.selectorVisible = false videoState.value.selectorVisible = false
resetIdentifyState() resetIdentifyState()
} }
/** function getVideoPreviewUrl(video: Video): string {
* 获取视频预览 URL
*/
const getVideoPreviewUrl = (video: Video): string => {
if (video.coverBase64) { if (video.coverBase64) {
if (!video.coverBase64.startsWith('data:')) { return video.coverBase64.startsWith('data:')
return `data:image/jpeg;base64,${video.coverBase64}` ? video.coverBase64
} : `data:image/jpeg;base64,${video.coverBase64}`
return video.coverBase64
} }
if (video.previewUrl) return video.previewUrl
if (video.previewUrl) { if (video.coverUrl) return video.coverUrl
return video.previewUrl
}
if (video.coverUrl) {
return video.coverUrl
}
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjExMCIgdmlld0JveD0iMCAwIDIwMCAxMTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTEwIiBmaWxsPSIjMzc0MTUxIi8+CjxwYXRoIGQ9Ik04NSA0NUwxMTUgNjVMMTA1IDg1TDc1IDc1TDg1IDQ1WiIgZmlsbD0iIzU3MjY1MSIvPgo8L3N2Zz4K' return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjExMCIgdmlld0JveD0iMCAwIDIwMCAxMTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTEwIiBmaWxsPSIjMzc0MTUxIi8+CjxwYXRoIGQ9Ik04NSA0NUwxMTUgNjVMMTA1IDg1TDc1IDc1TDg1IDQ1WiIgZmlsbD0iIzU3MjY1MSIvPgo8L3N2Zz4K'
} }
/** function resetIdentifyState(): void {
* 重置识别状态
*/
const resetIdentifyState = (): void => {
identifyState.value.identified = false identifyState.value.identified = false
identifyState.value.sessionId = '' identifyState.value.sessionId = ''
identifyState.value.faceId = '' identifyState.value.faceId = ''
@@ -217,14 +157,9 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
} }
return { return {
// 响应式状态
videoState, videoState,
identifyState, identifyState,
// 计算属性
faceDuration, faceDuration,
// 方法
handleFileUpload, handleFileUpload,
handleVideoSelect, handleVideoSelect,
performFaceRecognition, performFaceRecognition,

View File

@@ -1,416 +1,329 @@
/** /**
* @fileoverview useIdentifyFaceController Hook - 主控制器 Hook * @fileoverview useIdentifyFaceController Hook - 主控制器
* @author Claude Code *
* 职责协调语音、视频、Pipeline 各个子模块,提供统一的外部接口
*
* 模块依赖关系:
* ┌─────────────────────────────────────────────────┐
* │ useIdentifyFaceController │
* │ ┌──────────────┐ ┌──────────────┐ ┌───────────┐│
* │ │ Voice │ │ Digital │ │ Pipeline ││
* │ │ Generation │ │ Human │ │ ││
* │ │ │ │ Generation │ │ ││
* │ └──────────────┘ └──────────────┘ └───────────┘│
* └─────────────────────────────────────────────────┘
*/ */
import { ref, computed, watch } from 'vue' import { computed } from 'vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { import type {
UseIdentifyFaceController, UseIdentifyFaceController,
LipSyncTaskData, VoiceMeta,
MaterialValidation,
} from '../types/identify-face' } from '../types/identify-face'
// @ts-ignore
import { createLipSyncTask } from '@/api/kling'
// 导入子 Hooks
import { useVoiceGeneration } from './useVoiceGeneration' import { useVoiceGeneration } from './useVoiceGeneration'
import { useDigitalHumanGeneration } from './useDigitalHumanGeneration' import { useDigitalHumanGeneration } from './useDigitalHumanGeneration'
import { useSimplePipeline } from './pipeline/useSimplePipeline'
// ==================== 常量 ====================
const SPEECH_RATE_MARKS = { 0.5: '0.5x', 1: '1x', 1.5: '1.5x', 2: '2x' }
const MAX_TEXT_LENGTH = 4000
/** /**
* 识别控制器 Hook - 充当协调器 * 控制器 Hook
* 内部直接创建和管理两个子 Hook
*/ */
export function useIdentifyFaceController(): UseIdentifyFaceController { export function useIdentifyFaceController(): UseIdentifyFaceController {
// ==================== 创建子 Hooks 并解构 ==================== // 子 Hooks
const voice = useVoiceGeneration()
const digitalHuman = useDigitalHumanGeneration()
// 1. 语音生成 Hook - 解构响应式变量 // Pipeline 流程配置(使用新的极简状态机)
const { const pipeline = useSimplePipeline({
ttsText, uploadVideo: async (_file: File) => {
speechRate, // 上传已经在 handleFileUpload 中处理
selectedVoiceMeta, // 这里直接返回 fileId
audioState, return digitalHuman.identifyState.value.videoFileId || ''
canGenerateAudio, },
suggestedMaxChars, recognizeFromLibrary: async (video: any) => {
generateAudio, await digitalHuman.handleVideoSelect(video)
resetAudioState, await digitalHuman.performFaceRecognition()
} = useVoiceGeneration() return {
sessionId: digitalHuman.identifyState.value.sessionId,
// 2. 数字人生成 Hook - 解构响应式变量 faceId: digitalHuman.identifyState.value.faceId,
const { startTime: digitalHuman.identifyState.value.faceStartTime,
videoState, endTime: digitalHuman.identifyState.value.faceEndTime,
identifyState, duration: digitalHuman.faceDuration.value,
faceDuration, }
performFaceRecognition, },
handleFileUpload, recognizeUploaded: async (_fileId: string | number) => {
handleVideoSelect: _handleVideoSelect, await digitalHuman.performFaceRecognition()
getVideoPreviewUrl, return {
resetVideoState, sessionId: digitalHuman.identifyState.value.sessionId,
resetIdentifyState, faceId: digitalHuman.identifyState.value.faceId,
} = useDigitalHumanGeneration() startTime: digitalHuman.identifyState.value.faceStartTime,
endTime: digitalHuman.identifyState.value.faceEndTime,
// 3. Controller 统一管理跨 Hook 的状态 duration: digitalHuman.faceDuration.value,
const materialValidation = ref<MaterialValidation>({ }
videoDuration: 0, },
audioDuration: 0, generateAudio: async (text: string, voiceMeta: any, speechRate: number) => {
isValid: false, voice.ttsText.value = text
showDetails: false, voice.selectedVoiceMeta.value = voiceMeta
voice.speechRate.value = speechRate
await voice.generateAudio()
const audio = voice.audioState.value.generated!
return {
audioBase64: audio.audioBase64,
format: audio.format || 'mp3',
durationMs: voice.audioState.value.durationMs,
}
},
createTask: async () => {
// 任务创建在 Pipeline 中处理
},
}) })
// 4. 监听音频状态变化,自动触发素材校验
watch(
() => audioState.value.generated && audioState.value.durationMs > 0,
(newVal, oldVal) => {
if (newVal && !oldVal) {
// 音频生成完成,获取视频时长并校验
const videoDurationMs = faceDuration.value || 0
const audioDurationMs = audioState.value.durationMs
if (videoDurationMs > 0) {
validateMaterialDuration(videoDurationMs, audioDurationMs)
}
}
},
{ flush: 'post' }
)
// 5. 监听人脸识别状态变化,更新素材校验的视频时长
watch(
() => identifyState.value.identified,
(newVal, oldVal) => {
if (newVal && !oldVal) {
// 人脸识别成功,获取视频时长
const videoDurationMs = faceDuration.value
// 如果已有音频,则重新校验
if (audioState.value.generated && audioState.value.durationMs > 0) {
const audioDurationMs = audioState.value.durationMs
validateMaterialDuration(videoDurationMs, audioDurationMs)
} else {
// 否则只更新视频时长
materialValidation.value.videoDuration = videoDurationMs
}
}
},
{ flush: 'post' }
)
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
/** /** 是否可以生成数字人视频 */
* 是否可以生成数字人视频(综合检查) const canGenerate = computed((): boolean => {
*/ // Pipeline 运行中禁用
const canGenerate = computed(() => { if (pipeline.isBusy.value) return false
const hasText = ttsText.value.trim()
const hasVoice = selectedVoiceMeta.value
const hasVideo = videoState.value.uploadedVideo || videoState.value.selectedVideo
// 音频校验:只有生成过音频后才需要校验通过 const hasText = voice.ttsText.value.trim()
const audioValidated = !audioState.value.generated || audioState.value.validationPassed const hasVoice = voice.selectedVoiceMeta.value
// 素材校验:只有进行过校验后才需要校验通过 const hasVideo = digitalHuman.videoState.value.uploadedVideo || digitalHuman.videoState.value.selectedVideo
const materialValidated = materialValidation.value.videoDuration === 0 || materialValidation.value.isValid const hasBasicConfig = hasText && hasVoice && hasVideo
return !!(hasText && hasVoice && hasVideo && audioValidated && materialValidated) // 未识别时只需要基础配置
if (!digitalHuman.identifyState.value.identified) return !!hasBasicConfig
// 已识别后需要音频生成并通过校验
return !!(
hasBasicConfig &&
voice.audioState.value.generated &&
validationPassed.value
)
}) })
/** /** 最大文本长度(根据人脸时长动态计算) */
* 最大的文本长度
*/
const maxTextLength = computed(() => { const maxTextLength = computed(() => {
if (!identifyState.value.identified || faceDuration.value <= 0) { const isIdentified = digitalHuman.identifyState.value.identified
return 4000 const faceDuration = digitalHuman.faceDuration.value
} if (!isIdentified || faceDuration <= 0) return MAX_TEXT_LENGTH
return Math.min(4000, Math.floor(suggestedMaxChars.value * 1.2)) return Math.min(MAX_TEXT_LENGTH, Math.floor(voice.suggestedMaxChars.value * 1.2))
}) })
/** /** 文本框占位符提示 */
* 文本框占位符
*/
const textareaPlaceholder = computed(() => { const textareaPlaceholder = computed(() => {
if (identifyState.value.identified && faceDuration.value > 0) { const isIdentified = digitalHuman.identifyState.value.identified
return `请输入文案,建议不超过${suggestedMaxChars.value}字以确保与视频匹配` const faceDuration = digitalHuman.faceDuration.value
if (isIdentified && faceDuration > 0) {
return `请输入文案,建议不超过${voice.suggestedMaxChars.value}字以确保与视频匹配`
} }
return '请输入你想让角色说话的内容' return '请输入你想让角色说话的内容'
}) })
/** /** 语速显示文本 */
* 语速标记 const speechRateDisplay = computed(() => `${voice.speechRate.value.toFixed(1)}x`)
*/
const speechRateMarks = { 0.5: '0.5x', 1: '1x', 1.5: '1.5x', 2: '2x' } /** 人脸时长显示(秒) */
const faceDurationSec = computed(() => (digitalHuman.faceDuration.value / 1000).toFixed(1))
/** 音频时长显示(秒) */
const audioDurationSec = computed(() => (voice.audioState.value.durationMs / 1000).toFixed(1))
/** 是否显示生成提示 */
const showGenerateHint = computed(() =>
digitalHuman.identifyState.value.identified &&
(!voice.audioState.value.generated || !validationPassed.value)
)
/** 音频播放 URL */
const audioUrl = computed(() => {
const audio = voice.audioState.value.generated
if (!audio) return ''
return audio.audioBase64 ? `data:audio/mp3;base64,${audio.audioBase64}` : audio.audioUrl || ''
})
/** /**
* 语速显示 * 校验是否通过(计算属性)
* 规则:音频时长 <= 人脸时长Kling 要求音频不能超过人脸区间)
*/ */
const speechRateDisplay = computed(() => `${speechRate.value.toFixed(1)}x`) const validationPassed = computed(() => {
const faceDuration = Number(faceDurationSec.value)
const audioDuration = Number(audioDurationSec.value)
return audioDuration <= faceDuration
})
// ==================== 业务流程方法 ==================== // ==================== 业务方法 ====================
/** /**
* 生成数字人视频 * 重置所有状态
*/ */
const generateDigitalHuman = async (): Promise<void> => { function resetAllStates(): void {
voice.resetAudioState()
digitalHuman.resetVideoState()
digitalHuman.resetIdentifyState()
pipeline.reset()
}
/**
* 生成数字人视频 - 使用新的 Pipeline API
*/
async function generateDigitalHuman(): Promise<void> {
if (!canGenerate.value) { if (!canGenerate.value) {
message.warning('请先完成配置') message.warning('请先完成配置')
return return
} }
const text = ttsText.value.trim() const text = voice.ttsText.value.trim()
const voiceMeta = voice.selectedVoiceMeta.value
if (!text) { if (!text) {
message.warning('请输入文案内容') message.warning('请输入文案内容')
return return
} }
const voice = selectedVoiceMeta.value if (!voiceMeta) {
if (!voice) {
message.warning('请选择音色') message.warning('请选择音色')
return return
} }
try { try {
// 如果未识别,先进行人脸识别 // 运行流程到 ready 状态
if (!identifyState.value.identified) { await pipeline.run({
const hasUploadFile = videoState.value.videoFile videoFile: digitalHuman.videoState.value.videoFile,
const hasSelectedVideo = videoState.value.selectedVideo selectedVideo: digitalHuman.videoState.value.selectedVideo,
text,
voice: voiceMeta,
speechRate: voice.speechRate.value,
})
if (!hasUploadFile && !hasSelectedVideo) { // 如果到达 ready 状态,自动创建任务
message.warning('请先选择或上传视频') if (pipeline.isReady.value) {
return await pipeline.createTask()
} // 任务提交成功后,重置所有状态
resetAllStates()
try {
await performFaceRecognition()
message.success('人脸识别完成')
} catch (error) {
return
}
} }
} catch {
const videoFileId = identifyState.value.videoFileId // 错误已在 Pipeline 中处理
const taskData: LipSyncTaskData = {
taskName: `数字人任务_${Date.now()}`,
videoFileId: videoFileId!,
inputText: ttsText.value,
speechRate: speechRate.value,
volume: 0,
guidanceScale: 1,
seed: 8888,
kling_session_id: identifyState.value.sessionId,
kling_face_id: identifyState.value.faceId,
kling_face_start_time: identifyState.value.faceStartTime,
kling_face_end_time: identifyState.value.faceEndTime,
ai_provider: 'kling',
voiceConfigId: voice.rawId || extractIdFromString(voice.id),
}
if (!taskData.voiceConfigId) {
message.warning('音色配置无效')
return
}
// 如果有预生成的音频,添加到任务数据中
if (audioState.value.generated && audioState.value.durationMs > 0) {
taskData.pre_generated_audio = {
audioBase64: audioState.value.generated.audioBase64,
format: audioState.value.generated.format || 'mp3',
}
taskData.sound_end_time = audioState.value.durationMs
}
const res = await createLipSyncTask(taskData)
if (res.code === 0) {
message.success('任务已提交到任务中心,请前往查看')
} else {
throw new Error(res.msg || '任务创建失败')
}
} catch (error: any) {
message.error(error.message || '任务提交失败')
} }
} }
/** /**
* 更换视频 * 更换视频
*/ */
const replaceVideo = (): void => { function replaceVideo(): void {
if (videoState.value.videoSource === 'upload') { digitalHuman.resetVideoState()
videoState.value.videoFile = null voice.resetAudioState()
videoState.value.uploadedVideo = '' pipeline.reset()
} else {
videoState.value.selectedVideo = null
videoState.value.videoFile = null
videoState.value.uploadedVideo = ''
}
// 重置所有状态
resetVideoState()
resetAudioState()
} }
/** // ==================== 事件处理方法 ====================
* 处理音色选择
*/ function handleVoiceSelect(voiceMeta: VoiceMeta): void {
const handleVoiceSelect = (voice: any): void => { voice.selectedVoiceMeta.value = voiceMeta
selectedVoiceMeta.value = voice
} }
/** function handleFileSelect(event: Event): void {
* 处理文件选择 const file = (event.target as HTMLInputElement).files?.[0]
*/ if (file) digitalHuman.handleFileUpload(file)
const handleFileSelect = (event: Event): void => {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
handleFileUpload(file)
}
} }
/** function handleDrop(event: DragEvent): void {
* 处理拖拽上传
*/
const handleDrop = (event: DragEvent): void => {
event.preventDefault() event.preventDefault()
const file = event.dataTransfer?.files[0] const file = event.dataTransfer?.files[0]
if (file) { if (file) digitalHuman.handleFileUpload(file)
handleFileUpload(file)
}
} }
/** function triggerFileSelect(): void {
* 触发文件选择 document.querySelector<HTMLInputElement>('input[type="file"]')?.click()
*/
const triggerFileSelect = (): void => {
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
fileInput?.click()
} }
/** function handleSelectUpload(): void {
* 选择上传模式 digitalHuman.videoState.value.videoSource = 'upload'
*/ digitalHuman.videoState.value.selectedVideo = null
const handleSelectUpload = (): void => { digitalHuman.resetIdentifyState()
videoState.value.videoSource = 'upload' pipeline.reset()
videoState.value.selectedVideo = null
resetIdentifyState()
} }
/** function handleSelectFromLibrary(): void {
* 从素材库选择 digitalHuman.videoState.value.videoSource = 'select'
*/ digitalHuman.videoState.value.videoFile = null
const handleSelectFromLibrary = (): void => { digitalHuman.videoState.value.uploadedVideo = ''
videoState.value.videoSource = 'select' digitalHuman.videoState.value.selectorVisible = true
videoState.value.videoFile = null pipeline.reset()
videoState.value.uploadedVideo = ''
videoState.value.selectorVisible = true
} }
/** async function handleVideoSelect(video: any): Promise<void> {
* 处理视频选择器选择 await digitalHuman.handleVideoSelect(video)
*/
const handleVideoSelect = (video: any): void => {
_handleVideoSelect(video)
} }
/** function handleVideoLoaded(videoUrl: string): void {
* 简化文案 digitalHuman.videoState.value.previewVideoUrl = videoUrl
*/
const handleSimplifyScript = (): void => {
const textarea = document.querySelector('.tts-textarea textarea') as HTMLTextAreaElement
if (textarea) {
textarea.focus()
textarea.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
} }
/** // ==================== UI 工具方法 ====================
* 处理视频加载
*/
const handleVideoLoaded = (videoUrl: string): void => {
videoState.value.previewVideoUrl = videoUrl
}
// ==================== UI 辅助方法 ==================== function formatDuration(seconds: number): string {
/**
* 格式化时长
*/
const formatDuration = (seconds: number): string => {
if (!seconds) return '--:--' if (!seconds) return '--:--'
const minutes = Math.floor(seconds / 60) const mins = Math.floor(seconds / 60)
const remainingSeconds = Math.floor(seconds % 60) const secs = Math.floor(seconds % 60)
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}` return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
} }
/** function formatFileSize(bytes: number): string {
* 格式化文件大小
*/
const formatFileSize = (bytes: number): string => {
if (!bytes) return '0 B' if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB'] const units = ['B', 'KB', 'MB', 'GB']
let size = bytes let size = bytes
let unitIndex = 0 let idx = 0
while (size >= 1024 && unitIndex < units.length - 1) { while (size >= 1024 && idx < units.length - 1) {
size /= 1024 size /= 1024
unitIndex++ idx++
} }
return `${size.toFixed(1)} ${units[unitIndex]}` return `${size.toFixed(1)} ${units[idx]}`
} }
/** // ==================== 返回接口 ====================
* 重置素材校验状态
*/
const resetMaterialValidation = (): void => {
materialValidation.value.videoDuration = 0
materialValidation.value.audioDuration = 0
materialValidation.value.isValid = false
}
/** /**
* 验证素材时长 * 包装的音频生成方法(延迟识别)
* 视频时长必须大于音频时长 * 在生成音频前先执行人脸识别
*/ */
const validateMaterialDuration = (videoDurationMs: number, audioDurationMs: number): boolean => { async function generateAudio(): Promise<void> {
materialValidation.value.videoDuration = videoDurationMs // 如果有视频但未识别,先执行识别
materialValidation.value.audioDuration = audioDurationMs const hasVideo = digitalHuman.videoState.value.uploadedVideo || digitalHuman.videoState.value.selectedVideo
materialValidation.value.isValid = videoDurationMs > audioDurationMs if (hasVideo && !digitalHuman.identifyState.value.identified) {
await digitalHuman.performFaceRecognition()
if (!materialValidation.value.isValid) {
const videoSec = (videoDurationMs / 1000).toFixed(1)
const audioSec = (audioDurationMs / 1000).toFixed(1)
message.warning(`素材校验失败:视频时长(${videoSec}s必须大于音频时长${audioSec}s`)
} }
await voice.generateAudio()
return materialValidation.value.isValid
} }
return { return {
// ==================== 语音生成相关 ==================== // 语音生成模块
ttsText, ttsText: voice.ttsText,
speechRate, speechRate: voice.speechRate,
selectedVoiceMeta, selectedVoiceMeta: voice.selectedVoiceMeta,
audioState, audioState: voice.audioState,
canGenerateAudio, canGenerateAudio: voice.canGenerateAudio,
suggestedMaxChars, suggestedMaxChars: voice.suggestedMaxChars,
generateAudio, generateAudio,
resetAudioState, resetAudioState: voice.resetAudioState,
// ==================== 数字人生成相关 ==================== // 数字人生成模块
videoState, videoState: digitalHuman.videoState,
identifyState, identifyState: digitalHuman.identifyState,
materialValidation, faceDuration: digitalHuman.faceDuration,
faceDuration, performFaceRecognition: digitalHuman.performFaceRecognition,
performFaceRecognition, handleFileUpload: digitalHuman.handleFileUpload,
handleFileUpload, getVideoPreviewUrl: digitalHuman.getVideoPreviewUrl,
getVideoPreviewUrl, resetVideoState: digitalHuman.resetVideoState,
resetVideoState, resetIdentifyState: digitalHuman.resetIdentifyState,
resetIdentifyState,
resetMaterialValidation,
validateMaterialDuration,
// ==================== 业务流程方法 ==================== // 业务方法
generateDigitalHuman, generateDigitalHuman,
replaceVideo, replaceVideo,
// ==================== 事件处理方法 ==================== // 事件处理
handleVoiceSelect, handleVoiceSelect,
handleFileSelect, handleFileSelect,
handleDrop, handleDrop,
@@ -418,26 +331,38 @@ export function useIdentifyFaceController(): UseIdentifyFaceController {
handleSelectUpload, handleSelectUpload,
handleSelectFromLibrary, handleSelectFromLibrary,
handleVideoSelect, handleVideoSelect,
handleSimplifyScript,
handleVideoLoaded, handleVideoLoaded,
// ==================== UI 辅助方法 ==================== // UI 工具
formatDuration, formatDuration,
formatFileSize, formatFileSize,
// ==================== 计算属性 ==================== // 计算属性
canGenerate, canGenerate,
maxTextLength, maxTextLength,
textareaPlaceholder, textareaPlaceholder,
speechRateMarks, speechRateMarks: SPEECH_RATE_MARKS,
speechRateDisplay, speechRateDisplay,
faceDurationSec,
audioDurationSec,
showGenerateHint,
audioUrl,
validationPassed,
// Pipeline 状态
pipelineState: pipeline.state,
pipelineStateLabel: pipeline.stateLabel,
pipelineStateDescription: pipeline.stateDescription,
isPipelineBusy: pipeline.isBusy,
isPipelineReady: pipeline.isReady,
isPipelineFailed: pipeline.isFailed,
isPipelineCompleted: pipeline.isCompleted,
pipelineProgress: pipeline.progress,
pipelineCurrentStepIndex: pipeline.currentStepIndex,
pipelineError: pipeline.error,
runPipeline: pipeline.run,
createPipelineTask: pipeline.createTask,
retryPipeline: pipeline.retry,
resetPipeline: pipeline.reset,
} }
} }
/**
* 从字符串中提取ID
*/
function extractIdFromString(str: string): string {
const match = str.match(/[\w-]+$/)
return match ? match[0] : str
}

View File

@@ -1,6 +1,5 @@
/** /**
* @fileoverview useVoiceGeneration Hook - 语音生成逻辑封装 * @fileoverview useVoiceGeneration Hook - 语音生成逻辑
* @author Claude Code
*/ */
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@@ -11,59 +10,41 @@ import type {
VoiceMeta, VoiceMeta,
AudioData, AudioData,
} from '../types/identify-face' } from '../types/identify-face'
// @ts-ignore
import { VoiceService } from '@/api/voice' import { VoiceService } from '@/api/voice'
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig' import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
/** // ========== 常量 ==========
* 语音生成 Hook const DEFAULT_MAX_TEXT_LENGTH = 4000
* 独立管理所有状态,不依赖外部状态 const DEFAULT_SPEECH_RATE = 1.0
*/
export function useVoiceGeneration(): UseVoiceGeneration {
// ==================== 响应式状态 ====================
export function useVoiceGeneration(): UseVoiceGeneration {
// ========== 状态 ==========
const ttsText = ref<string>('') const ttsText = ref<string>('')
const speechRate = ref<number>(1.0) const speechRate = ref<number>(DEFAULT_SPEECH_RATE)
const selectedVoiceMeta = ref<VoiceMeta | null>(null) const selectedVoiceMeta = ref<VoiceMeta | null>(null)
const audioState = ref<AudioState>({ const audioState = ref<AudioState>({
generated: null, generated: null,
durationMs: 0, durationMs: 0,
validationPassed: false,
generating: false, generating: false,
}) })
// ==================== 计算属性 ==================== // ========== 计算属性 ==========
const canGenerateAudio = computed(function() {
/** return !!(ttsText.value.trim() && selectedVoiceMeta.value && !audioState.value.generating)
* 是否可以生成配音
*/
const canGenerateAudio = computed(() => {
const hasText = ttsText.value.trim()
const hasVoice = selectedVoiceMeta.value
const hasVideo = true // 语音生成不依赖视频状态
return !!(hasText && hasVoice && hasVideo && !audioState.value.generating)
}) })
/** const suggestedMaxChars = computed(function() {
* 建议的最大字符数(需要从外部传入) return DEFAULT_MAX_TEXT_LENGTH
*/
const suggestedMaxChars = computed(() => {
// 默认为 4000需要从外部设置
return 4000
}) })
// ==================== 核心方法 ==================== // ========== 方法 ==========
/** async function generateAudio(): Promise<void> {
* 生成配音
*/
const generateAudio = async (): Promise<void> => {
const voice = selectedVoiceMeta.value const voice = selectedVoiceMeta.value
if (!voice) { if (!voice) {
message.warning('请选择音色') message.warning('请选择音色')
return return
} }
if (!ttsText.value.trim()) { if (!ttsText.value.trim()) {
message.warning('请输入文案内容') message.warning('请输入文案内容')
return return
@@ -75,7 +56,7 @@ export function useVoiceGeneration(): UseVoiceGeneration {
const params = { const params = {
inputText: ttsText.value, inputText: ttsText.value,
voiceConfigId: voice.rawId || extractIdFromString(voice.id), voiceConfigId: voice.rawId || extractIdFromString(voice.id),
speechRate: speechRate.value || 1.0, speechRate: speechRate.value || DEFAULT_SPEECH_RATE,
audioFormat: 'mp3' as const, audioFormat: 'mp3' as const,
providerType: DEFAULT_VOICE_PROVIDER, providerType: DEFAULT_VOICE_PROVIDER,
} }
@@ -84,144 +65,92 @@ export function useVoiceGeneration(): UseVoiceGeneration {
if (res.code === 0) { if (res.code === 0) {
const audioData = res.data as AudioData const audioData = res.data as AudioData
if (!audioData.audioBase64) { if (!audioData.audioBase64) {
throw new Error('未收到音频数据,无法进行时长解析') throw new Error('未收到音频数据')
} }
audioState.value.generated = audioData audioState.value.generated = audioData
audioState.value.durationMs = await parseAudioDuration(audioData.audioBase64)
try { message.success('配音生成成功!')
// 解析音频时长
audioState.value.durationMs = await parseAudioDuration(audioData.audioBase64)
// 验证音频时长
validateAudioDuration()
message.success('配音生成成功!')
} catch (error) {
message.error('音频解析失败,请重新生成配音')
audioState.value.durationMs = 0
audioState.value.generated = null
audioState.value.validationPassed = false
}
} else { } else {
throw new Error(res.msg || '配音生成失败') throw new Error(res.msg || '配音生成失败')
} }
} catch (error: any) { } catch (error: unknown) {
message.error(error.message || '配音生成失败') const err = error as Error
message.error(err.message || '配音生成失败')
audioState.value.generated = null
audioState.value.durationMs = 0
} finally { } finally {
audioState.value.generating = false audioState.value.generating = false
} }
} }
/** async function parseAudioDuration(base64Data: string): Promise<number> {
* 解析音频时长 const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data
*/ const binaryString = window.atob(base64)
const parseAudioDuration = async (base64Data: string): Promise<number> => { const bytes = new Uint8Array(binaryString.length)
return new Promise((resolve, reject) => { for (let i = 0; i < binaryString.length; i++) {
try { bytes[i] = binaryString.charCodeAt(i)
const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data }
const binaryString = window.atob(base64) return new Promise(function(resolve, reject) {
const bytes = new Uint8Array(binaryString.length) const blob = new Blob([bytes], { type: 'audio/mp3' })
for (let i = 0; i < binaryString.length; i++) { const audio = new Audio()
bytes[i] = binaryString.charCodeAt(i) const objectUrl = URL.createObjectURL(blob)
// 超时机制5秒后拒绝
const timeoutId = setTimeout(function() {
URL.revokeObjectURL(objectUrl)
reject(new Error('音频时长解析超时'))
}, 5000)
function onLoadedMetadata() {
clearTimeout(timeoutId)
URL.revokeObjectURL(objectUrl)
const duration = audio.duration
if (!isFinite(duration) || duration <= 0 || isNaN(duration)) {
reject(new Error(`音频时长无效: ${duration},请检查音频格式是否正确`))
return
} }
const blob = new Blob([bytes], { type: 'audio/mp3' }) const durationMs = Math.round(duration * 1000)
const audio = new Audio() console.log('[parseAudioDuration] 音频时长解析成功:', durationMs, 'ms')
const objectUrl = URL.createObjectURL(blob) resolve(durationMs)
audio.addEventListener('loadedmetadata', () => {
URL.revokeObjectURL(objectUrl)
const durationMs = Math.round(audio.duration * 1000)
resolve(durationMs)
})
audio.addEventListener('error', (error) => {
URL.revokeObjectURL(objectUrl)
reject(error)
})
audio.src = objectUrl
audio.load()
} catch (error) {
reject(error)
} }
function onError() {
clearTimeout(timeoutId)
URL.revokeObjectURL(objectUrl)
reject(new Error('音频解析失败,请检查音频格式'))
}
audio.addEventListener('loadedmetadata', onLoadedMetadata)
audio.addEventListener('error', onError)
audio.src = objectUrl
audio.load()
}) })
} }
/** function resetAudioState(): void {
* 验证音频与人脸区间的重合时长(外部调用时传入校验参数)
*/
const validateAudioDuration = (
faceStartTime: number = 0,
faceEndTime: number = 0,
minOverlapMs: number = 2000
): boolean => {
if (faceStartTime <= 0 || faceEndTime <= 0) {
audioState.value.validationPassed = false
return false
}
const faceDurationMs = faceEndTime - faceStartTime
const audioDuration = audioState.value.durationMs
const overlapStart = faceStartTime
const overlapEnd = Math.min(faceEndTime, faceStartTime + audioDuration)
const overlapDuration = Math.max(0, overlapEnd - overlapStart)
const isValid = overlapDuration >= minOverlapMs
audioState.value.validationPassed = isValid
if (!isValid) {
const overlapSec = (overlapDuration / 1000).toFixed(1)
message.warning(
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}秒,至少需要${(minOverlapMs/1000)}`
)
} else {
message.success('时长校验通过!')
}
return isValid
}
/**
* 重置音频状态
*/
const resetAudioState = (): void => {
audioState.value.generated = null audioState.value.generated = null
audioState.value.durationMs = 0 audioState.value.durationMs = 0
audioState.value.validationPassed = false
audioState.value.generating = false audioState.value.generating = false
} }
return { return {
// 响应式状态
ttsText, ttsText,
speechRate, speechRate,
selectedVoiceMeta, selectedVoiceMeta,
audioState, audioState,
// 计算属性
canGenerateAudio, canGenerateAudio,
suggestedMaxChars, suggestedMaxChars,
// 方法
generateAudio, generateAudio,
parseAudioDuration,
validateAudioDuration,
resetAudioState, resetAudioState,
} }
} }
/**
* 从字符串中提取ID
*/
function extractIdFromString(str: string): string { function extractIdFromString(str: string): string {
// 尝试从各种格式中提取ID
const match = str.match(/[\w-]+$/) const match = str.match(/[\w-]+$/)
return match ? match[0] : str return match ? match[0] : str
} }

View File

@@ -11,6 +11,7 @@ export interface VideoState {
videoFile: File | null videoFile: File | null
previewVideoUrl: string previewVideoUrl: string
selectedVideo: Video | null selectedVideo: Video | null
fileId: string | number | null
videoSource: 'upload' | 'select' | null videoSource: 'upload' | 'select' | null
selectorVisible: boolean selectorVisible: boolean
} }
@@ -20,6 +21,7 @@ export interface VideoState {
*/ */
export interface Video { export interface Video {
id: string | number id: string | number
fileId: string | number
fileName: string fileName: string
fileUrl: string fileUrl: string
fileSize: number fileSize: number
@@ -48,7 +50,6 @@ export interface IdentifyState {
export interface AudioState { export interface AudioState {
generated: AudioData | null generated: AudioData | null
durationMs: number durationMs: number
validationPassed: boolean
generating: boolean generating: boolean
} }
@@ -61,16 +62,6 @@ export interface AudioData {
format?: string format?: string
} }
/**
* 素材校验接口
*/
export interface MaterialValidation {
videoDuration: number
audioDuration: number
isValid: boolean
showDetails: boolean
}
/** /**
* 音色元数据接口 * 音色元数据接口
*/ */
@@ -97,8 +88,6 @@ export interface UseVoiceGeneration {
// 方法 // 方法
generateAudio: () => Promise<void> generateAudio: () => Promise<void>
parseAudioDuration: (base64Data: string) => Promise<number>
validateAudioDuration: () => boolean
resetAudioState: () => void resetAudioState: () => void
} }
@@ -115,7 +104,7 @@ export interface UseDigitalHumanGeneration {
// 方法 // 方法
handleFileUpload: (file: File) => Promise<void> handleFileUpload: (file: File) => Promise<void>
handleVideoSelect: (video: Video) => void handleVideoSelect: (video: Video) => Promise<void>
performFaceRecognition: () => Promise<void> performFaceRecognition: () => Promise<void>
resetVideoState: () => void resetVideoState: () => void
resetIdentifyState: () => void resetIdentifyState: () => void
@@ -140,29 +129,25 @@ export interface UseIdentifyFaceController {
// ==================== 数字人生成相关 ==================== // ==================== 数字人生成相关 ====================
videoState: import('vue').Ref<VideoState> videoState: import('vue').Ref<VideoState>
identifyState: import('vue').Ref<IdentifyState> identifyState: import('vue').Ref<IdentifyState>
materialValidation: import('vue').Ref<MaterialValidation>
faceDuration: import('vue').ComputedRef<number> faceDuration: import('vue').ComputedRef<number>
performFaceRecognition: () => Promise<void> performFaceRecognition: () => Promise<void>
handleFileUpload: (file: File) => Promise<void> handleFileUpload: (file: File) => Promise<void>
getVideoPreviewUrl: (video: Video) => string getVideoPreviewUrl: (video: Video) => string
resetVideoState: () => void resetVideoState: () => void
resetIdentifyState: () => void resetIdentifyState: () => void
resetMaterialValidation: () => void
validateMaterialDuration: (videoDurationMs: number, audioDurationMs: number) => boolean
// ==================== 业务流程方法 ==================== // ==================== 业务流程方法 ====================
generateDigitalHuman: () => Promise<void> generateDigitalHuman: () => Promise<void>
replaceVideo: () => void replaceVideo: () => void
// ==================== 事件处理方法 ==================== // ==================== 事件处理方法 ====================
handleVoiceSelect: (voice: VoiceMeta) => void handleVoiceSelect: (voiceMeta: VoiceMeta) => void
handleFileSelect: (event: Event) => void handleFileSelect: (event: Event) => void
handleDrop: (event: DragEvent) => void handleDrop: (event: DragEvent) => void
triggerFileSelect: () => void triggerFileSelect: () => void
handleSelectUpload: () => void handleSelectUpload: () => void
handleSelectFromLibrary: () => void handleSelectFromLibrary: () => void
handleVideoSelect: (video: Video) => void handleVideoSelect: (video: Video) => Promise<void>
handleSimplifyScript: () => void
handleVideoLoaded: (videoUrl: string) => void handleVideoLoaded: (videoUrl: string) => void
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================
@@ -171,6 +156,32 @@ export interface UseIdentifyFaceController {
textareaPlaceholder: import('vue').ComputedRef<string> textareaPlaceholder: import('vue').ComputedRef<string>
speechRateMarks: Record<number, string> speechRateMarks: Record<number, string>
speechRateDisplay: import('vue').ComputedRef<string> speechRateDisplay: import('vue').ComputedRef<string>
faceDurationSec: import('vue').ComputedRef<string>
audioDurationSec: import('vue').ComputedRef<string>
showGenerateHint: import('vue').ComputedRef<boolean>
audioUrl: import('vue').ComputedRef<string>
validationPassed: import('vue').ComputedRef<boolean>
// ==================== 流程状态 ====================
pipelineState: import('vue').Ref<string>
pipelineStateLabel: import('vue').ComputedRef<string>
pipelineStateDescription: import('vue').ComputedRef<string>
isPipelineBusy: import('vue').ComputedRef<boolean>
isPipelineReady: import('vue').ComputedRef<boolean>
isPipelineFailed: import('vue').ComputedRef<boolean>
isPipelineCompleted: import('vue').ComputedRef<boolean>
pipelineProgress: import('vue').ComputedRef<number>
pipelineError: import('vue').Ref<string | null>
runPipeline: (params: {
videoFile: File | null
selectedVideo: any
text: string
voice: VoiceMeta
speechRate: number
}) => Promise<void>
createPipelineTask: () => Promise<void>
retryPipeline: () => void
resetPipeline: () => void
// ==================== UI 辅助方法 ==================== // ==================== UI 辅助方法 ====================
formatDuration: (seconds: number) => string formatDuration: (seconds: number) => string

View File

@@ -445,12 +445,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
} }
@Override @Override
public String getVideoPlayUrl(Long infraFileId) { public String getVideoPlayUrl(Long fileId) {
Long userId = SecurityFrameworkUtils.getLoginUserId(); Long userId = SecurityFrameworkUtils.getLoginUserId();
// 查询文件(根据 infraFileId 字段查询) // 查询文件(根据 fileId 字段查询)
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>() TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
.eq(TikUserFileDO::getFileId, infraFileId) .eq(TikUserFileDO::getFileId, fileId)
.eq(TikUserFileDO::getUserId, userId)); .eq(TikUserFileDO::getUserId, userId));
if (file == null) { if (file == null) {
@@ -473,12 +473,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
} }
@Override @Override
public String getAudioPlayUrl(Long infraFileId) { public String getAudioPlayUrl(Long fileId) {
Long userId = SecurityFrameworkUtils.getLoginUserId(); Long userId = SecurityFrameworkUtils.getLoginUserId();
// 查询文件(根据 infraFileId 字段查询) // 查询文件(根据 fileId 字段查询)
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>() TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
.eq(TikUserFileDO::getFileId, infraFileId) .eq(TikUserFileDO::getFileId, fileId)
.eq(TikUserFileDO::getUserId, userId)); .eq(TikUserFileDO::getUserId, userId));
if (file == null) { if (file == null) {

View File

@@ -14,9 +14,12 @@ import java.time.LocalDateTime;
@Data @Data
public class AppTikUserFileRespVO { public class AppTikUserFileRespVO {
@Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") @Schema(description = "文件编号(主键)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long id; private Long id;
@Schema(description = "素材文件编号(关联 infra_file.id用于获取播放URL", example = "100")
private Long fileId;
@Schema(description = "文件名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "test.mp4") @Schema(description = "文件名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "test.mp4")
private String fileName; private String fileName;

View File

@@ -52,34 +52,12 @@ public class KlingClient {
validateRequest(request); validateRequest(request);
Map<String, Object> payload = buildPayload(request); Map<String, Object> payload = buildPayload(request);
try { String url = properties.getBaseUrl() + "/klingai/v1/videos/identify-face";
String body = objectMapper.writeValueAsString(payload); Request httpRequest = buildPostRequest(url, payload);
String url = properties.getBaseUrl() + "/klingai/v1/videos/identify-face";
Request httpRequest = new Request.Builder() KlingIdentifyFaceResponse response = executeRequest(httpRequest, "identify-face", KlingIdentifyFaceResponse.class);
.url(url) validateSessionId(response.getData() != null ? response.getData().getSessionId() : null, "sessionId");
.addHeader("Authorization", "Bearer " + properties.getApiKey()) return response;
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.getBytes(StandardCharsets.UTF_8), JSON))
.build();
try {
KlingIdentifyFaceResponse response = executeRequest(httpRequest, "identify-face", KlingIdentifyFaceResponse.class);
// 验证sessionId
if (StrUtil.isBlank(response.getData() == null ? null : response.getData().getSessionId())) {
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "可灵返回 sessionId 为空");
}
return response;
} catch (ServiceException ex) {
throw ex;
} catch (Exception ex) {
log.error("[Kling][identify-face exception]", ex);
throw exception(LATENTSYNC_SUBMIT_FAILED);
}
} catch (Exception ex) {
log.error("[Kling][build request exception]", ex);
throw exception(LATENTSYNC_SUBMIT_FAILED);
}
} }
/** /**
@@ -89,35 +67,13 @@ public class KlingClient {
validateEnabled(); validateEnabled();
validateLipSyncRequest(request); validateLipSyncRequest(request);
try { String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync";
String body = objectMapper.writeValueAsString(request); Request httpRequest = buildPostRequest(url, request);
log.info("[Kling][create-lip-sync请求体] {}", body); log.info("[Kling][create-lip-sync请求体] {}", request);
String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync";
Request httpRequest = new Request.Builder() KlingLipSyncCreateResponse response = executeRequest(httpRequest, "create-lip-sync", KlingLipSyncCreateResponse.class);
.url(url) validateSessionId(response.getData() != null ? response.getData().getTaskId() : null, "taskId");
.addHeader("Authorization", "Bearer " + properties.getApiKey()) return response;
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.getBytes(StandardCharsets.UTF_8), JSON))
.build();
try {
KlingLipSyncCreateResponse response = executeRequest(httpRequest, "create-lip-sync", KlingLipSyncCreateResponse.class);
// 验证taskId
if (StrUtil.isBlank(response.getData() == null ? null : response.getData().getTaskId())) {
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "可灵返回 taskId 为空");
}
return response;
} catch (ServiceException ex) {
throw ex;
} catch (Exception ex) {
log.error("[Kling][create-lip-sync exception]", ex);
throw exception(LATENTSYNC_SUBMIT_FAILED);
}
} catch (Exception ex) {
log.error("[Kling][build request exception]", ex);
throw exception(LATENTSYNC_SUBMIT_FAILED);
}
} }
/** /**
@@ -129,29 +85,10 @@ public class KlingClient {
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "任务ID不能为空"); throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "任务ID不能为空");
} }
try { String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync/" + taskId;
String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync/" + taskId; Request httpRequest = buildGetRequest(url);
Request httpRequest = new Request.Builder() return executeRequest(httpRequest, "get-lip-sync", KlingLipSyncQueryResponse.class);
.url(url)
.addHeader("Authorization", "Bearer " + properties.getApiKey())
.addHeader("Content-Type", "application/json")
.get()
.build();
try {
KlingLipSyncQueryResponse response = executeRequest(httpRequest, "get-lip-sync", KlingLipSyncQueryResponse.class);
return response;
} catch (ServiceException ex) {
throw ex;
} catch (Exception ex) {
log.error("[Kling][get-lip-sync exception]", ex);
throw exception(LATENTSYNC_SUBMIT_FAILED);
}
} catch (Exception ex) {
log.error("[Kling][build request exception]", ex);
throw exception(LATENTSYNC_SUBMIT_FAILED);
}
} }
private void validateEnabled() { private void validateEnabled() {
@@ -272,10 +209,39 @@ public class KlingClient {
throw buildException(responseBody); throw buildException(responseBody);
} }
} }
// 解析响应
T result = objectMapper.readValue(responseBody, responseClass);
// ✅ 检查业务错误码(可灵 API 可能返回 HTTP 200 但 code !== 0
try {
JsonNode root = objectMapper.readTree(responseBody);
if (root.has("code")) {
int code = root.get("code").asInt();
if (code != 0) {
String message = root.has("message") ? root.get("message").asText() :
root.has("detail") ? root.get("detail").asText() : "未知错误";
String requestId = root.has("request_id") ? root.get("request_id").asText() : "unknown";
log.error("[Kling][{} business error] code={}, message={}, request_id={}", operation, code, message, requestId);
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(),
String.format("[%s] %s (code: %d, request_id: %s)", operation, message, code, requestId));
}
}
} catch (ServiceException ex) {
throw ex;
} catch (Exception ex) {
log.warn("[Kling][{} check business code failed, continuing]", operation, ex);
}
log.info("[Kling][{} success][responseBody={}]", operation, responseBody); log.info("[Kling][{} success][responseBody={}]", operation, responseBody);
return objectMapper.readValue(responseBody, responseClass); return result;
} catch (Exception ex) { } catch (Exception ex) {
if (ex instanceof ServiceException) {
throw (ServiceException) ex;
}
log.error("[Kling][{} exception]", operation, ex); log.error("[Kling][{} exception]", operation, ex);
throw exception(LATENTSYNC_SUBMIT_FAILED); throw exception(LATENTSYNC_SUBMIT_FAILED);
} }
@@ -319,4 +285,43 @@ public class KlingClient {
return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), body); return exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), body);
} }
} }
/**
* 构建 POST 请求
*/
private Request buildPostRequest(String url, Object payload) {
try {
String body = objectMapper.writeValueAsString(payload);
return new Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer " + properties.getApiKey())
.addHeader("Content-Type", "application/json")
.post(RequestBody.create(body.getBytes(StandardCharsets.UTF_8), JSON))
.build();
} catch (Exception ex) {
log.error("[Kling][build POST request exception]", ex);
throw exception(LATENTSYNC_SUBMIT_FAILED);
}
}
/**
* 构建 GET 请求
*/
private Request buildGetRequest(String url) {
return new Request.Builder()
.url(url)
.addHeader("Authorization", "Bearer " + properties.getApiKey())
.addHeader("Content-Type", "application/json")
.get()
.build();
}
/**
* 验证响应中的 sessionId 不为空
*/
private void validateSessionId(String sessionId, String fieldName) {
if (StrUtil.isBlank(sessionId)) {
throw exception0(LATENTSYNC_SUBMIT_FAILED.getCode(), "可灵返回 " + fieldName + " 为空");
}
}
} }

View File

@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.tik.voice.dal.dataobject;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence; import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*; import lombok.*;
@@ -163,6 +164,7 @@ public class TikDigitalHumanTaskDO extends TenantBaseDO {
/** /**
* 可灵口型同步任务ID从advanced-lip-sync接口获取 * 可灵口型同步任务ID从advanced-lip-sync接口获取
*/ */
@TableField("kling_task_id")
private String klingTaskId; private String klingTaskId;
} }

View File

@@ -4,9 +4,11 @@ import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO; import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
import cn.iocoder.yudao.module.tik.voice.vo.AppTikDigitalHumanPageReqVO; import cn.iocoder.yudao.module.tik.voice.vo.AppTikDigitalHumanPageReqVO;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List; import java.util.List;
@@ -62,4 +64,20 @@ public interface TikDigitalHumanTaskMapper extends BaseMapperX<TikDigitalHumanTa
.last("LIMIT " + limit)); .last("LIMIT " + limit));
} }
/**
* 查询待轮询的可灵任务状态为PROCESSING且有klingTaskId最近6小时内
* 使用条件构造器,配合服务类 @TenantIgnore 注解忽略租户限制
*/
@TenantIgnore
default List<TikDigitalHumanTaskDO> selectPendingKlingTasks() {
return selectList(new LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
.eq(TikDigitalHumanTaskDO::getStatus, "PROCESSING")
.eq(TikDigitalHumanTaskDO::getAiProvider, "kling")
.isNotNull(TikDigitalHumanTaskDO::getKlingTaskId)
.ne(TikDigitalHumanTaskDO::getKlingTaskId, "")
.apply("create_time >= DATE_SUB(NOW(), INTERVAL 6 HOUR)")
.orderByDesc(TikDigitalHumanTaskDO::getCreateTime)
.last("LIMIT 50"));
}
} }

View File

@@ -23,11 +23,11 @@ public class DigitalHumanTaskStatusSyncJob {
*/ */
@Scheduled(fixedDelay = 10000) @Scheduled(fixedDelay = 10000)
public void syncTaskStatus() { public void syncTaskStatus() {
log.debug("开始同步数字人任务状态"); log.info("[DigitalHumanTaskStatusSyncJob][开始同步数字人任务状态]");
try { try {
latentsyncPollingService.pollLatentsyncTasks(); latentsyncPollingService.pollLatentsyncTasks();
} catch (Exception e) { } catch (Exception e) {
log.error("同步数字人任务状态失败", e); log.error("[DigitalHumanTaskStatusSyncJob][同步数字人任务状态失败]", e);
} }
} }

View File

@@ -82,11 +82,14 @@ public class LatentsyncPollingService {
* 执行轮询任务的具体逻辑 * 执行轮询任务的具体逻辑
*/ */
private void executePollingTasks() { private void executePollingTasks() {
log.info("[executePollingTasks][开始执行轮询任务]");
try { try {
// 轮询Latentsync任务 // 轮询Latentsync任务
List<String> taskIds = getPendingPollingTasks(); List<String> taskIds = getPendingPollingTasks();
log.info("[executePollingTasks][获取到Latentsync任务数量={}]", taskIds.size());
if (!taskIds.isEmpty()) { if (!taskIds.isEmpty()) {
log.debug("[pollLatentsyncTasks][开始轮询Latentsync任务][任务数量={}]", taskIds.size()); log.info("[pollLatentsyncTasks][开始轮询Latentsync任务][任务数量={}]", taskIds.size());
// 逐个处理Latentsync任务 // 逐个处理Latentsync任务
for (String taskIdStr : taskIds) { for (String taskIdStr : taskIds) {
@@ -100,7 +103,9 @@ public class LatentsyncPollingService {
} }
// 轮询可灵任务 // 轮询可灵任务
log.info("[executePollingTasks][准备轮询可灵任务]");
pollKlingTasks(); pollKlingTasks();
log.info("[executePollingTasks][可灵任务轮询完成]");
} catch (Exception e) { } catch (Exception e) {
log.error("[pollLatentsyncTasks][轮询任务异常]", e); log.error("[pollLatentsyncTasks][轮询任务异常]", e);
@@ -536,28 +541,19 @@ public class LatentsyncPollingService {
*/ */
private void pollKlingTasks() { private void pollKlingTasks() {
try { try {
// 参考混剪任务实现:添加时间和数量限制,避免并发问题 log.info("[pollKlingTasks][开始查询待轮询的可灵任务]");
// 1. 时间范围限制只检查最近6小时内的任务避免检查历史任务
// 2. 数量限制每次最多检查50个任务避免单次查询过多
LocalDateTime startTime = LocalDateTime.now().minusHours(6);
// 查询待轮询的可灵任务状态为PROCESSING且有klingTaskId限制时间和数量 // 显式忽略租户限制查询待轮询的可灵任务
List<TikDigitalHumanTaskDO> klingTasks = taskMapper.selectList( List<TikDigitalHumanTaskDO> klingTasks = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<TikDigitalHumanTaskDO>() () -> taskMapper.selectPendingKlingTasks()
.eq(TikDigitalHumanTaskDO::getStatus, "PROCESSING")
.eq(TikDigitalHumanTaskDO::getAiProvider, "kling")
.isNotNull(TikDigitalHumanTaskDO::getKlingTaskId)
.ne(TikDigitalHumanTaskDO::getKlingTaskId, "")
.ge(TikDigitalHumanTaskDO::getCreateTime, startTime) // 只检查最近6小时
.orderByDesc(TikDigitalHumanTaskDO::getCreateTime)
.last("LIMIT 50") // 限制数量,避免并发
); );
if (klingTasks.isEmpty()) { if (klingTasks.isEmpty()) {
log.info("[pollKlingTasks][没有待轮询的可灵任务]");
return; return;
} }
log.debug("[pollKlingTasks][开始轮询可灵任务][任务数量={}]", klingTasks.size()); log.info("[pollKlingTasks][开始轮询可灵任务][任务数量={}]", klingTasks.size());
// 逐个处理可灵任务 // 逐个处理可灵任务
for (TikDigitalHumanTaskDO task : klingTasks) { for (TikDigitalHumanTaskDO task : klingTasks) {
@@ -589,53 +585,12 @@ public class LatentsyncPollingService {
String taskStatus = response.getData().getTaskStatus(); String taskStatus = response.getData().getTaskStatus();
String taskStatusMsg = response.getData().getTaskStatusMsg(); String taskStatusMsg = response.getData().getTaskStatusMsg();
log.debug("[pollKlingSingleTask][任务({})状态更新][klingTaskId={}, status={}]", log.info("[pollKlingSingleTask][任务({})状态更新][klingTaskId={}, status={}, msg={}]",
task.getId(), klingTaskId, taskStatus); task.getId(), klingTaskId, taskStatus, taskStatusMsg);
// 根据状态更新任务 // 根据状态更新任务
if ("succeed".equalsIgnoreCase(taskStatus)) { if ("succeed".equalsIgnoreCase(taskStatus)) {
// 任务成功完成 handleKlingTaskSucceed(task, response);
List<KlingLipSyncVideoVO> videos = response.getData().getTaskResult().getVideos();
if (videos != null && !videos.isEmpty()) {
String videoUrl = videos.get(0).getUrl();
// 保存视频到OSS异步处理轻量化逻辑
OssSaveResult saveResult = null;
try {
// 保存视频到OSS避免临时URL过期
saveResult = saveVideoToOss(task, videoUrl);
log.info("[pollKlingSingleTask][任务({})视频已保存到OSS][url={}]", task.getId(), saveResult.getUrl());
} catch (Exception e) {
log.warn("[pollKlingSingleTask][任务({})保存视频失败使用原URL][error={}]", task.getId(), e.getMessage());
saveResult = new OssSaveResult(videoUrl, 0, null, null); // 降级处理
}
// 更新任务状态为成功
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
updateObj.setId(task.getId());
updateObj.setStatus("SUCCESS");
updateObj.setCurrentStep("finishing");
updateObj.setProgress(100);
updateObj.setResultVideoUrl(saveResult.getUrl());
updateObj.setFinishTime(LocalDateTime.now());
taskMapper.updateById(updateObj);
// 缓存结果到Redis快速回显
try {
String resultKey = "digital_human:task:result:" + task.getId();
stringRedisTemplate.opsForValue().set(resultKey, saveResult.getUrl(), Duration.ofHours(24));
} catch (Exception e) {
log.warn("[pollKlingSingleTask][任务({})缓存结果失败]", task.getId(), e);
}
// 保存结果视频到用户文件表
saveResultVideoToUserFiles(task, saveResult);
log.info("[pollKlingSingleTask][任务({})完成][videoUrl={}]", task.getId(), saveResult.getUrl());
} else {
log.warn("[pollKlingSingleTask][任务({})成功但无视频结果]", task.getId());
}
} else if ("failed".equalsIgnoreCase(taskStatus)) { } else if ("failed".equalsIgnoreCase(taskStatus)) {
// 任务失败 // 任务失败
String errorMsg = "可灵任务执行失败: " + (StrUtil.isNotBlank(taskStatusMsg) ? taskStatusMsg : "未知错误"); String errorMsg = "可灵任务执行失败: " + (StrUtil.isNotBlank(taskStatusMsg) ? taskStatusMsg : "未知错误");
@@ -645,7 +600,7 @@ public class LatentsyncPollingService {
} else if ("submitted".equalsIgnoreCase(taskStatus) || "processing".equalsIgnoreCase(taskStatus)) { } else if ("submitted".equalsIgnoreCase(taskStatus) || "processing".equalsIgnoreCase(taskStatus)) {
// 任务还在处理中,更新进度 // 任务还在处理中,更新进度
updateTaskStatus(task.getId(), "PROCESSING", "sync_lip", 70, "口型同步处理中", null); updateTaskStatus(task.getId(), "PROCESSING", "sync_lip", 70, "口型同步处理中", null);
log.debug("[pollKlingSingleTask][任务({})处理中]", task.getId()); log.info("[pollKlingSingleTask][任务({})处理中][klingTaskId={}, status={}]", task.getId(), klingTaskId, taskStatus);
} else { } else {
log.warn("[pollKlingSingleTask][任务({})未知状态][status={}]", task.getId(), taskStatus); log.warn("[pollKlingSingleTask][任务({})未知状态][status={}]", task.getId(), taskStatus);
@@ -687,7 +642,64 @@ public class LatentsyncPollingService {
updateObj.setFinishTime(LocalDateTime.now()); updateObj.setFinishTime(LocalDateTime.now());
} }
// 显式忽略租户限制执行更新操作
cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore(
() -> taskMapper.updateById(updateObj)
);
}
/**
* 处理可灵任务成功
*/
private void handleKlingTaskSucceed(TikDigitalHumanTaskDO task, KlingLipSyncQueryResponse response) {
List<KlingLipSyncVideoVO> videos = response.getData().getTaskResult().getVideos();
if (videos == null || videos.isEmpty()) {
log.warn("[pollKlingSingleTask][任务({})成功但无视频结果]", task.getId());
return;
}
String videoUrl = videos.get(0).getUrl();
OssSaveResult saveResult;
try {
saveResult = saveVideoToOss(task, videoUrl);
log.info("[pollKlingSingleTask][任务({})视频已保存到OSS][url={}]", task.getId(), saveResult.getUrl());
} catch (Exception e) {
log.warn("[pollKlingSingleTask][任务({})保存视频失败使用原URL][error={}]", task.getId(), e.getMessage());
saveResult = new OssSaveResult(videoUrl, 0, null, null);
}
updateTaskSuccess(task.getId(), saveResult.getUrl());
cacheTaskResult(task.getId(), saveResult.getUrl());
saveResultVideoToUserFiles(task, saveResult);
log.info("[pollKlingSingleTask][任务({})完成][videoUrl={}]", task.getId(), saveResult.getUrl());
}
/**
* 更新任务为成功状态
*/
private void updateTaskSuccess(Long taskId, String videoUrl) {
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
updateObj.setId(taskId);
updateObj.setStatus("SUCCESS");
updateObj.setCurrentStep("finishing");
updateObj.setProgress(100);
updateObj.setResultVideoUrl(videoUrl);
updateObj.setFinishTime(LocalDateTime.now());
taskMapper.updateById(updateObj); taskMapper.updateById(updateObj);
} }
/**
* 缓存任务结果到 Redis
*/
private void cacheTaskResult(Long taskId, String url) {
try {
String resultKey = "digital_human:task:result:" + taskId;
stringRedisTemplate.opsForValue().set(resultKey, url, Duration.ofHours(24));
} catch (Exception e) {
log.warn("[cacheTaskResult][任务({})缓存结果失败]", taskId, e);
}
}
} }