diff --git a/apps/web/src/api/viral-video/types.ts b/apps/web/src/api/viral-video/types.ts index 835c4f362..ad6a56052 100644 --- a/apps/web/src/api/viral-video/types.ts +++ b/apps/web/src/api/viral-video/types.ts @@ -176,7 +176,7 @@ export interface ViralVideoJob { fusion_level?: FusionLevel voice_id?: string voice_mode?: "global" | "per_video" - voice_source?: "preset" | "library" | "clone" | "upload" + voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice" bgm_preference?: string intent_result?: IntentResult intent_text?: string @@ -212,7 +212,7 @@ export interface GenerateViralVideoRequest { user_copy_text?: string fusion_level?: FusionLevel voice_id?: string - voice_source?: "preset" | "library" | "clone" | "upload" + voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice" bgm_preference?: string industry?: string target_customer?: string @@ -244,7 +244,7 @@ export interface AnalyzeImagesRequest { /** TTS 音色 ID(STEP1 已选音色时传) */ voice_id?: string /** 音色来源:preset | library | clone | upload */ - voice_source?: "preset" | "library" | "clone" | "upload" + voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice" /** Seedance 视频比例:9:16 | 16:9 | 1:1 */ video_ratio?: string /** Seedance 模型 ID(空则使用服务端默认) */ @@ -273,7 +273,7 @@ export interface GenerateCopyRequest { /** TTS 音色 ID(优先级高于 persona_id) */ voice_id?: string /** 音色来源:preset | library | clone | upload */ - voice_source?: "preset" | "library" | "clone" | "upload" + voice_source?: "preset" | "library" | "clone" | "upload" | "my_voice" /** Seedance 视频比例(9:16/16:9/1:1 等) */ video_ratio?: string /** Seedance 模型 ID(空则使用服务端默认) */ diff --git a/apps/web/src/api/voice-clone/types.ts b/apps/web/src/api/voice-clone/types.ts index c0acb8416..f2b9a901a 100644 --- a/apps/web/src/api/voice-clone/types.ts +++ b/apps/web/src/api/voice-clone/types.ts @@ -18,6 +18,8 @@ export interface VoiceClone { language: string gender: string error_message: string | null + /** CosyVoice 实际使用的音色 ID(status=ready 时由后端填充,用于 TTS 调用) */ + voice_id?: string | null created_at: string updated_at: string } diff --git a/apps/web/src/api/voice-clone/utils.ts b/apps/web/src/api/voice-clone/utils.ts index 43a42a5b1..d907bd31c 100644 --- a/apps/web/src/api/voice-clone/utils.ts +++ b/apps/web/src/api/voice-clone/utils.ts @@ -18,6 +18,7 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({ language: profile.language || "", gender: profile.gender || "", error_message: profile.error_message || null, + voice_id: profile.voice_id, created_at: profile.created_at, updated_at: profile.updated_at, }) diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.css b/apps/web/src/pages/viral-video/ViralVideoPage.css index aa152c3ca..d6d6b6dc1 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.css +++ b/apps/web/src/pages/viral-video/ViralVideoPage.css @@ -1834,3 +1834,117 @@ color: #7c3aed; font-weight: 500; } + +/* ── v1.6 我的音色(默认主路径) ── */ +.vv-voice-section { + margin-top: 4px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + padding: 10px; +} +.vv-voice-section-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} +.vv-voice-section-title { + font-size: 13px; + font-weight: 600; + color: #1f2937; + display: inline-flex; + align-items: center; + gap: 6px; +} +.vv-voice-section-title .anticon { + color: #7c3aed; +} +.vv-link-btn-sm { + font-size: 12px; + padding: 2px 6px; + display: inline-flex; + align-items: center; + gap: 4px; +} +.vv-my-voice-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 220px; + overflow-y: auto; +} +.vv-my-voice-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} +.vv-my-voice-item:hover { + background: #f5f0ff; +} +.vv-my-voice-item.selected { + background: #f5f0ff; +} +.vv-my-voice-item.selected .vv-voice-radio { + border-color: #7c3aed; + background: #7c3aed; + box-shadow: inset 0 0 0 2px #fff; +} +.vv-voice-empty { + padding: 16px 12px; + text-align: center; + color: #9ca3af; + font-size: 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} +.vv-voice-empty-ic { + font-size: 28px; + color: #d1d5db; +} +.vv-voice-empty-text { + font-size: 12px; +} +.vv-voice-alt-row { + display: flex; + gap: 8px; + margin-top: 8px; +} +.vv-voice-alt-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 10px; + background: #fafafe; + border: 1px solid #e5e7eb; + border-radius: 6px; + color: #6b7280; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; +} +.vv-voice-alt-btn:hover { + border-color: #7c3aed; + color: #7c3aed; + background: #f5f0ff; +} +.vv-voice-alt-btn.selected { + background: #f5f0ff; + border-color: #7c3aed; + color: #7c3aed; +} +.vv-voice-panel-actions { + display: flex; + gap: 12px; + margin-bottom: 6px; + padding-bottom: 6px; + border-bottom: 1px dashed #e5e7eb; +} diff --git a/apps/web/src/pages/viral-video/ViralVideoPage.tsx b/apps/web/src/pages/viral-video/ViralVideoPage.tsx index 237cbf65e..29fb3db31 100644 --- a/apps/web/src/pages/viral-video/ViralVideoPage.tsx +++ b/apps/web/src/pages/viral-video/ViralVideoPage.tsx @@ -23,10 +23,13 @@ import { CheckCircleFilled, EditOutlined, HistoryOutlined, + UserOutlined, } from "@ant-design/icons" import { Select, Input, message } from "antd" import { uploadAssetDirect, getAssetLibraries, getAssetsByKind, type AssetItem } from "@/api/assets" import { fetchPresetVoices } from "@/api/voices" +import { getVoiceClones, getVoiceClonePreview } from "@/api/voice-clone" +import type { VoiceClone } from "@/api/voice-clone" import { FUSION_LEVELS, STYLE_STRENGTHS, @@ -94,13 +97,15 @@ type RefVideo = { } | null type RefAudio = { - source: "preset" | "library" | "upload" | "clone" + source: "preset" | "library" | "upload" | "clone" | "my_voice" id?: string name: string url?: string + /** 克隆音色的 CosyVoice voice_id(source=my_voice 时使用) */ + ttsVoiceId?: string } | null -type VoicePanel = "preset" | "upload" | "library" | "record" | "douyin" | null +type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | "douyin" | null type TabTask = { id: string @@ -485,6 +490,9 @@ const ViralVideoPage: React.FC = () => { { id: string; name: string; url: string; desc?: string }[] >([]) const [presetVoices, setPresetVoices] = useState([]) + /** 我的克隆音色(GET /voice-clones?status=ready) */ + const [myVoices, setMyVoices] = useState([]) + const [myVoicesLoading, setMyVoicesLoading] = useState(false) const [voicePickerOpen, setVoicePickerOpen] = useState(false) const [videoModels, setVideoModels] = useState(FALLBACK_VIDEO_MODELS) const [assetPicker, setAssetPicker] = useState<{ @@ -514,8 +522,18 @@ const ViralVideoPage: React.FC = () => { [activeId], ) - /* ── 加载音色 ── */ + /* ── 加载音色(我的克隆音色 + 上传的配音素材 + 预设音色) ── */ + const reloadMyVoices = useCallback(() => { + setMyVoicesLoading(true) + getVoiceClones({ status: "ready", limit: 50 }) + .then((list) => { + setMyVoices(list.filter((v) => v.status === "ready")) + }) + .catch(() => setMyVoices([])) + .finally(() => setMyVoicesLoading(false)) + }, []) useEffect(() => { + reloadMyVoices() getAssetsByKind("voice", { limit: 50 }) .then((list) => setVoiceAssets( @@ -523,7 +541,7 @@ const ViralVideoPage: React.FC = () => { id: a.id, name: a.name, url: a.file_url || "", - desc: a.duration ? `${Math.round(a.duration)}s` : "我的音色", + desc: a.duration ? `${Math.round(a.duration)}s` : "配音文件", })), ), ) @@ -544,7 +562,7 @@ const ViralVideoPage: React.FC = () => { // 接口失败兜底:弹窗内 MOCK_VOICES 会生效(voice_id 与后端 CosyVoice 一致) setPresetVoices([]) }) - }, []) + }, [reloadMyVoices]) /* ── 加载视频模型列表 ── */ useEffect(() => { @@ -914,21 +932,36 @@ const ViralVideoPage: React.FC = () => { [setTask, uploadFile], ) - const onCloneSuccess = (voice: { id?: string; name?: string }) => { + const onCloneSuccess = ( + voice: VoiceClone | { id?: string; name?: string; voice_id?: string | null }, + ) => { setCloneModalOpen(false) + // 克隆成功后刷新我的音色列表 + reloadMyVoices() + const vid = "voice_id" in voice ? voice.voice_id : undefined if (voice?.id) { setTask({ - refAudio: { source: "clone", id: voice.id, name: voice.name || "我录制的音色" }, + refAudio: { + source: "my_voice", + id: voice.id, + name: voice.name || "我录制的音色", + ttsVoiceId: vid || undefined, + }, voicePanel: null, }) getAssetsByKind("voice", { limit: 50 }) .then((list) => setVoiceAssets( - list.map((a) => ({ id: a.id, name: a.name, url: a.file_url || "", desc: "我的音色" })), + list.map((a) => ({ + id: a.id, + name: a.name, + url: a.file_url || "", + desc: a.duration ? `${Math.round(a.duration)}s` : "配音文件", + })), ), ) .catch(() => {}) - message.success("录音已完成,已自动选中") + message.success("音色克隆完成,已自动选中") } } @@ -1014,7 +1047,12 @@ const ViralVideoPage: React.FC = () => { : undefined, fusion_level: task.fusionLevel, style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, - voice_id: task.refAudio?.source === "preset" ? task.refAudio.id : undefined, + voice_id: + task.refAudio?.source === "preset" + ? task.refAudio.id + : task.refAudio?.source === "my_voice" + ? task.refAudio.ttsVoiceId || task.refAudio.id + : undefined, voice_source: task.refAudio?.source || undefined, video_ratio: task.videoRatio, video_model: task.videoModel, @@ -1058,7 +1096,12 @@ const ViralVideoPage: React.FC = () => { style_strength: task.refVideo?.ossUrl ? task.styleStrength : undefined, user_copy_text: task.storyboard?.voiceover_script || task.userCopy || undefined, fusion_level: task.fusionLevel, - voice_id: task.refAudio?.id || undefined, + voice_id: + task.refAudio?.source === "preset" + ? task.refAudio.id + : task.refAudio?.source === "my_voice" + ? task.refAudio.ttsVoiceId || task.refAudio.id + : undefined, voice_source: task.refAudio?.source, industry: task.industry || undefined, target_customer: task.targetCustomer || undefined, @@ -2033,43 +2076,119 @@ const ViralVideoPage: React.FC = () => { )} - {/* 参考音频 2x2 */} + {/* 配音选择(默认"我的音色") */}
- 参考音频 + AI 配音 选填 - 最大 10MB · 最长 30 秒 + + 默认使用我的克隆音色,也可选择内置音色或上传配音 +
-
+ + {/* 我的音色(主路径) */} +
+
+ + 我的音色 + + +
+ {myVoicesLoading ? ( +
加载中…
+ ) : myVoices.length === 0 ? ( +
+ +
还没有克隆音色
+ +
+ ) : ( +
+ {myVoices.map((v) => { + const selected = + task.refAudio?.source === "my_voice" && task.refAudio.id === v.id + const playing = task.playingVoiceId === `my-${v.id}` + return ( +
+ setTask({ + refAudio: { + source: "my_voice", + id: v.id, + name: v.name, + ttsVoiceId: v.voice_id || undefined, + }, + voicePanel: null, + }) + } + > +
+
+
{v.name}
+
+ AI 克隆声音 + {v.gender + ? ` · ${v.gender === "male" ? "男" : v.gender === "female" ? "女" : ""}` + : ""} +
+
+ +
+ ) + })} +
+ )} +
+ + {/* 次要选项:内置音色 / 上传配音 */} +
- -
+ { {task.refAudio && (
- 已选:{task.refAudio.name} + + 已选: + {task.refAudio.source === "my_voice" + ? `我的音色 · ${task.refAudio.name}` + : task.refAudio.source === "preset" + ? `内置 · ${task.refAudio.name}` + : task.refAudio.name} + @@ -2091,16 +2217,32 @@ const ViralVideoPage: React.FC = () => { )} {task.voicePanel === "library" && (
+
+ + +
{voiceAssets.length === 0 ? (
- 暂无已上传的音频 + 暂无已上传的配音文件
) : (
{voiceAssets.map((v) => (
pickVoiceAsset({ id: v.id,