feat(P1): 智能剪辑配音支持从配音库选择 #566

Merged
auto-approve-bot merged 3 commits from feat/smart-edit-voice-library into develop 2026-07-19 10:55:19 +08:00
+157 -2
View File
@@ -24,7 +24,7 @@ import {
CloseOutlined,
} from "@ant-design/icons"
import type { AssetItem } from "@/api/assets"
import { getAssets, getAssetLibraries } from "@/api/assets"
import { getAssets, getAssetLibraries, getAssetsByKind } from "@/api/assets"
import {
createEditPlan,
generateEditPlan,
@@ -129,8 +129,15 @@ const GeneratePage: React.FC = () => {
/* ── 配音 ── */
const [selectedVoice, setSelectedVoice] = useState<string>("")
const [playingVoice, setPlayingVoice] = useState<string | null>(null)
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset")
const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone" | "library">("preset")
const [customVoiceText, setCustomVoiceText] = useState("")
/* ── 从配音库选择 ── */
const [selectedVoiceAssetId, setSelectedVoiceAssetId] = useState("")
const { data: voiceAssets = [], isLoading: voiceAssetsLoading } = useQuery({
queryKey: ["voice-assets-library"],
queryFn: () => getAssetsByKind("voice"),
staleTime: 60_000,
})
/* ── 生成数量 ── */
const [generateCount, setGenerateCount] = useState(1)
@@ -465,6 +472,10 @@ const GeneratePage: React.FC = () => {
message.warning("请先选择一个克隆音色")
return
}
if (voiceMode === "library" && !selectedVoiceAssetId) {
message.warning("请从配音库选择一个配音")
return
}
setGenerating(true)
setProgress(0)
@@ -484,6 +495,11 @@ const GeneratePage: React.FC = () => {
voiceConfig.voice_id = selectedVoice || undefined
if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl
if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim()
} else if (voiceMode === "library") {
const voiceAsset = voiceAssets.find((a) => a.id === selectedVoiceAssetId)
if (voiceAsset?.file_url) {
voiceConfig.custom_audio_url = voiceAsset.file_url
}
}
const plan = await createEditPlan({
@@ -678,6 +694,8 @@ const GeneratePage: React.FC = () => {
selectedVoice,
voiceMode,
selectedClonedVoice,
selectedVoiceAssetId,
voiceAssets,
customAudioUrl,
customVoiceText,
videoRatio,
@@ -761,6 +779,10 @@ const GeneratePage: React.FC = () => {
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
return cv ? cv.name : "未选择"
}
if (voiceMode === "library") {
const va = voiceAssets.find((a) => a.id === selectedVoiceAssetId)
return va ? va.name : "未选择"
}
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
return pv ? pv.name : "未选择"
}
@@ -1114,6 +1136,32 @@ const GeneratePage: React.FC = () => {
<p></p>
</div>
</div>
{/* 从配音库选择 */}
<div
className={`xx-voice-choice-item ${voiceMode === "library" ? "selected" : ""}`}
onClick={() => setVoiceMode("library")}
role="button"
tabIndex={0}
aria-pressed={voiceMode === "library"}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setVoiceMode("library")
}
}}
>
<span className="xx-voice-choice-check"></span>
<div
className="xx-voice-choice-avatar"
style={{ background: "linear-gradient(135deg, #f59e0b, #d97706)" }}
>
📁
</div>
<div className="xx-voice-choice-info">
<h4></h4>
<p></p>
</div>
</div>
</div>
{/* 预设音色详细列表(当 voiceMode === preset 时显示 API 返回的音色) */}
@@ -1167,6 +1215,113 @@ const GeneratePage: React.FC = () => {
</div>
)}
{/* 从配音库选择 */}
{voiceMode === "library" && (
<div>
<div className="xx-form-field">
<label></label>
{voiceAssetsLoading ? (
<p style={{ color: "var(--text-tertiary)", fontSize: 13 }}></p>
) : voiceAssets.length === 0 ? (
<div style={{ padding: "20px 0", textAlign: "center" }}>
<p style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</p>
</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gap: 10,
maxHeight: 300,
overflowY: "auto",
}}
>
{voiceAssets.map((asset) => (
<div
key={asset.id}
onClick={() => {
setSelectedVoiceAssetId(asset.id)
}}
style={{
padding: 12,
border: `2px solid ${
selectedVoiceAssetId === asset.id
? "var(--primary, #6366f1)"
: "var(--border-color, #e2e8f0)"
}`,
borderRadius: "var(--radius-sm, 10px)",
cursor: "pointer",
background:
selectedVoiceAssetId === asset.id
? "rgba(99, 102, 241, 0.05)"
: "transparent",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 6,
}}
>
<span style={{ fontSize: 18 }}>🎵</span>
<span
style={{
fontWeight: 500,
fontSize: 13,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={asset.name}
>
{asset.name}
</span>
</div>
<div
style={{
fontSize: 11,
color: "var(--text-tertiary)",
display: "flex",
justifyContent: "space-between",
}}
>
<span>
{asset.duration
? `${Math.floor(asset.duration / 60)}:${String(asset.duration % 60).padStart(2, "0")}`
: "—"}
</span>
{asset.file_url && (
<button
onClick={(e) => {
e.stopPropagation()
toggleVoicePlay(asset.id, asset.file_url!)
}}
style={{
background: "none",
border: "none",
color: "var(--primary)",
cursor: "pointer",
fontSize: 11,
padding: 0,
}}
>
{playingVoice === asset.id ? "⏸ 暂停" : "▶ 试听"}
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* 自定义录制 */}
{voiceMode === "custom" && (
<div>