feat(viral-video): 音色选择默认改为"我的音色"克隆音色主路径 #2189
@@ -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(空则使用服务端默认) */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user