feat: Step5 配音选择改为展示用户上传的配音素材 #1252
@@ -94,14 +94,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceMode,
|
||||
onVoiceModeChange,
|
||||
selectedClonedVoice,
|
||||
onSelectedClonedVoiceChange,
|
||||
clonedVoices,
|
||||
addClone,
|
||||
hasProcessing,
|
||||
cloneModalOpen,
|
||||
onCloneModalOpenChange,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
@@ -170,16 +164,6 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
<Step5VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={onVoiceModeChange}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
onSelectedClonedVoiceChange={onSelectedClonedVoiceChange}
|
||||
clonedVoices={clonedVoices}
|
||||
addClone={addClone}
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={onCloneModalOpenChange}
|
||||
titleText={titleSettings.title}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
|
||||
@@ -1,137 +1,250 @@
|
||||
/**
|
||||
* Step 5 配音选择组件
|
||||
* 展示用户已上传的配音素材,支持选中、预览播放
|
||||
*/
|
||||
import React from "react"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useStep5Voice } from "../hooks/useStep5Voice"
|
||||
import VoiceRecommendSection from "./voice/VoiceRecommendSection"
|
||||
import VoiceChoiceCard from "./voice/VoiceChoiceCard"
|
||||
import PresetVoiceDetail from "./voice/PresetVoiceDetail"
|
||||
import CustomVoicePanel from "./voice/CustomVoicePanel"
|
||||
import SaveVoiceModal from "./voice/SaveVoiceModal"
|
||||
import CloneVoiceSection from "./voice/CloneVoiceSection"
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
|
||||
const v = useStep5Voice(props)
|
||||
/** 格式化时长 mm:ss */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes || bytes <= 0) return "未知"
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
}) => {
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// 获取用户上传的配音素材
|
||||
const { data: materials = [], isLoading } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const togglePlay = useCallback(
|
||||
(material: AssetItem) => {
|
||||
// 如果当前正在播放同一个素材,则暂停
|
||||
if (playingId === material.id && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前的播放
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
|
||||
// 创建新的 audio 元素播放
|
||||
const audio = new Audio(material.file_url)
|
||||
audioRef.current = audio
|
||||
setPlayingId(material.id)
|
||||
|
||||
audio.onended = () => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.play().catch(() => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
})
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
|
||||
/** 选中素材 */
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelectedVoiceChange(id)
|
||||
},
|
||||
[onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/** 跳转到配音库上传 */
|
||||
const handleGoToUpload = useCallback(() => {
|
||||
window.location.href = "/voices"
|
||||
}, [])
|
||||
|
||||
// 加载中状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
||||
加载配音素材中...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 空状态
|
||||
if (materials.length === 0) {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "60px 0",
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 16 }} />
|
||||
<p style={{ fontSize: 16, marginBottom: 16 }}>暂无配音素材</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToUpload}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
去配音库上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
|
||||
从配音库中选择已上传的素材,点击卡片可预览播放
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{materials.map((item) => {
|
||||
const isSelected = selectedVoice === item.id
|
||||
const isPlaying = playingId === item.id
|
||||
|
||||
<VoiceRecommendSection
|
||||
presetVoices={v.presetVoices}
|
||||
voiceRecommendLoading={v.voiceRecommendLoading}
|
||||
voiceRecommendations={v.voiceRecommendations}
|
||||
hasVoiceRecommend={v.hasVoiceRecommend}
|
||||
onRecommend={v.handleVoiceRecommend}
|
||||
onSelectVoice={v.handleSelectRecommendedVoice}
|
||||
selectedVoiceId={v.selectedVoice}
|
||||
voiceMode={v.voiceMode}
|
||||
VOICE_GENDER_ICON={v.VOICE_GENDER_ICON}
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
background: isSelected ? "#e6f4ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{/* 顶部:图标 + 播放按钮 */}
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
background: isSelected
|
||||
? "linear-gradient(135deg, #1677ff, #4096ff)"
|
||||
: "linear-gradient(135deg, #f0f0f0, #e8e8e8)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined style={{ fontSize: 20, color: isSelected ? "#fff" : "#666" }} />
|
||||
</div>
|
||||
{item.file_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePlay(item)
|
||||
}}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
background: isPlaying ? "#ff4d4f" : "#1677ff",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 14,
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
<SoundOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>全部音色</span>
|
||||
{/* 名称 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "#333",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
|
||||
{/* 时长 + 大小 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
<span>{formatDuration(item.duration)}</span>
|
||||
<span>{formatFileSize(item.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
||||
{v.presetVoices.slice(0, 3).map((pv) => (
|
||||
<VoiceChoiceCard
|
||||
key={pv.voice_id}
|
||||
selected={v.voiceMode === "preset" && v.selectedVoice === pv.voice_id}
|
||||
onClick={() => v.handleSelectPresetVoice(pv.voice_id)}
|
||||
avatar={v.VOICE_GENDER_ICON[pv.gender] ?? "✨"}
|
||||
title={pv.name}
|
||||
description={pv.description}
|
||||
/>
|
||||
))}
|
||||
{v.presetVoicesLoading && (
|
||||
<VoiceChoiceCard
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
avatar="⏳"
|
||||
title="加载中…"
|
||||
loading
|
||||
/>
|
||||
)}
|
||||
<VoiceChoiceCard
|
||||
selected={v.voiceMode === "clone"}
|
||||
onClick={v.handleSelectCloneVoice}
|
||||
avatar="🎤"
|
||||
title="克隆我的声音"
|
||||
description="上传语音样本克隆"
|
||||
avatarStyle={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{v.voiceMode === "preset" && (
|
||||
<PresetVoiceDetail
|
||||
presetVoices={v.presetVoices}
|
||||
selectedVoice={v.selectedVoice}
|
||||
onSelect={v.handleSelectPresetVoice}
|
||||
playingVoice={v.playingVoice}
|
||||
onTogglePlay={v.toggleVoicePlay}
|
||||
presetVoicesLoading={v.presetVoicesLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "custom" && (
|
||||
<>
|
||||
<CustomVoicePanel
|
||||
customVoiceText={v.customVoiceText}
|
||||
onTextChange={v.setCustomVoiceText}
|
||||
synthesizePending={v.synthesizeMutation.isPending}
|
||||
onSynthesize={v.handleSynthesizeVoice}
|
||||
ttsError={v.ttsError}
|
||||
customAudioUrl={v.customAudioUrl}
|
||||
completedTtsJobId={v.completedTtsJobId}
|
||||
onOpenSaveModal={v.handleOpenSaveModal}
|
||||
/>
|
||||
<SaveVoiceModal
|
||||
open={v.saveModalOpen}
|
||||
onClose={() => v.setSaveModalOpen(false)}
|
||||
saveName={v.saveName}
|
||||
onNameChange={v.setSaveName}
|
||||
saveTagIds={v.saveTagIds}
|
||||
onTagIdsChange={v.setSaveTagIds}
|
||||
saveNewTag={v.saveNewTag}
|
||||
onNewTagChange={v.setSaveNewTag}
|
||||
onAddTag={v.handleAddTagInModal}
|
||||
allTags={v.allTags}
|
||||
savePending={v.saveToLibraryMutation.isPending}
|
||||
onConfirm={v.handleConfirmSave}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "clone" && (
|
||||
<CloneVoiceSection
|
||||
clonedVoices={v.clonedVoices}
|
||||
hasProcessing={v.hasProcessing}
|
||||
selectedClonedVoice={v.selectedClonedVoice}
|
||||
onSelect={v.handleSelectClonedVoice}
|
||||
onOpenCloneModal={v.handleOpenCloneModal}
|
||||
playingVoiceId={v.playingCloneVoice}
|
||||
onPlaySample={v.handlePlayCloneSample}
|
||||
CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG}
|
||||
formatDuration={v.formatDuration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* 封装生成确认页的展示逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { COVER_MODE_LABELS } from "../constants"
|
||||
|
||||
interface UseStep7GenerateProps {
|
||||
@@ -39,11 +41,11 @@ export function useStep7Generate({
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
title,
|
||||
voiceMode,
|
||||
voiceMode: _voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
selectedClonedVoice: _selectedClonedVoice,
|
||||
presetVoices: _presetVoices,
|
||||
clonedVoices: _clonedVoices,
|
||||
coverSettings,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
@@ -65,14 +67,16 @@ export function useStep7Generate({
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
// 从配音素材库中查找 voiceName
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
const voiceName = useMemo(() => {
|
||||
if (voiceMode === "clone") {
|
||||
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
|
||||
return cv ? cv.name : "未选择"
|
||||
}
|
||||
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
|
||||
return pv ? pv.name : "未选择"
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice, presetVoices, clonedVoices])
|
||||
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
|
||||
return asset ? asset.name : "未选择"
|
||||
}, [voiceMaterials, selectedVoice])
|
||||
|
||||
const coverSummary = useMemo(() => {
|
||||
if (!coverSettings.enabled) return "不使用"
|
||||
|
||||
Reference in New Issue
Block a user