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:
2025-12-28 00:19:17 +08:00
parent effbbc694c
commit 36195ea55a
46 changed files with 4258 additions and 3454 deletions

View File

@@ -1,25 +0,0 @@
<script setup>
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-bold">生成数字人</h2>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<section class="p-4 bg-white rounded shadow lg:col-span-1">
<div class="space-y-3">
<div class="text-sm text-gray-600">形象背景脚本分辨率字幕等配置</div>
<button class="px-4 py-2 text-white bg-purple-600 rounded">生成视频</button>
</div>
</section>
<section class="p-4 bg-white rounded shadow lg:col-span-2">
<div class="text-gray-500">视频预览任务队列渲染进度</div>
</section>
</div>
</div>
</template>
<style scoped>
</style>

View File

@@ -61,7 +61,7 @@ const userVoiceCards = computed(() =>
id: `user-${profile.id}`,
rawId: profile.id,
name: profile.name || '未命名',
category: profile.gender === 'male' ? '男青年' : '女青',
category: '',
gender: profile.gender || 'female',
description: profile.note || '我的配音',
fileUrl: profile.fileUrl,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,239 @@
/**
* @fileoverview useDigitalHumanGeneration Hook - 数字人生成逻辑封装
* @author Claude Code
*/
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type {
UseDigitalHumanGeneration,
VideoState,
IdentifyState,
MaterialValidation,
Video,
AudioState,
} from '../types/identify-face'
import { identifyUploadedVideo, uploadAndIdentifyVideo } from '@/api/kling'
/**
* 数字人生成 Hook
* @param audioState 音频状态(来自父 Hook
*/
export function useDigitalHumanGeneration(
audioState: AudioState
): UseDigitalHumanGeneration {
// ==================== 响应式状态 ====================
const videoState = ref<VideoState>({
uploadedVideo: '',
videoFile: null,
previewVideoUrl: '',
selectedVideo: null,
videoSource: null,
selectorVisible: false,
})
const identifyState = ref<IdentifyState>({
identifying: false,
identified: false,
sessionId: '',
faceId: '',
faceStartTime: 0,
faceEndTime: 0,
videoFileId: null,
})
const materialValidation = ref<MaterialValidation>({
videoDuration: 0,
audioDuration: 0,
isValid: false,
showDetails: false,
})
// ==================== 计算属性 ====================
/**
* 人脸出现时长
*/
const faceDuration = computed(() => {
return identifyState.value.faceEndTime - identifyState.value.faceStartTime
})
/**
* 是否可以生成数字人视频
*/
const canGenerate = computed(() => {
const hasVideo = videoState.value.uploadedVideo || videoState.value.selectedVideo
const audioValidated = audioState.validationPassed
const materialValidated = materialValidation.value.isValid
return !!(hasVideo && audioValidated && materialValidated)
})
// ==================== 核心方法 ====================
/**
* 处理视频文件上传
*/
const handleFileUpload = async (file: File): Promise<void> => {
if (!file.name.match(/\.(mp4|mov)$/i)) {
message.error('仅支持 MP4 和 MOV')
return
}
videoState.value.videoFile = file
videoState.value.uploadedVideo = URL.createObjectURL(file)
videoState.value.selectedVideo = null
videoState.value.previewVideoUrl = ''
videoState.value.videoSource = 'upload'
resetIdentifyState()
resetMaterialValidation()
await performFaceRecognition()
}
/**
* 处理从素材库选择视频
*/
const handleVideoSelect = (video: Video): void => {
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
materialValidation.value.videoDuration = (video.duration || 0) * 1000
performFaceRecognition()
}
/**
* 执行人脸识别
*/
const performFaceRecognition = async (): Promise<void> => {
const hasUploadFile = videoState.value.videoFile
const hasSelectedVideo = videoState.value.selectedVideo
if (!hasUploadFile && !hasSelectedVideo) {
return
}
identifyState.value.identifying = true
try {
let res
if (hasSelectedVideo) {
res = await identifyUploadedVideo(hasSelectedVideo)
identifyState.value.videoFileId = hasSelectedVideo.id
} else {
res = await uploadAndIdentifyVideo(hasUploadFile!)
identifyState.value.videoFileId = res.data.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
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 || '识别失败')
throw error
} finally {
identifyState.value.identifying = false
}
}
/**
* 验证素材时长
*/
const validateMaterialDuration = (videoDurationMs: number, audioDurationMs: number): boolean => {
const isValid = videoDurationMs > audioDurationMs
materialValidation.value.videoDuration = videoDurationMs
materialValidation.value.audioDuration = audioDurationMs
materialValidation.value.isValid = isValid
return isValid
}
/**
* 重置视频状态
*/
const resetVideoState = (): void => {
videoState.value.uploadedVideo = ''
videoState.value.videoFile = null
videoState.value.selectedVideo = null
videoState.value.videoSource = null
videoState.value.previewVideoUrl = ''
videoState.value.selectorVisible = false
resetIdentifyState()
resetMaterialValidation()
}
/**
* 获取视频预览 URL
*/
const getVideoPreviewUrl = (video: Video): string => {
if (video.coverBase64) {
if (!video.coverBase64.startsWith('data:')) {
return `data:image/jpeg;base64,${video.coverBase64}`
}
return video.coverBase64
}
if (video.previewUrl) {
return video.previewUrl
}
if (video.coverUrl) {
return video.coverUrl
}
return 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjExMCIgdmlld0JveD0iMCAwIDIwMCAxMTAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTEwIiBmaWxsPSIjMzc0MTUxIi8+CjxwYXRoIGQ9Ik04NSA0NUwxMTUgNjVMMTA1IDg1TDc1IDc1TDg1IDQ1WiIgZmlsbD0iIzU3MjY1MSIvPgo8L3N2Zz4K'
}
/**
* 重置识别状态
*/
const resetIdentifyState = (): void => {
identifyState.value.identified = false
identifyState.value.sessionId = ''
identifyState.value.faceId = ''
identifyState.value.videoFileId = null
}
/**
* 重置素材校验状态
*/
const resetMaterialValidation = (): void => {
materialValidation.value.videoDuration = 0
materialValidation.value.audioDuration = 0
materialValidation.value.isValid = false
}
return {
// 响应式状态
videoState,
identifyState,
materialValidation,
// 计算属性
faceDuration,
canGenerate,
// 方法
handleFileUpload,
handleVideoSelect,
performFaceRecognition,
validateMaterialDuration,
resetVideoState,
getVideoPreviewUrl,
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,228 @@
/**
* @fileoverview useVoiceGeneration Hook - 语音生成逻辑封装
* @author Claude Code
*/
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import type {
UseVoiceGeneration,
AudioState,
VoiceMeta,
IdentifyState,
AudioData,
} from '../types/identify-face'
import { VoiceService } from '@/api/voice'
/**
* 语音生成 Hook
* @param identifyState 人脸识别状态(来自父 Hook
* @param faceDuration 人脸出现时长(毫秒)
*/
export function useVoiceGeneration(
identifyState: IdentifyState,
faceDuration: number
): UseVoiceGeneration {
// ==================== 响应式状态 ====================
const ttsText = ref<string>('')
const speechRate = ref<number>(1.0)
const selectedVoiceMeta = ref<VoiceMeta | null>(null)
const audioState = ref<AudioState>({
generated: null,
durationMs: 0,
validationPassed: false,
generating: false,
})
// ==================== 计算属性 ====================
/**
* 是否可以生成配音
*/
const canGenerateAudio = computed(() => {
const hasText = ttsText.value.trim()
const hasVoice = selectedVoiceMeta.value
const hasVideo = identifyState.identified
return !!(hasText && hasVoice && hasVideo && !audioState.value.generating)
})
/**
* 建议的最大字符数
*/
const suggestedMaxChars = computed(() => {
const durationSec = faceDuration / 1000
const adjustedRate = speechRate.value || 1.0
return Math.floor(durationSec * 3.5 * adjustedRate)
})
// ==================== 核心方法 ====================
/**
* 生成配音
*/
const generateAudio = async (): Promise<void> => {
const voice = selectedVoiceMeta.value
if (!voice) {
message.warning('请选择音色')
return
}
if (!ttsText.value.trim()) {
message.warning('请输入文案内容')
return
}
audioState.value.generating = true
try {
const params = {
inputText: ttsText.value,
voiceConfigId: voice.rawId || extractIdFromString(voice.id),
speechRate: speechRate.value || 1.0,
audioFormat: 'mp3' as const,
}
const res = await VoiceService.synthesize(params)
if (res.code === 0) {
const audioData = res.data as AudioData
if (!audioData.audioBase64) {
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
}
} else {
throw new Error(res.msg || '配音生成失败')
}
} catch (error: any) {
message.error(error.message || '配音生成失败')
} finally {
audioState.value.generating = false
}
}
/**
* 解析音频时长
*/
const parseAudioDuration = async (base64Data: string): Promise<number> => {
return new Promise((resolve, reject) => {
try {
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 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 validateAudioDuration = (): boolean => {
if (!identifyState.identified || faceDuration <= 0) {
audioState.value.validationPassed = false
return false
}
const faceStart = identifyState.faceStartTime
const faceEnd = identifyState.faceEndTime
const faceDurationMs = faceEnd - faceStart
const audioDuration = audioState.value.durationMs
const overlapStart = faceStart
const overlapEnd = Math.min(faceEnd, faceStart + audioDuration)
const overlapDuration = Math.max(0, overlapEnd - overlapStart)
const isValid = overlapDuration >= 2000
audioState.value.validationPassed = isValid
if (!isValid) {
const overlapSec = (overlapDuration / 1000).toFixed(1)
message.warning(
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}至少需要2秒`
)
} else {
message.success('时长校验通过!')
}
return isValid
}
/**
* 重置音频状态
*/
const 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
}

View File

@@ -0,0 +1,175 @@
/**
* @fileoverview IdentifyFace 组件类型定义
* @author Claude Code
*/
/**
* 视频状态接口
*/
export interface VideoState {
uploadedVideo: string
videoFile: File | null
previewVideoUrl: string
selectedVideo: Video | null
videoSource: 'upload' | 'select' | null
selectorVisible: boolean
}
/**
* 视频对象接口(来自素材库)
*/
export interface Video {
id: string | number
fileName: string
fileUrl: string
fileSize: number
duration: number
coverBase64?: string
previewUrl?: string
coverUrl?: string
}
/**
* 人脸识别状态接口
*/
export interface IdentifyState {
identifying: boolean
identified: boolean
sessionId: string
faceId: string
faceStartTime: number
faceEndTime: number
videoFileId: string | number | null
}
/**
* 音频状态接口
*/
export interface AudioState {
generated: AudioData | null
durationMs: number
validationPassed: boolean
generating: boolean
}
/**
* 音频数据接口
*/
export interface AudioData {
audioBase64: string
audioUrl?: string
format?: string
}
/**
* 素材校验接口
*/
export interface MaterialValidation {
videoDuration: number
audioDuration: number
isValid: boolean
showDetails: boolean
}
/**
* 音色元数据接口
*/
export interface VoiceMeta {
id: string
rawId?: string
name?: string
[key: string]: any
}
/**
* useVoiceGeneration Hook 返回接口
*/
export interface UseVoiceGeneration {
// 响应式状态
ttsText: import('vue').Ref<string>
speechRate: import('vue').Ref<number>
selectedVoiceMeta: import('vue').Ref<VoiceMeta | null>
audioState: import('vue').Ref<AudioState>
// 计算属性
canGenerateAudio: import('vue').ComputedRef<boolean>
suggestedMaxChars: import('vue').ComputedRef<number>
// 方法
generateAudio: () => Promise<void>
parseAudioDuration: (base64Data: string) => Promise<number>
validateAudioDuration: () => boolean
resetAudioState: () => void
}
/**
* useDigitalHumanGeneration Hook 返回接口
*/
export interface UseDigitalHumanGeneration {
// 响应式状态
videoState: import('vue').Ref<VideoState>
identifyState: import('vue').Ref<IdentifyState>
materialValidation: import('vue').Ref<MaterialValidation>
// 计算属性
faceDuration: import('vue').ComputedRef<number>
canGenerate: import('vue').ComputedRef<boolean>
// 方法
handleFileUpload: (file: File) => Promise<void>
handleVideoSelect: (video: Video) => void
performFaceRecognition: () => Promise<void>
validateMaterialDuration: (videoMs: number, audioMs: number) => boolean
resetVideoState: () => void
getVideoPreviewUrl: (video: Video) => string
}
/**
* useIdentifyFaceController Hook 返回接口
*/
export interface UseIdentifyFaceController {
// 组合子 Hooks
voiceGeneration: UseVoiceGeneration
digitalHuman: UseDigitalHumanGeneration
// 业务流程方法
generateDigitalHuman: () => Promise<void>
replaceVideo: () => void
// UI 辅助方法
formatDuration: (seconds: number) => string
formatFileSize: (bytes: number) => string
}
/**
* Kling API 响应接口
*/
export interface KlingApiResponse<T = any> {
code: number
data: T
msg?: string
}
/**
* 数字人生成任务数据接口
*/
export interface LipSyncTaskData {
taskName: string
videoFileId: string | number
inputText: string
speechRate: number
volume: number
guidanceScale: number
seed: number
kling_session_id: string
kling_face_id: string
kling_face_start_time: number
kling_face_end_time: number
ai_provider: string
voiceConfigId: string
pre_generated_audio?: {
audioBase64: string
format: string
}
sound_end_time?: number
}