Merge branch 'master' into 'main'
Master See merge request root/sionrui!3
This commit is contained in:
@@ -82,7 +82,8 @@
|
||||
"Skill(pptx:*)",
|
||||
"Bash(pdftoppm:*)",
|
||||
"Bash(pip install:*)",
|
||||
"Bash(where:*)"
|
||||
"Bash(where:*)",
|
||||
"mcp__web-reader__webReader"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"ai": "^6.0.39",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"aplayer": "^1.10.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"markdown-it": "^14.1.0",
|
||||
"path-to-regexp": "^6.3.0",
|
||||
|
||||
@@ -2,18 +2,66 @@
|
||||
* 可灵数字人 API
|
||||
*/
|
||||
import request from './http'
|
||||
import { message } from "ant-design-vue"
|
||||
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) {
|
||||
return request({
|
||||
@@ -38,93 +86,46 @@ export function getLipSyncTask(taskId) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别已上传的视频
|
||||
*/
|
||||
export async function identifyUploadedVideo(videoFile) {
|
||||
try {
|
||||
showLoading('正在识别视频中的人脸...')
|
||||
const identifyRes = await identifyFace({ video_url: videoFile.fileUrl })
|
||||
hideLoading()
|
||||
|
||||
if (identifyRes.code !== 0) {
|
||||
throw new Error(identifyRes.msg || '识别失败')
|
||||
const urlRes = await MaterialService.getVideoPlayUrl(videoFile.fileId)
|
||||
if (urlRes.code !== 0 || !urlRes.data) {
|
||||
throw new Error(urlRes.msg || '获取播放链接失败')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
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
|
||||
}
|
||||
}
|
||||
const identifyData = await performFaceIdentification(urlRes.data)
|
||||
return buildIdentifyResponse(videoFile.id, urlRes.data, identifyData, false)
|
||||
} catch (error) {
|
||||
hideLoading()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传视频并识别
|
||||
*/
|
||||
export async function uploadAndIdentifyVideo(file) {
|
||||
const coverBase64 = await extractVideoCoverOptional(file)
|
||||
|
||||
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)
|
||||
hideLoading()
|
||||
|
||||
if (uploadRes.code !== 0) {
|
||||
throw new Error(uploadRes.msg || '上传失败')
|
||||
}
|
||||
|
||||
const fileId = uploadRes.data
|
||||
|
||||
showLoading('正在生成播放链接...')
|
||||
const urlRes = await MaterialService.getVideoPlayUrl(fileId)
|
||||
hideLoading()
|
||||
|
||||
if (urlRes.code !== 0) {
|
||||
throw new Error(urlRes.msg || '获取播放链接失败')
|
||||
}
|
||||
|
||||
const videoUrl = urlRes.data
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
const identifyData = await performFaceIdentification(urlRes.data)
|
||||
return buildIdentifyResponse(fileId, urlRes.data, identifyData, true)
|
||||
} catch (error) {
|
||||
hideLoading()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const VoiceService = {
|
||||
* @param {string} data.language - 语言(可选)
|
||||
* @param {string} data.gender - 音色类型(可选)
|
||||
* @param {string} data.note - 备注(可选)
|
||||
* @param {string} data.text - 音频文本(用于语音复刻,前端通过音频识别获取)
|
||||
* @param {string} data.providerType - 供应商类型(可选):cosyvoice-阿里云,siliconflow-硅基流动
|
||||
* @returns {Promise}
|
||||
*/
|
||||
|
||||
112
frontend/app/web-gold/src/components/PipelineProgress.vue
Normal file
112
frontend/app/web-gold/src/components/PipelineProgress.vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="voice-selector">
|
||||
<div v-if="displayedVoices.length === 0" class="empty-voices">
|
||||
<div v-if="userVoiceCards.length === 0" class="empty-voices">
|
||||
还没有配音,可先在"配音管理"中上传
|
||||
</div>
|
||||
|
||||
@@ -19,50 +19,110 @@
|
||||
size="small"
|
||||
:disabled="!selectedVoiceId"
|
||||
:loading="previewLoadingVoiceId === selectedVoiceId"
|
||||
@click="handlePreviewCurrentVoice"
|
||||
@click="handleSynthesize"
|
||||
>
|
||||
<template #icon>
|
||||
<SoundOutlined />
|
||||
</template>
|
||||
试听
|
||||
合成
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- APlayer 播放器容器 -->
|
||||
<div v-if="audioUrl" ref="playerContainer" class="aplayer-container"></div>
|
||||
|
||||
<!-- 下载按钮 -->
|
||||
<a-button
|
||||
v-if="audioUrl"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="downloadAudio"
|
||||
class="download-link"
|
||||
>
|
||||
下载音频
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { useVoiceCopyStore } from '@/stores/voiceCopy'
|
||||
import { useTTS, TTS_PROVIDERS } from '@/composables/useTTS'
|
||||
import APlayer from 'aplayer'
|
||||
|
||||
const props = defineProps({
|
||||
synthText: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
speechRate: {
|
||||
type: Number,
|
||||
default: 1.0
|
||||
}
|
||||
})
|
||||
|
||||
const voiceStore = useVoiceCopyStore()
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
// 使用TTS Hook,默认使用Qwen供应商
|
||||
let player = null
|
||||
const playerContainer = ref(null)
|
||||
const audioUrl = ref('')
|
||||
const currentVoiceName = ref('')
|
||||
|
||||
// 默认封面图片(音频波形图标)
|
||||
const defaultCover = `data:image/svg+xml;base64,${btoa(`
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" fill="#1f2937" rx="8"/>
|
||||
<g fill="#60a5fa">
|
||||
<rect x="20" y="35" width="4" height="30" rx="2">
|
||||
<animate attributeName="height" values="30;20;30" dur="0.8s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="35;40;35" dur="0.8s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="30" y="30" width="4" height="40" rx="2">
|
||||
<animate attributeName="height" values="40;25;40" dur="0.6s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="30;37.5;30" dur="0.6s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="40" y="25" width="4" height="50" rx="2">
|
||||
<animate attributeName="height" values="50;30;50" dur="0.7s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="25;35;25" dur="0.7s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="50" y="28" width="4" height="44" rx="2">
|
||||
<animate attributeName="height" values="44;28;44" dur="0.9s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="28;36;28" dur="0.9s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="60" y="32" width="4" height="36" rx="2">
|
||||
<animate attributeName="height" values="36;22;36" dur="0.5s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="32;39;32" dur="0.5s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
<rect x="70" y="38" width="4" height="24" rx="2">
|
||||
<animate attributeName="height" values="24;15;24" dur="0.7s" repeatCount="indefinite"/>
|
||||
<animate attributeName="y" values="38;42.5;38" dur="0.7s" repeatCount="indefinite"/>
|
||||
</rect>
|
||||
</g>
|
||||
<circle cx="50" cy="50" r="18" fill="none" stroke="#60a5fa" stroke-width="2" opacity="0.3"/>
|
||||
<path d="M44 44 L44 56 L56 50 Z" fill="#60a5fa" opacity="0.5"/>
|
||||
</svg>
|
||||
`.trim())}`
|
||||
|
||||
// 使用TTS Hook
|
||||
const {
|
||||
previewLoadingVoiceId,
|
||||
playingPreviewVoiceId,
|
||||
ttsText,
|
||||
speechRate,
|
||||
playVoiceSample,
|
||||
setText,
|
||||
setSpeechRate,
|
||||
resetPreviewState
|
||||
setSpeechRate
|
||||
} = useTTS({
|
||||
provider: TTS_PROVIDERS.SILICONFLOW
|
||||
})
|
||||
|
||||
// 当前选中的音色ID
|
||||
const selectedVoiceId = ref('')
|
||||
|
||||
// 从store数据构建音色列表
|
||||
const userVoiceCards = computed(() =>
|
||||
(voiceStore.profiles || []).map(profile => ({
|
||||
id: `user-${profile.id}`,
|
||||
rawId: profile.id,
|
||||
name: profile.name || '未命名',
|
||||
category:'',
|
||||
category: '',
|
||||
gender: profile.gender || 'female',
|
||||
description: profile.note || '我的配音',
|
||||
fileUrl: profile.fileUrl,
|
||||
@@ -72,76 +132,139 @@ const userVoiceCards = computed(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
const displayedVoices = computed(() => userVoiceCards.value)
|
||||
|
||||
// 转换为下拉框选项格式
|
||||
const voiceOptions = computed(() =>
|
||||
displayedVoices.value.map(voice => ({
|
||||
userVoiceCards.value.map(voice => ({
|
||||
value: voice.id,
|
||||
label: voice.name,
|
||||
data: voice // 保存完整数据
|
||||
data: voice
|
||||
}))
|
||||
)
|
||||
|
||||
// 音色选择变化处理
|
||||
const handleVoiceChange = (value, option) => {
|
||||
const voice = option.data
|
||||
selectedVoiceId.value = value
|
||||
currentVoiceName.value = voice.name
|
||||
emit('select', voice)
|
||||
}
|
||||
|
||||
// 试听当前选中的音色
|
||||
const handlePreviewCurrentVoice = () => {
|
||||
const handleSynthesize = () => {
|
||||
if (!selectedVoiceId.value) return
|
||||
|
||||
const voice = displayedVoices.value.find(v => v.id === selectedVoiceId.value)
|
||||
const voice = userVoiceCards.value.find(v => v.id === selectedVoiceId.value)
|
||||
if (!voice) return
|
||||
|
||||
currentVoiceName.value = voice.name
|
||||
handlePlayVoiceSample(voice)
|
||||
}
|
||||
|
||||
// 监听 prop 变化,更新 TTS 参数
|
||||
watch(() => props.synthText, (newText) => {
|
||||
setText(newText || '')
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.speechRate, (newRate) => {
|
||||
setSpeechRate(newRate)
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* 处理音色试听
|
||||
* 使用Hook提供的playVoiceSample方法
|
||||
* 处理音色
|
||||
*/
|
||||
const handlePlayVoiceSample = (voice) => {
|
||||
currentVoiceName.value = voice.name
|
||||
playVoiceSample(
|
||||
voice,
|
||||
(audioData) => {
|
||||
// 成功回调
|
||||
console.log('音频播放成功', audioData)
|
||||
(data) => {
|
||||
const url = data.audioUrl || data.objectUrl
|
||||
if (!url) {
|
||||
console.error('无效的音频数据格式', data)
|
||||
return
|
||||
}
|
||||
initPlayer(url)
|
||||
},
|
||||
(error) => {
|
||||
// 错误回调
|
||||
console.error('音频播放失败', error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置要试听的文本(供父组件调用)
|
||||
* @param {string} text 要试听的文本
|
||||
* 初始化 APlayer
|
||||
*/
|
||||
const setPreviewText = (text) => {
|
||||
setText(text)
|
||||
const initPlayer = (url) => {
|
||||
destroyPlayer()
|
||||
audioUrl.value = url
|
||||
|
||||
nextTick(() => {
|
||||
if (!playerContainer.value) return
|
||||
|
||||
player = new APlayer({
|
||||
container: playerContainer.value,
|
||||
autoplay: true,
|
||||
theme: '#3b82f6',
|
||||
preload: 'auto',
|
||||
volume: 0.7,
|
||||
audio: [{
|
||||
name: currentVoiceName.value || '语音合成',
|
||||
artist: '合成',
|
||||
url: url,
|
||||
cover: defaultCover
|
||||
}]
|
||||
})
|
||||
|
||||
player.on('ended', () => {
|
||||
if (audioUrl.value?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(audioUrl.value)
|
||||
}
|
||||
audioUrl.value = ''
|
||||
})
|
||||
|
||||
player.on('error', (e) => {
|
||||
console.error('APlayer 播放错误:', e)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置语速(供父组件调用)
|
||||
* @param {number} rate 语速倍率
|
||||
* 下载音频
|
||||
*/
|
||||
const setPreviewSpeechRate = (rate) => {
|
||||
setSpeechRate(rate)
|
||||
const downloadAudio = () => {
|
||||
if (!audioUrl.value) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = audioUrl.value
|
||||
link.download = `${currentVoiceName.value || '语音合成'}.mp3`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
setPreviewText,
|
||||
setPreviewSpeechRate
|
||||
})
|
||||
/**
|
||||
* 销毁播放器
|
||||
*/
|
||||
const destroyPlayer = () => {
|
||||
if (player) {
|
||||
try {
|
||||
player.destroy()
|
||||
} catch (e) {
|
||||
console.error('销毁播放器失败:', e)
|
||||
}
|
||||
player = null
|
||||
}
|
||||
if (audioUrl.value) {
|
||||
URL.revokeObjectURL(audioUrl.value)
|
||||
audioUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({})
|
||||
|
||||
onMounted(async () => {
|
||||
await voiceStore.refresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
destroyPlayer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -176,4 +299,16 @@ onMounted(async () => {
|
||||
height: 32px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* APlayer 容器样式 */
|
||||
.aplayer-container {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* 下载链接样式 */
|
||||
.download-link {
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,72 +7,97 @@ import { message } from 'ant-design-vue'
|
||||
import { VoiceService } from '@/api/voice'
|
||||
import { normalizeProviderType, VOICE_PROVIDER_TYPES } from '@/config/voiceConfig'
|
||||
|
||||
// 兼容旧代码的导出
|
||||
// ========== 常量 ==========
|
||||
|
||||
/** 兼容旧代码的导出 */
|
||||
const TTS_PROVIDERS = VOICE_PROVIDER_TYPES
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
apiEndpoint: '/api/tik/voice/tts',
|
||||
audioFormat: 'mp3',
|
||||
supportedFormats: ['mp3', 'wav']
|
||||
/** 默认配置 */
|
||||
const DEFAULT_CONFIG = {
|
||||
apiEndpoint: '/api/tik/voice/tts',
|
||||
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 = {}) {
|
||||
const {
|
||||
provider = VOICE_PROVIDER_TYPES.SILICONFLOW,
|
||||
customConfig = {}
|
||||
} = options
|
||||
|
||||
// 状态管理
|
||||
// 状态管理(移到函数内部,避免模块级状态污染)
|
||||
const previewAudioCache = new Map()
|
||||
const MAX_PREVIEW_CACHE_SIZE = 50
|
||||
const previewLoadingVoiceId = ref(null)
|
||||
const playingPreviewVoiceId = ref(null)
|
||||
const ttsText = ref('')
|
||||
const speechRate = ref(1.0)
|
||||
|
||||
// 音频实例
|
||||
// 音频实例(移到函数内部)
|
||||
let previewAudio = null
|
||||
let previewObjectUrl = ''
|
||||
|
||||
// 获取当前供应商配置
|
||||
const getProviderConfig = () => {
|
||||
// ========== 辅助函数 ==========
|
||||
|
||||
function getProviderConfig() {
|
||||
return DEFAULT_CONFIG
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放音频预览
|
||||
* @param {string} url 音频URL
|
||||
* @param {Object} options 播放选项
|
||||
* @param {string} url - 音频 URL
|
||||
* @param {Object} playOptions - 播放选项
|
||||
* @param {boolean} [playOptions.revokeOnEnd=false] - 播放结束后是否释放 URL
|
||||
* @param {Function} [playOptions.onEnded] - 播放结束回调
|
||||
*/
|
||||
const playAudioPreview = (url, options = {}) => {
|
||||
if (!url) return message.warning('暂无可试听的音频')
|
||||
function playAudioPreview(url, playOptions = {}) {
|
||||
if (!url) {
|
||||
message.warning('暂无可试听的音频')
|
||||
return
|
||||
}
|
||||
|
||||
// 停止当前播放
|
||||
try {
|
||||
previewAudio?.pause?.()
|
||||
previewAudio = null
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 忽略停止播放的错误
|
||||
}
|
||||
|
||||
const audio = new Audio(url)
|
||||
const cleanup = () => {
|
||||
if (options.revokeOnEnd && url.startsWith('blob:')) {
|
||||
|
||||
function cleanup() {
|
||||
if (playOptions.revokeOnEnd && url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(url)
|
||||
previewObjectUrl === url && (previewObjectUrl = '')
|
||||
if (previewObjectUrl === url) {
|
||||
previewObjectUrl = ''
|
||||
}
|
||||
}
|
||||
previewAudio = null
|
||||
options.onEnded && options.onEnded()
|
||||
playOptions.onEnded?.()
|
||||
}
|
||||
|
||||
audio.play()
|
||||
.then(() => {
|
||||
.then(function() {
|
||||
previewAudio = audio
|
||||
audio.onended = cleanup
|
||||
audio.onerror = () => {
|
||||
audio.onerror = function() {
|
||||
cleanup()
|
||||
message.error('播放失败')
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
.catch(function() {
|
||||
cleanup()
|
||||
message.error('播放失败')
|
||||
})
|
||||
@@ -80,10 +105,10 @@ export function useTTS(options = {}) {
|
||||
|
||||
/**
|
||||
* 生成预览缓存键
|
||||
* @param {Object} voice 音色对象
|
||||
* @param {Object} voice - 音色对象
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
const generatePreviewCacheKey = (voice) => {
|
||||
function generatePreviewCacheKey(voice) {
|
||||
const voiceId = voice.voiceId || voice.rawId || voice.id
|
||||
const text = ttsText.value.trim()
|
||||
const rate = speechRate.value
|
||||
@@ -92,12 +117,12 @@ export function useTTS(options = {}) {
|
||||
|
||||
/**
|
||||
* 解码并缓存Base64音频
|
||||
* @param {string} audioBase64 Base64编码的音频数据
|
||||
* @param {string} format 音频格式
|
||||
* @param {string} cacheKey 缓存键
|
||||
* @param {string} audioBase64 - Base64 编码的音频数据
|
||||
* @param {string} [format='mp3'] - 音频格式
|
||||
* @param {string} cacheKey - 缓存键
|
||||
* @returns {Promise<Object>} 音频数据
|
||||
*/
|
||||
const decodeAndCacheBase64 = async (audioBase64, format = 'mp3', cacheKey) => {
|
||||
async function decodeAndCacheBase64(audioBase64, format = 'mp3', cacheKey) {
|
||||
const byteCharacters = window.atob(audioBase64)
|
||||
const byteNumbers = new Uint8Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
@@ -123,10 +148,10 @@ export function useTTS(options = {}) {
|
||||
|
||||
/**
|
||||
* 播放缓存的音频
|
||||
* @param {Object} audioData 音频数据
|
||||
* @param {Function} onEnded 播放结束回调
|
||||
* @param {Object} audioData - 音频数据
|
||||
* @param {Function} onEnded - 播放结束回调
|
||||
*/
|
||||
const playCachedAudio = (audioData, onEnded) => {
|
||||
function playCachedAudio(audioData, onEnded) {
|
||||
if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) {
|
||||
URL.revokeObjectURL(previewObjectUrl)
|
||||
}
|
||||
@@ -134,7 +159,10 @@ export function useTTS(options = {}) {
|
||||
|
||||
playAudioPreview(previewObjectUrl, {
|
||||
revokeOnEnd: false,
|
||||
onEnded: () => {
|
||||
onEnded: function() {
|
||||
if (audioData.objectUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(audioData.objectUrl)
|
||||
}
|
||||
onEnded && onEnded()
|
||||
}
|
||||
})
|
||||
@@ -143,17 +171,17 @@ export function useTTS(options = {}) {
|
||||
/**
|
||||
* 重置预览状态
|
||||
*/
|
||||
const resetPreviewState = () => {
|
||||
function resetPreviewState() {
|
||||
previewLoadingVoiceId.value = null
|
||||
playingPreviewVoiceId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取ID从字符串
|
||||
* @param {string} idStr 包含前缀的ID字符串
|
||||
* @param {string} idStr - 包含前缀的ID字符串
|
||||
* @returns {number|null} 提取的ID
|
||||
*/
|
||||
const extractIdFromString = (idStr) => {
|
||||
function extractIdFromString(idStr) {
|
||||
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
|
||||
const extractedId = parseInt(idStr.replace('user-', ''))
|
||||
return Number.isNaN(extractedId) ? null : extractedId
|
||||
@@ -161,10 +189,10 @@ export function useTTS(options = {}) {
|
||||
|
||||
/**
|
||||
* 构建预览参数
|
||||
* @param {Object} voice 音色对象
|
||||
* @param {Object} voice - 音色对象
|
||||
* @returns {Object|null} 预览参数
|
||||
*/
|
||||
const buildPreviewParams = (voice) => {
|
||||
function buildPreviewParams(voice) {
|
||||
const configId = voice.rawId || extractIdFromString(voice.id)
|
||||
if (!configId) {
|
||||
message.error('配音配置无效')
|
||||
@@ -184,11 +212,11 @@ export function useTTS(options = {}) {
|
||||
|
||||
/**
|
||||
* 播放音色试听
|
||||
* @param {Object} voice 音色对象
|
||||
* @param {Function} onSuccess 成功回调
|
||||
* @param {Function} onError 错误回调
|
||||
* @param {Object} voice - 音色对象
|
||||
* @param {Function} onSuccess - 成功回调
|
||||
* @param {Function} onError - 错误回调
|
||||
*/
|
||||
const playVoiceSample = async (voice, onSuccess, onError) => {
|
||||
async function playVoiceSample(voice, onSuccess, onError) {
|
||||
if (!voice) return
|
||||
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
|
||||
return
|
||||
@@ -197,7 +225,8 @@ export function useTTS(options = {}) {
|
||||
try {
|
||||
previewAudio?.pause?.()
|
||||
previewAudio = null
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,21 +254,30 @@ export function useTTS(options = {}) {
|
||||
if (res.code !== 0) {
|
||||
message.error(res.msg || '试听失败')
|
||||
resetPreviewState()
|
||||
onError && onError(new Error(res.msg || '试听失败'))
|
||||
onError?.(new Error(res.msg || '试听失败'))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.data?.audioUrl) {
|
||||
playAudioPreview(res.data.audioUrl, { onEnded: resetPreviewState })
|
||||
onSuccess && onSuccess(res.data)
|
||||
resetPreviewState()
|
||||
playAudioPreview(res.data.audioUrl, {
|
||||
revokeOnEnd: true,
|
||||
onEnded: function() {
|
||||
URL.revokeObjectURL(res.data.audioUrl)
|
||||
}
|
||||
})
|
||||
onSuccess?.(res.data)
|
||||
} else if (res.data?.audioBase64) {
|
||||
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
|
||||
playCachedAudio(audioData, resetPreviewState)
|
||||
onSuccess && onSuccess(audioData)
|
||||
resetPreviewState()
|
||||
playCachedAudio(audioData, function() {
|
||||
URL.revokeObjectURL(audioData.objectUrl)
|
||||
})
|
||||
onSuccess?.(audioData)
|
||||
} else {
|
||||
message.error('试听失败')
|
||||
resetPreviewState()
|
||||
onError && onError(new Error('未收到音频数据'))
|
||||
onError?.(new Error('未收到音频数据'))
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('试听失败')
|
||||
@@ -250,10 +288,10 @@ export function useTTS(options = {}) {
|
||||
|
||||
/**
|
||||
* TTS文本转语音
|
||||
* @param {Object} params TTS参数
|
||||
* @returns {Promise<Object>} TTS结果
|
||||
* @param {Object} params - TTS 参数
|
||||
* @returns {Promise<Object>} TTS 结果
|
||||
*/
|
||||
const synthesize = async (params) => {
|
||||
async function synthesize(params) {
|
||||
const providerConfig = getProviderConfig()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置语速
|
||||
* @param {number} rate 语速倍率
|
||||
* @param {number} rate - 语速倍率
|
||||
*/
|
||||
const setSpeechRate = (rate) => {
|
||||
function setSpeechRate(rate) {
|
||||
speechRate.value = rate
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除音频缓存
|
||||
*/
|
||||
const clearAudioCache = () => {
|
||||
previewAudioCache.forEach((audioData) => {
|
||||
function clearAudioCache() {
|
||||
previewAudioCache.forEach(function(audioData) {
|
||||
URL.revokeObjectURL(audioData.objectUrl)
|
||||
})
|
||||
previewAudioCache.clear()
|
||||
@@ -296,14 +334,17 @@ export function useTTS(options = {}) {
|
||||
/**
|
||||
* 停止当前播放
|
||||
*/
|
||||
const stopCurrentPlayback = () => {
|
||||
function stopCurrentPlayback() {
|
||||
try {
|
||||
previewAudio?.pause?.()
|
||||
previewAudio = null
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 返回接口 ==========
|
||||
|
||||
return {
|
||||
// 状态
|
||||
previewLoadingVoiceId,
|
||||
|
||||
@@ -133,28 +133,23 @@ export function useUpload() {
|
||||
fileType: file.type,
|
||||
groupId,
|
||||
coverBase64,
|
||||
duration: file.type.startsWith('video/') ? null : undefined // 视频时长由后端处理或前端可选传递
|
||||
duration: file.type.startsWith('video/') ? null : undefined
|
||||
})
|
||||
|
||||
// 设置成功状态
|
||||
state.uploading = false
|
||||
state.status = 'success'
|
||||
state.progress = 100
|
||||
|
||||
// 通知成功
|
||||
const fileId = completeData.data?.infraFileId || completeData.data?.userFileId
|
||||
onSuccess && onSuccess(fileId)
|
||||
const fileUrl = presignedData.data.presignedUrl
|
||||
onSuccess && onSuccess(fileId, fileUrl)
|
||||
|
||||
return fileId
|
||||
} catch (error) {
|
||||
// 设置错误状态
|
||||
state.uploading = false
|
||||
state.status = 'error'
|
||||
state.error = error.message || '上传失败'
|
||||
|
||||
// 通知错误
|
||||
onError && onError(error)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'normalize.css'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import 'aplayer/dist/APlayer.min.css'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
|
||||
@@ -105,14 +105,15 @@
|
||||
</template>
|
||||
|
||||
<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 { 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 dayjs from 'dayjs'
|
||||
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'
|
||||
|
||||
// ========== 常量 ==========
|
||||
|
||||
@@ -123,9 +124,15 @@ const DEFAULT_FORM_DATA = {
|
||||
autoTranscribe: true,
|
||||
language: 'zh-CN',
|
||||
gender: 'female',
|
||||
note: ''
|
||||
note: '',
|
||||
text: '',
|
||||
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 submitting = ref(false)
|
||||
@@ -155,6 +162,9 @@ const formData = reactive({ ...DEFAULT_FORM_DATA })
|
||||
// ========== Upload Hook ==========
|
||||
const { state: uploadState, upload } = useUpload()
|
||||
|
||||
// ========== VoiceText Hook ==========
|
||||
const { getVoiceText } = useVoiceText()
|
||||
|
||||
// ========== 计算属性 ==========
|
||||
const isCreateMode = computed(() => formMode.value === 'create')
|
||||
|
||||
@@ -210,31 +220,31 @@ const loadVoiceList = async () => {
|
||||
}
|
||||
|
||||
// ========== 搜索和分页 ==========
|
||||
const handleSearch = () => {
|
||||
function handleSearch() {
|
||||
pagination.current = 1
|
||||
loadVoiceList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
function handleReset() {
|
||||
searchParams.name = ''
|
||||
pagination.current = 1
|
||||
loadVoiceList()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
function handleTableChange(pag) {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadVoiceList()
|
||||
}
|
||||
|
||||
// ========== CRUD 操作 ==========
|
||||
const handleCreate = () => {
|
||||
function handleCreate() {
|
||||
formMode.value = 'create'
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = async (record) => {
|
||||
async function handleEdit(record) {
|
||||
formMode.value = 'edit'
|
||||
try {
|
||||
const res = await VoiceService.get(record.id)
|
||||
@@ -246,13 +256,13 @@ const handleEdit = async (record) => {
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
function handleDelete(record) {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除配音「${record.name}」吗?此操作不可恢复。`,
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
onOk: async function() {
|
||||
try {
|
||||
const res = await VoiceService.delete(record.id)
|
||||
if (res.code !== 0) return message.error(res.msg || '删除失败')
|
||||
@@ -268,7 +278,7 @@ const handleDelete = (record) => {
|
||||
}
|
||||
|
||||
// ========== 音频播放 ==========
|
||||
const handlePlayAudio = (record) => {
|
||||
function handlePlayAudio(record) {
|
||||
if (record.fileUrl && audioPlayer.value) {
|
||||
audioPlayer.value.src = record.fileUrl
|
||||
audioPlayer.value.play()
|
||||
@@ -278,20 +288,20 @@ const handlePlayAudio = (record) => {
|
||||
}
|
||||
|
||||
// ========== 文件上传 ==========
|
||||
const handleBeforeUpload = (file) => {
|
||||
const MAX_FILE_SIZE = 50 * 1024 * 1024
|
||||
function handleBeforeUpload(file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
message.error('文件大小不能超过 50MB')
|
||||
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 fileType = file.type.toLowerCase()
|
||||
|
||||
const isValidType = validTypes.some(type => fileType.includes(type)) ||
|
||||
validExtensions.some(ext => fileName.endsWith(ext))
|
||||
const isValidType = VALID_AUDIO_TYPES.some(function(type) {
|
||||
return fileType.includes(type)
|
||||
}) || VALID_AUDIO_EXTENSIONS.some(function(ext) {
|
||||
return fileName.endsWith(ext)
|
||||
})
|
||||
|
||||
if (!isValidType) {
|
||||
message.error('请上传音频文件(MP3、WAV、AAC、M4A、FLAC、OGG)')
|
||||
@@ -301,22 +311,23 @@ const handleBeforeUpload = (file) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const handleCustomUpload = async (options) => {
|
||||
// ========== 文件上传相关 ==========
|
||||
async function handleCustomUpload(options) {
|
||||
const { file, onSuccess, onError } = options
|
||||
|
||||
try {
|
||||
const fileId = await upload(file, {
|
||||
fileCategory: 'voice',
|
||||
groupId: null, // 配音模块不使用groupId
|
||||
groupId: null,
|
||||
coverBase64: null,
|
||||
onStart: () => {},
|
||||
onProgress: () => {},
|
||||
onSuccess: (id) => {
|
||||
onSuccess: async function(id, fileUrl) {
|
||||
formData.fileId = id
|
||||
formData.fileUrl = fileUrl
|
||||
message.success('文件上传成功')
|
||||
await fetchAudioTextById(id)
|
||||
onSuccess?.({ code: 0, data: id }, file)
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: function(error) {
|
||||
const errorMsg = error.message || '上传失败,请稍后重试'
|
||||
message.error(errorMsg)
|
||||
onError?.(error)
|
||||
@@ -330,24 +341,43 @@ const handleCustomUpload = async (options) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileListChange = (info) => {
|
||||
// 处理文件列表变化,避免直接修改导致 DOM 错误
|
||||
const { fileList: newFileList } = info
|
||||
|
||||
// 只更新文件列表,不直接修改文件项的状态
|
||||
// 让组件自己管理状态
|
||||
if (newFileList) {
|
||||
fileList.value = newFileList.filter(item => item.status !== 'removed')
|
||||
// 通过fileId获取音频文本
|
||||
async function fetchAudioTextById(fileId) {
|
||||
if (!fileId) return
|
||||
try {
|
||||
const res = await MaterialService.getAudioPlayUrl(fileId)
|
||||
if (res.code === 0 && res.data) {
|
||||
const rawFileUrl = res.data
|
||||
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 handleRemoveFile = () => {
|
||||
function handleFileListChange(info) {
|
||||
const { fileList: newFileList } = info
|
||||
if (newFileList) {
|
||||
fileList.value = newFileList.filter(function(item) {
|
||||
return item.status !== 'removed'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveFile() {
|
||||
formData.fileId = null
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
// ========== 表单操作 ==========
|
||||
const handleSubmit = async () => {
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
@@ -363,7 +393,8 @@ const handleSubmit = async () => {
|
||||
autoTranscribe: formData.autoTranscribe,
|
||||
language: formData.language,
|
||||
gender: formData.gender,
|
||||
note: formData.note
|
||||
note: formData.note,
|
||||
text: formData.text
|
||||
}
|
||||
: {
|
||||
id: formData.id,
|
||||
@@ -394,19 +425,19 @@ const handleSubmit = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
function handleCancel() {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
function resetForm() {
|
||||
Object.assign(formData, { ...DEFAULT_FORM_DATA })
|
||||
fileList.value = []
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
onMounted(() => {
|
||||
onMounted(function() {
|
||||
loadVoiceList()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
:show-count="true"
|
||||
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 class="section">
|
||||
<h3>音色</h3>
|
||||
<VoiceSelector ref="voiceSelectorRef" @select="handleVoiceSelect" />
|
||||
<VoiceSelector
|
||||
:synth-text="ttsText"
|
||||
:speech-rate="speechRate"
|
||||
@select="handleVoiceSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TTS 控制 -->
|
||||
@@ -137,143 +137,74 @@
|
||||
</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">
|
||||
<h3>配音生成与校验</h3>
|
||||
|
||||
<!-- 生成配音按钮 -->
|
||||
<div class="generate-audio-row">
|
||||
<a-button
|
||||
type="default"
|
||||
size="large"
|
||||
:disabled="!canGenerateAudio"
|
||||
:loading="audioState.generating"
|
||||
block
|
||||
@click="generateAudio"
|
||||
>
|
||||
{{ audioState.generating ? '生成中...' : '生成配音(用于校验时长)' }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 音频预览(生成后显示) -->
|
||||
<div v-if="audioState.generated" class="audio-preview">
|
||||
<div class="audio-info">
|
||||
<h4>生成的配音</h4>
|
||||
<div class="duration-info">
|
||||
<span class="label">音频时长:</span>
|
||||
<span class="value">{{ (audioState.durationMs / 1000).toFixed(1) }} 秒</span>
|
||||
</div>
|
||||
<div class="duration-info">
|
||||
<span class="label">人脸区间:</span>
|
||||
<span class="value">{{ (faceDuration / 1000).toFixed(1) }} 秒</span>
|
||||
</div>
|
||||
<div class="duration-info" :class="{ 'validation-passed': audioState.validationPassed, 'validation-failed': !audioState.validationPassed }">
|
||||
<span class="label">校验结果:</span>
|
||||
<span class="value">
|
||||
{{ audioState.validationPassed ? '✅ 通过' : '❌ 不通过(需至少2秒重合)' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 配音生成(仅在 Pipeline 到达 ready 状态后显示) -->
|
||||
<div v-if="isPipelineReady" class="section audio-section">
|
||||
<!-- 已生成音频 -->
|
||||
<div v-if="audioState.generated" class="audio-generated">
|
||||
<div class="audio-header">
|
||||
<span class="audio-title">配音</span>
|
||||
<span class="audio-duration">{{ audioDurationSec }}秒</span>
|
||||
</div>
|
||||
|
||||
<!-- 音频播放器 -->
|
||||
<div class="audio-player">
|
||||
<audio
|
||||
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 v-if="audioUrl" class="audio-player-wrapper">
|
||||
<audio :src="audioUrl" controls class="audio-player" />
|
||||
</div>
|
||||
|
||||
<!-- 重新生成按钮 -->
|
||||
<div class="regenerate-row">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="generateAudio"
|
||||
:loading="audioState.generating"
|
||||
>
|
||||
重新生成
|
||||
</a-button>
|
||||
<!-- 校验失败提示 -->
|
||||
<div v-if="!validationPassed" class="validation-warning">
|
||||
<span class="warning-icon">⚠️</span>
|
||||
<span class="warning-text">音频时长({{ audioDurationSec }}秒)超过视频人脸区间({{ faceDurationSec }}秒),请缩短文案或调整语速</span>
|
||||
</div>
|
||||
|
||||
<!-- 重新生成 -->
|
||||
<a-button type="link" size="small" :loading="audioState.generating" @click="generateAudio">
|
||||
重新生成
|
||||
</a-button>
|
||||
</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">
|
||||
<!-- 准备阶段:先运行到 ready -->
|
||||
<a-button
|
||||
v-if="!isPipelineReady"
|
||||
type="primary"
|
||||
size="large"
|
||||
:disabled="!canGenerate"
|
||||
:loading="isPipelineBusy"
|
||||
block
|
||||
@click="generateAudio"
|
||||
>
|
||||
{{ isPipelineBusy ? '处理中...' : '生成配音并验证' }}
|
||||
</a-button>
|
||||
|
||||
<!-- Ready 后:生成数字人视频 -->
|
||||
<a-button
|
||||
v-else
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="isPipelineBusy"
|
||||
block
|
||||
@click="generateDigitalHuman"
|
||||
>
|
||||
生成数字人视频
|
||||
{{ isPipelineBusy ? '处理中...' : '生成数字人视频' }}
|
||||
</a-button>
|
||||
|
||||
<!-- 添加提示信息 -->
|
||||
<div v-if="canGenerate && !audioState.validationPassed" class="generate-hint">
|
||||
<span class="hint-icon">⚠️</span>
|
||||
<span>请先生成配音并通过时长校验</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -295,13 +226,12 @@ import VideoSelector from '@/components/VideoSelector.vue'
|
||||
import VoiceSelector from '@/components/VoiceSelector.vue'
|
||||
import ResultPanel from '@/components/ResultPanel.vue'
|
||||
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue'
|
||||
import PipelineProgress from '@/components/PipelineProgress.vue'
|
||||
|
||||
// Controller Hook
|
||||
import { useIdentifyFaceController } from './hooks/useIdentifyFaceController'
|
||||
|
||||
const voiceStore = useVoiceCopyStore()
|
||||
const voiceSelectorRef: any = ref(null)
|
||||
|
||||
const dragOver = ref(false)
|
||||
|
||||
// ==================== 初始化 Controller ====================
|
||||
@@ -315,15 +245,10 @@ const {
|
||||
ttsText,
|
||||
speechRate,
|
||||
audioState,
|
||||
canGenerateAudio,
|
||||
suggestedMaxChars,
|
||||
generateAudio,
|
||||
|
||||
// 数字人生成相关
|
||||
videoState,
|
||||
identifyState,
|
||||
materialValidation,
|
||||
faceDuration,
|
||||
getVideoPreviewUrl,
|
||||
|
||||
// 计算属性
|
||||
@@ -332,6 +257,21 @@ const {
|
||||
textareaPlaceholder,
|
||||
speechRateMarks,
|
||||
speechRateDisplay,
|
||||
faceDurationSec,
|
||||
audioDurationSec,
|
||||
audioUrl,
|
||||
validationPassed,
|
||||
|
||||
// Pipeline 状态(单一状态源)
|
||||
pipelineState,
|
||||
isPipelineBusy,
|
||||
isPipelineReady,
|
||||
isPipelineFailed,
|
||||
isPipelineCompleted,
|
||||
pipelineProgress,
|
||||
pipelineError,
|
||||
retryPipeline,
|
||||
resetPipeline,
|
||||
|
||||
// 事件处理方法
|
||||
handleVoiceSelect,
|
||||
@@ -341,7 +281,6 @@ const {
|
||||
handleSelectUpload,
|
||||
handleSelectFromLibrary,
|
||||
handleVideoSelect,
|
||||
handleSimplifyScript,
|
||||
handleVideoLoaded,
|
||||
replaceVideo,
|
||||
generateDigitalHuman,
|
||||
@@ -355,12 +294,6 @@ const {
|
||||
|
||||
onMounted(async () => {
|
||||
await voiceStore.refresh()
|
||||
|
||||
// 设置VoiceSelector的试听文本和语速
|
||||
if (voiceSelectorRef.value) {
|
||||
voiceSelectorRef.value.setPreviewText(ttsText.value)
|
||||
voiceSelectorRef.value.setPreviewSpeechRate(speechRate.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -423,20 +356,15 @@ onMounted(async () => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-content h4,
|
||||
.audio-info h4 {
|
||||
.card-content h4 {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-content p,
|
||||
.duration-label span:first-child {
|
||||
.card-content p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-content p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -454,24 +382,6 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.text-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(var(--color-primary), 0.1);
|
||||
border: 1px solid rgba(var(--color-primary), 0.3);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ========== 控制面板 ========== */
|
||||
.control-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -688,175 +598,76 @@ onMounted(async () => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ========== 验证结果 ========== */
|
||||
.validation-result {
|
||||
padding: 16px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
/* ========== 音频区域 ========== */
|
||||
.audio-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.validation-result.validation-passed {
|
||||
border-color: var(--color-success);
|
||||
background: rgba(var(--color-success), 0.05);
|
||||
.audio-generated {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.validation-result.validation-failed {
|
||||
border-color: var(--color-error);
|
||||
background: rgba(var(--color-error), 0.05);
|
||||
}
|
||||
|
||||
.validation-status {
|
||||
.audio-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: var(--text-primary);
|
||||
.audio-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========== 时长对比进度条 ========== */
|
||||
.duration-comparison {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.duration-bar {
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.audio-bar .progress-fill {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.video-bar .progress-fill.success {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.video-bar .progress-fill.error {
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
/* ========== 错误提示 ========== */
|
||||
.validation-error {
|
||||
padding: 12px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-error);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========== 音频生成 ========== */
|
||||
.audio-generation-section {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.generate-audio-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.audio-preview {
|
||||
padding: 16px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.duration-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.duration-info .label {
|
||||
.audio-duration {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.duration-info .value {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.duration-info.validation-passed .value {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.duration-info.validation-failed .value {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.audio-element {
|
||||
.audio-player-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.regenerate-row {
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.validation-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(var(--color-warning), 0.1);
|
||||
border: 1px solid rgba(var(--color-warning), 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.audio-prompt {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
padding: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--border-light);
|
||||
}
|
||||
|
||||
.audio-prompt p {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========== 操作按钮 ========== */
|
||||
@@ -876,18 +687,6 @@ onMounted(async () => {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.generate-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(var(--color-warning), 0.1);
|
||||
border: 1px solid rgba(var(--color-warning), 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
/* ========== 响应式 ========== */
|
||||
@media (max-width: 1024px) {
|
||||
.kling-content {
|
||||
|
||||
124
frontend/app/web-gold/src/views/kling/hooks/pipeline/states.ts
Normal file
124
frontend/app/web-gold/src/views/kling/hooks/pipeline/states.ts
Normal 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'
|
||||
}
|
||||
126
frontend/app/web-gold/src/views/kling/hooks/pipeline/types.ts
Normal file
126
frontend/app/web-gold/src/views/kling/hooks/pipeline/types.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,34 @@
|
||||
/**
|
||||
* @fileoverview useDigitalHumanGeneration Hook - 数字人生成逻辑封装
|
||||
* @author Claude Code
|
||||
* @fileoverview useDigitalHumanGeneration Hook - 数字人生成逻辑
|
||||
*
|
||||
* 重构后:不管理识别状态,只提供数据和操作方法
|
||||
* 状态由 Pipeline 统一管理
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type {
|
||||
UseDigitalHumanGeneration,
|
||||
VideoState,
|
||||
IdentifyState,
|
||||
IdentifyResult,
|
||||
Video,
|
||||
} from '../types/identify-face'
|
||||
import { identifyUploadedVideo } from '@/api/kling'
|
||||
import { useUpload } from '@/composables/useUpload'
|
||||
|
||||
/**
|
||||
* 数字人生成 Hook
|
||||
* 独立管理所有状态,不依赖外部状态
|
||||
*/
|
||||
export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
|
||||
// ==================== 响应式状态 ====================
|
||||
|
||||
export function useDigitalHumanGeneration() {
|
||||
// ========== 状态 ==========
|
||||
const videoState = ref<VideoState>({
|
||||
uploadedVideo: '',
|
||||
videoFile: null,
|
||||
previewVideoUrl: '',
|
||||
selectedVideo: null,
|
||||
fileId: null,
|
||||
videoSource: null,
|
||||
selectorVisible: false,
|
||||
})
|
||||
|
||||
const identifyState = ref<IdentifyState>({
|
||||
identifying: false,
|
||||
identified: false,
|
||||
// 识别结果数据(不含状态标志)
|
||||
const identifyResult = ref<IdentifyResult>({
|
||||
sessionId: '',
|
||||
faceId: '',
|
||||
faceStartTime: 0,
|
||||
@@ -40,24 +36,24 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
|
||||
videoFileId: null,
|
||||
})
|
||||
|
||||
// ==================== Upload Hook ====================
|
||||
const { upload } = useUpload()
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/**
|
||||
* 人脸出现时长
|
||||
*/
|
||||
const faceDuration = computed(() => {
|
||||
return identifyState.value.faceEndTime - identifyState.value.faceStartTime
|
||||
// ========== 计算属性 ==========
|
||||
const faceDuration = computed(function() {
|
||||
return identifyResult.value.faceEndTime - identifyResult.value.faceStartTime
|
||||
})
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
const hasVideo = computed(function() {
|
||||
return !!videoState.value.uploadedVideo || !!videoState.value.selectedVideo
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理视频文件上传
|
||||
*/
|
||||
const handleFileUpload = async (file: File): Promise<void> => {
|
||||
const isIdentified = computed(function() {
|
||||
return !!identifyResult.value.sessionId
|
||||
})
|
||||
|
||||
// ========== 方法 ==========
|
||||
|
||||
async function handleFileUpload(file: File): Promise<void> {
|
||||
if (!file.name.match(/\.(mp4|mov)$/i)) {
|
||||
message.error('仅支持 MP4 和 MOV')
|
||||
return
|
||||
@@ -68,168 +64,115 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
|
||||
videoState.value.selectedVideo = null
|
||||
videoState.value.previewVideoUrl = ''
|
||||
videoState.value.videoSource = 'upload'
|
||||
|
||||
resetIdentifyState()
|
||||
|
||||
await performFaceRecognition()
|
||||
resetIdentifyResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理从素材库选择视频
|
||||
*/
|
||||
const handleVideoSelect = (video: Video): void => {
|
||||
async function handleVideoSelect(video: Video): Promise<void> {
|
||||
videoState.value.selectedVideo = video
|
||||
videoState.value.uploadedVideo = video.fileUrl
|
||||
videoState.value.videoFile = null
|
||||
videoState.value.videoSource = 'select'
|
||||
videoState.value.selectorVisible = false
|
||||
resetIdentifyState()
|
||||
identifyState.value.videoFileId = video.id
|
||||
resetIdentifyResult()
|
||||
identifyResult.value.videoFileId = video.fileId
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行人脸识别
|
||||
* 返回识别结果供 Pipeline 使用
|
||||
*/
|
||||
const performFaceRecognition = async (): Promise<void> => {
|
||||
async function performFaceRecognition(): Promise<IdentifyResult> {
|
||||
const hasUploadFile = videoState.value.videoFile
|
||||
const hasSelectedVideo = videoState.value.selectedVideo
|
||||
|
||||
if (!hasUploadFile && !hasSelectedVideo) {
|
||||
return
|
||||
throw new Error('请先选择视频')
|
||||
}
|
||||
|
||||
identifyState.value.identifying = true
|
||||
|
||||
try {
|
||||
let res
|
||||
if (hasSelectedVideo) {
|
||||
res = await identifyUploadedVideo(hasSelectedVideo)
|
||||
identifyState.value.videoFileId = hasSelectedVideo.id
|
||||
} else {
|
||||
// 处理文件上传(提取封面)
|
||||
const file = hasUploadFile!
|
||||
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 {
|
||||
// 封面提取失败不影响主流程
|
||||
}
|
||||
|
||||
// 使用useUpload Hook上传文件
|
||||
const fileId = await upload(file, {
|
||||
fileCategory: 'video',
|
||||
groupId: null, // 数字人模块不使用groupId
|
||||
coverBase64,
|
||||
onStart: () => {},
|
||||
onProgress: () => {},
|
||||
onSuccess: () => {
|
||||
message.success('文件上传成功')
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
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
|
||||
if (hasSelectedVideo) {
|
||||
const res = await identifyUploadedVideo(hasSelectedVideo) as {
|
||||
success: boolean;
|
||||
data: { sessionId: string; faceId: string | null; startTime: number; endTime: number }
|
||||
}
|
||||
identifyResult.value.videoFileId = hasSelectedVideo.fileId
|
||||
identifyResult.value.sessionId = res.data.sessionId
|
||||
identifyResult.value.faceId = res.data.faceId || ''
|
||||
identifyResult.value.faceStartTime = res.data.startTime || 0
|
||||
identifyResult.value.faceEndTime = res.data.endTime || 0
|
||||
} else {
|
||||
const file = hasUploadFile!
|
||||
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 {
|
||||
// 封面提取失败不影响主流程
|
||||
}
|
||||
|
||||
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
|
||||
const fileId = await upload(file, {
|
||||
fileCategory: 'video',
|
||||
groupId: null,
|
||||
coverBase64,
|
||||
onStart: function() {},
|
||||
onProgress: function() {},
|
||||
onSuccess: function() {},
|
||||
onError: function(err: Error) {
|
||||
message.error(err.message || '上传失败')
|
||||
}
|
||||
})
|
||||
|
||||
const durationSec = faceDuration.value / 1000
|
||||
const suggestedMaxChars = Math.floor(durationSec * 3.5)
|
||||
message.success(`识别完成!人脸出现时长约 ${durationSec.toFixed(1)} 秒,建议文案不超过 ${suggestedMaxChars} 字`)
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '识别失败')
|
||||
throw error
|
||||
} finally {
|
||||
identifyState.value.identifying = false
|
||||
identifyResult.value.videoFileId = fileId
|
||||
// 上传后需要再调用识别接口获取人脸信息
|
||||
// 暂时清空,等待后续识别
|
||||
identifyResult.value.sessionId = ''
|
||||
identifyResult.value.faceId = ''
|
||||
identifyResult.value.faceStartTime = 0
|
||||
identifyResult.value.faceEndTime = 0
|
||||
}
|
||||
|
||||
return { ...identifyResult.value }
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 重置视频状态
|
||||
*/
|
||||
const resetVideoState = (): void => {
|
||||
function resetVideoState(): void {
|
||||
videoState.value.uploadedVideo = ''
|
||||
videoState.value.videoFile = null
|
||||
videoState.value.selectedVideo = null
|
||||
videoState.value.fileId = null
|
||||
videoState.value.videoSource = null
|
||||
videoState.value.previewVideoUrl = ''
|
||||
videoState.value.selectorVisible = false
|
||||
|
||||
resetIdentifyState()
|
||||
resetIdentifyResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频预览 URL
|
||||
*/
|
||||
const getVideoPreviewUrl = (video: Video): string => {
|
||||
function getVideoPreviewUrl(video: Video): string {
|
||||
if (video.coverBase64) {
|
||||
if (!video.coverBase64.startsWith('data:')) {
|
||||
return `data:image/jpeg;base64,${video.coverBase64}`
|
||||
}
|
||||
return video.coverBase64
|
||||
return video.coverBase64.startsWith('data:')
|
||||
? video.coverBase64
|
||||
: `data:image/jpeg;base64,${video.coverBase64}`
|
||||
}
|
||||
|
||||
if (video.previewUrl) {
|
||||
return video.previewUrl
|
||||
}
|
||||
|
||||
if (video.coverUrl) {
|
||||
return video.coverUrl
|
||||
}
|
||||
|
||||
if (video.previewUrl) return video.previewUrl
|
||||
if (video.coverUrl) return video.coverUrl
|
||||
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjExMCIgdmlld0JveD0iMCAwIDIwMCAxMTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTEwIiBmaWxsPSIjMzc0MTUxIi8+CjxwYXRoIGQ9Ik04NSA0NUwxMTUgNjVMMTA1IDg1TDc1IDc1TDg1IDQ1WiIgZmlsbD0iIzU3MjY1MSIvPgo8L3N2Zz4K'
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置识别状态
|
||||
*/
|
||||
const resetIdentifyState = (): void => {
|
||||
identifyState.value.identified = false
|
||||
identifyState.value.sessionId = ''
|
||||
identifyState.value.faceId = ''
|
||||
identifyState.value.videoFileId = null
|
||||
function resetIdentifyResult(): void {
|
||||
identifyResult.value.sessionId = ''
|
||||
identifyResult.value.faceId = ''
|
||||
identifyResult.value.videoFileId = null
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式状态
|
||||
videoState,
|
||||
identifyState,
|
||||
|
||||
// 计算属性
|
||||
identifyResult,
|
||||
hasVideo,
|
||||
isIdentified,
|
||||
faceDuration,
|
||||
|
||||
// 方法
|
||||
handleFileUpload,
|
||||
handleVideoSelect,
|
||||
performFaceRecognition,
|
||||
resetVideoState,
|
||||
resetIdentifyState,
|
||||
resetIdentifyResult,
|
||||
getVideoPreviewUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,416 +1,344 @@
|
||||
/**
|
||||
* @fileoverview useIdentifyFaceController Hook - 主控制器 Hook
|
||||
* @author Claude Code
|
||||
* @fileoverview useIdentifyFaceController Hook - 主控制器(重构版)
|
||||
*
|
||||
* 设计理念:
|
||||
* - 所有操作统一通过 Pipeline 状态机
|
||||
* - 移除独立的 identifyState,使用 pipeline 状态
|
||||
* - 点击"生成配音" → 运行到 ready 状态
|
||||
* - 点击"生成数字人视频" → 从 ready 继续 → completed
|
||||
*
|
||||
* 模块依赖关系:
|
||||
* ┌─────────────────────────────────────────────────┐
|
||||
* │ useIdentifyFaceController │
|
||||
* │ ┌──────────────┐ ┌──────────────┐ ┌───────────┐│
|
||||
* │ │ Voice │ │ Digital │ │ Pipeline ││
|
||||
* │ │ Generation │ │ Human │ │ ││
|
||||
* │ │ │ │ Generation │ │ 状态机 ││
|
||||
* │ └──────────────┘ └──────────────┘ └───────────┘│
|
||||
* └─────────────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type {
|
||||
UseIdentifyFaceController,
|
||||
LipSyncTaskData,
|
||||
MaterialValidation,
|
||||
VoiceMeta,
|
||||
} from '../types/identify-face'
|
||||
// @ts-ignore
|
||||
import { createLipSyncTask } from '@/api/kling'
|
||||
|
||||
// 导入子 Hooks
|
||||
import { useVoiceGeneration } from './useVoiceGeneration'
|
||||
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 {
|
||||
// ==================== 创建子 Hooks 并解构 ====================
|
||||
export function useIdentifyFaceController() {
|
||||
// 子 Hooks
|
||||
const voice = useVoiceGeneration()
|
||||
const digitalHuman = useDigitalHumanGeneration()
|
||||
|
||||
// 1. 语音生成 Hook - 解构响应式变量
|
||||
const {
|
||||
ttsText,
|
||||
speechRate,
|
||||
selectedVoiceMeta,
|
||||
audioState,
|
||||
canGenerateAudio,
|
||||
suggestedMaxChars,
|
||||
generateAudio,
|
||||
resetAudioState,
|
||||
} = useVoiceGeneration()
|
||||
|
||||
// 2. 数字人生成 Hook - 解构响应式变量
|
||||
const {
|
||||
videoState,
|
||||
identifyState,
|
||||
faceDuration,
|
||||
performFaceRecognition,
|
||||
handleFileUpload,
|
||||
handleVideoSelect: _handleVideoSelect,
|
||||
getVideoPreviewUrl,
|
||||
resetVideoState,
|
||||
resetIdentifyState,
|
||||
} = useDigitalHumanGeneration()
|
||||
|
||||
// 3. Controller 统一管理跨 Hook 的状态
|
||||
const materialValidation = ref<MaterialValidation>({
|
||||
videoDuration: 0,
|
||||
audioDuration: 0,
|
||||
isValid: false,
|
||||
showDetails: false,
|
||||
// Pipeline 流程配置
|
||||
const pipeline = useSimplePipeline({
|
||||
uploadVideo: async (_file: File) => {
|
||||
// 上传已经在 handleFileUpload 中处理
|
||||
return digitalHuman.identifyResult.value.videoFileId || ''
|
||||
},
|
||||
recognizeFromLibrary: async (video: any) => {
|
||||
await digitalHuman.handleVideoSelect(video)
|
||||
const result = await digitalHuman.performFaceRecognition()
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
faceId: result.faceId,
|
||||
startTime: result.faceStartTime,
|
||||
endTime: result.faceEndTime,
|
||||
duration: digitalHuman.faceDuration.value,
|
||||
}
|
||||
},
|
||||
recognizeUploaded: async (_fileId: string | number) => {
|
||||
const result = await digitalHuman.performFaceRecognition()
|
||||
return {
|
||||
sessionId: result.sessionId,
|
||||
faceId: result.faceId,
|
||||
startTime: result.faceStartTime,
|
||||
endTime: result.faceEndTime,
|
||||
duration: digitalHuman.faceDuration.value,
|
||||
}
|
||||
},
|
||||
generateAudio: async (text: string, voiceMeta: any, speechRate: number) => {
|
||||
voice.ttsText.value = text
|
||||
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(() => {
|
||||
const hasText = ttsText.value.trim()
|
||||
const hasVoice = selectedVoiceMeta.value
|
||||
const hasVideo = videoState.value.uploadedVideo || videoState.value.selectedVideo
|
||||
/** 是否可以生成数字人视频 */
|
||||
const canGenerate = computed((): boolean => {
|
||||
// Pipeline 运行中禁用
|
||||
if (pipeline.isBusy.value) return false
|
||||
|
||||
// 音频校验:只有生成过音频后才需要校验通过
|
||||
const audioValidated = !audioState.value.generated || audioState.value.validationPassed
|
||||
// 素材校验:只有进行过校验后才需要校验通过
|
||||
const materialValidated = materialValidation.value.videoDuration === 0 || materialValidation.value.isValid
|
||||
const hasText = voice.ttsText.value.trim()
|
||||
const hasVoice = voice.selectedVoiceMeta.value
|
||||
const hasVideo = digitalHuman.videoState.value.uploadedVideo || digitalHuman.videoState.value.selectedVideo
|
||||
const hasBasicConfig = hasText && hasVoice && hasVideo
|
||||
|
||||
return !!(hasText && hasVoice && hasVideo && audioValidated && materialValidated)
|
||||
// 未识别或未到 ready 状态需要基础配置
|
||||
if (!pipeline.isReady.value) return !!hasBasicConfig
|
||||
|
||||
// 已到 ready 状态可以生成
|
||||
return true
|
||||
})
|
||||
|
||||
/**
|
||||
* 最大的文本长度
|
||||
*/
|
||||
/** 最大文本长度(根据人脸时长动态计算) */
|
||||
const maxTextLength = computed(() => {
|
||||
if (!identifyState.value.identified || faceDuration.value <= 0) {
|
||||
return 4000
|
||||
}
|
||||
return Math.min(4000, Math.floor(suggestedMaxChars.value * 1.2))
|
||||
const faceDuration = digitalHuman.faceDuration.value
|
||||
if (faceDuration <= 0) return MAX_TEXT_LENGTH
|
||||
return Math.min(MAX_TEXT_LENGTH, Math.floor(voice.suggestedMaxChars.value * 1.2))
|
||||
})
|
||||
|
||||
/**
|
||||
* 文本框占位符
|
||||
*/
|
||||
/** 文本框占位符提示 */
|
||||
const textareaPlaceholder = computed(() => {
|
||||
if (identifyState.value.identified && faceDuration.value > 0) {
|
||||
return `请输入文案,建议不超过${suggestedMaxChars.value}字以确保与视频匹配`
|
||||
const faceDuration = digitalHuman.faceDuration.value
|
||||
if (faceDuration > 0) {
|
||||
return `请输入文案,建议不超过${voice.suggestedMaxChars.value}字以确保与视频匹配`
|
||||
}
|
||||
return '请输入你想让角色说话的内容'
|
||||
})
|
||||
|
||||
/**
|
||||
* 语速标记
|
||||
*/
|
||||
const speechRateMarks = { 0.5: '0.5x', 1: '1x', 1.5: '1.5x', 2: '2x' }
|
||||
/** 语速显示文本 */
|
||||
const speechRateDisplay = computed(() => `${voice.speechRate.value.toFixed(1)}x`)
|
||||
|
||||
/** 人脸时长显示(秒) */
|
||||
const faceDurationSec = computed(() => (digitalHuman.faceDuration.value / 1000).toFixed(1))
|
||||
|
||||
/** 音频时长显示(秒) */
|
||||
const audioDurationSec = computed(() => (voice.audioState.value.durationMs / 1000).toFixed(1))
|
||||
|
||||
/** 音频播放 URL */
|
||||
const audioUrl = computed(() => {
|
||||
const audio = voice.audioState.value.generated
|
||||
if (!audio) return ''
|
||||
return audio.audioBase64 ? `data:audio/mp3;base64,${audio.audioBase64}` : audio.audioUrl || ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 语速显示
|
||||
* 校验是否通过
|
||||
* 规则:音频时长 <= 人脸时长
|
||||
*/
|
||||
const speechRateDisplay = computed(() => `${speechRate.value.toFixed(1)}x`)
|
||||
const validationPassed = computed(() => {
|
||||
const faceDuration = digitalHuman.faceDuration.value
|
||||
const audioDuration = voice.audioState.value.durationMs
|
||||
return audioDuration <= faceDuration
|
||||
})
|
||||
|
||||
// ==================== 业务流程方法 ====================
|
||||
// ==================== 业务方法 ====================
|
||||
|
||||
/**
|
||||
* 生成数字人视频
|
||||
* 重置所有状态
|
||||
*/
|
||||
const generateDigitalHuman = async (): Promise<void> => {
|
||||
function resetAllStates(): void {
|
||||
voice.resetAudioState()
|
||||
digitalHuman.resetVideoState()
|
||||
pipeline.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成配音 - 运行 Pipeline 到 ready 状态
|
||||
*/
|
||||
async function generateAudio(): Promise<void> {
|
||||
const hasVideo = digitalHuman.videoState.value.uploadedVideo || digitalHuman.videoState.value.selectedVideo
|
||||
const hasText = voice.ttsText.value.trim()
|
||||
const hasVoice = voice.selectedVoiceMeta.value
|
||||
|
||||
if (!hasText) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasVoice) {
|
||||
message.warning('请选择音色')
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasVideo) {
|
||||
message.warning('请先选择视频')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 运行流程到 ready 状态(包含识别、生成、校验)
|
||||
await pipeline.run({
|
||||
videoFile: digitalHuman.videoState.value.videoFile,
|
||||
selectedVideo: digitalHuman.videoState.value.selectedVideo,
|
||||
text: voice.ttsText.value,
|
||||
voice: voice.selectedVoiceMeta.value,
|
||||
speechRate: voice.speechRate.value,
|
||||
})
|
||||
} catch {
|
||||
// 错误已在 Pipeline 中处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数字人视频 - 从 ready 状态继续到 completed
|
||||
*/
|
||||
async function generateDigitalHuman(): Promise<void> {
|
||||
if (!canGenerate.value) {
|
||||
message.warning('请先完成配置')
|
||||
return
|
||||
}
|
||||
|
||||
const text = ttsText.value.trim()
|
||||
const text = voice.ttsText.value.trim()
|
||||
const voiceMeta = voice.selectedVoiceMeta.value
|
||||
|
||||
if (!text) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
}
|
||||
|
||||
const voice = selectedVoiceMeta.value
|
||||
if (!voice) {
|
||||
if (!voiceMeta) {
|
||||
message.warning('请选择音色')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果未识别,先进行人脸识别
|
||||
if (!identifyState.value.identified) {
|
||||
const hasUploadFile = videoState.value.videoFile
|
||||
const hasSelectedVideo = videoState.value.selectedVideo
|
||||
|
||||
if (!hasUploadFile && !hasSelectedVideo) {
|
||||
message.warning('请先选择或上传视频')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await performFaceRecognition()
|
||||
message.success('人脸识别完成')
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
// 如果还没到 ready 状态,先运行到 ready
|
||||
if (!pipeline.isReady.value) {
|
||||
await pipeline.run({
|
||||
videoFile: digitalHuman.videoState.value.videoFile,
|
||||
selectedVideo: digitalHuman.videoState.value.selectedVideo,
|
||||
text,
|
||||
voice: voiceMeta,
|
||||
speechRate: voice.speechRate.value,
|
||||
})
|
||||
}
|
||||
|
||||
const videoFileId = identifyState.value.videoFileId
|
||||
|
||||
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),
|
||||
// 如果到达 ready 状态,创建任务
|
||||
if (pipeline.isReady.value) {
|
||||
await pipeline.createTask()
|
||||
// 任务提交成功后,重置所有状态
|
||||
resetAllStates()
|
||||
}
|
||||
|
||||
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 || '任务提交失败')
|
||||
} catch {
|
||||
// 错误已在 Pipeline 中处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更换视频
|
||||
*/
|
||||
const replaceVideo = (): void => {
|
||||
if (videoState.value.videoSource === 'upload') {
|
||||
videoState.value.videoFile = null
|
||||
videoState.value.uploadedVideo = ''
|
||||
} else {
|
||||
videoState.value.selectedVideo = null
|
||||
videoState.value.videoFile = null
|
||||
videoState.value.uploadedVideo = ''
|
||||
}
|
||||
|
||||
// 重置所有状态
|
||||
resetVideoState()
|
||||
resetAudioState()
|
||||
function replaceVideo(): void {
|
||||
digitalHuman.resetVideoState()
|
||||
voice.resetAudioState()
|
||||
pipeline.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理音色选择
|
||||
*/
|
||||
const handleVoiceSelect = (voice: any): void => {
|
||||
selectedVoiceMeta.value = voice
|
||||
// ==================== 事件处理方法 ====================
|
||||
|
||||
function handleVoiceSelect(voiceMeta: VoiceMeta): void {
|
||||
voice.selectedVoiceMeta.value = voiceMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理文件选择
|
||||
*/
|
||||
const handleFileSelect = (event: Event): void => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
function handleFileSelect(event: Event): void {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (file) digitalHuman.handleFileUpload(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理拖拽上传
|
||||
*/
|
||||
const handleDrop = (event: DragEvent): void => {
|
||||
function handleDrop(event: DragEvent): void {
|
||||
event.preventDefault()
|
||||
const file = event.dataTransfer?.files[0]
|
||||
if (file) {
|
||||
handleFileUpload(file)
|
||||
}
|
||||
if (file) digitalHuman.handleFileUpload(file)
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发文件选择
|
||||
*/
|
||||
const triggerFileSelect = (): void => {
|
||||
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fileInput?.click()
|
||||
function triggerFileSelect(): void {
|
||||
document.querySelector<HTMLInputElement>('input[type="file"]')?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择上传模式
|
||||
*/
|
||||
const handleSelectUpload = (): void => {
|
||||
videoState.value.videoSource = 'upload'
|
||||
videoState.value.selectedVideo = null
|
||||
resetIdentifyState()
|
||||
function handleSelectUpload(): void {
|
||||
digitalHuman.videoState.value.videoSource = 'upload'
|
||||
digitalHuman.videoState.value.selectedVideo = null
|
||||
digitalHuman.resetIdentifyResult()
|
||||
pipeline.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从素材库选择
|
||||
*/
|
||||
const handleSelectFromLibrary = (): void => {
|
||||
videoState.value.videoSource = 'select'
|
||||
videoState.value.videoFile = null
|
||||
videoState.value.uploadedVideo = ''
|
||||
videoState.value.selectorVisible = true
|
||||
function handleSelectFromLibrary(): void {
|
||||
digitalHuman.videoState.value.videoSource = 'select'
|
||||
digitalHuman.videoState.value.videoFile = null
|
||||
digitalHuman.videoState.value.uploadedVideo = ''
|
||||
digitalHuman.videoState.value.selectorVisible = true
|
||||
pipeline.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理视频选择器选择
|
||||
*/
|
||||
const handleVideoSelect = (video: any): void => {
|
||||
_handleVideoSelect(video)
|
||||
async function handleVideoSelect(video: any): Promise<void> {
|
||||
await digitalHuman.handleVideoSelect(video)
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化文案
|
||||
*/
|
||||
const handleSimplifyScript = (): void => {
|
||||
const textarea = document.querySelector('.tts-textarea textarea') as HTMLTextAreaElement
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
textarea.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
function handleVideoLoaded(videoUrl: string): void {
|
||||
digitalHuman.videoState.value.previewVideoUrl = videoUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理视频加载
|
||||
*/
|
||||
const handleVideoLoaded = (videoUrl: string): void => {
|
||||
videoState.value.previewVideoUrl = videoUrl
|
||||
}
|
||||
// ==================== UI 工具方法 ====================
|
||||
|
||||
// ==================== UI 辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 格式化时长
|
||||
*/
|
||||
const formatDuration = (seconds: number): string => {
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '--:--'
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${String(minutes).padStart(2, '0')}:${String(remainingSeconds).padStart(2, '0')}`
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let size = bytes
|
||||
let unitIndex = 0
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
let idx = 0
|
||||
while (size >= 1024 && idx < units.length - 1) {
|
||||
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 => {
|
||||
materialValidation.value.videoDuration = videoDurationMs
|
||||
materialValidation.value.audioDuration = audioDurationMs
|
||||
materialValidation.value.isValid = videoDurationMs > audioDurationMs
|
||||
|
||||
if (!materialValidation.value.isValid) {
|
||||
const videoSec = (videoDurationMs / 1000).toFixed(1)
|
||||
const audioSec = (audioDurationMs / 1000).toFixed(1)
|
||||
message.warning(`素材校验失败:视频时长(${videoSec}s)必须大于音频时长(${audioSec}s)`)
|
||||
}
|
||||
|
||||
return materialValidation.value.isValid
|
||||
}
|
||||
// ==================== 返回接口 ====================
|
||||
|
||||
return {
|
||||
// ==================== 语音生成相关 ====================
|
||||
ttsText,
|
||||
speechRate,
|
||||
selectedVoiceMeta,
|
||||
audioState,
|
||||
canGenerateAudio,
|
||||
suggestedMaxChars,
|
||||
// 语音生成模块
|
||||
ttsText: voice.ttsText,
|
||||
speechRate: voice.speechRate,
|
||||
selectedVoiceMeta: voice.selectedVoiceMeta,
|
||||
audioState: voice.audioState,
|
||||
canGenerateAudio: voice.canGenerateAudio,
|
||||
suggestedMaxChars: voice.suggestedMaxChars,
|
||||
generateAudio,
|
||||
resetAudioState,
|
||||
resetAudioState: voice.resetAudioState,
|
||||
|
||||
// ==================== 数字人生成相关 ====================
|
||||
videoState,
|
||||
identifyState,
|
||||
materialValidation,
|
||||
faceDuration,
|
||||
performFaceRecognition,
|
||||
handleFileUpload,
|
||||
getVideoPreviewUrl,
|
||||
resetVideoState,
|
||||
resetIdentifyState,
|
||||
resetMaterialValidation,
|
||||
validateMaterialDuration,
|
||||
// 数字人生成模块
|
||||
videoState: digitalHuman.videoState,
|
||||
identifyResult: digitalHuman.identifyResult,
|
||||
isIdentified: digitalHuman.isIdentified,
|
||||
faceDuration: digitalHuman.faceDuration,
|
||||
handleFileUpload: digitalHuman.handleFileUpload,
|
||||
getVideoPreviewUrl: digitalHuman.getVideoPreviewUrl,
|
||||
resetVideoState: digitalHuman.resetVideoState,
|
||||
resetIdentifyResult: digitalHuman.resetIdentifyResult,
|
||||
|
||||
// ==================== 业务流程方法 ====================
|
||||
// 业务方法
|
||||
generateDigitalHuman,
|
||||
replaceVideo,
|
||||
|
||||
// ==================== 事件处理方法 ====================
|
||||
// 事件处理
|
||||
handleVoiceSelect,
|
||||
handleFileSelect,
|
||||
handleDrop,
|
||||
@@ -418,26 +346,35 @@ export function useIdentifyFaceController(): UseIdentifyFaceController {
|
||||
handleSelectUpload,
|
||||
handleSelectFromLibrary,
|
||||
handleVideoSelect,
|
||||
handleSimplifyScript,
|
||||
handleVideoLoaded,
|
||||
|
||||
// ==================== UI 辅助方法 ====================
|
||||
// UI 工具
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
// 计算属性
|
||||
canGenerate,
|
||||
maxTextLength,
|
||||
textareaPlaceholder,
|
||||
speechRateMarks,
|
||||
speechRateMarks: SPEECH_RATE_MARKS,
|
||||
speechRateDisplay,
|
||||
faceDurationSec,
|
||||
audioDurationSec,
|
||||
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,
|
||||
retryPipeline: pipeline.retry,
|
||||
resetPipeline: pipeline.reset,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串中提取ID
|
||||
*/
|
||||
function extractIdFromString(str: string): string {
|
||||
const match = str.match(/[\w-]+$/)
|
||||
return match ? match[0] : str
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* @fileoverview useVoiceGeneration Hook - 语音生成逻辑封装
|
||||
* @author Claude Code
|
||||
* @fileoverview useVoiceGeneration Hook - 语音生成逻辑
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
@@ -11,59 +10,41 @@ import type {
|
||||
VoiceMeta,
|
||||
AudioData,
|
||||
} from '../types/identify-face'
|
||||
// @ts-ignore
|
||||
import { VoiceService } from '@/api/voice'
|
||||
import { DEFAULT_VOICE_PROVIDER } from '@/config/voiceConfig'
|
||||
|
||||
/**
|
||||
* 语音生成 Hook
|
||||
* 独立管理所有状态,不依赖外部状态
|
||||
*/
|
||||
export function useVoiceGeneration(): UseVoiceGeneration {
|
||||
// ==================== 响应式状态 ====================
|
||||
// ========== 常量 ==========
|
||||
const DEFAULT_MAX_TEXT_LENGTH = 4000
|
||||
const DEFAULT_SPEECH_RATE = 1.0
|
||||
|
||||
export function useVoiceGeneration(): UseVoiceGeneration {
|
||||
// ========== 状态 ==========
|
||||
const ttsText = ref<string>('')
|
||||
const speechRate = ref<number>(1.0)
|
||||
const speechRate = ref<number>(DEFAULT_SPEECH_RATE)
|
||||
const selectedVoiceMeta = ref<VoiceMeta | null>(null)
|
||||
const audioState = ref<AudioState>({
|
||||
generated: null,
|
||||
durationMs: 0,
|
||||
validationPassed: false,
|
||||
generating: false,
|
||||
})
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
|
||||
/**
|
||||
* 是否可以生成配音
|
||||
*/
|
||||
const canGenerateAudio = computed(() => {
|
||||
const hasText = ttsText.value.trim()
|
||||
const hasVoice = selectedVoiceMeta.value
|
||||
const hasVideo = true // 语音生成不依赖视频状态
|
||||
return !!(hasText && hasVoice && hasVideo && !audioState.value.generating)
|
||||
// ========== 计算属性 ==========
|
||||
const canGenerateAudio = computed(function() {
|
||||
return !!(ttsText.value.trim() && selectedVoiceMeta.value && !audioState.value.generating)
|
||||
})
|
||||
|
||||
/**
|
||||
* 建议的最大字符数(需要从外部传入)
|
||||
*/
|
||||
const suggestedMaxChars = computed(() => {
|
||||
// 默认为 4000,需要从外部设置
|
||||
return 4000
|
||||
const suggestedMaxChars = computed(function() {
|
||||
return DEFAULT_MAX_TEXT_LENGTH
|
||||
})
|
||||
|
||||
// ==================== 核心方法 ====================
|
||||
// ========== 方法 ==========
|
||||
|
||||
/**
|
||||
* 生成配音
|
||||
*/
|
||||
const generateAudio = async (): Promise<void> => {
|
||||
async function generateAudio(): Promise<void> {
|
||||
const voice = selectedVoiceMeta.value
|
||||
if (!voice) {
|
||||
message.warning('请选择音色')
|
||||
return
|
||||
}
|
||||
|
||||
if (!ttsText.value.trim()) {
|
||||
message.warning('请输入文案内容')
|
||||
return
|
||||
@@ -74,8 +55,8 @@ export function useVoiceGeneration(): UseVoiceGeneration {
|
||||
try {
|
||||
const params = {
|
||||
inputText: ttsText.value,
|
||||
voiceConfigId: voice.rawId || extractIdFromString(voice.id),
|
||||
speechRate: speechRate.value || 1.0,
|
||||
voiceConfigId: voice.rawId ?? extractIdFromString(voice.id),
|
||||
speechRate: speechRate.value,
|
||||
audioFormat: 'mp3' as const,
|
||||
providerType: DEFAULT_VOICE_PROVIDER,
|
||||
}
|
||||
@@ -84,144 +65,104 @@ export function useVoiceGeneration(): UseVoiceGeneration {
|
||||
|
||||
if (res.code === 0) {
|
||||
const audioData = res.data as AudioData
|
||||
|
||||
if (!audioData.audioBase64) {
|
||||
throw new Error('未收到音频数据,无法进行时长解析')
|
||||
throw new Error('未收到音频数据')
|
||||
}
|
||||
|
||||
audioState.value.generated = audioData
|
||||
|
||||
try {
|
||||
// 解析音频时长
|
||||
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
|
||||
}
|
||||
audioState.value.durationMs = await parseAudioDuration(audioData.audioBase64)
|
||||
message.success('配音生成成功!')
|
||||
} else {
|
||||
throw new Error(res.msg || '配音生成失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error.message || '配音生成失败')
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error
|
||||
message.error(err.message || '配音生成失败')
|
||||
audioState.value.generated = null
|
||||
audioState.value.durationMs = 0
|
||||
} finally {
|
||||
audioState.value.generating = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析音频时长
|
||||
* 解析音频时长(浏览器环境)
|
||||
* 使用 HTML5 Audio API,添加安全边距避免精度误差
|
||||
*/
|
||||
const parseAudioDuration = async (base64Data: string): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data
|
||||
async function parseAudioDuration(base64Data: string): Promise<number> {
|
||||
const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data
|
||||
const binaryString = window.atob(base64)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
}
|
||||
|
||||
const binaryString = window.atob(base64)
|
||||
const bytes = new Uint8Array(binaryString.length)
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i)
|
||||
return new Promise<number>(function(resolve, reject) {
|
||||
const blob = new Blob([bytes], { type: 'audio/mp3' })
|
||||
const audio = new Audio()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
const timeoutId = setTimeout(function() {
|
||||
cleanup()
|
||||
reject(new Error('音频时长解析超时'))
|
||||
}, 10000)
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeoutId)
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata)
|
||||
audio.removeEventListener('error', onError)
|
||||
audio.removeEventListener('canplay', onLoadedMetadata)
|
||||
}
|
||||
|
||||
function onLoadedMetadata() {
|
||||
const duration = audio.duration
|
||||
if (!isFinite(duration) || duration <= 0) {
|
||||
cleanup()
|
||||
reject(new Error(`音频时长无效: ${duration}`))
|
||||
return
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: 'audio/mp3' })
|
||||
const audio = new Audio()
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
|
||||
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)
|
||||
// 减去安全边距(200ms),避免因解析误差导致 sound_end_time 超过实际音频时长
|
||||
const durationMs = Math.floor(duration * 1000) - 200
|
||||
const rawDurationMs = Math.floor(duration * 1000)
|
||||
console.log('[parseAudioDuration] 解析成功:', durationMs, 'ms (原始:', rawDurationMs, 'ms)')
|
||||
cleanup()
|
||||
resolve(durationMs)
|
||||
}
|
||||
|
||||
function onError() {
|
||||
cleanup()
|
||||
reject(new Error('音频解析失败,请检查音频格式'))
|
||||
}
|
||||
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata)
|
||||
audio.addEventListener('error', onError)
|
||||
audio.addEventListener('canplay', onLoadedMetadata, { once: true })
|
||||
audio.src = objectUrl
|
||||
audio.load()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证音频与人脸区间的重合时长(外部调用时传入校验参数)
|
||||
*/
|
||||
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 => {
|
||||
function resetAudioState(): void {
|
||||
audioState.value.generated = null
|
||||
audioState.value.durationMs = 0
|
||||
audioState.value.validationPassed = false
|
||||
audioState.value.generating = false
|
||||
}
|
||||
|
||||
return {
|
||||
// 响应式状态
|
||||
ttsText,
|
||||
speechRate,
|
||||
selectedVoiceMeta,
|
||||
audioState,
|
||||
|
||||
// 计算属性
|
||||
canGenerateAudio,
|
||||
suggestedMaxChars,
|
||||
|
||||
// 方法
|
||||
generateAudio,
|
||||
parseAudioDuration,
|
||||
validateAudioDuration,
|
||||
resetAudioState,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从字符串中提取ID
|
||||
*/
|
||||
function extractIdFromString(str: string): string {
|
||||
// 尝试从各种格式中提取ID
|
||||
const match = str.match(/[\w-]+$/)
|
||||
return match ? match[0] : str
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface VideoState {
|
||||
videoFile: File | null
|
||||
previewVideoUrl: string
|
||||
selectedVideo: Video | null
|
||||
fileId: string | number | null
|
||||
videoSource: 'upload' | 'select' | null
|
||||
selectorVisible: boolean
|
||||
}
|
||||
@@ -20,6 +21,7 @@ export interface VideoState {
|
||||
*/
|
||||
export interface Video {
|
||||
id: string | number
|
||||
fileId: string | number
|
||||
fileName: string
|
||||
fileUrl: string
|
||||
fileSize: number
|
||||
@@ -42,13 +44,23 @@ export interface IdentifyState {
|
||||
videoFileId: string | number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 人脸识别结果接口(不包含状态标志)
|
||||
*/
|
||||
export interface IdentifyResult {
|
||||
sessionId: string
|
||||
faceId: string
|
||||
faceStartTime: number
|
||||
faceEndTime: number
|
||||
videoFileId: string | number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 音频状态接口
|
||||
*/
|
||||
export interface AudioState {
|
||||
generated: AudioData | null
|
||||
durationMs: number
|
||||
validationPassed: boolean
|
||||
generating: boolean
|
||||
}
|
||||
|
||||
@@ -61,16 +73,6 @@ export interface AudioData {
|
||||
format?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材校验接口
|
||||
*/
|
||||
export interface MaterialValidation {
|
||||
videoDuration: number
|
||||
audioDuration: number
|
||||
isValid: boolean
|
||||
showDetails: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 音色元数据接口
|
||||
*/
|
||||
@@ -97,8 +99,6 @@ export interface UseVoiceGeneration {
|
||||
|
||||
// 方法
|
||||
generateAudio: () => Promise<void>
|
||||
parseAudioDuration: (base64Data: string) => Promise<number>
|
||||
validateAudioDuration: () => boolean
|
||||
resetAudioState: () => void
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ export interface UseDigitalHumanGeneration {
|
||||
|
||||
// 方法
|
||||
handleFileUpload: (file: File) => Promise<void>
|
||||
handleVideoSelect: (video: Video) => void
|
||||
handleVideoSelect: (video: Video) => Promise<void>
|
||||
performFaceRecognition: () => Promise<void>
|
||||
resetVideoState: () => void
|
||||
resetIdentifyState: () => void
|
||||
@@ -140,29 +140,25 @@ export interface UseIdentifyFaceController {
|
||||
// ==================== 数字人生成相关 ====================
|
||||
videoState: import('vue').Ref<VideoState>
|
||||
identifyState: import('vue').Ref<IdentifyState>
|
||||
materialValidation: import('vue').Ref<MaterialValidation>
|
||||
faceDuration: import('vue').ComputedRef<number>
|
||||
performFaceRecognition: () => Promise<void>
|
||||
handleFileUpload: (file: File) => Promise<void>
|
||||
getVideoPreviewUrl: (video: Video) => string
|
||||
resetVideoState: () => void
|
||||
resetIdentifyState: () => void
|
||||
resetMaterialValidation: () => void
|
||||
validateMaterialDuration: (videoDurationMs: number, audioDurationMs: number) => boolean
|
||||
|
||||
// ==================== 业务流程方法 ====================
|
||||
generateDigitalHuman: () => Promise<void>
|
||||
replaceVideo: () => void
|
||||
|
||||
// ==================== 事件处理方法 ====================
|
||||
handleVoiceSelect: (voice: VoiceMeta) => void
|
||||
handleVoiceSelect: (voiceMeta: VoiceMeta) => void
|
||||
handleFileSelect: (event: Event) => void
|
||||
handleDrop: (event: DragEvent) => void
|
||||
triggerFileSelect: () => void
|
||||
handleSelectUpload: () => void
|
||||
handleSelectFromLibrary: () => void
|
||||
handleVideoSelect: (video: Video) => void
|
||||
handleSimplifyScript: () => void
|
||||
handleVideoSelect: (video: Video) => Promise<void>
|
||||
handleVideoLoaded: (videoUrl: string) => void
|
||||
|
||||
// ==================== 计算属性 ====================
|
||||
@@ -171,6 +167,32 @@ export interface UseIdentifyFaceController {
|
||||
textareaPlaceholder: import('vue').ComputedRef<string>
|
||||
speechRateMarks: Record<number, 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 辅助方法 ====================
|
||||
formatDuration: (seconds: number) => string
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
>
|
||||
<template #action>
|
||||
<a-space>
|
||||
|
||||
|
||||
<a-popconfirm
|
||||
title="确定要删除选中的任务吗?删除后无法恢复。"
|
||||
@confirm="handleBatchDelete"
|
||||
@@ -124,19 +124,7 @@
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<!-- 预览按钮 -->
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handlePreview(record)"
|
||||
class="action-btn-preview"
|
||||
>
|
||||
<template #icon>
|
||||
<PlayCircleOutlined />
|
||||
</template>
|
||||
预览
|
||||
</a-button>
|
||||
|
||||
<!-- 下载按钮 -->
|
||||
<a-button
|
||||
v-if="isStatus(record.status, 'success')"
|
||||
@@ -171,23 +159,6 @@
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</div>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<a-modal
|
||||
v-model:open="previewVisible"
|
||||
title="视频预览"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<video
|
||||
v-if="previewUrl"
|
||||
:src="previewUrl"
|
||||
controls
|
||||
style="width: 100%; max-height: 600px"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -242,10 +213,6 @@ useTaskPolling(getDigitalHumanTaskPage, {
|
||||
}
|
||||
})
|
||||
|
||||
// 预览相关
|
||||
const previewVisible = ref(false)
|
||||
const previewUrl = ref('')
|
||||
|
||||
// 表格选择相关
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
@@ -333,78 +300,17 @@ const isStatus = (status, targetStatus) => {
|
||||
return status === targetStatus || status === targetStatus.toUpperCase()
|
||||
}
|
||||
|
||||
// 预览视频
|
||||
const handlePreview = (record) => {
|
||||
if (!record.resultVideoUrl) {
|
||||
message.warning('该任务暂无视频结果,请稍后再试')
|
||||
return
|
||||
}
|
||||
|
||||
previewUrl.value = record.resultVideoUrl
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
// 下载视频
|
||||
// 下载视频 - 新窗口打开(浏览器自动处理下载)
|
||||
const handleDownload = (record) => {
|
||||
console.log(record)
|
||||
if (!record.resultVideoUrl) {
|
||||
message.warning('该任务暂无视频结果,请稍后再试')
|
||||
return
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = record.resultVideoUrl
|
||||
link.download = `数字人视频_${record.id}_${Date.now()}.mp4`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 批量下载视频
|
||||
const handleBatchDownload = () => {
|
||||
// 获取选中的已完成任务
|
||||
const selectedTasks = list.value.filter(task =>
|
||||
selectedRowKeys.value.includes(task.id) &&
|
||||
isStatus(task.status, 'success')
|
||||
)
|
||||
|
||||
if (selectedTasks.length === 0) {
|
||||
message.warning('请选择已完成的任务')
|
||||
return
|
||||
}
|
||||
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
|
||||
// 逐个直接下载
|
||||
for (const task of selectedTasks) {
|
||||
if (!task.resultVideoUrl) {
|
||||
failCount++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const link = document.createElement('a')
|
||||
link.href = task.resultVideoUrl
|
||||
link.download = `数字人视频_${task.id}_${Date.now()}.mp4`
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`下载任务 ${task.id} 失败:`, error)
|
||||
failCount++
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
message.success(`已触发 ${successCount} 个文件的下载`)
|
||||
} else if (successCount === 0) {
|
||||
message.error('所有文件下载失败,请重试')
|
||||
} else {
|
||||
message.warning(`成功下载 ${successCount} 个文件,${failCount} 个文件下载失败`)
|
||||
}
|
||||
window.open(record.resultVideoUrl, '_blank')
|
||||
}
|
||||
|
||||
// 批量删除任务
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.0.6",
|
||||
"aplayer": "^1.10.1",
|
||||
"axios": "^1.12.2",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"localforage": "^1.10.0",
|
||||
|
||||
@@ -445,12 +445,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVideoPlayUrl(Long infraFileId) {
|
||||
public String getVideoPlayUrl(Long fileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件(根据 infraFileId 字段查询)
|
||||
// 查询文件(根据 fileId 字段查询)
|
||||
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eq(TikUserFileDO::getFileId, infraFileId)
|
||||
.eq(TikUserFileDO::getFileId, fileId)
|
||||
.eq(TikUserFileDO::getUserId, userId));
|
||||
|
||||
if (file == null) {
|
||||
@@ -473,12 +473,12 @@ public class TikUserFileServiceImpl implements TikUserFileService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAudioPlayUrl(Long infraFileId) {
|
||||
public String getAudioPlayUrl(Long fileId) {
|
||||
Long userId = SecurityFrameworkUtils.getLoginUserId();
|
||||
|
||||
// 查询文件(根据 infraFileId 字段查询)
|
||||
// 查询文件(根据 fileId 字段查询)
|
||||
TikUserFileDO file = userFileMapper.selectOne(new LambdaQueryWrapperX<TikUserFileDO>()
|
||||
.eq(TikUserFileDO::getFileId, infraFileId)
|
||||
.eq(TikUserFileDO::getFileId, fileId)
|
||||
.eq(TikUserFileDO::getUserId, userId));
|
||||
|
||||
if (file == null) {
|
||||
|
||||
@@ -14,9 +14,12 @@ import java.time.LocalDateTime;
|
||||
@Data
|
||||
public class AppTikUserFileRespVO {
|
||||
|
||||
@Schema(description = "文件编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@Schema(description = "文件编号(主键)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "素材文件编号(关联 infra_file.id,用于获取播放URL)", example = "100")
|
||||
private Long fileId;
|
||||
|
||||
@Schema(description = "文件名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "test.mp4")
|
||||
private String fileName;
|
||||
|
||||
|
||||
@@ -52,34 +52,12 @@ public class KlingClient {
|
||||
validateRequest(request);
|
||||
|
||||
Map<String, Object> payload = buildPayload(request);
|
||||
try {
|
||||
String body = objectMapper.writeValueAsString(payload);
|
||||
String url = properties.getBaseUrl() + "/klingai/v1/videos/identify-face";
|
||||
String url = properties.getBaseUrl() + "/klingai/v1/videos/identify-face";
|
||||
Request httpRequest = buildPostRequest(url, payload);
|
||||
|
||||
Request httpRequest = 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();
|
||||
|
||||
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);
|
||||
}
|
||||
KlingIdentifyFaceResponse response = executeRequest(httpRequest, "identify-face", KlingIdentifyFaceResponse.class);
|
||||
validateSessionId(response.getData() != null ? response.getData().getSessionId() : null, "sessionId");
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,35 +67,13 @@ public class KlingClient {
|
||||
validateEnabled();
|
||||
validateLipSyncRequest(request);
|
||||
|
||||
try {
|
||||
String body = objectMapper.writeValueAsString(request);
|
||||
log.info("[Kling][create-lip-sync请求体] {}", body);
|
||||
String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync";
|
||||
String url = properties.getBaseUrl() + "/klingai/v1/videos/advanced-lip-sync";
|
||||
Request httpRequest = buildPostRequest(url, request);
|
||||
log.info("[Kling][create-lip-sync请求体] {}", request);
|
||||
|
||||
Request httpRequest = 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();
|
||||
|
||||
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);
|
||||
}
|
||||
KlingLipSyncCreateResponse response = executeRequest(httpRequest, "create-lip-sync", KlingLipSyncCreateResponse.class);
|
||||
validateSessionId(response.getData() != null ? response.getData().getTaskId() : null, "taskId");
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,29 +85,10 @@ public class KlingClient {
|
||||
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()
|
||||
.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);
|
||||
}
|
||||
return executeRequest(httpRequest, "get-lip-sync", KlingLipSyncQueryResponse.class);
|
||||
}
|
||||
|
||||
private void validateEnabled() {
|
||||
@@ -272,10 +209,39 @@ public class KlingClient {
|
||||
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);
|
||||
|
||||
return objectMapper.readValue(responseBody, responseClass);
|
||||
return result;
|
||||
} catch (Exception ex) {
|
||||
if (ex instanceof ServiceException) {
|
||||
throw (ServiceException) ex;
|
||||
}
|
||||
log.error("[Kling][{} exception]", operation, ex);
|
||||
throw exception(LATENTSYNC_SUBMIT_FAILED);
|
||||
}
|
||||
@@ -319,4 +285,43 @@ public class KlingClient {
|
||||
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 + " 为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,6 @@ public class SiliconFlowProvider implements VoiceCloneProvider {
|
||||
sfRequest.setText(getOrDefault(request.getTranscriptionText(), config.getPreviewText()));
|
||||
sfRequest.setAudio(AUDIO_MIME_TYPE + base64Audio);
|
||||
|
||||
// 调用上传参考音频 API
|
||||
String url = config.getBaseUrl() + config.getVoiceUploadUrl();
|
||||
String requestBody = JSONUtil.toJsonStr(sfRequest);
|
||||
log.debug("[SiliconFlowProvider][请求体]{}", requestBody);
|
||||
@@ -67,7 +66,7 @@ public class SiliconFlowProvider implements VoiceCloneProvider {
|
||||
.header("Authorization", "Bearer " + config.getApiKey())
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(requestBody)
|
||||
.timeout((int) config.getConnectTimeout().toMillis())
|
||||
.timeout((int) config.getReadTimeout().toMillis())
|
||||
.execute();
|
||||
|
||||
String responseBody = response.body();
|
||||
@@ -116,14 +115,13 @@ public class SiliconFlowProvider implements VoiceCloneProvider {
|
||||
|
||||
try {
|
||||
SiliconFlowTtsRequest sfRequest = SiliconFlowTtsRequest.builder()
|
||||
.model(getOrDefault(request.getModel(), config.getDefaultModel()))
|
||||
.model(getOrDefault(request.getModel(), getOrDefault(config.getDefaultModel(), "IndexTeam/IndexTTS-2")))
|
||||
.input(request.getText())
|
||||
.voice(request.getVoiceId())
|
||||
.speed(request.getSpeechRate() != null ? request.getSpeechRate() : 1.0f)
|
||||
.responseFormat(getOrDefault(request.getAudioFormat(), config.getAudioFormat()))
|
||||
.build();
|
||||
|
||||
// 调用文本转语音 API
|
||||
String url = config.getBaseUrl() + config.getTtsUrl();
|
||||
String requestBody = JSONUtil.toJsonStr(sfRequest);
|
||||
log.debug("[SiliconFlowProvider][请求体]{}", requestBody);
|
||||
@@ -142,7 +140,6 @@ public class SiliconFlowProvider implements VoiceCloneProvider {
|
||||
throw new RuntimeException("硅基流动文本转语音失败: " + errorBody);
|
||||
}
|
||||
|
||||
// 硅基流动直接返回二进制音频数据
|
||||
byte[] audioBytes = response.bodyBytes();
|
||||
String base64Audio = Base64.getEncoder().encodeToString(audioBytes);
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ public class SiliconFlowTtsRequest {
|
||||
*/
|
||||
private Float speed;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 响应格式(mp3, opus, wav, pcm)(API 参数名:response_format)
|
||||
*/
|
||||
|
||||
@@ -32,8 +32,11 @@ public class SiliconFlowProviderConfig extends VoiceProviderProperties.ProviderC
|
||||
|
||||
/**
|
||||
* 默认采样率
|
||||
* <p>mp3: 32000, 44100 (默认 44100)</p>
|
||||
* <p>opus: 48000</p>
|
||||
* <p>wav/pcm: 8000, 16000, 24000, 32000, 44100 (默认 44100)</p>
|
||||
*/
|
||||
private Integer sampleRate = 24000;
|
||||
private Integer sampleRate = 44100;
|
||||
|
||||
/**
|
||||
* 默认音频格式
|
||||
@@ -61,9 +64,9 @@ public class SiliconFlowProviderConfig extends VoiceProviderProperties.ProviderC
|
||||
private Duration connectTimeout = Duration.ofSeconds(10);
|
||||
|
||||
/**
|
||||
* 读取超时时间(3分钟,提升语音合成成功率)
|
||||
* 读取超时时间(5分钟,提升语音合成成功率)
|
||||
*/
|
||||
private Duration readTimeout = Duration.ofSeconds(180);
|
||||
private Duration readTimeout = Duration.ofSeconds(300);
|
||||
|
||||
/**
|
||||
* 检查是否可用(有 API Key 即可用)
|
||||
|
||||
@@ -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.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.KeySequence;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
@@ -163,6 +164,7 @@ public class TikDigitalHumanTaskDO extends TenantBaseDO {
|
||||
/**
|
||||
* 可灵口型同步任务ID(从advanced-lip-sync接口获取)
|
||||
*/
|
||||
@TableField("kling_task_id")
|
||||
private String klingTaskId;
|
||||
|
||||
}
|
||||
|
||||
@@ -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.mybatis.core.mapper.BaseMapperX;
|
||||
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.vo.AppTikDigitalHumanPageReqVO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -62,4 +64,20 @@ public interface TikDigitalHumanTaskMapper extends BaseMapperX<TikDigitalHumanTa
|
||||
.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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ public class DigitalHumanTaskStatusSyncJob {
|
||||
*/
|
||||
@Scheduled(fixedDelay = 10000)
|
||||
public void syncTaskStatus() {
|
||||
log.debug("开始同步数字人任务状态");
|
||||
log.info("[DigitalHumanTaskStatusSyncJob][开始同步数字人任务状态]");
|
||||
try {
|
||||
latentsyncPollingService.pollLatentsyncTasks();
|
||||
} catch (Exception e) {
|
||||
log.error("同步数字人任务状态失败", e);
|
||||
log.error("[DigitalHumanTaskStatusSyncJob][同步数字人任务状态失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -425,12 +425,6 @@ public class DigitalHumanTaskServiceImpl implements DigitalHumanTaskService {
|
||||
// 设置当前步骤描述
|
||||
respVO.setCurrentStepDesc(DigitalHumanTaskStepEnum.getDesc(task.getCurrentStep()));
|
||||
|
||||
// 对 resultVideoUrl 进行预签名处理
|
||||
if (StrUtil.isNotBlank(task.getResultVideoUrl())) {
|
||||
String presignedUrl = fileApi.presignGetUrl(task.getResultVideoUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
respVO.setResultVideoUrl(presignedUrl);
|
||||
}
|
||||
|
||||
return respVO;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
package cn.iocoder.yudao.module.tik.voice.service;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpRequest;
|
||||
import cn.hutool.http.HttpResponse;
|
||||
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
|
||||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
|
||||
import cn.iocoder.yudao.module.infra.service.file.FileConfigService;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO;
|
||||
import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper;
|
||||
import cn.iocoder.yudao.module.tik.file.service.TikOssInitService;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO;
|
||||
import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper;
|
||||
import cn.iocoder.yudao.module.tik.voice.vo.AppTikLatentsyncResultRespVO;
|
||||
@@ -30,7 +20,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Latentsync任务轮询服务 - 轻量化异步处理
|
||||
* 数字人任务轮询服务
|
||||
* 使用@TenantIgnore忽略租户检查,因为轮询服务没有用户上下文
|
||||
*
|
||||
* @author 芋道源码
|
||||
@@ -44,31 +34,22 @@ public class LatentsyncPollingService {
|
||||
private final TikDigitalHumanTaskMapper taskMapper;
|
||||
private final LatentsyncService latentsyncService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final TikOssInitService ossInitService;
|
||||
private final cn.iocoder.yudao.module.infra.api.file.FileApi fileApi;
|
||||
private final TikUserFileMapper userFileMapper;
|
||||
private final FileMapper fileMapper;
|
||||
private final FileConfigService fileConfigService;
|
||||
private final KlingService klingService;
|
||||
|
||||
/**
|
||||
* Redis键前缀
|
||||
*/
|
||||
// ========== 常量 ==========
|
||||
private static final String REDIS_POLLING_PREFIX = "latentsync:polling:";
|
||||
private static final String REDIS_POLLING_TASKS_SET = "latentsync:polling:tasks";
|
||||
private static final String REDIS_POLLING_COUNT_PREFIX = "latentsync:polling:count:";
|
||||
private static final String REDIS_RESULT_PREFIX = "digital_human:task:result:";
|
||||
|
||||
/**
|
||||
* 轮询配置
|
||||
*/
|
||||
private static final int MAX_POLLING_COUNT = 90; // 最多轮询90次(15分钟)
|
||||
private static final int POLLING_INTERVAL_SECONDS = 10; // 轮询间隔10秒
|
||||
private static final Duration CACHE_EXPIRE_TIME = Duration.ofHours(1); // 缓存1小时
|
||||
private static final Duration CACHE_EXPIRE_TIME = Duration.ofHours(1);
|
||||
private static final Duration RESULT_CACHE_TIME = Duration.ofHours(24);
|
||||
|
||||
// ========== 公开方法 ==========
|
||||
|
||||
/**
|
||||
* 定时轮询Latentsync任务状态 - 每10秒执行一次
|
||||
* 移除了分布式锁,通过查询条件和限制避免并发问题
|
||||
* 注意:此方法现在由 DigitalHumanTaskStatusSyncJob 定时调用,不在服务内部使用 @Scheduled 注解
|
||||
* 定时轮询任务状态入口
|
||||
*/
|
||||
public void pollLatentsyncTasks() {
|
||||
try {
|
||||
@@ -78,48 +59,15 @@ public class LatentsyncPollingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行轮询任务的具体逻辑
|
||||
*/
|
||||
private void executePollingTasks() {
|
||||
try {
|
||||
// 轮询Latentsync任务
|
||||
List<String> taskIds = getPendingPollingTasks();
|
||||
if (!taskIds.isEmpty()) {
|
||||
log.debug("[pollLatentsyncTasks][开始轮询Latentsync任务][任务数量={}]", taskIds.size());
|
||||
|
||||
// 逐个处理Latentsync任务
|
||||
for (String taskIdStr : taskIds) {
|
||||
try {
|
||||
Long taskId = Long.parseLong(taskIdStr);
|
||||
pollSingleTask(taskId);
|
||||
} catch (Exception e) {
|
||||
log.error("[pollLatentsyncTasks][轮询Latentsync任务失败][taskId={}]", taskIdStr, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询可灵任务
|
||||
pollKlingTasks();
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollLatentsyncTasks][轮询任务异常]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到轮询队列
|
||||
*/
|
||||
public void addTaskToPollingQueue(Long taskId, String requestId) {
|
||||
try {
|
||||
// 存储任务信息
|
||||
String taskKey = REDIS_POLLING_PREFIX + requestId;
|
||||
stringRedisTemplate.opsForValue().set(taskKey, taskId.toString(), CACHE_EXPIRE_TIME);
|
||||
|
||||
// 添加到待轮询集合
|
||||
stringRedisTemplate.opsForZSet().add(REDIS_POLLING_TASKS_SET, requestId, System.currentTimeMillis());
|
||||
|
||||
// 初始化轮询次数
|
||||
String countKey = REDIS_POLLING_COUNT_PREFIX + requestId;
|
||||
stringRedisTemplate.opsForValue().set(countKey, "0", CACHE_EXPIRE_TIME);
|
||||
|
||||
@@ -130,184 +78,210 @@ public class LatentsyncPollingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个任务轮询
|
||||
* 清理过期任务(由定时任务调用)
|
||||
*/
|
||||
public void cleanupExpiredTasks() {
|
||||
try {
|
||||
stringRedisTemplate.delete(REDIS_POLLING_TASKS_SET);
|
||||
} catch (Exception e) {
|
||||
log.error("[cleanupExpiredTasks][清理过期任务异常]", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
/**
|
||||
* 执行轮询任务的具体逻辑
|
||||
*/
|
||||
private void executePollingTasks() {
|
||||
// 轮询 Latentsync 任务
|
||||
List<String> taskIds = getPendingPollingTasks();
|
||||
for (String taskIdStr : taskIds) {
|
||||
try {
|
||||
pollSingleTask(Long.parseLong(taskIdStr));
|
||||
} catch (Exception e) {
|
||||
log.error("[executePollingTasks][轮询任务失败][taskId={}]", taskIdStr, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询 Kling 任务
|
||||
pollKlingTasks();
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询单个 Latentsync 任务
|
||||
*/
|
||||
private void pollSingleTask(Long taskId) {
|
||||
// 获取任务的requestId和轮询次数(在try块外声明,供catch块使用)
|
||||
String requestId = null;
|
||||
String countKey = null;
|
||||
int currentCount = 0;
|
||||
|
||||
try {
|
||||
// 获取任务的requestId
|
||||
String taskKey = REDIS_POLLING_PREFIX + "task_" + taskId;
|
||||
requestId = stringRedisTemplate.opsForValue().get(taskKey);
|
||||
|
||||
if (StrUtil.isBlank(requestId)) {
|
||||
// 如果没有requestId,说明任务可能已完成或已取消,从轮询队列中移除
|
||||
removeFromPollingQueue(taskId, null);
|
||||
removeFromPollingQueue(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查轮询次数
|
||||
countKey = REDIS_POLLING_COUNT_PREFIX + requestId;
|
||||
String countKey = REDIS_POLLING_COUNT_PREFIX + requestId;
|
||||
String countStr = stringRedisTemplate.opsForValue().get(countKey);
|
||||
currentCount = countStr != null ? Integer.parseInt(countStr) : 0;
|
||||
|
||||
if (currentCount >= MAX_POLLING_COUNT) {
|
||||
// 超时,标记任务失败
|
||||
log.warn("[pollSingleTask][任务轮询超时][taskId={}, requestId={}, count={}]", taskId, requestId, currentCount);
|
||||
markTaskFailed(taskId, "Latentsync处理超时");
|
||||
removeFromPollingQueue(taskId, requestId);
|
||||
markTaskFailed(taskId, "处理超时");
|
||||
removeFromPollingQueue(requestId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用Latentsync API检查状态
|
||||
// 查询任务状态
|
||||
AppTikLatentsyncResultRespVO result = latentsyncService.getTaskResult(requestId);
|
||||
String status = result.getStatus();
|
||||
|
||||
log.debug("[pollSingleTask][轮询任务状态][taskId={}, requestId={}, status={}]", taskId, requestId, status);
|
||||
|
||||
// 检查任务状态
|
||||
if ("COMPLETED".equals(status)) {
|
||||
// 任务完成
|
||||
handleTaskCompleted(taskId, result.getVideo().getUrl(), requestId);
|
||||
completeTask(taskId, result.getVideo().getUrl(), requestId);
|
||||
} else if ("FAILED".equals(status) || "ERROR".equals(status)) {
|
||||
// 任务失败
|
||||
handleTaskFailed(taskId, "Latentsync处理失败: " + status, requestId);
|
||||
markTaskFailed(taskId, "处理失败: " + status);
|
||||
removeFromPollingQueue(requestId);
|
||||
} else {
|
||||
// 继续轮询,更新轮询次数
|
||||
stringRedisTemplate.opsForValue().set(countKey, String.valueOf(currentCount + 1), CACHE_EXPIRE_TIME);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollSingleTask][轮询任务异常][taskId={}]", taskId, e);
|
||||
|
||||
// 轮询异常处理:增加轮询次数,避免无限重试
|
||||
int errorCount = currentCount + 1;
|
||||
if (errorCount >= MAX_POLLING_COUNT) {
|
||||
// 达到最大次数,标记任务失败
|
||||
log.warn("[pollSingleTask][任务轮询异常次数过多,标记失败][taskId={}, count={}]", taskId, errorCount);
|
||||
if (requestId != null && StrUtil.isNotBlank(requestId)) {
|
||||
markTaskFailed(taskId, "Latentsync API调用异常:" + e.getMessage());
|
||||
removeFromPollingQueue(taskId, requestId);
|
||||
}
|
||||
markTaskFailed(taskId, "API调用异常");
|
||||
removeFromPollingQueue(requestId);
|
||||
} else {
|
||||
// 更新轮询次数,继续重试
|
||||
if (countKey != null) {
|
||||
stringRedisTemplate.opsForValue().set(countKey, String.valueOf(errorCount), CACHE_EXPIRE_TIME);
|
||||
log.debug("[pollSingleTask][轮询异常,增加次数后继续重试][taskId={}, count={}]", taskId, errorCount);
|
||||
}
|
||||
String countKey = REDIS_POLLING_COUNT_PREFIX + requestId;
|
||||
stringRedisTemplate.opsForValue().set(countKey, String.valueOf(errorCount), CACHE_EXPIRE_TIME);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理任务完成
|
||||
* 轮询 Kling 任务
|
||||
*/
|
||||
private void pollKlingTasks() {
|
||||
try {
|
||||
List<TikDigitalHumanTaskDO> klingTasks = TenantUtils.executeIgnore(
|
||||
() -> taskMapper.selectPendingKlingTasks()
|
||||
);
|
||||
|
||||
for (TikDigitalHumanTaskDO task : klingTasks) {
|
||||
try {
|
||||
pollKlingSingleTask(task);
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingTasks][轮询任务失败][taskId={}]", task.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingTasks][轮询Kling任务异常]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询单个 Kling 任务
|
||||
*/
|
||||
private void pollKlingSingleTask(TikDigitalHumanTaskDO task) {
|
||||
String klingTaskId = task.getKlingTaskId();
|
||||
if (StrUtil.isBlank(klingTaskId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
KlingLipSyncQueryResponse response = klingService.getLipSyncTask(klingTaskId);
|
||||
String taskStatus = response.getData().getTaskStatus();
|
||||
|
||||
if ("succeed".equalsIgnoreCase(taskStatus)) {
|
||||
List<KlingLipSyncVideoVO> videos = response.getData().getTaskResult().getVideos();
|
||||
if (videos != null && !videos.isEmpty()) {
|
||||
completeTask(task.getId(), videos.get(0).getUrl(), null);
|
||||
}
|
||||
} else if ("failed".equalsIgnoreCase(taskStatus)) {
|
||||
String errorMsg = "可灵任务执行失败: " + response.getData().getTaskStatusMsg();
|
||||
updateTaskStatus(task.getId(), "FAILED", task.getCurrentStep(), task.getProgress(), errorMsg);
|
||||
} else if ("submitted".equalsIgnoreCase(taskStatus) || "processing".equalsIgnoreCase(taskStatus)) {
|
||||
updateTaskStatus(task.getId(), "PROCESSING", "sync_lip", 70, null);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingSingleTask][任务({})查询失败]", task.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成任务(通用方法)
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void handleTaskCompleted(Long taskId, String videoUrl, String requestId) {
|
||||
TikDigitalHumanTaskDO task = null;
|
||||
private void completeTask(Long taskId, String videoUrl, String requestId) {
|
||||
try {
|
||||
// 获取任务信息
|
||||
task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
log.error("[handleTaskCompleted][任务不存在][taskId={}]", taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存视频到OSS(异步处理,轻量化逻辑)
|
||||
OssSaveResult saveResult = null;
|
||||
try {
|
||||
// 保存视频到OSS,避免临时URL过期
|
||||
saveResult = saveVideoToOss(task, videoUrl);
|
||||
log.info("[handleTaskCompleted][任务({})视频已保存到OSS][url={}]", taskId, saveResult.getUrl());
|
||||
} catch (Exception e) {
|
||||
log.warn("[handleTaskCompleted][任务({})保存视频失败,使用原URL][error={}]", taskId, e.getMessage());
|
||||
saveResult = new OssSaveResult(videoUrl, 0, null, null); // 降级处理
|
||||
}
|
||||
|
||||
// 更新任务状态为成功
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
updateObj.setStatus("SUCCESS");
|
||||
updateObj.setCurrentStep("finishing");
|
||||
updateObj.setProgress(100);
|
||||
updateObj.setResultVideoUrl(saveResult.getUrl());
|
||||
updateObj.setResultVideoUrl(videoUrl);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
taskMapper.updateById(updateObj);
|
||||
|
||||
// 缓存结果到Redis(快速回显)
|
||||
try {
|
||||
String resultKey = "digital_human:task:result:" + taskId;
|
||||
stringRedisTemplate.opsForValue().set(resultKey, saveResult.getUrl(), Duration.ofHours(24));
|
||||
} catch (Exception e) {
|
||||
log.warn("[handleTaskCompleted][任务({})缓存结果失败]", taskId, e);
|
||||
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
|
||||
|
||||
// 缓存结果
|
||||
String resultKey = REDIS_RESULT_PREFIX + taskId;
|
||||
stringRedisTemplate.opsForValue().set(resultKey, videoUrl, RESULT_CACHE_TIME);
|
||||
|
||||
if (requestId != null) {
|
||||
removeFromPollingQueue(requestId);
|
||||
}
|
||||
|
||||
// 保存结果视频到用户文件表(这样用户可以在素材库中查看)
|
||||
saveResultVideoToUserFiles(task, saveResult);
|
||||
|
||||
// 从轮询队列中移除
|
||||
removeFromPollingQueue(taskId, requestId);
|
||||
|
||||
log.info("[handleTaskCompleted][任务完成][taskId={}, requestId={}]", taskId, requestId);
|
||||
log.info("[completeTask][任务完成][taskId={}, videoUrl={}]", taskId, videoUrl);
|
||||
} catch (Exception e) {
|
||||
log.error("[handleTaskCompleted][处理任务完成失败][taskId={}]", taskId, e);
|
||||
log.error("[completeTask][处理任务完成失败][taskId={}]", taskId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理任务失败
|
||||
* 标记任务失败
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void handleTaskFailed(Long taskId, String errorMessage, String requestId) {
|
||||
private void markTaskFailed(Long taskId, String errorMessage) {
|
||||
try {
|
||||
// 更新任务状态为失败
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
updateObj.setStatus("FAILED");
|
||||
updateObj.setErrorMessage(errorMessage);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
taskMapper.updateById(updateObj);
|
||||
|
||||
// 从轮询队列中移除
|
||||
removeFromPollingQueue(taskId, requestId);
|
||||
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
|
||||
|
||||
log.warn("[handleTaskFailed][任务失败][taskId={}, requestId={}, error={}]", taskId, requestId, errorMessage);
|
||||
log.warn("[markTaskFailed][任务失败][taskId={}, error={}]", taskId, errorMessage);
|
||||
} catch (Exception e) {
|
||||
log.error("[handleTaskFailed][处理任务失败失败][taskId={}]", taskId, e);
|
||||
log.error("[markTaskFailed][标记任务失败失败][taskId={}]", taskId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记任务失败(内部使用)
|
||||
* 更新任务状态
|
||||
*/
|
||||
private void markTaskFailed(Long taskId, String errorMessage) {
|
||||
handleTaskFailed(taskId, errorMessage, null);
|
||||
}
|
||||
private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress, String errorMessage) {
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
updateObj.setStatus(status);
|
||||
updateObj.setCurrentStep(currentStep);
|
||||
updateObj.setProgress(progress);
|
||||
|
||||
/**
|
||||
* 从轮询队列中移除任务
|
||||
*/
|
||||
private void removeFromPollingQueue(Long taskId, String requestId) {
|
||||
try {
|
||||
if (StrUtil.isNotBlank(requestId)) {
|
||||
// 移除具体任务
|
||||
String taskKey = REDIS_POLLING_PREFIX + requestId;
|
||||
stringRedisTemplate.delete(taskKey);
|
||||
|
||||
String countKey = REDIS_POLLING_COUNT_PREFIX + requestId;
|
||||
stringRedisTemplate.delete(countKey);
|
||||
|
||||
stringRedisTemplate.opsForZSet().remove(REDIS_POLLING_TASKS_SET, requestId);
|
||||
} else {
|
||||
// 尝试通过taskId找到requestId并移除
|
||||
String pattern = REDIS_POLLING_PREFIX + "task_" + taskId;
|
||||
// 这里可以优化为直接查询,但为了简单起见,先不实现
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[removeFromPollingQueue][移除任务失败][taskId={}, requestId={}]", taskId, requestId, e);
|
||||
if ("SUCCESS".equals(status)) {
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
} else if ("PROCESSING".equals(status)) {
|
||||
updateObj.setStartTime(LocalDateTime.now());
|
||||
} else if ("FAILED".equals(status)) {
|
||||
updateObj.setErrorMessage(errorMessage);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
}
|
||||
|
||||
TenantUtils.executeIgnore(() -> taskMapper.updateById(updateObj));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -315,8 +289,6 @@ public class LatentsyncPollingService {
|
||||
*/
|
||||
private List<String> getPendingPollingTasks() {
|
||||
try {
|
||||
// 获取所有待轮询的任务
|
||||
// 注意:这里返回的是requestId,不是taskId
|
||||
List<String> requestIds = stringRedisTemplate.opsForZSet()
|
||||
.range(REDIS_POLLING_TASKS_SET, 0, -1)
|
||||
.stream()
|
||||
@@ -326,12 +298,8 @@ public class LatentsyncPollingService {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 将requestId转换为taskId
|
||||
return requestIds.stream()
|
||||
.map(requestId -> {
|
||||
String taskKey = REDIS_POLLING_PREFIX + requestId;
|
||||
return stringRedisTemplate.opsForValue().get(taskKey);
|
||||
})
|
||||
.map(requestId -> stringRedisTemplate.opsForValue().get(REDIS_POLLING_PREFIX + requestId))
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.toList();
|
||||
} catch (Exception e) {
|
||||
@@ -341,353 +309,18 @@ public class LatentsyncPollingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期任务(每天凌晨2点执行)
|
||||
* 注意:此方法现在由外部调度器调用,不在服务内部使用 @Scheduled 注解
|
||||
* 从轮询队列中移除任务
|
||||
*/
|
||||
public void cleanupExpiredTasks() {
|
||||
private void removeFromPollingQueue(String requestId) {
|
||||
try {
|
||||
log.info("[cleanupExpiredTasks][开始清理过期轮询任务]");
|
||||
|
||||
// 清理过期的轮询记录
|
||||
stringRedisTemplate.delete(REDIS_POLLING_TASKS_SET);
|
||||
|
||||
// 可以添加更多清理逻辑
|
||||
|
||||
log.info("[cleanupExpiredTasks][清理完成]");
|
||||
if (StrUtil.isNotBlank(requestId)) {
|
||||
stringRedisTemplate.delete(REDIS_POLLING_PREFIX + requestId);
|
||||
stringRedisTemplate.delete(REDIS_POLLING_COUNT_PREFIX + requestId);
|
||||
stringRedisTemplate.opsForZSet().remove(REDIS_POLLING_TASKS_SET, requestId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[cleanupExpiredTasks][清理过期任务异常]", e);
|
||||
log.error("[removeFromPollingQueue][移除任务失败][requestId={}]", requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存视频到OSS - 直接保存到 infra_file 避免重复
|
||||
* 返回保存结果,包含URL、文件大小和文件ID
|
||||
*/
|
||||
private OssSaveResult saveVideoToOss(TikDigitalHumanTaskDO task, String remoteVideoUrl) throws Exception {
|
||||
log.info("[saveVideoToOss][任务({})开始下载并保存视频到OSS][remoteUrl={}]", task.getId(), remoteVideoUrl);
|
||||
|
||||
try {
|
||||
// 1. 下载远程视频文件
|
||||
byte[] videoBytes = downloadRemoteFile(remoteVideoUrl);
|
||||
|
||||
// 2. 内存检查:超过50MB记录警告
|
||||
int sizeMB = videoBytes.length / 1024 / 1024;
|
||||
if (sizeMB > 50) {
|
||||
log.warn("[saveVideoToOss][任务({})视频文件较大][size={}MB]", task.getId(), sizeMB);
|
||||
}
|
||||
|
||||
// 3. 获取OSS目录和文件名
|
||||
Long userId = task.getUserId();
|
||||
String baseDirectory = ossInitService.getOssDirectoryByCategory(userId, "generate");
|
||||
String fileName = String.format("数字人视频_%d_%d.mp4", task.getId(), System.currentTimeMillis());
|
||||
|
||||
// 4. 获取FileClient并上传到OSS
|
||||
FileClient client = fileConfigService.getMasterFileClient();
|
||||
if (client == null) {
|
||||
throw new Exception("获取FileClient失败");
|
||||
}
|
||||
|
||||
// 5. 生成上传路径(包含日期前缀和时间戳后缀)
|
||||
String filePath = generateUploadPath(fileName, baseDirectory);
|
||||
|
||||
// 6. 上传到OSS
|
||||
String presignedUrl = client.upload(videoBytes, filePath, "video/mp4");
|
||||
|
||||
// 7. 移除预签名参数,获取基础URL
|
||||
String cleanUrl = HttpUtils.removeUrlQuery(presignedUrl);
|
||||
|
||||
// 8. 保存到 infra_file 表
|
||||
FileDO infraFile = new FileDO()
|
||||
.setConfigId(client.getId())
|
||||
.setName(fileName)
|
||||
.setPath(filePath)
|
||||
.setUrl(cleanUrl)
|
||||
.setType("video/mp4")
|
||||
.setSize(videoBytes.length);
|
||||
fileMapper.insert(infraFile);
|
||||
Long infraFileId = infraFile.getId();
|
||||
|
||||
log.info("[saveVideoToOss][任务({})视频保存完成][infraFileId={}, size={}MB]",
|
||||
task.getId(), infraFileId, sizeMB);
|
||||
return new OssSaveResult(cleanUrl, videoBytes.length, filePath, infraFileId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[saveVideoToOss][任务({})保存视频失败][remoteUrl={}]", task.getId(), remoteVideoUrl, e);
|
||||
return new OssSaveResult(remoteVideoUrl, 0, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成上传路径(与 FileService 保持一致)
|
||||
*/
|
||||
private String generateUploadPath(String name, String directory) {
|
||||
String prefix = cn.hutool.core.date.LocalDateTimeUtil.format(
|
||||
cn.hutool.core.date.LocalDateTimeUtil.now(),
|
||||
cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN);
|
||||
String suffix = String.valueOf(System.currentTimeMillis());
|
||||
|
||||
String ext = cn.hutool.core.io.FileUtil.extName(name);
|
||||
if (StrUtil.isNotEmpty(ext)) {
|
||||
name = cn.hutool.core.io.FileUtil.mainName(name) + "_" + suffix + "." + ext;
|
||||
} else {
|
||||
name = name + "_" + suffix;
|
||||
}
|
||||
|
||||
if (StrUtil.isNotEmpty(prefix)) {
|
||||
name = prefix + "/" + name;
|
||||
}
|
||||
if (StrUtil.isNotEmpty(directory)) {
|
||||
name = directory + "/" + name;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* OSS保存结果
|
||||
*/
|
||||
private static class OssSaveResult {
|
||||
private final String url;
|
||||
private final int fileSize;
|
||||
private final String filePath;
|
||||
private final Long infraFileId;
|
||||
|
||||
public OssSaveResult(String url, int fileSize, String filePath, Long infraFileId) {
|
||||
this.url = url;
|
||||
this.fileSize = fileSize;
|
||||
this.filePath = filePath;
|
||||
this.infraFileId = infraFileId;
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public int getFileSize() {
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
public String getFilePath() {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public Long getInfraFileId() {
|
||||
return infraFileId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载远程文件 - 内存优化
|
||||
*/
|
||||
private byte[] downloadRemoteFile(String remoteUrl) throws Exception {
|
||||
log.info("[downloadRemoteFile][下载文件][url={}]", remoteUrl);
|
||||
|
||||
try (HttpResponse response = HttpRequest.get(remoteUrl)
|
||||
.execute()) {
|
||||
|
||||
if (!response.isOk()) {
|
||||
throw new Exception("下载文件失败: HTTP " + response.getStatus());
|
||||
}
|
||||
|
||||
// 流式读取:分块处理避免大文件OOM
|
||||
byte[] bytes = response.bodyBytes();
|
||||
int sizeMB = bytes.length / 1024 / 1024;
|
||||
log.info("[downloadRemoteFile][文件下载完成][size={} bytes, {}MB]", bytes.length, sizeMB);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存结果视频到用户文件表
|
||||
*/
|
||||
private void saveResultVideoToUserFiles(TikDigitalHumanTaskDO task, OssSaveResult saveResult) {
|
||||
try {
|
||||
Long userId = task.getUserId();
|
||||
Long infraFileId = saveResult.getInfraFileId();
|
||||
|
||||
// 验证必要参数
|
||||
if (userId == null || infraFileId == null) {
|
||||
log.warn("[saveResultVideoToUserFiles][任务({})参数不完整,无法保存][userId={}, infraFileId={}]",
|
||||
task.getId(), userId, infraFileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建用户文件记录
|
||||
TikUserFileDO userFile = new TikUserFileDO();
|
||||
userFile.setUserId(userId);
|
||||
userFile.setFileId(infraFileId);
|
||||
userFile.setFileName(String.format("数字人视频_%d_%d.mp4", task.getId(), System.currentTimeMillis()));
|
||||
userFile.setFileType("video/mp4");
|
||||
userFile.setFileCategory("generate");
|
||||
userFile.setFileUrl(saveResult.getUrl());
|
||||
userFile.setFilePath(saveResult.getFilePath());
|
||||
userFile.setFileSize((long) saveResult.getFileSize());
|
||||
|
||||
userFileMapper.insert(userFile);
|
||||
|
||||
log.info("[saveResultVideoToUserFiles][任务({})文件记录已保存][userFileId={}, infraFileId={}]",
|
||||
task.getId(), userFile.getId(), infraFileId);
|
||||
} catch (Exception e) {
|
||||
log.error("[saveResultVideoToUserFiles][任务({})保存失败]", task.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询可灵任务状态
|
||||
*/
|
||||
private void pollKlingTasks() {
|
||||
try {
|
||||
// 参考混剪任务实现:添加时间和数量限制,避免并发问题
|
||||
// 1. 时间范围限制:只检查最近6小时内的任务(避免检查历史任务)
|
||||
// 2. 数量限制:每次最多检查50个任务(避免单次查询过多)
|
||||
LocalDateTime startTime = LocalDateTime.now().minusHours(6);
|
||||
|
||||
// 查询有待轮询的可灵任务(状态为PROCESSING且有klingTaskId,限制时间和数量)
|
||||
List<TikDigitalHumanTaskDO> klingTasks = taskMapper.selectList(
|
||||
new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX<TikDigitalHumanTaskDO>()
|
||||
.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()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("[pollKlingTasks][开始轮询可灵任务][任务数量={}]", klingTasks.size());
|
||||
|
||||
// 逐个处理可灵任务
|
||||
for (TikDigitalHumanTaskDO task : klingTasks) {
|
||||
try {
|
||||
pollKlingSingleTask(task);
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingTasks][轮询可灵任务失败][taskId={}]", task.getId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingTasks][轮询可灵任务异常]", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询单个可灵任务
|
||||
*/
|
||||
private void pollKlingSingleTask(TikDigitalHumanTaskDO task) {
|
||||
String klingTaskId = task.getKlingTaskId();
|
||||
if (StrUtil.isBlank(klingTaskId)) {
|
||||
log.warn("[pollKlingSingleTask][任务({})缺少klingTaskId]", task.getId());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 查询可灵任务状态
|
||||
KlingLipSyncQueryResponse response = klingService.getLipSyncTask(klingTaskId);
|
||||
String taskStatus = response.getData().getTaskStatus();
|
||||
String taskStatusMsg = response.getData().getTaskStatusMsg();
|
||||
|
||||
log.debug("[pollKlingSingleTask][任务({})状态更新][klingTaskId={}, status={}]",
|
||||
task.getId(), klingTaskId, taskStatus);
|
||||
|
||||
// 根据状态更新任务
|
||||
if ("succeed".equalsIgnoreCase(taskStatus)) {
|
||||
// 任务成功完成
|
||||
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)) {
|
||||
// 任务失败
|
||||
String errorMsg = "可灵任务执行失败: " + (StrUtil.isNotBlank(taskStatusMsg) ? taskStatusMsg : "未知错误");
|
||||
updateTaskStatus(task.getId(), "FAILED", task.getCurrentStep(), task.getProgress(), errorMsg, null, errorMsg);
|
||||
log.error("[pollKlingSingleTask][任务({})失败][error={}]", task.getId(), errorMsg);
|
||||
|
||||
} else if ("submitted".equalsIgnoreCase(taskStatus) || "processing".equalsIgnoreCase(taskStatus)) {
|
||||
// 任务还在处理中,更新进度
|
||||
updateTaskStatus(task.getId(), "PROCESSING", "sync_lip", 70, "口型同步处理中", null);
|
||||
log.debug("[pollKlingSingleTask][任务({})处理中]", task.getId());
|
||||
|
||||
} else {
|
||||
log.warn("[pollKlingSingleTask][任务({})未知状态][status={}]", task.getId(), taskStatus);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[pollKlingSingleTask][任务({})查询失败]", task.getId(), e);
|
||||
// 不更新任务状态,避免误判
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态
|
||||
*/
|
||||
private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress,
|
||||
String message, String resultVideoUrl) {
|
||||
updateTaskStatus(taskId, status, currentStep, progress, message, resultVideoUrl, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务状态(带错误详情)
|
||||
*/
|
||||
private void updateTaskStatus(Long taskId, String status, String currentStep, Integer progress,
|
||||
String message, String resultVideoUrl, String errorDetail) {
|
||||
TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO();
|
||||
updateObj.setId(taskId);
|
||||
updateObj.setStatus(status);
|
||||
updateObj.setCurrentStep(currentStep);
|
||||
updateObj.setProgress(progress);
|
||||
|
||||
if ("SUCCESS".equals(status)) {
|
||||
updateObj.setResultVideoUrl(resultVideoUrl);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
} else if ("PROCESSING".equals(status)) {
|
||||
updateObj.setStartTime(LocalDateTime.now());
|
||||
} else if ("FAILED".equals(status)) {
|
||||
updateObj.setErrorMessage(message);
|
||||
updateObj.setErrorDetail(errorDetail);
|
||||
updateObj.setFinishTime(LocalDateTime.now());
|
||||
}
|
||||
|
||||
taskMapper.updateById(updateObj);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.iocoder.yudao.module.tik.voice.service;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.json.JSONArray;
|
||||
@@ -144,34 +145,38 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
.setLanguage(StrUtil.blankToDefault(createReqVO.getLanguage(), "zh-CN"))
|
||||
.setGender(StrUtil.blankToDefault(createReqVO.getGender(), "female"))
|
||||
.setNote(createReqVO.getNote())
|
||||
.setTranscription(null); // 初始为空,表示未识别
|
||||
.setTranscription(createReqVO.getText()); // 使用前端传入的文本
|
||||
voiceMapper.insert(voice);
|
||||
|
||||
// 4. 调用语音克隆服务,生成 voice_id
|
||||
try {
|
||||
log.info("[createVoice][开始语音复刻,配音编号({}),文件ID({}),供应商({})]",
|
||||
voice.getId(), fileDO.getId(), createReqVO.getProviderType());
|
||||
String fileAccessUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
if (StrUtil.isNotBlank(createReqVO.getText())) {
|
||||
try {
|
||||
log.info("[createVoice][开始语音复刻,配音编号({}),文件ID({}),供应商({})]",
|
||||
voice.getId(), fileDO.getId(), createReqVO.getProviderType());
|
||||
String fileAccessUrl = fileApi.presignGetUrl(fileDO.getUrl(), PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
|
||||
VoiceCloneProvider provider = voiceProviderFactory.getProvider(createReqVO.getProviderType());
|
||||
String providerType = getProviderType(createReqVO.getProviderType(), provider);
|
||||
String model = getModelByProvider(providerType);
|
||||
VoiceCloneProvider provider = voiceProviderFactory.getProvider(createReqVO.getProviderType());
|
||||
String providerType = getProviderType(createReqVO.getProviderType(), provider);
|
||||
String model = getModelByProvider(providerType);
|
||||
|
||||
VoiceCloneRequest cloneRequest = new VoiceCloneRequest();
|
||||
cloneRequest.setAudioUrl(fileAccessUrl);
|
||||
cloneRequest.setModel(model);
|
||||
cloneRequest.setPrefix("voice" + voice.getId());
|
||||
cloneRequest.setTranscriptionText(voice.getTranscription());
|
||||
VoiceCloneRequest cloneRequest = new VoiceCloneRequest();
|
||||
cloneRequest.setAudioUrl(fileAccessUrl);
|
||||
cloneRequest.setModel(model);
|
||||
cloneRequest.setPrefix("voice" + voice.getId());
|
||||
cloneRequest.setTranscriptionText(createReqVO.getText()); // 使用前端传入的文本
|
||||
|
||||
VoiceCloneResult cloneResult = provider.cloneVoice(cloneRequest);
|
||||
String voiceId = cloneResult.getVoiceId();
|
||||
VoiceCloneResult cloneResult = provider.cloneVoice(cloneRequest);
|
||||
String voiceId = cloneResult.getVoiceId();
|
||||
|
||||
voice.setVoiceId(voiceId);
|
||||
voiceMapper.updateById(voice);
|
||||
voice.setVoiceId(voiceId);
|
||||
voiceMapper.updateById(voice);
|
||||
|
||||
log.info("[createVoice][语音复刻成功,配音编号({}),voice_id({})]", voice.getId(), voiceId);
|
||||
} catch (Exception e) {
|
||||
log.error("[createVoice][语音复刻失败,配音编号({}),错误信息: {}]", voice.getId(), e.getMessage(), e);
|
||||
log.info("[createVoice][语音复刻成功,配音编号({}),voice_id({})]", voice.getId(), voiceId);
|
||||
} catch (Exception e) {
|
||||
log.error("[createVoice][语音复刻失败,配音编号({}),错误信息: {}]", voice.getId(), e.getMessage(), e);
|
||||
}
|
||||
} else {
|
||||
log.info("[createVoice][未提供文本,跳过语音复刻,配音编号({})]", voice.getId());
|
||||
}
|
||||
|
||||
|
||||
@@ -495,9 +500,9 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
log.info("[previewVoice][试听,voiceConfigId={}, voiceId={}, userId={}]",
|
||||
voiceConfigId, reqVO.getVoiceId(), userId);
|
||||
|
||||
String voiceId = null;
|
||||
String fileUrl = null;
|
||||
String referenceText = null;
|
||||
String voiceId;
|
||||
String fileUrl;
|
||||
String referenceText;
|
||||
|
||||
// 1. 通过语音URL合成
|
||||
if (StrUtil.isNotBlank(reqVO.getFileUrl()) && StrUtil.isNotBlank(reqVO.getTranscriptionText())) {
|
||||
@@ -506,6 +511,7 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
? reqVO.getFileUrl()
|
||||
: fileApi.presignGetUrl(rawFileUrl, PRESIGN_URL_EXPIRATION_SECONDS);
|
||||
referenceText = reqVO.getTranscriptionText();
|
||||
voiceId = null;
|
||||
}
|
||||
// 2. 用户配音
|
||||
else if (voiceConfigId != null) {
|
||||
@@ -514,8 +520,10 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
throw exception(VOICE_NOT_EXISTS, "配音不存在");
|
||||
}
|
||||
|
||||
if (StrUtil.isNotBlank(voice.getVoiceId())) {
|
||||
voiceId = voice.getVoiceId();
|
||||
voiceId = voice.getVoiceId();
|
||||
if (StrUtil.isNotBlank(voiceId)) {
|
||||
fileUrl = null;
|
||||
referenceText = null;
|
||||
} else {
|
||||
FileDO fileDO = fileMapper.selectById(voice.getFileId());
|
||||
if (fileDO == null) {
|
||||
@@ -534,14 +542,14 @@ public class TikUserVoiceServiceImpl implements TikUserVoiceService {
|
||||
if (StrUtil.isBlank(voiceId)) {
|
||||
throw exception(VOICE_NOT_EXISTS, "系统配音音色ID不能为空");
|
||||
}
|
||||
fileUrl = null;
|
||||
referenceText = null;
|
||||
}
|
||||
|
||||
// 统一处理:使用前端传入的 inputText,否则使用默认试听文本
|
||||
String finalText = StrUtil.blankToDefault(reqVO.getInputText(), getPreviewText());
|
||||
|
||||
String instruction = reqVO.getInstruction();
|
||||
Float speechRate = reqVO.getSpeechRate() != null ? reqVO.getSpeechRate() : 1.0f;
|
||||
Float volume = reqVO.getVolume() != null ? reqVO.getVolume() : 0f;
|
||||
Float speechRate = ObjectUtil.defaultIfNull(reqVO.getSpeechRate(), 1.0f);
|
||||
Float volume = ObjectUtil.defaultIfNull(reqVO.getVolume(), 0f);
|
||||
String audioFormat = StrUtil.blankToDefault(reqVO.getAudioFormat(), "mp3");
|
||||
|
||||
// 缓存
|
||||
|
||||
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.tik.voice.vo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,10 @@ public class AppTikUserVoiceCreateReqVO {
|
||||
@Schema(description = "备注", example = "这是一个测试配音")
|
||||
private String note;
|
||||
|
||||
@Schema(description = "音频文本(用于语音复刻,前端通过音频识别获取)")
|
||||
@Size(max = 4000, message = "音频文本不能超过 4000 个字符")
|
||||
private String text;
|
||||
|
||||
@Schema(description = "供应商类型:cosyvoice-阿里云,siliconflow-硅基流动(不传则使用默认)", example = "cosyvoice")
|
||||
private String providerType;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user