feat(viral-video): 音色选择默认改为"我的音色"克隆音色主路径 #2189

Merged
auto-approve-bot merged 1 commits from feat/viral-video-my-voices-default into develop 2026-10-05 12:01:39 +08:00
5 changed files with 302 additions and 43 deletions
+4 -4
View File
@@ -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(空则使用服务端默认) */
+2
View File
@@ -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
}
+1
View File
@@ -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,
})
@@ -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;
}
+181 -39
View File
@@ -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<PresetVoice[]>([])
/** 我的克隆音色(GET /voice-clones?status=ready) */
const [myVoices, setMyVoices] = useState<VoiceClone[]>([])
const [myVoicesLoading, setMyVoicesLoading] = useState(false)
const [voicePickerOpen, setVoicePickerOpen] = useState(false)
const [videoModels, setVideoModels] = useState<ViralVideoModel[]>(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 = () => {
)}
</div>
{/* 参考音频 2x2 */}
{/* 配音选择(默认"我的音色") */}
<div className="vv-subblock">
<div className="vv-subblock-head">
<span className="vv-subblock-title">参考音频</span>
<span className="vv-subblock-title">AI 配音</span>
<span className="vv-opt-tag">选填</span>
<span className="vv-subblock-hint">最大 10MB · 最长 30 秒</span>
<span className="vv-subblock-hint">
默认使用我的克隆音色,也可选择内置音色或上传配音
</span>
</div>
<div className="vv-audio-grid">
{/* 我的音色(主路径) */}
<div className="vv-voice-section">
<div className="vv-voice-section-head">
<span className="vv-voice-section-title">
<UserOutlined /> 我的音色
</span>
<button
className="vv-link-btn vv-link-btn-sm"
onClick={() => setCloneModalOpen(true)}
>
<PlusOutlined /> 录制新音色
</button>
</div>
{myVoicesLoading ? (
<div className="vv-muted vv-voice-empty">加载中…</div>
) : myVoices.length === 0 ? (
<div className="vv-voice-empty">
<AudioMutedOutlined className="vv-voice-empty-ic" />
<div className="vv-voice-empty-text">还没有克隆音色</div>
<button
className="vv-btn vv-btn-primary vv-btn-sm"
onClick={() => setCloneModalOpen(true)}
>
去录制一个
</button>
</div>
) : (
<div className="vv-my-voice-list">
{myVoices.map((v) => {
const selected =
task.refAudio?.source === "my_voice" && task.refAudio.id === v.id
const playing = task.playingVoiceId === `my-${v.id}`
return (
<div
key={v.id}
className={`vv-my-voice-item ${selected ? "selected" : ""}`}
onClick={() =>
setTask({
refAudio: {
source: "my_voice",
id: v.id,
name: v.name,
ttsVoiceId: v.voice_id || undefined,
},
voicePanel: null,
})
}
>
<div className="vv-voice-radio" />
<div className="vv-voice-info">
<div className="vv-voice-name">{v.name}</div>
<div className="vv-voice-desc">
AI 克隆声音
{v.gender
? ` · ${v.gender === "male" ? "男" : v.gender === "female" ? "女" : ""}`
: ""}
</div>
</div>
<button
className={`vv-voice-play ${playing ? "playing" : ""}`}
onClick={(e) => {
e.stopPropagation()
// 克隆音色试听:优先 sample_url,否则调用 preview 接口
if (v.sample_url) {
toggleVoice({ id: `my-${v.id}`, url: v.sample_url })
} else {
message.info("正在获取试听音频…")
getVoiceClonePreview(v.id)
.then((r) => {
if (r.audio_url)
toggleVoice({ id: `my-${v.id}`, url: r.audio_url })
})
.catch(() => message.error("试听获取失败"))
}
}}
>
{playing ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
</button>
</div>
)
})}
</div>
)}
</div>
{/* 次要选项:内置音色 / 上传配音 */}
<div className="vv-voice-alt-row">
<button
className={`vv-audio-cell ${task.refAudio?.source === "preset" ? "selected" : ""}`}
className={`vv-voice-alt-btn ${task.refAudio?.source === "preset" ? "selected" : ""}`}
onClick={() => setVoicePickerOpen(true)}
>
<FileTextOutlined className="vv-audio-ic" />
<span>选择内置音色</span>
<FileTextOutlined /> 内置音色
</button>
<button
className={`vv-audio-cell ${task.voicePanel === "upload" ? "active" : ""} ${task.refAudio?.source === "upload" ? "selected" : ""}`}
onClick={() => voiceInputRef.current?.click()}
className={`vv-voice-alt-btn ${task.refAudio?.source === "upload" || task.refAudio?.source === "library" ? "selected" : ""}`}
onClick={() =>
setTask({ voicePanel: task.voicePanel === "library" ? null : "library" })
}
>
<UploadOutlined className="vv-audio-ic" />
<span>本地上传</span>
</button>
<button
className={`vv-audio-cell ${task.voicePanel === "library" ? "active" : ""} ${task.refAudio?.source === "library" ? "selected" : ""}`}
onClick={() => setAssetPicker({ open: true, kind: "voice", multiple: false })}
>
<FolderOpenOutlined className="vv-audio-ic" />
<span>从素材库选择</span>
</button>
<button
className={`vv-audio-cell ${task.voicePanel === "record" ? "active" : ""} ${task.refAudio?.source === "clone" ? "selected" : ""}`}
onClick={() => setCloneModalOpen(true)}
>
<AudioMutedOutlined className="vv-audio-ic" />
<span>直接录音</span>
<FolderOpenOutlined /> 上传配音
</button>
</div>
<input
ref={voiceInputRef}
type="file"
@@ -2083,7 +2202,14 @@ const ViralVideoPage: React.FC = () => {
{task.refAudio && (
<div className="vv-selected-audio">
<SoundOutlined style={{ color: "#7c3aed", marginRight: 6 }} />
<span style={{ flex: 1 }}>已选:{task.refAudio.name}</span>
<span style={{ flex: 1 }}>
已选:
{task.refAudio.source === "my_voice"
? `我的音色 · ${task.refAudio.name}`
: task.refAudio.source === "preset"
? `内置 · ${task.refAudio.name}`
: task.refAudio.name}
</span>
<button className="vv-link-btn" onClick={() => setTask({ refAudio: null })}>
移除
</button>
@@ -2091,16 +2217,32 @@ const ViralVideoPage: React.FC = () => {
)}
{task.voicePanel === "library" && (
<div className="vv-voice-panel">
<div className="vv-voice-panel-actions">
<button
className="vv-link-btn vv-link-btn-sm"
onClick={() => voiceInputRef.current?.click()}
>
<UploadOutlined /> 本地上传音频文件
</button>
<button
className="vv-link-btn vv-link-btn-sm"
onClick={() =>
setAssetPicker({ open: true, kind: "voice", multiple: false })
}
>
<FolderOpenOutlined /> 从素材库选择
</button>
</div>
{voiceAssets.length === 0 ? (
<div className="vv-muted" style={{ padding: 12, textAlign: "center" }}>
暂无已上传的音频
暂无已上传的配音文件
</div>
) : (
<div className="vv-voice-list">
{voiceAssets.map((v) => (
<div
key={v.id}
className={`vv-voice-item ${task.refAudio?.id === v.id && task.refAudio.source === "library" ? "selected" : ""}`}
className={`vv-voice-item ${task.refAudio?.id === v.id && (task.refAudio.source === "library" || task.refAudio.source === "upload") ? "selected" : ""}`}
onClick={() =>
pickVoiceAsset({
id: v.id,