/** * @fileoverview useIdentifyFaceController Hook - 主控制器 Hook * @author Claude Code */ import { computed } from 'vue' import { message } from 'ant-design-vue' import type { UseIdentifyFaceController, UseVoiceGeneration, UseDigitalHumanGeneration, LipSyncTaskData, } from '../types/identify-face' import { createLipSyncTask } from '@/api/kling' /** * 识别控制器 Hook * @param voiceGeneration 语音生成 Hook * @param digitalHuman 数字人生成 Hook */ export function useIdentifyFaceController( voiceGeneration: UseVoiceGeneration, digitalHuman: UseDigitalHumanGeneration ): UseIdentifyFaceController { // ==================== 计算属性 ==================== /** * 是否可以生成数字人视频(综合检查) */ const canGenerate = computed(() => { const hasText = voiceGeneration.ttsText.value.trim() const hasVoice = voiceGeneration.selectedVoiceMeta.value const hasVideo = digitalHuman.videoState.value.uploadedVideo || digitalHuman.videoState.value.selectedVideo const audioValidated = voiceGeneration.audioState.value.validationPassed const materialValidated = digitalHuman.materialValidation.value.isValid return !!(hasText && hasVoice && hasVideo && audioValidated && materialValidated) }) /** * 最大的文本长度 */ const maxTextLength = computed(() => { if (!digitalHuman.identifyState.value.identified || digitalHuman.faceDuration.value <= 0) { return 4000 } return Math.min(4000, Math.floor(voiceGeneration.suggestedMaxChars.value * 1.2)) }) /** * 文本框占位符 */ const textareaPlaceholder = computed(() => { if (digitalHuman.identifyState.value.identified && digitalHuman.faceDuration.value > 0) { return `请输入文案,建议不超过${voiceGeneration.suggestedMaxChars.value}字以确保与视频匹配` } return '请输入你想让角色说话的内容' }) /** * 语速标记 */ const speechRateMarks = { 0.5: '0.5x', 1: '1x', 1.5: '1.5x', 2: '2x' } /** * 语速显示 */ const speechRateDisplay = computed(() => `${voiceGeneration.speechRate.value.toFixed(1)}x`) // ==================== 业务流程方法 ==================== /** * 生成数字人视频 */ const generateDigitalHuman = async (): Promise => { if (!canGenerate.value) { message.warning('请先完成配置') return } const text = voiceGeneration.ttsText.value.trim() if (!text) { message.warning('请输入文案内容') return } const voice = voiceGeneration.selectedVoiceMeta.value if (!voice) { message.warning('请选择音色') return } try { // 如果未识别,先进行人脸识别 if (!digitalHuman.identifyState.value.identified) { message.loading('正在进行人脸识别...', 0) const hasUploadFile = digitalHuman.videoState.value.videoFile const hasSelectedVideo = digitalHuman.videoState.value.selectedVideo if (!hasUploadFile && !hasSelectedVideo) { message.destroy() message.warning('请先选择或上传视频') return } try { await digitalHuman.performFaceRecognition() message.destroy() message.success('人脸识别完成') } catch (error) { message.destroy() return } } const videoFileId = digitalHuman.identifyState.value.videoFileId const taskData: LipSyncTaskData = { taskName: `数字人任务_${Date.now()}`, videoFileId: videoFileId!, inputText: voiceGeneration.ttsText.value, speechRate: voiceGeneration.speechRate.value, volume: 0, guidanceScale: 1, seed: 8888, kling_session_id: digitalHuman.identifyState.value.sessionId, kling_face_id: digitalHuman.identifyState.value.faceId, kling_face_start_time: digitalHuman.identifyState.value.faceStartTime, kling_face_end_time: digitalHuman.identifyState.value.faceEndTime, ai_provider: 'kling', voiceConfigId: voice.rawId || extractIdFromString(voice.id), } if (!taskData.voiceConfigId) { message.warning('音色配置无效') return } // 如果有预生成的音频,添加到任务数据中 if (voiceGeneration.audioState.value.generated && voiceGeneration.audioState.value.durationMs > 0) { taskData.pre_generated_audio = { audioBase64: voiceGeneration.audioState.value.generated.audioBase64, format: voiceGeneration.audioState.value.generated.format || 'mp3', } taskData.sound_end_time = voiceGeneration.audioState.value.durationMs } const res = await createLipSyncTask(taskData) if (res.code === 0) { message.success('任务已提交到任务中心,请前往查看') } else { throw new Error(res.msg || '任务创建失败') } } catch (error: any) { message.error(error.message || '任务提交失败') } } /** * 更换视频 */ const replaceVideo = (): void => { if (digitalHuman.videoState.value.videoSource === 'upload') { digitalHuman.videoState.value.videoFile = null digitalHuman.videoState.value.uploadedVideo = '' } else { digitalHuman.videoState.value.selectedVideo = null digitalHuman.videoState.value.videoFile = null digitalHuman.videoState.value.uploadedVideo = '' } // 重置所有状态 digitalHuman.resetVideoState() voiceGeneration.resetAudioState() } /** * 处理音色选择 */ const handleVoiceSelect = (voice: any): void => { voiceGeneration.selectedVoiceMeta.value = voice } /** * 处理文件选择 */ const handleFileSelect = (event: Event): void => { const input = event.target as HTMLInputElement const file = input.files?.[0] if (file) { digitalHuman.handleFileUpload(file) } } /** * 处理拖拽上传 */ const handleDrop = (event: DragEvent): void => { event.preventDefault() const file = event.dataTransfer?.files[0] if (file) { digitalHuman.handleFileUpload(file) } } /** * 触发文件选择 */ const triggerFileSelect = (): void => { document.querySelector('input[type="file"]')?.click() } /** * 选择上传模式 */ const handleSelectUpload = (): void => { digitalHuman.videoState.value.videoSource = 'upload' digitalHuman.videoState.value.selectedVideo = null digitalHuman.resetIdentifyState() digitalHuman.resetMaterialValidation() } /** * 从素材库选择 */ const handleSelectFromLibrary = (): void => { digitalHuman.videoState.value.videoSource = 'select' digitalHuman.videoState.value.videoFile = null digitalHuman.videoState.value.uploadedVideo = '' digitalHuman.videoState.value.selectorVisible = true } /** * 处理视频选择器选择 */ const handleVideoSelect = (video: any): void => { 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' }) } } /** * 处理视频加载 */ const handleVideoLoaded = (videoUrl: string): void => { digitalHuman.videoState.value.previewVideoUrl = videoUrl } // ==================== UI 辅助方法 ==================== /** * 格式化时长 */ const 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 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) { size /= 1024 unitIndex++ } return `${size.toFixed(1)} ${units[unitIndex]}` } return { // 组合子 Hooks voiceGeneration, digitalHuman, // 业务流程方法 generateDigitalHuman, replaceVideo, // 事件处理方法 handleVoiceSelect, handleFileSelect, handleDrop, triggerFileSelect, handleSelectUpload, handleSelectFromLibrary, handleVideoSelect, handleSimplifyScript, handleVideoLoaded, // UI 辅助方法 formatDuration, formatFileSize, // 计算属性 canGenerate, maxTextLength, textareaPlaceholder, speechRateMarks, speechRateDisplay, } } /** * 从字符串中提取ID */ function extractIdFromString(str: string): string { const match = str.match(/[\w-]+$/) return match ? match[0] : str }