feat: 功能

This commit is contained in:
2025-12-28 13:49:45 +08:00
parent 36195ea55a
commit bbf8093ca3
25 changed files with 1046 additions and 1970 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -9,19 +9,15 @@ 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 {
export function useDigitalHumanGeneration(): UseDigitalHumanGeneration {
// ==================== 响应式状态 ====================
const videoState = ref<VideoState>({
@@ -43,13 +39,6 @@ export function useDigitalHumanGeneration(
videoFileId: null,
})
const materialValidation = ref<MaterialValidation>({
videoDuration: 0,
audioDuration: 0,
isValid: false,
showDetails: false,
})
// ==================== 计算属性 ====================
/**
@@ -59,16 +48,6 @@ export function useDigitalHumanGeneration(
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)
})
// ==================== 核心方法 ====================
/**
@@ -87,7 +66,6 @@ export function useDigitalHumanGeneration(
videoState.value.videoSource = 'upload'
resetIdentifyState()
resetMaterialValidation()
await performFaceRecognition()
}
@@ -104,7 +82,6 @@ export function useDigitalHumanGeneration(
resetIdentifyState()
identifyState.value.videoFileId = video.id
materialValidation.value.videoDuration = (video.duration || 0) * 1000
performFaceRecognition()
}
@@ -149,18 +126,6 @@ export function useDigitalHumanGeneration(
}
}
/**
* 验证素材时长
*/
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
}
/**
* 重置视频状态
@@ -174,7 +139,6 @@ export function useDigitalHumanGeneration(
videoState.value.selectorVisible = false
resetIdentifyState()
resetMaterialValidation()
}
/**
@@ -209,31 +173,20 @@ export function useDigitalHumanGeneration(
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,
resetIdentifyState,
getVideoPreviewUrl,
}
}

View File

@@ -3,25 +3,78 @@
* @author Claude Code
*/
import { computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import type {
UseIdentifyFaceController,
UseVoiceGeneration,
UseDigitalHumanGeneration,
LipSyncTaskData,
MaterialValidation,
} from '../types/identify-face'
// @ts-ignore
import { createLipSyncTask } from '@/api/kling'
// 导入子 Hooks
import { useVoiceGeneration } from './useVoiceGeneration'
import { useDigitalHumanGeneration } from './useDigitalHumanGeneration'
/**
* 识别控制器 Hook
* @param voiceGeneration 语音生成 Hook
* @param digitalHuman 数字人生成 Hook
* 识别控制器 Hook - 充当协调器
* 内部直接创建和管理两个子 Hook
*/
export function useIdentifyFaceController(
voiceGeneration: UseVoiceGeneration,
digitalHuman: UseDigitalHumanGeneration
): UseIdentifyFaceController {
export function useIdentifyFaceController(): UseIdentifyFaceController {
// ==================== 创建子 Hooks ====================
// 1. 创建语音生成 Hook独立管理状态
const voiceGeneration = useVoiceGeneration()
// 2. 创建数字人生成 Hook独立管理状态
const digitalHuman = useDigitalHumanGeneration()
// 3. Controller 统一管理跨 Hook 的状态
const materialValidation = ref<MaterialValidation>({
videoDuration: 0,
audioDuration: 0,
isValid: false,
showDetails: false,
})
// 4. 监听音频状态变化,自动触发素材校验
watch(
() => voiceGeneration.audioState.value.generated && voiceGeneration.audioState.value.durationMs > 0,
(newVal, oldVal) => {
if (newVal && !oldVal) {
// 音频生成完成,获取视频时长并校验
const videoDuration = digitalHuman.faceDuration.value || 0
const audioDuration = voiceGeneration.audioState.value.durationMs
if (videoDuration > 0) {
validateMaterialDuration(videoDuration, audioDuration)
}
}
},
{ flush: 'post' }
)
// 5. 监听人脸识别状态变化,更新素材校验的视频时长
watch(
() => digitalHuman.identifyState.value.identified,
(newVal, oldVal) => {
if (newVal && !oldVal) {
// 人脸识别成功,获取视频时长
const videoDuration = digitalHuman.faceDuration.value
// 如果已有音频,则重新校验
if (voiceGeneration.audioState.value.generated && voiceGeneration.audioState.value.durationMs > 0) {
const audioDuration = voiceGeneration.audioState.value.durationMs
validateMaterialDuration(videoDuration, audioDuration)
} else {
// 否则只更新视频时长
materialValidation.value.videoDuration = videoDuration
}
}
},
{ flush: 'post' }
)
// ==================== 计算属性 ====================
/**
@@ -32,7 +85,7 @@ export function useIdentifyFaceController(
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
const materialValidated = materialValidation.value.isValid
return !!(hasText && hasVoice && hasVideo && audioValidated && materialValidated)
})
@@ -92,23 +145,18 @@ export function useIdentifyFaceController(
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
}
}
@@ -209,7 +257,8 @@ export function useIdentifyFaceController(
* 触发文件选择
*/
const triggerFileSelect = (): void => {
document.querySelector('input[type="file"]')?.click()
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
fileInput?.click()
}
/**
@@ -219,7 +268,6 @@ export function useIdentifyFaceController(
digitalHuman.videoState.value.videoSource = 'upload'
digitalHuman.videoState.value.selectedVideo = null
digitalHuman.resetIdentifyState()
digitalHuman.resetMaterialValidation()
}
/**
@@ -284,16 +332,88 @@ export function useIdentifyFaceController(
return `${size.toFixed(1)} ${units[unitIndex]}`
}
return {
// 组合子 Hooks
voiceGeneration,
digitalHuman,
/**
* 重置素材校验状态
*/
const resetMaterialValidation = (): void => {
materialValidation.value.videoDuration = 0
materialValidation.value.audioDuration = 0
materialValidation.value.isValid = false
}
// 业务流程方法
/**
* 验证素材时长
* 视频时长必须大于音频时长
*/
const validateMaterialDuration = (videoDurationMs: number, audioDurationMs: number): boolean => {
materialValidation.value.videoDuration = videoDurationMs
materialValidation.value.audioDuration = audioDurationMs
materialValidation.value.isValid = videoDurationMs > audioDurationMs
if (!materialValidation.value.isValid) {
const videoSec = (videoDurationMs / 1000).toFixed(1)
const audioSec = (audioDurationMs / 1000).toFixed(1)
message.warning(`素材校验失败:视频时长(${videoSec}s必须大于音频时长${audioSec}s`)
}
return materialValidation.value.isValid
}
// ==================== 解构子 Hooks 的响应式变量 ====================
// 语音生成相关
const {
ttsText,
speechRate,
selectedVoiceMeta,
audioState,
canGenerateAudio,
suggestedMaxChars,
generateAudio,
resetAudioState,
} = voiceGeneration
// 数字人生成相关
const {
videoState,
identifyState,
faceDuration,
performFaceRecognition,
handleFileUpload,
getVideoPreviewUrl,
resetVideoState,
resetIdentifyState,
} = digitalHuman
return {
// ==================== 语音生成相关 ====================
ttsText,
speechRate,
selectedVoiceMeta,
audioState,
canGenerateAudio,
suggestedMaxChars,
generateAudio,
resetAudioState,
// ==================== 数字人生成相关 ====================
videoState,
identifyState,
materialValidation,
faceDuration,
performFaceRecognition,
handleFileUpload,
getVideoPreviewUrl,
resetVideoState,
resetIdentifyState,
resetMaterialValidation,
validateMaterialDuration,
// ==================== 业务流程方法 ====================
generateDigitalHuman,
replaceVideo,
// 事件处理方法
// ==================== 事件处理方法 ====================
handleVoiceSelect,
handleFileSelect,
handleDrop,
@@ -304,11 +424,11 @@ export function useIdentifyFaceController(
handleSimplifyScript,
handleVideoLoaded,
// UI 辅助方法
// ==================== UI 辅助方法 ====================
formatDuration,
formatFileSize,
// 计算属性
// ==================== 计算属性 ====================
canGenerate,
maxTextLength,
textareaPlaceholder,

View File

@@ -9,20 +9,16 @@ import type {
UseVoiceGeneration,
AudioState,
VoiceMeta,
IdentifyState,
AudioData,
} from '../types/identify-face'
// @ts-ignore
import { VoiceService } from '@/api/voice'
/**
* 语音生成 Hook
* @param identifyState 人脸识别状态(来自父 Hook
* @param faceDuration 人脸出现时长(毫秒)
* 独立管理所有状态,不依赖外部状态
*/
export function useVoiceGeneration(
identifyState: IdentifyState,
faceDuration: number
): UseVoiceGeneration {
export function useVoiceGeneration(): UseVoiceGeneration {
// ==================== 响应式状态 ====================
const ttsText = ref<string>('')
@@ -43,17 +39,16 @@ export function useVoiceGeneration(
const canGenerateAudio = computed(() => {
const hasText = ttsText.value.trim()
const hasVoice = selectedVoiceMeta.value
const hasVideo = identifyState.identified
const hasVideo = true // 语音生成不依赖视频状态
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)
// 默认为 4000需要从外部设置
return 4000
})
// ==================== 核心方法 ====================
@@ -156,31 +151,33 @@ export function useVoiceGeneration(
}
/**
* 验证音频与人脸区间的重合时长
* 验证音频与人脸区间的重合时长(外部调用时传入校验参数)
*/
const validateAudioDuration = (): boolean => {
if (!identifyState.identified || faceDuration <= 0) {
const validateAudioDuration = (
faceStartTime: number = 0,
faceEndTime: number = 0,
minOverlapMs: number = 2000
): boolean => {
if (faceStartTime <= 0 || faceEndTime <= 0) {
audioState.value.validationPassed = false
return false
}
const faceStart = identifyState.faceStartTime
const faceEnd = identifyState.faceEndTime
const faceDurationMs = faceEnd - faceStart
const faceDurationMs = faceEndTime - faceStartTime
const audioDuration = audioState.value.durationMs
const overlapStart = faceStart
const overlapEnd = Math.min(faceEnd, faceStart + audioDuration)
const overlapStart = faceStartTime
const overlapEnd = Math.min(faceEndTime, faceStartTime + audioDuration)
const overlapDuration = Math.max(0, overlapEnd - overlapStart)
const isValid = overlapDuration >= 2000
const isValid = overlapDuration >= minOverlapMs
audioState.value.validationPassed = isValid
if (!isValid) {
const overlapSec = (overlapDuration / 1000).toFixed(1)
message.warning(
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}秒,至少需要2`
`音频时长(${(audioDuration/1000).toFixed(1)}秒)与人脸区间(${(faceDurationMs/1000).toFixed(1)}秒)不匹配,重合部分仅${overlapSec}秒,至少需要${(minOverlapMs/1000)}`
)
} else {
message.success('时长校验通过!')

View File

@@ -109,34 +109,70 @@ 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
resetIdentifyState: () => void
getVideoPreviewUrl: (video: Video) => string
}
/**
* useIdentifyFaceController Hook 返回接口
* 扁平化结构,直接暴露所有响应式变量和方法
*/
export interface UseIdentifyFaceController {
// 组合子 Hooks
voiceGeneration: UseVoiceGeneration
digitalHuman: UseDigitalHumanGeneration
// ==================== 语音生成相关 ====================
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>
resetAudioState: () => void
// 业务流程方法
// ==================== 数字人生成相关 ====================
videoState: import('vue').Ref<VideoState>
identifyState: import('vue').Ref<IdentifyState>
materialValidation: import('vue').Ref<MaterialValidation>
faceDuration: import('vue').ComputedRef<number>
performFaceRecognition: () => Promise<void>
handleFileUpload: (file: File) => Promise<void>
getVideoPreviewUrl: (video: Video) => string
resetVideoState: () => void
resetIdentifyState: () => void
resetMaterialValidation: () => void
validateMaterialDuration: (videoDurationMs: number, audioDurationMs: number) => boolean
// ==================== 业务流程方法 ====================
generateDigitalHuman: () => Promise<void>
replaceVideo: () => void
// UI 辅助方法
// ==================== 事件处理方法 ====================
handleVoiceSelect: (voice: VoiceMeta) => void
handleFileSelect: (event: Event) => void
handleDrop: (event: DragEvent) => void
triggerFileSelect: () => void
handleSelectUpload: () => void
handleSelectFromLibrary: () => void
handleVideoSelect: (video: Video) => void
handleSimplifyScript: () => void
handleVideoLoaded: (videoUrl: string) => void
// ==================== 计算属性 ====================
canGenerate: import('vue').ComputedRef<boolean>
maxTextLength: import('vue').ComputedRef<number>
textareaPlaceholder: import('vue').ComputedRef<string>
speechRateMarks: Record<number, string>
speechRateDisplay: import('vue').ComputedRef<string>
// ==================== UI 辅助方法 ====================
formatDuration: (seconds: number) => string
formatFileSize: (bytes: number) => string
}