- 新增 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>
327 lines
8.8 KiB
TypeScript
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
|
|
}
|