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

@@ -0,0 +1,355 @@
/**
* TTS (Text-to-Speech) 公共Hook
* 支持多个供应商Qwen, Azure, AWS等
*/
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { VoiceService } from '@/api/voice'
// 供应商配置
const TTS_PROVIDERS = {
QWEN: 'qwen',
AZURE: 'azure',
AWS: 'aws'
}
// 默认配置
const DEFAULT_CONFIG = {
qwen: {
apiEndpoint: '/api/tik/voice/tts',
audioFormat: 'mp3',
supportedFormats: ['mp3', 'wav']
},
azure: {
apiEndpoint: '/api/tik/voice/azure/tts',
audioFormat: 'mp3',
supportedFormats: ['mp3', 'wav', 'ogg']
},
aws: {
apiEndpoint: '/api/tik/voice/aws/tts',
audioFormat: 'mp3',
supportedFormats: ['mp3', 'wav', 'ogg']
}
}
/**
* TTS Hook主函数
* @param {Object} options 配置选项
* @param {string} options.provider 供应商名称,默认'qwen'
* @param {Object} options.customConfig 自定义配置
* @returns {Object} TTS相关的方法和状态
*/
export function useTTS(options = {}) {
const {
provider = TTS_PROVIDERS.QWEN,
customConfig = {}
} = options
// 状态管理
const previewAudioCache = new Map()
const MAX_PREVIEW_CACHE_SIZE = 50
const previewLoadingVoiceId = ref(null)
const playingPreviewVoiceId = ref(null)
const ttsText = ref('')
const speechRate = ref(1.0)
// 音频实例
let previewAudio = null
let previewObjectUrl = ''
// 获取当前供应商配置
const getProviderConfig = () => {
const config = DEFAULT_CONFIG[provider] || DEFAULT_CONFIG[TTS_PROVIDERS.QWEN]
return { ...config, ...customConfig }
}
/**
* 播放音频预览
* @param {string} url 音频URL
* @param {Object} options 播放选项
*/
const playAudioPreview = (url, options = {}) => {
if (!url) return message.warning('暂无可试听的音频')
try {
previewAudio?.pause?.()
previewAudio = null
} catch (error) {
}
const audio = new Audio(url)
const cleanup = () => {
if (options.revokeOnEnd && url.startsWith('blob:')) {
URL.revokeObjectURL(url)
previewObjectUrl === url && (previewObjectUrl = '')
}
previewAudio = null
options.onEnded && options.onEnded()
}
audio.play()
.then(() => {
previewAudio = audio
audio.onended = cleanup
audio.onerror = () => {
cleanup()
message.error('播放失败')
}
})
.catch(err => {
cleanup()
message.error('播放失败')
})
}
/**
* 生成预览缓存键
* @param {Object} voice 音色对象
* @returns {string} 缓存键
*/
const generatePreviewCacheKey = (voice) => {
const voiceId = voice.voiceId || voice.rawId || voice.id
const text = ttsText.value.trim()
const rate = speechRate.value
return `${voiceId}:${text}:${rate}`
}
/**
* 解码并缓存Base64音频
* @param {string} audioBase64 Base64编码的音频数据
* @param {string} format 音频格式
* @param {string} cacheKey 缓存键
* @returns {Promise<Object>} 音频数据
*/
const decodeAndCacheBase64 = async (audioBase64, format = 'mp3', cacheKey) => {
const byteCharacters = window.atob(audioBase64)
const byteNumbers = new Uint8Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const mime = format === 'mp3' ? 'audio/mpeg' : `audio/${format}`
const blob = new Blob([byteNumbers], { type: mime })
const objectUrl = URL.createObjectURL(blob)
const audioData = { blob, objectUrl, format }
previewAudioCache.set(cacheKey, audioData)
if (previewAudioCache.size > MAX_PREVIEW_CACHE_SIZE) {
const firstKey = previewAudioCache.keys().next().value
const oldData = previewAudioCache.get(firstKey)
URL.revokeObjectURL(oldData.objectUrl)
previewAudioCache.delete(firstKey)
}
return audioData
}
/**
* 播放缓存的音频
* @param {Object} audioData 音频数据
* @param {Function} onEnded 播放结束回调
*/
const playCachedAudio = (audioData, onEnded) => {
if (previewObjectUrl && previewObjectUrl !== audioData.objectUrl) {
URL.revokeObjectURL(previewObjectUrl)
}
previewObjectUrl = audioData.objectUrl
playAudioPreview(previewObjectUrl, {
revokeOnEnd: false,
onEnded: () => {
onEnded && onEnded()
}
})
}
/**
* 重置预览状态
*/
const resetPreviewState = () => {
previewLoadingVoiceId.value = null
playingPreviewVoiceId.value = null
}
/**
* 提取ID从字符串
* @param {string} idStr 包含前缀的ID字符串
* @returns {number|null} 提取的ID
*/
const extractIdFromString = (idStr) => {
if (typeof idStr !== 'string' || !idStr.startsWith('user-')) return null
const extractedId = parseInt(idStr.replace('user-', ''))
return Number.isNaN(extractedId) ? null : extractedId
}
/**
* 构建预览参数
* @param {Object} voice 音色对象
* @returns {Object|null} 预览参数
*/
const buildPreviewParams = (voice) => {
const configId = voice.rawId || extractIdFromString(voice.id)
if (!configId) {
message.error('配音配置无效')
return null
}
const providerConfig = getProviderConfig()
return {
voiceConfigId: configId,
inputText: ttsText.value,
speechRate: speechRate.value || 1.0,
audioFormat: providerConfig.audioFormat,
timestamp: Date.now(),
provider: provider
}
}
/**
* 播放音色试听
* @param {Object} voice 音色对象
* @param {Function} onSuccess 成功回调
* @param {Function} onError 错误回调
*/
const playVoiceSample = async (voice, onSuccess, onError) => {
if (!voice) return
if (previewLoadingVoiceId.value === voice.id || playingPreviewVoiceId.value === voice.id) {
return
}
if (playingPreviewVoiceId.value && playingPreviewVoiceId.value !== voice.id) {
try {
previewAudio?.pause?.()
previewAudio = null
} catch (error) {
}
}
previewLoadingVoiceId.value = voice.id
playingPreviewVoiceId.value = voice.id
const cacheKey = generatePreviewCacheKey(voice)
const cachedAudio = previewAudioCache.get(cacheKey)
if (cachedAudio) {
playCachedAudio(cachedAudio, resetPreviewState)
onSuccess && onSuccess(cachedAudio)
return
}
try {
const params = buildPreviewParams(voice)
if (!params) {
resetPreviewState()
onError && onError(new Error('参数构建失败'))
return
}
const res = await VoiceService.preview(params)
if (res.code !== 0) {
message.error(res.msg || '试听失败')
resetPreviewState()
onError && onError(new Error(res.msg || '试听失败'))
return
}
if (res.data?.audioUrl) {
playAudioPreview(res.data.audioUrl, { onEnded: resetPreviewState })
onSuccess && onSuccess(res.data)
} else if (res.data?.audioBase64) {
const audioData = await decodeAndCacheBase64(res.data.audioBase64, res.data.format, cacheKey)
playCachedAudio(audioData, resetPreviewState)
onSuccess && onSuccess(audioData)
} else {
message.error('试听失败')
resetPreviewState()
onError && onError(new Error('未收到音频数据'))
}
} catch (error) {
message.error('试听失败')
resetPreviewState()
onError && onError(error)
}
}
/**
* TTS文本转语音
* @param {Object} params TTS参数
* @returns {Promise<Object>} TTS结果
*/
const synthesize = async (params) => {
const providerConfig = getProviderConfig()
const ttsParams = {
inputText: params.inputText || ttsText.value,
voiceConfigId: params.voiceConfigId,
speechRate: params.speechRate || speechRate.value,
audioFormat: params.audioFormat || providerConfig.audioFormat,
provider: provider
}
return await VoiceService.synthesize(ttsParams)
}
/**
* 设置文本
* @param {string} text 要设置的文本
*/
const setText = (text) => {
ttsText.value = text
}
/**
* 设置语速
* @param {number} rate 语速倍率
*/
const setSpeechRate = (rate) => {
speechRate.value = rate
}
/**
* 清除音频缓存
*/
const clearAudioCache = () => {
previewAudioCache.forEach((audioData) => {
URL.revokeObjectURL(audioData.objectUrl)
})
previewAudioCache.clear()
}
/**
* 停止当前播放
*/
const stopCurrentPlayback = () => {
try {
previewAudio?.pause?.()
previewAudio = null
} catch (error) {
}
}
return {
// 状态
previewLoadingVoiceId,
playingPreviewVoiceId,
ttsText,
speechRate,
// 方法
playVoiceSample,
synthesize,
setText,
setSpeechRate,
playAudioPreview,
clearAudioCache,
stopCurrentPlayback,
resetPreviewState,
// 配置
getProviderConfig,
TTS_PROVIDERS,
DEFAULT_CONFIG
}
}
export { TTS_PROVIDERS }