Files
sionrui/frontend/app/web-gold/src/views/kling/hooks/useIdentifyFaceController.ts
sion123 36195ea55a feat: 重构 IdentifyFace.vue 为 Hooks 架构
- 新增 hooks/ 目录,包含三个专用 Hook:
  * useVoiceGeneration - 语音生成和校验逻辑
  * useDigitalHumanGeneration - 数字人视频生成逻辑
  * useIdentifyFaceController - 协调两个子 Hook 的控制器

- 新增 types/identify-face.ts 完整类型定义

- 重构 IdentifyFace.vue 使用 hooks 架构:
  * 视图层与业务逻辑分离
  * 状态管理清晰化
  * 模块解耦,逻辑清晰

- 遵循单一职责原则,每个 Hook 只负责一个领域
- 提升代码可测试性和可维护性
- 支持两种视频素材来源:素材库选择和直接上传
- 实现语音生成优先校验的业务规则

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-28 00:19:17 +08:00

327 lines
8.8 KiB
TypeScript

/**
* @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<void> => {
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
}