00f8c0b566
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Build Staging API Image (push) Successful in 21s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 22s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 45s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m4s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Integration Tests (push) Successful in 3m57s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m3s
CI/CD Pipeline / Validate - Style (push) Successful in 4m41s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m21s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 7m7s
CI/CD Pipeline / Validate - Security (push) Successful in 9m9s
CI/CD Pipeline / Unit Tests (push) Successful in 11m6s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Failing after 61h22m13s
CI/CD Pipeline / Build Production Web Image (push) Failing after 61h22m18s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Failing after 61h32m37s
CI/CD Pipeline / PR Build API Image (push) Failing after 61h33m0s
CI/CD Pipeline / Retag skipped Staging API Image (push) Failing after 61h32m11s
CI/CD Pipeline / CI Gate (push) Failing after 61h21m52s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Failing after 61h32m11s
CI/CD Pipeline / PR Build Web Image (push) Failing after 61h33m0s
CI/CD Pipeline / Canary Release to Production (push) Failing after 61h21m47s
CI/CD Pipeline / Build Production Worker Image (push) Failing after 61h21m52s
CI/CD Pipeline / Build Production API Image (push) Failing after 61h21m52s
CI/CD Pipeline / PR Build Worker Image (push) Failing after 61h32m59s
CI/CD Pipeline / Frontend Lint (push) Failing after 61h33m0s
CI/CD Pipeline / Check if frontend-only change (push) Failing after 61h33m5s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
166 lines
5.8 KiB
TypeScript
166 lines
5.8 KiB
TypeScript
/**
|
||
* 批量变体配音预览音频解析(#1750)
|
||
*
|
||
* 独立配音模式下每个变体挂载各自的配音 URL:
|
||
* - 配音素材(voice 资产,有 file_url)→ 直接用素材文件 URL;
|
||
* - AI 音色(预置/克隆,无实体文件)→ 按该变体自己的标题调 /tts/preview 合成;
|
||
* - 共用模式下所有变体解析为同一条(等价于旧 previewVoiceAudioUrl)。
|
||
*
|
||
* N=1 不使用本 hook(单视频配音预览逻辑在 GeneratePage 内保持不变,零回归)。
|
||
*/
|
||
import { useEffect, useRef, useState } from "react"
|
||
import { useQuery } from "@tanstack/react-query"
|
||
import { getAssetsByKind } from "@/api/assets"
|
||
import type { AssetItem } from "@/api/assets"
|
||
import { previewTts } from "@/api/tts"
|
||
|
||
interface UseVariantVoicePreviewOptions {
|
||
/** 是否批量模式(count>1) */
|
||
enabled: boolean
|
||
/** 变体数量 */
|
||
count: number
|
||
/** 是否每个视频独立配音 */
|
||
perVideo: boolean
|
||
/** 共用配音 ID(独立模式下为变体 0 的配音) */
|
||
sharedVoiceId: string
|
||
/** 克隆音色 ID 覆盖(共用模式,与旧逻辑一致:selectedClonedVoice || selectedVoice) */
|
||
clonedVoiceId?: string
|
||
/** 各变体独立配音 ID(独立模式);长度不足时回退共用 */
|
||
variantVoiceIds: string[]
|
||
/** 各变体标题(TTS 合成文案源) */
|
||
titles: string[]
|
||
}
|
||
|
||
/** 稳定的空数组常量:避免 useQuery 数据未就绪时每次渲染产生新引用导致 effect 无限触发 */
|
||
const EMPTY_VOICE_MATERIALS: AssetItem[] = []
|
||
|
||
/** 判断配音 ID 是否对应实体素材(有 file_url);否则视为 AI 音色需 TTS */
|
||
function findMaterialUrl(id: string, materials: AssetItem[]): string | null {
|
||
if (!id) return null
|
||
const m = materials.find((x) => x.id === id)
|
||
return m?.file_url || null
|
||
}
|
||
|
||
export function useVariantVoicePreview({
|
||
enabled,
|
||
count,
|
||
perVideo,
|
||
sharedVoiceId,
|
||
clonedVoiceId = "",
|
||
variantVoiceIds,
|
||
titles,
|
||
}: UseVariantVoicePreviewOptions): (string | null)[] {
|
||
const [urls, setUrls] = useState<(string | null)[]>([])
|
||
// 配音素材库:组件内部自取,避免调用方传入不稳定数组引用导致 effect 反复触发
|
||
const { data: voiceMaterialsData } = useQuery({
|
||
queryKey: ["assets", "voice"],
|
||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||
})
|
||
const voiceMaterials: AssetItem[] = voiceMaterialsData ?? EMPTY_VOICE_MATERIALS
|
||
// 已缓存的 TTS 结果:key = `${voiceId}|${title}`,避免重复合成
|
||
const ttsCacheRef = useRef<Map<string, string>>(new Map())
|
||
// 在途请求 AbortController
|
||
const abortRef = useRef<AbortController | null>(null)
|
||
const seqRef = useRef(0)
|
||
|
||
useEffect(() => {
|
||
if (!enabled || count <= 1) {
|
||
setUrls((prev) => (prev.length === 0 ? prev : []))
|
||
return
|
||
}
|
||
|
||
const seq = ++seqRef.current
|
||
abortRef.current?.abort()
|
||
const controller = new AbortController()
|
||
abortRef.current = controller
|
||
|
||
// 每个变体实际使用的配音 ID
|
||
const voiceIds = Array.from({ length: count }, (_, i) =>
|
||
perVideo ? variantVoiceIds[i] || sharedVoiceId : sharedVoiceId,
|
||
)
|
||
|
||
// 先用素材 URL 同步填充;AI 音色位置先置 null,待 TTS 完成
|
||
const result: (string | null)[] = voiceIds.map((id, i) => {
|
||
const materialUrl = findMaterialUrl(id, voiceMaterials)
|
||
if (materialUrl) return materialUrl
|
||
// 共用模式下克隆音色 ID 可能与 selectedVoice 不同(与旧 useEffect 逻辑一致)
|
||
if (!perVideo && i === 0 && clonedVoiceId) {
|
||
return findMaterialUrl(clonedVoiceId, voiceMaterials)
|
||
}
|
||
return null
|
||
})
|
||
|
||
// 收集需要 TTS 的变体:无素材 URL 且有标题且有音色 ID
|
||
const ttsJobs: { index: number; voiceId: string; title: string }[] = []
|
||
voiceIds.forEach((id, i) => {
|
||
if (result[i]) return
|
||
// 共用模式沿用旧逻辑:voice_id = selectedClonedVoice || selectedVoice
|
||
const ttsVoiceId = !perVideo && i === 0 ? clonedVoiceId || id : id
|
||
const title = titles[i] || ""
|
||
if (!ttsVoiceId || !title) return
|
||
ttsJobs.push({ index: i, voiceId: ttsVoiceId, title })
|
||
})
|
||
|
||
setUrls((prev) =>
|
||
prev.length === result.length && prev.every((v, i) => v === result[i]) ? prev : result,
|
||
)
|
||
|
||
if (ttsJobs.length === 0) return
|
||
|
||
let cancelled = false
|
||
void (async () => {
|
||
// 串行 TTS,避免瞬间 N 个合成请求打爆后端
|
||
for (const job of ttsJobs) {
|
||
const cacheKey = `${job.voiceId}|${job.title}`
|
||
const cached = ttsCacheRef.current.get(cacheKey)
|
||
if (cached) {
|
||
if (seq === seqRef.current) {
|
||
setUrls((prev) => {
|
||
if (prev[job.index] === cached) return prev
|
||
const next = [...prev]
|
||
next[job.index] = cached
|
||
return next
|
||
})
|
||
}
|
||
continue
|
||
}
|
||
try {
|
||
const res = await previewTts({ text: job.title, voice_id: job.voiceId })
|
||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||
const audioUrl = res.audio_url || ""
|
||
if (audioUrl) {
|
||
ttsCacheRef.current.set(cacheKey, audioUrl)
|
||
setUrls((prev) => {
|
||
if (prev[job.index] === audioUrl) return prev
|
||
const next = [...prev]
|
||
next[job.index] = audioUrl
|
||
return next
|
||
})
|
||
}
|
||
} catch (err) {
|
||
if (cancelled || controller.signal.aborted || seq !== seqRef.current) return
|
||
console.warn(`[变体${job.index + 1}预览配音生成失败]`, err)
|
||
}
|
||
}
|
||
})()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
controller.abort()
|
||
}
|
||
}, [
|
||
enabled,
|
||
count,
|
||
perVideo,
|
||
sharedVoiceId,
|
||
clonedVoiceId,
|
||
variantVoiceIds,
|
||
titles,
|
||
voiceMaterials,
|
||
])
|
||
|
||
return urls
|
||
}
|
||
|
||
export default useVariantVoicePreview
|