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>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
Reference in New Issue
Block a user