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,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
|
||||
}
|
||||
Reference in New Issue
Block a user