diff --git a/frontend/app/web-gold/src/api/kling.js b/frontend/app/web-gold/src/api/kling.js index 7a6d32059c..e2dedc2673 100644 --- a/frontend/app/web-gold/src/api/kling.js +++ b/frontend/app/web-gold/src/api/kling.js @@ -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 } } + diff --git a/frontend/app/web-gold/src/components/PipelineProgress.vue b/frontend/app/web-gold/src/components/PipelineProgress.vue new file mode 100644 index 0000000000..28fe6c1a74 --- /dev/null +++ b/frontend/app/web-gold/src/components/PipelineProgress.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/frontend/app/web-gold/src/composables/useTTS.js b/frontend/app/web-gold/src/composables/useTTS.js index 34a9b02d1c..4a01745d00 100644 --- a/frontend/app/web-gold/src/composables/useTTS.js +++ b/frontend/app/web-gold/src/composables/useTTS.js @@ -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} 音频数据 */ - 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} TTS结果 + * @param {Object} params - TTS 参数 + * @returns {Promise} 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, diff --git a/frontend/app/web-gold/src/views/dh/VoiceCopy.vue b/frontend/app/web-gold/src/views/dh/VoiceCopy.vue index e2de05f5db..03e3c3bbe1 100644 --- a/frontend/app/web-gold/src/views/dh/VoiceCopy.vue +++ b/frontend/app/web-gold/src/views/dh/VoiceCopy.vue @@ -105,15 +105,15 @@ diff --git a/frontend/app/web-gold/src/views/kling/IdentifyFace.vue b/frontend/app/web-gold/src/views/kling/IdentifyFace.vue index d92eb02b1e..d502d347e8 100644 --- a/frontend/app/web-gold/src/views/kling/IdentifyFace.vue +++ b/frontend/app/web-gold/src/views/kling/IdentifyFace.vue @@ -15,10 +15,6 @@ :show-count="true" class="tts-textarea" /> -
- 💡 - 视频中人脸出现时长约 {{ (faceDuration / 1000).toFixed(1) }} 秒,建议文案不超过 {{ suggestedMaxChars }} 字 -
@@ -141,58 +137,6 @@ - -
-

素材校验

-
-
- {{ materialValidation.isValid ? '✅' : '❌' }} - {{ materialValidation.isValid ? '校验通过' : '校验失败' }} -
- - -
-
-
- 音频时长 - {{ (materialValidation.audioDuration / 1000).toFixed(1) }}s -
-
-
-
-
- -
-
- 视频时长 - {{ (materialValidation.videoDuration / 1000).toFixed(1) }}s -
-
-
-
-
-
- - -
-

- 视频时长必须大于音频时长才能生成数字人视频 -

-
- 更换视频 - 精简文案 -
-
-
-
-

配音生成与校验

@@ -211,73 +155,67 @@
- +

生成的配音

音频时长: - {{ (audioState.durationMs / 1000).toFixed(1) }} 秒 + {{ audioDurationSec }} 秒
人脸区间: - {{ (faceDuration / 1000).toFixed(1) }} 秒 + {{ faceDurationSec }} 秒
-
+
校验结果: - {{ audioState.validationPassed ? '✅ 通过' : '❌ 不通过(需至少2秒重合)' }} + {{ validationPassed ? '✅ 通过' : '❌ 不通过(音频时长不能超过人脸时长)' }}
-
-
+ + +
- 生成数字人视频 + {{ isPipelineBusy ? '处理中...' : '生成数字人视频' }} - -
- ⚠️ - 请先生成配音并通过时长校验 -
@@ -299,6 +237,7 @@ 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' @@ -311,6 +250,7 @@ const dragOver = ref(false) // Controller 内部直接创建和管理两个子 Hook const controller = useIdentifyFaceController() + // 解构 controller 以简化模板调用 const { // 语音生成相关 @@ -318,14 +258,11 @@ const { speechRate, audioState, canGenerateAudio, - suggestedMaxChars, generateAudio, // 数字人生成相关 videoState, identifyState, - materialValidation, - faceDuration, getVideoPreviewUrl, // 计算属性 @@ -334,6 +271,21 @@ const { textareaPlaceholder, speechRateMarks, speechRateDisplay, + faceDurationSec, + audioDurationSec, + audioUrl, + validationPassed, + + // Pipeline 状态 + pipelineState, + isPipelineBusy, + isPipelineReady, + isPipelineFailed, + isPipelineCompleted, + pipelineProgress, + pipelineError, + retryPipeline, + resetPipeline, // 事件处理方法 handleVoiceSelect, @@ -343,7 +295,6 @@ const { handleSelectUpload, handleSelectFromLibrary, handleVideoSelect, - handleSimplifyScript, handleVideoLoaded, replaceVideo, generateDigitalHuman, diff --git a/frontend/app/web-gold/src/views/kling/hooks/pipeline/states.ts b/frontend/app/web-gold/src/views/kling/hooks/pipeline/states.ts new file mode 100644 index 0000000000..2f4e74941d --- /dev/null +++ b/frontend/app/web-gold/src/views/kling/hooks/pipeline/states.ts @@ -0,0 +1,124 @@ +/** + * @fileoverview 状态机配置 - 状态定义和配置 + */ + +import type { PipelineState, StateConfig } from './types' + +/** + * 状态配置映射表 + */ +export const STATE_CONFIG: Record = { + 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' +} diff --git a/frontend/app/web-gold/src/views/kling/hooks/pipeline/types.ts b/frontend/app/web-gold/src/views/kling/hooks/pipeline/types.ts new file mode 100644 index 0000000000..2c78f47b26 --- /dev/null +++ b/frontend/app/web-gold/src/views/kling/hooks/pipeline/types.ts @@ -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 { + /** 是否成功 */ + 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 + /** 从库中识别 */ + recognizeFromLibrary: (video: any) => Promise + /** 识别已上传视频 */ + recognizeUploaded: (fileId: string | number) => Promise + /** 生成音频 */ + generateAudio: (text: string, voice: any, speechRate: number) => Promise<{ + audioBase64: string + format?: string + durationMs?: number + }> + /** 创建任务 */ + createTask: (data: any) => Promise +} + +/** + * 状态机执行状态 + */ +export interface ExecutionState { + /** 当前状态 */ + current: PipelineState + /** 历史状态 */ + history: PipelineState[] + /** 上下文数据 */ + context: Partial + /** 是否可以继续下一步 */ + canNext: boolean + /** 是否可以重试 */ + canRetry: boolean +} diff --git a/frontend/app/web-gold/src/views/kling/hooks/pipeline/useSimplePipeline.ts b/frontend/app/web-gold/src/views/kling/hooks/pipeline/useSimplePipeline.ts new file mode 100644 index 0000000000..48e50fd0d8 --- /dev/null +++ b/frontend/app/web-gold/src/views/kling/hooks/pipeline/useSimplePipeline.ts @@ -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 = { + 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('idle') + const context = ref>({ ...INITIAL_CONTEXT }) + const error = ref(null) + const history = ref(['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( + newState: PipelineState, + fn: () => Promise + ): Promise { + setState(newState) + try { + return await fn() + } catch (err) { + setError(err as Error) + throw err + } + } + + // ========== 公开方法 ========== + + /** + * 运行完整流程(到 ready 状态) + */ + async function run(params: PipelineParams): Promise { + // 重置状态 + 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 { + 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, + } +} diff --git a/frontend/app/web-gold/src/views/kling/hooks/useDigitalHumanGeneration.ts b/frontend/app/web-gold/src/views/kling/hooks/useDigitalHumanGeneration.ts index 0a5168740d..e875313cef 100644 --- a/frontend/app/web-gold/src/views/kling/hooks/useDigitalHumanGeneration.ts +++ b/frontend/app/web-gold/src/views/kling/hooks/useDigitalHumanGeneration.ts @@ -1,6 +1,5 @@ /** - * @fileoverview useDigitalHumanGeneration Hook - 数字人生成逻辑封装 - * @author Claude Code + * @fileoverview useDigitalHumanGeneration Hook - 数字人生成逻辑 */ import { ref, computed } from 'vue' @@ -14,18 +13,14 @@ import type { import { identifyUploadedVideo } from '@/api/kling' import { useUpload } from '@/composables/useUpload' -/** - * 数字人生成 Hook - * 独立管理所有状态,不依赖外部状态 - */ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration { - // ==================== 响应式状态 ==================== - + // ========== 状态 ========== const videoState = ref({ uploadedVideo: '', videoFile: null, previewVideoUrl: '', selectedVideo: null, + fileId: null, videoSource: null, selectorVisible: false, }) @@ -40,24 +35,16 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration { videoFileId: null, }) - // ==================== Upload Hook ==================== const { upload } = useUpload() - // ==================== 计算属性 ==================== - - /** - * 人脸出现时长 - */ - const faceDuration = computed(() => { + // ========== 计算属性 ========== + const faceDuration = computed(function() { return identifyState.value.faceEndTime - identifyState.value.faceStartTime }) - // ==================== 核心方法 ==================== + // ========== 方法 ========== - /** - * 处理视频文件上传 - */ - const handleFileUpload = async (file: File): Promise => { + async function handleFileUpload(file: File): Promise { if (!file.name.match(/\.(mp4|mov)$/i)) { message.error('仅支持 MP4 和 MOV') return @@ -68,148 +55,101 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration { videoState.value.selectedVideo = null videoState.value.previewVideoUrl = '' videoState.value.videoSource = 'upload' - resetIdentifyState() - - await performFaceRecognition() } - /** - * 处理从素材库选择视频 - */ - const handleVideoSelect = (video: Video): void => { + async function handleVideoSelect(video: Video): Promise { 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 + identifyState.value.videoFileId = video.fileId } - /** - * 执行人脸识别 - */ - const performFaceRecognition = async (): Promise => { + async function performFaceRecognition(): Promise { const hasUploadFile = videoState.value.videoFile const hasSelectedVideo = videoState.value.selectedVideo - if (!hasUploadFile && !hasSelectedVideo) { - return - } + if (!hasUploadFile && !hasSelectedVideo) return identifyState.value.identifying = true try { - let res if (hasSelectedVideo) { - res = await identifyUploadedVideo(hasSelectedVideo) - identifyState.value.videoFileId = hasSelectedVideo.id + const res = await identifyUploadedVideo(hasSelectedVideo) as { success: boolean; data: { sessionId: string; faceId: string | null; startTime: number; endTime: number } } + identifyState.value.videoFileId = hasSelectedVideo.fileId + + identifyState.value.sessionId = res.data.sessionId + identifyState.value.faceId = res.data.faceId || '' + identifyState.value.faceStartTime = res.data.startTime || 0 + identifyState.value.faceEndTime = res.data.endTime || 0 } else { - // 处理文件上传(提取封面) const file = hasUploadFile! let coverBase64 = null try { const { extractVideoCover } = await import('@/utils/video-cover') - const cover = await extractVideoCover(file, { - maxWidth: 800, - quality: 0.8 - }) + 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 + groupId: null, coverBase64, - onStart: () => {}, - onProgress: () => {}, - onSuccess: () => { - message.success('文件上传成功') - }, - onError: (err: Error) => { + onStart: function() {}, + onProgress: function() {}, + onSuccess: function() {}, + onError: function(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 + identifyState.value.sessionId = '' + identifyState.value.faceId = '' + identifyState.value.faceStartTime = 0 + identifyState.value.faceEndTime = 0 } - identifyState.value.sessionId = res.data.sessionId - identifyState.value.faceId = res.data.faceId - identifyState.value.faceStartTime = res.data.startTime || 0 - identifyState.value.faceEndTime = res.data.endTime || 0 identifyState.value.identified = true - 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 || '识别失败') + // 识别完成,不显示提示信息 + } catch (error: unknown) { + const err = error as Error + message.error(err.message || '识别失败') throw error } finally { identifyState.value.identifying = false } } - - /** - * 重置视频状态 - */ - 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() } - /** - * 获取视频预览 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 => { + function resetIdentifyState(): void { identifyState.value.identified = false identifyState.value.sessionId = '' identifyState.value.faceId = '' @@ -217,14 +157,9 @@ export function useDigitalHumanGeneration(): UseDigitalHumanGeneration { } return { - // 响应式状态 videoState, identifyState, - - // 计算属性 faceDuration, - - // 方法 handleFileUpload, handleVideoSelect, performFaceRecognition, diff --git a/frontend/app/web-gold/src/views/kling/hooks/useIdentifyFaceController.ts b/frontend/app/web-gold/src/views/kling/hooks/useIdentifyFaceController.ts index 48b69e031f..64120f1aea 100644 --- a/frontend/app/web-gold/src/views/kling/hooks/useIdentifyFaceController.ts +++ b/frontend/app/web-gold/src/views/kling/hooks/useIdentifyFaceController.ts @@ -1,416 +1,329 @@ /** - * @fileoverview useIdentifyFaceController Hook - 主控制器 Hook - * @author Claude Code + * @fileoverview useIdentifyFaceController Hook - 主控制器 + * + * 职责:协调语音、视频、Pipeline 各个子模块,提供统一的外部接口 + * + * 模块依赖关系: + * ┌─────────────────────────────────────────────────┐ + * │ useIdentifyFaceController │ + * │ ┌──────────────┐ ┌──────────────┐ ┌───────────┐│ + * │ │ Voice │ │ Digital │ │ Pipeline ││ + * │ │ Generation │ │ Human │ │ ││ + * │ │ │ │ Generation │ │ ││ + * │ └──────────────┘ └──────────────┘ └───────────┘│ + * └─────────────────────────────────────────────────┘ */ -import { ref, computed, watch } from 'vue' +import { computed } from 'vue' import { message } from 'ant-design-vue' import 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 并解构 ==================== + // 子 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({ - videoDuration: 0, - audioDuration: 0, - isValid: false, - showDetails: false, + // Pipeline 流程配置(使用新的极简状态机) + const pipeline = useSimplePipeline({ + uploadVideo: async (_file: File) => { + // 上传已经在 handleFileUpload 中处理 + // 这里直接返回 fileId + return digitalHuman.identifyState.value.videoFileId || '' + }, + recognizeFromLibrary: async (video: any) => { + await digitalHuman.handleVideoSelect(video) + await digitalHuman.performFaceRecognition() + return { + sessionId: digitalHuman.identifyState.value.sessionId, + faceId: digitalHuman.identifyState.value.faceId, + startTime: digitalHuman.identifyState.value.faceStartTime, + endTime: digitalHuman.identifyState.value.faceEndTime, + duration: digitalHuman.faceDuration.value, + } + }, + recognizeUploaded: async (_fileId: string | number) => { + await digitalHuman.performFaceRecognition() + return { + sessionId: digitalHuman.identifyState.value.sessionId, + faceId: digitalHuman.identifyState.value.faceId, + startTime: digitalHuman.identifyState.value.faceStartTime, + endTime: digitalHuman.identifyState.value.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) + // 未识别时只需要基础配置 + if (!digitalHuman.identifyState.value.identified) return !!hasBasicConfig + + // 已识别后需要音频生成并通过校验 + return !!( + hasBasicConfig && + voice.audioState.value.generated && + validationPassed.value + ) }) - /** - * 最大的文本长度 - */ + /** 最大文本长度(根据人脸时长动态计算) */ const maxTextLength = computed(() => { - if (!identifyState.value.identified || faceDuration.value <= 0) { - return 4000 - } - return Math.min(4000, Math.floor(suggestedMaxChars.value * 1.2)) + const isIdentified = digitalHuman.identifyState.value.identified + const faceDuration = digitalHuman.faceDuration.value + if (!isIdentified || 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 isIdentified = digitalHuman.identifyState.value.identified + const faceDuration = digitalHuman.faceDuration.value + if (isIdentified && 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)) + + /** 是否显示生成提示 */ + const showGenerateHint = computed(() => + digitalHuman.identifyState.value.identified && + (!voice.audioState.value.generated || !validationPassed.value) + ) + + /** 音频播放 URL */ + const audioUrl = computed(() => { + const audio = voice.audioState.value.generated + if (!audio) return '' + return audio.audioBase64 ? `data:audio/mp3;base64,${audio.audioBase64}` : audio.audioUrl || '' + }) /** - * 语速显示 + * 校验是否通过(计算属性) + * 规则:音频时长 <= 人脸时长(Kling 要求音频不能超过人脸区间) */ - const speechRateDisplay = computed(() => `${speechRate.value.toFixed(1)}x`) + const validationPassed = computed(() => { + const faceDuration = Number(faceDurationSec.value) + const audioDuration = Number(audioDurationSec.value) + return audioDuration <= faceDuration + }) - // ==================== 业务流程方法 ==================== + // ==================== 业务方法 ==================== /** - * 生成数字人视频 + * 重置所有状态 */ - const generateDigitalHuman = async (): Promise => { + function resetAllStates(): void { + voice.resetAudioState() + digitalHuman.resetVideoState() + digitalHuman.resetIdentifyState() + pipeline.reset() + } + + /** + * 生成数字人视频 - 使用新的 Pipeline API + */ + async function generateDigitalHuman(): Promise { 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 + // 运行流程到 ready 状态 + await pipeline.run({ + videoFile: digitalHuman.videoState.value.videoFile, + selectedVideo: digitalHuman.videoState.value.selectedVideo, + text, + voice: voiceMeta, + speechRate: voice.speechRate.value, + }) - if (!hasUploadFile && !hasSelectedVideo) { - message.warning('请先选择或上传视频') - return - } - - try { - await performFaceRecognition() - message.success('人脸识别完成') - } catch (error) { - return - } + // 如果到达 ready 状态,自动创建任务 + if (pipeline.isReady.value) { + await pipeline.createTask() + // 任务提交成功后,重置所有状态 + resetAllStates() } - - 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), - } - - 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('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.resetIdentifyState() + 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 { + 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)`) + async function generateAudio(): Promise { + // 如果有视频但未识别,先执行识别 + const hasVideo = digitalHuman.videoState.value.uploadedVideo || digitalHuman.videoState.value.selectedVideo + if (hasVideo && !digitalHuman.identifyState.value.identified) { + await digitalHuman.performFaceRecognition() } - - return materialValidation.value.isValid + await voice.generateAudio() } 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, + identifyState: digitalHuman.identifyState, + faceDuration: digitalHuman.faceDuration, + performFaceRecognition: digitalHuman.performFaceRecognition, + handleFileUpload: digitalHuman.handleFileUpload, + getVideoPreviewUrl: digitalHuman.getVideoPreviewUrl, + resetVideoState: digitalHuman.resetVideoState, + resetIdentifyState: digitalHuman.resetIdentifyState, - // ==================== 业务流程方法 ==================== + // 业务方法 generateDigitalHuman, replaceVideo, - // ==================== 事件处理方法 ==================== + // 事件处理 handleVoiceSelect, handleFileSelect, handleDrop, @@ -418,26 +331,38 @@ export function useIdentifyFaceController(): UseIdentifyFaceController { handleSelectUpload, handleSelectFromLibrary, handleVideoSelect, - handleSimplifyScript, handleVideoLoaded, - // ==================== UI 辅助方法 ==================== + // UI 工具 formatDuration, formatFileSize, - // ==================== 计算属性 ==================== + // 计算属性 canGenerate, maxTextLength, textareaPlaceholder, - speechRateMarks, + speechRateMarks: SPEECH_RATE_MARKS, speechRateDisplay, + faceDurationSec, + audioDurationSec, + showGenerateHint, + audioUrl, + validationPassed, + + // Pipeline 状态 + pipelineState: pipeline.state, + pipelineStateLabel: pipeline.stateLabel, + pipelineStateDescription: pipeline.stateDescription, + isPipelineBusy: pipeline.isBusy, + isPipelineReady: pipeline.isReady, + isPipelineFailed: pipeline.isFailed, + isPipelineCompleted: pipeline.isCompleted, + pipelineProgress: pipeline.progress, + pipelineCurrentStepIndex: pipeline.currentStepIndex, + pipelineError: pipeline.error, + runPipeline: pipeline.run, + createPipelineTask: pipeline.createTask, + retryPipeline: pipeline.retry, + resetPipeline: pipeline.reset, } } - -/** - * 从字符串中提取ID - */ -function extractIdFromString(str: string): string { - const match = str.match(/[\w-]+$/) - return match ? match[0] : str -} diff --git a/frontend/app/web-gold/src/views/kling/hooks/useVoiceGeneration.ts b/frontend/app/web-gold/src/views/kling/hooks/useVoiceGeneration.ts index 7706e89a20..53663a8d60 100644 --- a/frontend/app/web-gold/src/views/kling/hooks/useVoiceGeneration.ts +++ b/frontend/app/web-gold/src/views/kling/hooks/useVoiceGeneration.ts @@ -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('') - const speechRate = ref(1.0) + const speechRate = ref(DEFAULT_SPEECH_RATE) const selectedVoiceMeta = ref(null) const audioState = ref({ 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 => { + async function generateAudio(): Promise { const voice = selectedVoiceMeta.value if (!voice) { message.warning('请选择音色') return } - if (!ttsText.value.trim()) { message.warning('请输入文案内容') return @@ -75,7 +56,7 @@ export function useVoiceGeneration(): UseVoiceGeneration { const params = { inputText: ttsText.value, voiceConfigId: voice.rawId || extractIdFromString(voice.id), - speechRate: speechRate.value || 1.0, + speechRate: speechRate.value || DEFAULT_SPEECH_RATE, audioFormat: 'mp3' as const, providerType: DEFAULT_VOICE_PROVIDER, } @@ -84,144 +65,92 @@ 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 } } - /** - * 解析音频时长 - */ - const parseAudioDuration = async (base64Data: string): Promise => { - return new Promise((resolve, reject) => { - try { - const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data + async function parseAudioDuration(base64Data: string): Promise { + const base64 = base64Data.includes(',') ? base64Data.split(',')[1] : base64Data + const binaryString = window.atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.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(function(resolve, reject) { + const blob = new Blob([bytes], { type: 'audio/mp3' }) + const audio = new Audio() + const objectUrl = URL.createObjectURL(blob) + + // 超时机制:5秒后拒绝 + const timeoutId = setTimeout(function() { + URL.revokeObjectURL(objectUrl) + reject(new Error('音频时长解析超时')) + }, 5000) + + function onLoadedMetadata() { + clearTimeout(timeoutId) + URL.revokeObjectURL(objectUrl) + + const duration = audio.duration + if (!isFinite(duration) || duration <= 0 || isNaN(duration)) { + reject(new Error(`音频时长无效: ${duration},请检查音频格式是否正确`)) + return } - const blob = new Blob([bytes], { type: 'audio/mp3' }) - const 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) + const durationMs = Math.round(duration * 1000) + console.log('[parseAudioDuration] 音频时长解析成功:', durationMs, 'ms') + resolve(durationMs) } + + function onError() { + clearTimeout(timeoutId) + URL.revokeObjectURL(objectUrl) + reject(new Error('音频解析失败,请检查音频格式')) + } + + audio.addEventListener('loadedmetadata', onLoadedMetadata) + audio.addEventListener('error', onError) + 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 } diff --git a/frontend/app/web-gold/src/views/kling/types/identify-face.ts b/frontend/app/web-gold/src/views/kling/types/identify-face.ts index e1f24015f3..78c1f7cd46 100644 --- a/frontend/app/web-gold/src/views/kling/types/identify-face.ts +++ b/frontend/app/web-gold/src/views/kling/types/identify-face.ts @@ -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 @@ -48,7 +50,6 @@ export interface IdentifyState { export interface AudioState { generated: AudioData | null durationMs: number - validationPassed: boolean generating: boolean } @@ -61,16 +62,6 @@ export interface AudioData { format?: string } -/** - * 素材校验接口 - */ -export interface MaterialValidation { - videoDuration: number - audioDuration: number - isValid: boolean - showDetails: boolean -} - /** * 音色元数据接口 */ @@ -97,8 +88,6 @@ export interface UseVoiceGeneration { // 方法 generateAudio: () => Promise - parseAudioDuration: (base64Data: string) => Promise - validateAudioDuration: () => boolean resetAudioState: () => void } @@ -115,7 +104,7 @@ export interface UseDigitalHumanGeneration { // 方法 handleFileUpload: (file: File) => Promise - handleVideoSelect: (video: Video) => void + handleVideoSelect: (video: Video) => Promise performFaceRecognition: () => Promise resetVideoState: () => void resetIdentifyState: () => void @@ -140,29 +129,25 @@ export interface UseIdentifyFaceController { // ==================== 数字人生成相关 ==================== videoState: import('vue').Ref identifyState: import('vue').Ref - materialValidation: import('vue').Ref faceDuration: import('vue').ComputedRef performFaceRecognition: () => Promise handleFileUpload: (file: File) => Promise getVideoPreviewUrl: (video: Video) => string resetVideoState: () => void resetIdentifyState: () => void - resetMaterialValidation: () => void - validateMaterialDuration: (videoDurationMs: number, audioDurationMs: number) => boolean // ==================== 业务流程方法 ==================== generateDigitalHuman: () => Promise 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 handleVideoLoaded: (videoUrl: string) => void // ==================== 计算属性 ==================== @@ -171,6 +156,32 @@ export interface UseIdentifyFaceController { textareaPlaceholder: import('vue').ComputedRef speechRateMarks: Record speechRateDisplay: import('vue').ComputedRef + faceDurationSec: import('vue').ComputedRef + audioDurationSec: import('vue').ComputedRef + showGenerateHint: import('vue').ComputedRef + audioUrl: import('vue').ComputedRef + validationPassed: import('vue').ComputedRef + + // ==================== 流程状态 ==================== + pipelineState: import('vue').Ref + pipelineStateLabel: import('vue').ComputedRef + pipelineStateDescription: import('vue').ComputedRef + isPipelineBusy: import('vue').ComputedRef + isPipelineReady: import('vue').ComputedRef + isPipelineFailed: import('vue').ComputedRef + isPipelineCompleted: import('vue').ComputedRef + pipelineProgress: import('vue').ComputedRef + pipelineError: import('vue').Ref + runPipeline: (params: { + videoFile: File | null + selectedVideo: any + text: string + voice: VoiceMeta + speechRate: number + }) => Promise + createPipelineTask: () => Promise + retryPipeline: () => void + resetPipeline: () => void // ==================== UI 辅助方法 ==================== formatDuration: (seconds: number) => string diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index 4984b62996..45745f2252 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -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() - .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() - .eq(TikUserFileDO::getFileId, infraFileId) + .eq(TikUserFileDO::getFileId, fileId) .eq(TikUserFileDO::getUserId, userId)); if (file == null) { diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java index 795c9c818a..e890f1d09f 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/vo/app/AppTikUserFileRespVO.java @@ -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; diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/kling/client/KlingClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/kling/client/KlingClient.java index fb7040873d..1323d5d10a 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/kling/client/KlingClient.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/kling/client/KlingClient.java @@ -52,34 +52,12 @@ public class KlingClient { validateRequest(request); Map 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 + " 为空"); + } + } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikDigitalHumanTaskDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikDigitalHumanTaskDO.java index 52d14c3161..51d656b361 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikDigitalHumanTaskDO.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/dataobject/TikDigitalHumanTaskDO.java @@ -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; } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikDigitalHumanTaskMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikDigitalHumanTaskMapper.java index 0753703e74..c0fd8ccfea 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikDigitalHumanTaskMapper.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/dal/mysql/TikDigitalHumanTaskMapper.java @@ -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 selectPendingKlingTasks() { + return selectList(new LambdaQueryWrapperX() + .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")); + } + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/job/DigitalHumanTaskStatusSyncJob.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/job/DigitalHumanTaskStatusSyncJob.java index e5a2e5d706..b54da3bbe1 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/job/DigitalHumanTaskStatusSyncJob.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/job/DigitalHumanTaskStatusSyncJob.java @@ -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); } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java index 7242ae89e8..2a57d8f86a 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java @@ -82,11 +82,14 @@ public class LatentsyncPollingService { * 执行轮询任务的具体逻辑 */ private void executePollingTasks() { + log.info("[executePollingTasks][开始执行轮询任务]"); try { // 轮询Latentsync任务 List taskIds = getPendingPollingTasks(); + log.info("[executePollingTasks][获取到Latentsync任务数量={}]", taskIds.size()); + if (!taskIds.isEmpty()) { - log.debug("[pollLatentsyncTasks][开始轮询Latentsync任务][任务数量={}]", taskIds.size()); + log.info("[pollLatentsyncTasks][开始轮询Latentsync任务][任务数量={}]", taskIds.size()); // 逐个处理Latentsync任务 for (String taskIdStr : taskIds) { @@ -100,7 +103,9 @@ public class LatentsyncPollingService { } // 轮询可灵任务 + log.info("[executePollingTasks][准备轮询可灵任务]"); pollKlingTasks(); + log.info("[executePollingTasks][可灵任务轮询完成]"); } catch (Exception e) { log.error("[pollLatentsyncTasks][轮询任务异常]", e); @@ -536,28 +541,19 @@ public class LatentsyncPollingService { */ private void pollKlingTasks() { try { - // 参考混剪任务实现:添加时间和数量限制,避免并发问题 - // 1. 时间范围限制:只检查最近6小时内的任务(避免检查历史任务) - // 2. 数量限制:每次最多检查50个任务(避免单次查询过多) - LocalDateTime startTime = LocalDateTime.now().minusHours(6); + log.info("[pollKlingTasks][开始查询待轮询的可灵任务]"); - // 查询有待轮询的可灵任务(状态为PROCESSING且有klingTaskId,限制时间和数量) - List klingTasks = taskMapper.selectList( - new cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX() - .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") // 限制数量,避免并发 + // 显式忽略租户限制查询待轮询的可灵任务 + List klingTasks = cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore( + () -> taskMapper.selectPendingKlingTasks() ); if (klingTasks.isEmpty()) { + log.info("[pollKlingTasks][没有待轮询的可灵任务]"); return; } - log.debug("[pollKlingTasks][开始轮询可灵任务][任务数量={}]", klingTasks.size()); + log.info("[pollKlingTasks][开始轮询可灵任务][任务数量={}]", klingTasks.size()); // 逐个处理可灵任务 for (TikDigitalHumanTaskDO task : klingTasks) { @@ -589,53 +585,12 @@ public class LatentsyncPollingService { String taskStatus = response.getData().getTaskStatus(); String taskStatusMsg = response.getData().getTaskStatusMsg(); - log.debug("[pollKlingSingleTask][任务({})状态更新][klingTaskId={}, status={}]", - task.getId(), klingTaskId, taskStatus); + log.info("[pollKlingSingleTask][任务({})状态更新][klingTaskId={}, status={}, msg={}]", + task.getId(), klingTaskId, taskStatus, taskStatusMsg); // 根据状态更新任务 if ("succeed".equalsIgnoreCase(taskStatus)) { - // 任务成功完成 - List 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()); - } - + handleKlingTaskSucceed(task, response); } else if ("failed".equalsIgnoreCase(taskStatus)) { // 任务失败 String errorMsg = "可灵任务执行失败: " + (StrUtil.isNotBlank(taskStatusMsg) ? taskStatusMsg : "未知错误"); @@ -645,7 +600,7 @@ public class LatentsyncPollingService { } else if ("submitted".equalsIgnoreCase(taskStatus) || "processing".equalsIgnoreCase(taskStatus)) { // 任务还在处理中,更新进度 updateTaskStatus(task.getId(), "PROCESSING", "sync_lip", 70, "口型同步处理中", null); - log.debug("[pollKlingSingleTask][任务({})处理中]", task.getId()); + log.info("[pollKlingSingleTask][任务({})处理中][klingTaskId={}, status={}]", task.getId(), klingTaskId, taskStatus); } else { log.warn("[pollKlingSingleTask][任务({})未知状态][status={}]", task.getId(), taskStatus); @@ -687,7 +642,64 @@ public class LatentsyncPollingService { updateObj.setFinishTime(LocalDateTime.now()); } + // 显式忽略租户限制执行更新操作 + cn.iocoder.yudao.framework.tenant.core.util.TenantUtils.executeIgnore( + () -> taskMapper.updateById(updateObj) + ); + } + + /** + * 处理可灵任务成功 + */ + private void handleKlingTaskSucceed(TikDigitalHumanTaskDO task, KlingLipSyncQueryResponse response) { + List videos = response.getData().getTaskResult().getVideos(); + if (videos == null || videos.isEmpty()) { + log.warn("[pollKlingSingleTask][任务({})成功但无视频结果]", task.getId()); + return; + } + + String videoUrl = videos.get(0).getUrl(); + OssSaveResult saveResult; + + try { + saveResult = saveVideoToOss(task, videoUrl); + log.info("[pollKlingSingleTask][任务({})视频已保存到OSS][url={}]", task.getId(), saveResult.getUrl()); + } catch (Exception e) { + log.warn("[pollKlingSingleTask][任务({})保存视频失败,使用原URL][error={}]", task.getId(), e.getMessage()); + saveResult = new OssSaveResult(videoUrl, 0, null, null); + } + + updateTaskSuccess(task.getId(), saveResult.getUrl()); + cacheTaskResult(task.getId(), saveResult.getUrl()); + saveResultVideoToUserFiles(task, saveResult); + + log.info("[pollKlingSingleTask][任务({})完成][videoUrl={}]", task.getId(), saveResult.getUrl()); + } + + /** + * 更新任务为成功状态 + */ + private void updateTaskSuccess(Long taskId, String videoUrl) { + TikDigitalHumanTaskDO updateObj = new TikDigitalHumanTaskDO(); + updateObj.setId(taskId); + updateObj.setStatus("SUCCESS"); + updateObj.setCurrentStep("finishing"); + updateObj.setProgress(100); + updateObj.setResultVideoUrl(videoUrl); + updateObj.setFinishTime(LocalDateTime.now()); taskMapper.updateById(updateObj); } + /** + * 缓存任务结果到 Redis + */ + private void cacheTaskResult(Long taskId, String url) { + try { + String resultKey = "digital_human:task:result:" + taskId; + stringRedisTemplate.opsForValue().set(resultKey, url, Duration.ofHours(24)); + } catch (Exception e) { + log.warn("[cacheTaskResult][任务({})缓存结果失败]", taskId, e); + } + } + }