From e13dd1681632fe44c51a0fb8386060092dbaccb9 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sun, 26 Jul 2026 18:28:32 +0800 Subject: [PATCH] refactor(generate): split useStep5Voice into sub-modules - Extract audio playback to useVoiceAudio.ts - Extract voice recommendation to useVoiceRecommend.ts - Extract TTS synthesis + polling to useTtsSynthesis.ts - Extract save-to-library to useSaveToLibrary.tsx - Main hook reduced from 397 to 184 lines (-54%) - Keep import path unchanged --- .../hooks/step5-voice/useSaveToLibrary.tsx | 113 +++++++ .../hooks/step5-voice/useTtsSynthesis.ts | 96 ++++++ .../hooks/step5-voice/useVoiceAudio.ts | 39 +++ .../hooks/step5-voice/useVoiceRecommend.ts | 68 ++++ .../pages/generate/hooks/useStep5Voice.tsx | 301 +++--------------- 5 files changed, 360 insertions(+), 257 deletions(-) create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts create mode 100644 apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx b/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx new file mode 100644 index 000000000..a7fe87307 --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useSaveToLibrary.tsx @@ -0,0 +1,113 @@ +import { useState, useCallback } from "react" +import { useNavigate } from "react-router-dom" +import { message } from "antd" +import { useQuery, useMutation } from "@tanstack/react-query" +import { saveTtsToLibrary } from "@/api/tts" +import { getTags, createTag } from "@/api/tags" + +/** + * 存为素材(配音库)弹窗逻辑 + */ +export function useSaveToLibrary(completedTtsJobId: string | null, resetTtsState: () => void) { + const navigate = useNavigate() + + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [saveName, setSaveName] = useState("") + const [saveTagIds, setSaveTagIds] = useState([]) + const [saveNewTag, setSaveNewTag] = useState("") + + const { data: allTags = [] } = useQuery({ + queryKey: ["generate-save-tags"], + queryFn: getTags, + staleTime: 30_000, + }) + + const handleGoToLibrary = useCallback(() => { + navigate("/app/voice-materials") + }, [navigate]) + + const saveToLibraryMutation = useMutation({ + mutationFn: (params: { name?: string; tag_ids?: string[] }) => + saveTtsToLibrary(completedTtsJobId!, params), + onSuccess: () => { + message.success({ + content: ( + + 已保存到配音库!{" "} + + 去视频库查看 + + + ), + duration: 5, + }) + setSaveModalOpen(false) + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + resetTtsState() + }, + onError: (err: Error) => { + message.error(`保存失败:${err.message || "请重试"}`) + }, + }) + + const handleOpenSaveModal = useCallback(() => { + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + setSaveModalOpen(true) + }, []) + + const handleConfirmSave = useCallback(() => { + if (!completedTtsJobId) return + saveToLibraryMutation.mutate({ + name: saveName.trim() || undefined, + tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, + }) + }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) + + const handleAddTagInModal = useCallback( + async (tagName: string) => { + const trimmed = tagName.trim() + if (!trimmed) return + const existing = allTags.find((t) => t.name === trimmed) + if (existing) { + if (!saveTagIds.includes(existing.id)) { + setSaveTagIds((prev) => [...prev, existing.id]) + } + return + } + try { + const created = await createTag(trimmed) + setSaveTagIds((prev) => [...prev, created.id]) + setSaveNewTag("") + } catch { + message.error(`创建标签"${trimmed}"失败`) + } + }, + [allTags, saveTagIds], + ) + + return { + saveModalOpen, + setSaveModalOpen, + saveName, + setSaveName, + saveTagIds, + setSaveTagIds, + saveNewTag, + setSaveNewTag, + allTags, + saveToLibraryMutation, + handleOpenSaveModal, + handleConfirmSave, + handleAddTagInModal, + } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts b/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts new file mode 100644 index 000000000..3c6447c9b --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useTtsSynthesis.ts @@ -0,0 +1,96 @@ +import { useState, useCallback, useEffect } from "react" +import { message } from "antd" +import { useMutation } from "@tanstack/react-query" +import { synthesizeSpeech, getTTSJobStatus } from "@/api/tts" + +/** + * TTS 自定义合成 + 轮询状态 + */ +export function useTtsSynthesis(selectedVoice: string) { + const [customVoiceText, setCustomVoiceText] = useState("") + const [customAudioUrl, setCustomAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const [ttsJobId, setTtsJobId] = useState(null) + const [completedTtsJobId, setCompletedTtsJobId] = useState(null) + + const synthesizeMutation = useMutation({ + mutationFn: synthesizeSpeech, + onSuccess: (data) => { + setTtsJobId(data.job_id) + message.info("语音合成已提交,等待处理…") + }, + onError: () => { + setTtsError("语音合成请求失败,请重试") + }, + }) + + /* 轮询 TTS 任务状态 */ + useEffect(() => { + if (!ttsJobId) return + let cancelled = false + let timer: ReturnType + + const poll = async () => { + try { + const status = await getTTSJobStatus(ttsJobId) + if (cancelled) return + if (status.status === "completed") { + setCustomAudioUrl(status.output_audio_url) + setCompletedTtsJobId(ttsJobId) + setTtsJobId(null) + setTtsError(null) + message.success("语音合成完成!") + return + } + if (status.status === "failed" || status.status === "cancelled") { + setTtsError(status.error_message || "语音合成失败") + setTtsJobId(null) + return + } + timer = setTimeout(poll, 2000) + } catch { + if (!cancelled) { + setTtsError("查询合成状态失败") + setTtsJobId(null) + } + } + } + + timer = setTimeout(poll, 2000) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [ttsJobId]) + + const handleSynthesizeVoice = useCallback(() => { + if (!customVoiceText.trim()) { + message.warning("请先输入配音文案") + return + } + setTtsError(null) + setCustomAudioUrl(null) + synthesizeMutation.mutate({ + text: customVoiceText.trim(), + voice_id: selectedVoice || undefined, + language: "zh-CN", + }) + }, [customVoiceText, selectedVoice, synthesizeMutation]) + + const resetTtsState = useCallback(() => { + setCompletedTtsJobId(null) + setCustomAudioUrl(null) + }, []) + + return { + customVoiceText, + setCustomVoiceText, + customAudioUrl, + ttsError, + ttsJobId, + completedTtsJobId, + synthesizeMutation, + handleSynthesizeVoice, + resetTtsState, + } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts new file mode 100644 index 000000000..70691ef6c --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceAudio.ts @@ -0,0 +1,39 @@ +import { useState, useRef, useCallback } from "react" +import { message } from "antd" + +/** + * 音色试听播放控制 + */ +export function useVoiceAudio() { + const audioRef = useRef(null) + const [playingVoice, setPlayingVoice] = useState(null) + + const toggleVoicePlay = useCallback( + (voiceId: string, previewUrl: string | null) => { + if (playingVoice === voiceId) { + audioRef.current?.pause() + audioRef.current = null + setPlayingVoice(null) + return + } + audioRef.current?.pause() + if (!previewUrl) { + message.warning("该音色暂无试听音频") + return + } + const audio = new Audio(previewUrl) + audioRef.current = audio + audio.play().catch(() => { + message.error("播放失败,请检查网络") + }) + audio.onended = () => { + setPlayingVoice(null) + audioRef.current = null + } + setPlayingVoice(voiceId) + }, + [playingVoice], + ) + + return { playingVoice, toggleVoicePlay } +} diff --git a/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts new file mode 100644 index 000000000..de78ffc8a --- /dev/null +++ b/apps/web/src/pages/generate/hooks/step5-voice/useVoiceRecommend.ts @@ -0,0 +1,68 @@ +import { useState, useCallback, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { fetchPresetVoices } from "@/api/voices" +import type { PresetVoiceItem } from "@/api/voices" + +/** + * 智能配音推荐 + * 根据标题内容风格模拟推荐音色 + */ +export function useVoiceRecommend(titleText: string) { + const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ + queryKey: ["preset-voices"], + queryFn: fetchPresetVoices, + }) + + const presetVoices: PresetVoiceItem[] = useMemo( + () => presetVoicesData?.items ?? [], + [presetVoicesData], + ) + + const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) + const [voiceRecommendations, setVoiceRecommendations] = useState([]) + const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) + + const handleVoiceRecommend = useCallback(async () => { + if (presetVoices.length === 0) return + setVoiceRecommendLoading(true) + setHasVoiceRecommend(true) + + await new Promise((resolve) => setTimeout(resolve, 1000)) + + const title = titleText.toLowerCase() + let recommended: string[] = [] + + const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) + const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) + const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) + + if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { + recommended = femaleVoices.slice(0, 3) + } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { + recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) + } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { + recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) + } else { + recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) + } + + if (recommended.length < 3) { + const others = presetVoices + .filter((v) => !recommended.includes(v.voice_id)) + .map((v) => v.voice_id) + recommended = recommended.concat(others.slice(0, 3 - recommended.length)) + } + + setVoiceRecommendations(recommended) + setVoiceRecommendLoading(false) + }, [presetVoices, titleText]) + + return { + presetVoices, + presetVoicesLoading, + voiceRecommendLoading, + voiceRecommendations, + hasVoiceRecommend, + handleVoiceRecommend, + } +} diff --git a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx index cf6dea7fe..54a85fd28 100644 --- a/apps/web/src/pages/generate/hooks/useStep5Voice.tsx +++ b/apps/web/src/pages/generate/hooks/useStep5Voice.tsx @@ -2,17 +2,15 @@ * Step 5 配音选择 Hook * 封装 AI 推荐、预设音色试听、TTS 自定义合成、存为素材等逻辑 */ -import { useState, useRef, useCallback, useEffect, useMemo } from "react" -import { useNavigate } from "react-router-dom" -import { message } from "antd" -import { useQuery, useMutation } from "@tanstack/react-query" -import type { PresetVoiceItem } from "@/api/voices" -import { fetchPresetVoices } from "@/api/voices" -import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" -import { getTags, createTag } from "@/api/tags" -import { formatDuration } from "../utils/formatDuration" +import { useCallback } from "react" import type { VoiceClone } from "@/api/voice-clone" +import { message } from "antd" import { VOICE_GENDER_ICON, CLONE_STATUS_CONFIG } from "../constants" +import { formatDuration } from "../utils/formatDuration" +import { useVoiceAudio } from "./step5-voice/useVoiceAudio" +import { useVoiceRecommend } from "./step5-voice/useVoiceRecommend" +import { useTtsSynthesis } from "./step5-voice/useTtsSynthesis" +import { useSaveToLibrary } from "./step5-voice/useSaveToLibrary" interface UseStep5VoiceProps { selectedVoice: string @@ -43,91 +41,47 @@ export function useStep5Voice({ onCloneModalOpenChange, titleText, }: UseStep5VoiceProps) { - const navigate = useNavigate() - /* ── 预置音色 API ── */ - const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ - queryKey: ["preset-voices"], - queryFn: fetchPresetVoices, - }) - const presetVoices: PresetVoiceItem[] = useMemo( - () => presetVoicesData?.items ?? [], - [presetVoicesData], - ) + /* ── 子模块 ── */ + const { playingVoice, toggleVoicePlay } = useVoiceAudio() - /* ── 音频播放 ── */ - const audioRef = useRef(null) - const [playingVoice, setPlayingVoice] = useState(null) + const { + presetVoices, + presetVoicesLoading, + voiceRecommendLoading, + voiceRecommendations, + hasVoiceRecommend, + handleVoiceRecommend, + } = useVoiceRecommend(titleText) - const toggleVoicePlay = useCallback( - (voiceId: string, previewUrl: string | null) => { - if (playingVoice === voiceId) { - audioRef.current?.pause() - audioRef.current = null - setPlayingVoice(null) - return - } - audioRef.current?.pause() - if (!previewUrl) { - message.warning("该音色暂无试听音频") - return - } - const audio = new Audio(previewUrl) - audioRef.current = audio - audio.play().catch(() => { - message.error("播放失败,请检查网络") - }) - audio.onended = () => { - setPlayingVoice(null) - audioRef.current = null - } - setPlayingVoice(voiceId) - }, - [playingVoice], - ) + const { + customVoiceText, + setCustomVoiceText, + customAudioUrl, + ttsError, + ttsJobId, + completedTtsJobId, + synthesizeMutation, + handleSynthesizeVoice, + resetTtsState, + } = useTtsSynthesis(selectedVoice) - /* ── 智能配音推荐 ── */ - const [voiceRecommendLoading, setVoiceRecommendLoading] = useState(false) - const [voiceRecommendations, setVoiceRecommendations] = useState([]) - const [hasVoiceRecommend, setHasVoiceRecommend] = useState(false) - - const handleVoiceRecommend = useCallback(async () => { - if (presetVoices.length === 0) return - setVoiceRecommendLoading(true) - setHasVoiceRecommend(true) - - await new Promise((resolve) => setTimeout(resolve, 1000)) - - // 根据标题内容风格模拟推荐:情感类→温柔女声,知识类→沉稳男声,活力类→阳光少年 - const title = titleText.toLowerCase() - let recommended: string[] = [] - - const femaleVoices = presetVoices.filter((v) => v.gender === "female").map((v) => v.voice_id) - const maleVoices = presetVoices.filter((v) => v.gender === "male").map((v) => v.voice_id) - const childVoices = presetVoices.filter((v) => v.gender === "child").map((v) => v.voice_id) - - if (/情感|感人|温暖|治愈|故事|回忆/.test(title)) { - recommended = femaleVoices.slice(0, 3) - } else if (/教程|知识|科普|干货|讲解|分析/.test(title)) { - recommended = maleVoices.slice(0, 2).concat(femaleVoices.slice(0, 1)) - } else if (/活力|热血|运动|搞笑|有趣/.test(title)) { - recommended = childVoices.slice(0, 1).concat(maleVoices.slice(0, 1), femaleVoices.slice(0, 1)) - } else { - // 默认推荐前3个 - recommended = presetVoices.slice(0, 3).map((v) => v.voice_id) - } - - // 不足3个时补足 - if (recommended.length < 3) { - const others = presetVoices - .filter((v) => !recommended.includes(v.voice_id)) - .map((v) => v.voice_id) - recommended = recommended.concat(others.slice(0, 3 - recommended.length)) - } - - setVoiceRecommendations(recommended) - setVoiceRecommendLoading(false) - }, [presetVoices, titleText]) + const { + saveModalOpen, + setSaveModalOpen, + saveName, + setSaveName, + saveTagIds, + setSaveTagIds, + saveNewTag, + setSaveNewTag, + allTags, + saveToLibraryMutation, + handleOpenSaveModal, + handleConfirmSave, + handleAddTagInModal, + } = useSaveToLibrary(completedTtsJobId, resetTtsState) + /* ── 推荐音色选择 ── */ const handleSelectRecommendedVoice = useCallback( (voiceId: string) => { onVoiceModeChange("preset") @@ -136,173 +90,6 @@ export function useStep5Voice({ [onVoiceModeChange, onSelectedVoiceChange], ) - /* ── TTS 自定义合成状态 ── */ - const [customVoiceText, setCustomVoiceText] = useState("") - const [customAudioUrl, setCustomAudioUrl] = useState(null) - const [ttsError, setTtsError] = useState(null) - const [ttsJobId, setTtsJobId] = useState(null) - /** 合成完成后保留的 job ID,用于"存为素材" */ - const [completedTtsJobId, setCompletedTtsJobId] = useState(null) - - /* ── TTS mutation ── */ - const synthesizeMutation = useMutation({ - mutationFn: synthesizeSpeech, - onSuccess: (data) => { - setTtsJobId(data.job_id) - message.info("语音合成已提交,等待处理…") - }, - onError: () => { - setTtsError("语音合成请求失败,请重试") - }, - }) - - /** 轮询 TTS 任务状态 */ - useEffect(() => { - if (!ttsJobId) return - let cancelled = false - let timer: ReturnType - - const poll = async () => { - try { - const status = await getTTSJobStatus(ttsJobId) - if (cancelled) return - if (status.status === "completed") { - setCustomAudioUrl(status.output_audio_url) - setCompletedTtsJobId(ttsJobId) - setTtsJobId(null) - setTtsError(null) - message.success("语音合成完成!") - return - } - if (status.status === "failed" || status.status === "cancelled") { - setTtsError(status.error_message || "语音合成失败") - setTtsJobId(null) - return - } - timer = setTimeout(poll, 2000) - } catch { - if (!cancelled) { - setTtsError("查询合成状态失败") - setTtsJobId(null) - } - } - } - - timer = setTimeout(poll, 2000) - return () => { - cancelled = true - clearTimeout(timer) - } - }, [ttsJobId]) - - /** 触发自定义文本 TTS 合成 */ - const handleSynthesizeVoice = useCallback(() => { - if (!customVoiceText.trim()) { - message.warning("请先输入配音文案") - return - } - setTtsError(null) - setCustomAudioUrl(null) - synthesizeMutation.mutate({ - text: customVoiceText.trim(), - voice_id: selectedVoice || undefined, - language: "zh-CN", - }) - }, [customVoiceText, selectedVoice, synthesizeMutation]) - - /* ── 存为素材弹窗状态 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false) - const [saveName, setSaveName] = useState("") - const [saveTagIds, setSaveTagIds] = useState([]) - const [saveNewTag, setSaveNewTag] = useState("") - - /* ── 标签列表(用于存为素材弹窗) ── */ - const { data: allTags = [] } = useQuery({ - queryKey: ["generate-save-tags"], - queryFn: getTags, - staleTime: 30_000, - }) - - /* ── 存为素材 mutation ── */ - const saveToLibraryMutation = useMutation({ - mutationFn: (params: { name?: string; tag_ids?: string[] }) => - saveTtsToLibrary(completedTtsJobId!, params), - onSuccess: () => { - message.success({ - content: ( - - 已保存到配音库!{" "} - - 去视频库查看 - - - ), - duration: 5, - }) - setSaveModalOpen(false) - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setCompletedTtsJobId(null) - setCustomAudioUrl(null) - }, - onError: (err: Error) => { - message.error(`保存失败:${err.message || "请重试"}`) - }, - }) - - /** 打开存为素材弹窗 */ - const handleOpenSaveModal = useCallback(() => { - setSaveName("") - setSaveTagIds([]) - setSaveNewTag("") - setSaveModalOpen(true) - }, []) - - /** 确认保存 */ - const handleConfirmSave = useCallback(() => { - if (!completedTtsJobId) return - saveToLibraryMutation.mutate({ - name: saveName.trim() || undefined, - tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, - }) - }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) - - /** 在弹窗中新增标签(先创建再选中) */ - const handleAddTagInModal = useCallback( - async (tagName: string) => { - const trimmed = tagName.trim() - if (!trimmed) return - /* 已在选中列表则跳过 */ - const existing = allTags.find((t) => t.name === trimmed) - if (existing) { - if (!saveTagIds.includes(existing.id)) { - setSaveTagIds((prev) => [...prev, existing.id]) - } - return - } - try { - const created = await createTag(trimmed) - setSaveTagIds((prev) => [...prev, created.id]) - setSaveNewTag("") - } catch { - message.error(`创建标签"${trimmed}"失败`) - } - }, - [allTags, saveTagIds], - ) - - /** 保存成功后跳转到视频库 */ - const handleGoToLibrary = useCallback(() => { - navigate("/app/voice-materials") - }, [navigate]) - /* ── 克隆成功回调 ── */ const handleCloneSuccess = useCallback( (voice: VoiceClone) => { -- 2.54.0