diff --git a/apps/web/src/api/voice-clone/clones.ts b/apps/web/src/api/voice-clone/clones.ts index d4c4dfc79..f63e71124 100644 --- a/apps/web/src/api/voice-clone/clones.ts +++ b/apps/web/src/api/voice-clone/clones.ts @@ -44,14 +44,19 @@ export const getVoiceCloneDetail = async (id: string): Promise => { const payload: CreateVoiceCloneRequestFull = { name: data.name, description: data.description, - source_audio_url: data.audio_url, + } + // 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆 + if (data.asset_id) { + payload.asset_id = data.asset_id + } else { + payload.source_audio_url = data.audio_url } const response = await apiClient.post("/voice-clones", payload) return response.data diff --git a/apps/web/src/api/voice-clone/types.ts b/apps/web/src/api/voice-clone/types.ts index 7cc9096a3..c0acb8416 100644 --- a/apps/web/src/api/voice-clone/types.ts +++ b/apps/web/src/api/voice-clone/types.ts @@ -22,10 +22,13 @@ export interface VoiceClone { updated_at: string } -/** 创建克隆请求(前端简化版) */ +/** 创建克隆请求(前端简化版:audio_url 与 asset_id 二选一) */ export interface CreateVoiceCloneRequest { name: string - audio_url: string + /** 录音/文件上传后的音频 URL(与 asset_id 二选一) */ + audio_url?: string + /** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */ + asset_id?: string description?: string } @@ -72,11 +75,13 @@ export interface VoiceCloneStatusResponse { retry_count: number } -/** 后端创建克隆请求(完整版) */ +/** 后端创建克隆请求(完整版:source_audio_url 与 asset_id 二选一) */ export interface CreateVoiceCloneRequestFull { name: string description?: string - source_audio_url: string + source_audio_url?: string + /** 从配音素材选择克隆时传素材 ID */ + asset_id?: string voice_model?: string language?: string gender?: string diff --git a/apps/web/src/components/voice/CloneModal/clone-modal.css b/apps/web/src/components/voice/CloneModal/clone-modal.css index 35952976d..73e84f260 100644 --- a/apps/web/src/components/voice/CloneModal/clone-modal.css +++ b/apps/web/src/components/voice/CloneModal/clone-modal.css @@ -149,53 +149,29 @@ /* ── 上传区域 ───────────────────────────────────────────── */ -.xx-clonemodal-upload-zone { - border: 2px dashed var(--xx-color-border, #e5e7eb); +/* ── 素材选择空态 ─────────────────────────────────────────── */ + +.xx-clonemodal-asset-empty { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border: 1px dashed var(--xx-color-border, #e5e7eb); border-radius: var(--radius-sm); - padding: 28px 20px; - text-align: center; - cursor: pointer; - transition: all 0.2s ease; background: var(--xx-color-bg-secondary, #f9fafb); } -.xx-clonemodal-upload-zone:hover { - border-color: var(--xx-color-primary, #6366f1); - background: rgba(99, 102, 241, 0.03); -} - -.xx-clonemodal-upload-zone--active { - border-color: var(--xx-color-primary, #6366f1); - background: rgba(99, 102, 241, 0.06); -} - -.xx-clonemodal-upload-zone--has-file { - border-style: solid; - border-color: var(--xx-color-primary, #6366f1); - background: rgba(99, 102, 241, 0.04); -} - -.xx-clonemodal-upload-icon { - font-size: 32px; - margin-bottom: 8px; -} - -.xx-clonemodal-upload-title { - font-size: 14px; - font-weight: 500; - color: var(--xx-color-text, #111827); - margin: 0 0 4px; - word-break: break-all; -} - -.xx-clonemodal-upload-hint { - font-size: 12px; - color: var(--xx-color-text-secondary, #6b7280); +.xx-clonemodal-asset-empty-text { margin: 0; + font-size: 13px; + color: var(--xx-color-text-secondary, #6b7280); } /* ── 错误提示 ───────────────────────────────────────────── */ +/* ── 错误提示 ───────────────────────────────────────────── */ + .xx-clonemodal-error { display: flex; align-items: center; diff --git a/apps/web/src/components/voice/CloneModal/constants.ts b/apps/web/src/components/voice/CloneModal/constants.ts index b1f6fd75f..65908b85e 100644 --- a/apps/web/src/components/voice/CloneModal/constants.ts +++ b/apps/web/src/components/voice/CloneModal/constants.ts @@ -7,14 +7,5 @@ export const PROGRESS_STEPS: ProgressStep[] = [ { key: "done", label: "完成", icon: "✅" }, ] -/** 支持的音频扩展名 */ -export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"] - -/** 文件选择器 accept 属性 */ -export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm" - -/** 最大文件大小:10MB */ -export const MAX_FILE_SIZE = 10 * 1024 * 1024 - /** 最长录制时长:5 分钟(秒) */ export const MAX_RECORD_SECONDS = 5 * 60 diff --git a/apps/web/src/components/voice/CloneModal/index.tsx b/apps/web/src/components/voice/CloneModal/index.tsx index be564f067..84044441f 100644 --- a/apps/web/src/components/voice/CloneModal/index.tsx +++ b/apps/web/src/components/voice/CloneModal/index.tsx @@ -1,10 +1,12 @@ import React, { useState, useCallback, useRef, useEffect } from "react" +import { useNavigate } from "react-router-dom" +import { useQuery } from "@tanstack/react-query" import { Modal, Button } from "@/components/ui" import { createVoiceClone, toVoiceClone } from "@/api/voice-clone" -import { uploadAssetDirect, ensureDefaultLibrary } from "@/api/assets" +import { uploadAssetDirect, ensureDefaultLibrary, getAssetsByKind } from "@/api/assets" import { getOrCreateDefaultProject } from "@/api/projects" -import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants" -import { validateFile } from "./utils" +import { PROGRESS_STEPS } from "./constants" +import { formatRecordTime } from "./utils" import { useAudioRecorder } from "./hooks/useAudioRecorder" import type { CloneModalProps, ModalPhase } from "./types" import "./clone-modal.css" @@ -18,15 +20,21 @@ const getExtensionFromMime = (mime: string): string => { return "webm" } +/** 格式化素材时长(秒 → mm:ss) */ +const formatAssetDuration = (seconds?: number): string => { + if (!seconds || seconds <= 0) return "--:--" + return formatRecordTime(Math.round(seconds)) +} + const CloneModal: React.FC = ({ open, onClose, onSuccess }) => { + const navigate = useNavigate() const [phase, setPhase] = useState("input") const [voiceName, setVoiceName] = useState("") const [voiceDescription, setVoiceDescription] = useState("") - const [selectedFile, setSelectedFile] = useState(null) - const [dragActive, setDragActive] = useState(false) + /** 从配音素材选择的素材 ID */ + const [selectedAssetId, setSelectedAssetId] = useState("") const [errorMessage, setErrorMessage] = useState("") - const fileInputRef = useRef(null) const timerRef = useRef | null>(null) /** 默认音色名称计数器(组件级 ref,避免多实例串号) */ const cloneCounterRef = useRef(1) @@ -34,6 +42,14 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => const isMountedRef = useRef(true) const isSubmittingRef = useRef(false) + /* ── 配音素材列表(「从配音素材选择」;弹窗打开时才发请求) ────── */ + const { data: voiceAssets, isLoading: assetsLoading } = useQuery({ + queryKey: ["assets", "voice", "clone-modal"], + queryFn: () => getAssetsByKind("voice", { limit: 100 }), + enabled: open, + staleTime: 30_000, + }) + /* ── 录音 Hook ──────────────────────────────────── */ const { isRecording, @@ -55,8 +71,7 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => setPhase("input") setVoiceName(getNextDefaultName()) setVoiceDescription("") - setSelectedFile(null) - setDragActive(false) + setSelectedAssetId("") // 注意:resetState 不得触碰 isSubmittingRef——提交锁仅属于 handleSubmit; // 此前在此上锁且无复位路径,弹窗打开即死锁 setErrorMessage("") @@ -85,65 +100,26 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => } }, []) - /* ── 文件上传 ──────────────────────────────────── */ + /* ── 素材/录音互斥:选择素材时清掉录音,开始录音时清掉素材选择 ── */ - const handleUploadClick = () => { - fileInputRef.current?.click() - } - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - const error = validateFile(file) - if (error) { - setErrorMessage(error) - setSelectedFile(null) - } else { - // 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作, - // 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁 - setErrorMessage("") - setSelectedFile(file) - resetRecorder() - } - } - e.target.value = "" - } - - /* ── 拖拽 ──────────────────────────────────────── */ - - const handleDrag = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (e.type === "dragenter" || e.type === "dragover") { - setDragActive(true) - } else if (e.type === "dragleave") { - setDragActive(false) + const handleSelectAsset = (assetId: string) => { + setSelectedAssetId(assetId) + if (assetId) { + resetRecorder() } } - const handleDrop = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setDragActive(false) - const file = e.dataTransfer.files?.[0] - if (file) { - const error = validateFile(file) - if (error) { - setErrorMessage(error) - setSelectedFile(null) - } else { - // 文件选择为纯同步 state 设置,无异步竞态;防重入只属于提交动作, - // 由 handleSubmit 的 isSubmittingRef + isProcessing 保证,此处不设锁 - setErrorMessage("") - setSelectedFile(file) - resetRecorder() - } + const handleToggleRecord = () => { + // 开始录音会清掉已选素材;停止录音保留录音结果 + if (!isRecording) { + setSelectedAssetId("") } + toggleRecord() } /* ── 计算属性 ──────────────────────────────────── */ - const hasAudio = selectedFile !== null || recordedBlob !== null + const hasAudio = selectedAssetId !== "" || recordedBlob !== null const isProcessing = phase === "uploading" || phase === "cloning" const canSubmit = hasAudio && !isProcessing @@ -158,7 +134,7 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => return } if (!hasAudio) { - setErrorMessage("请上传音频文件或录制一段声音") + setErrorMessage("请从配音素材选择一段音频,或直接录制声音") return } @@ -167,20 +143,37 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => setErrorMessage("") try { - // 阶段 1:上传音频 + // 路径 A:从配音素材选择 → 无需上传,直接克隆 + if (selectedAssetId) { + setPhase("cloning") + const result = await createVoiceClone({ + name, + description: voiceDescription.trim() || undefined, + asset_id: selectedAssetId, + }) + + if (!isMountedRef.current) return + + isSubmittingRef.current = false + setPhase("done") + timerRef.current = setTimeout(() => { + if (isMountedRef.current) { + onSuccess?.(toVoiceClone(result)) + handleClose() + } + }, 2000) + return + } + + // 路径 B:录音 → 先上传为配音素材,再克隆 setPhase("uploading") - let fileToUpload: File - if (selectedFile) { - fileToUpload = selectedFile - } else { - // 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配 - const mimeType = recordedBlob?.type || "audio/webm" - const ext = getExtensionFromMime(mimeType) - fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, { - type: mimeType, - }) - } + // 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配 + const mimeType = recordedBlob?.type || "audio/webm" + const ext = getExtensionFromMime(mimeType) + const fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, { + type: mimeType, + }) // 获取默认项目和素材库 const project = await getOrCreateDefaultProject() @@ -226,6 +219,8 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) => } } + const hasAssets = (voiceAssets?.length ?? 0) > 0 + return ( = ({ open, onClose, onSuccess }) =>
1
- 上传/录制音频 + 选择/录制音频
@@ -274,30 +269,42 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) =>
{voiceName.length}/20
- {/* 上传区域 */} + {/* 从配音素材选择 */}
- -
-
{selectedFile ? "📄" : "🎵"}
-

- {selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"} -

-

支持 MP3、WAV、M4A 格式,最大 10MB

- -
+ + {hasAssets ? ( + + ) : ( +
+

+ {assetsLoading ? "素材加载中…" : "请先在配音库上传素材"} +

+ {!assetsLoading && ( + + )} +
+ )}
{/* 或分隔 */} @@ -332,7 +339,7 @@ const CloneModal: React.FC = ({ open, onClose, onSuccess }) =>