From 4795acc3678da78c72af1eab0fb56f091071f9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Wed, 22 Apr 2026 00:17:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Vidu=20=E8=AF=AD=E9=9F=B3=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E5=85=A8=E9=9D=A2=E6=8E=A5=E5=85=A5=EF=BC=8C=E9=9F=B3?= =?UTF-8?q?=E9=A2=91=E5=BD=92=E5=B1=9E=E4=BF=AE=E6=AD=A3=E8=87=B3=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 Voice API 全面切换至 Vidu(TTS/克隆/对口型) - 前端配音页面 UI 优化:重新生成+播放音频双按钮 - 素材库克隆适配:Vidu 同步克隆,前端预校验格式/大小/时长 - 音频数据归属修正:生成配音保存到 meta.json(dubbingAudioUrl/Path/VoiceId) - 不再写入 audios.json 和 segments.json,统一项目级一份配音 - Rust save_audio 支持 skip_list 参数跳过 audios.json 写入 --- python-api/app/api/v1/voice.py | 4 +- tauri-app/src-tauri/src/commands/voice.rs | 2 + tauri-app/src-tauri/src/storage/voice.rs | 33 +++++++------ tauri-app/src/api/modules/localStorage.ts | 6 +++ tauri-app/src/api/modules/voice.ts | 1 + .../VoiceMaterialLibrary.tsx | 18 ++++--- .../src/pages/VideoCreation/VoiceDubbing.css | 9 ++++ .../src/pages/VideoCreation/VoiceDubbing.tsx | 47 +++++++++++++++++-- tauri-app/src/store/projectStore.ts | 3 ++ tauri-app/src/store/voiceStore.ts | 12 ++--- 10 files changed, 102 insertions(+), 33 deletions(-) diff --git a/python-api/app/api/v1/voice.py b/python-api/app/api/v1/voice.py index 65abbc4..de61d27 100644 --- a/python-api/app/api/v1/voice.py +++ b/python-api/app/api/v1/voice.py @@ -153,8 +153,8 @@ async def upload_voice_file( # 根据类型校验 MIME if file_type == "audio": - allowed_types = {"audio/mpeg", "audio/mp3", "audio/wav"} - max_size = 50 * 1024 * 1024 # 50MB + allowed_types = {"audio/mpeg", "audio/mp3", "audio/wav", "audio/x-wav", "audio/mp4"} + max_size = 20 * 1024 * 1024 # 20MB prefix = "meijiaka-zj/voice" type_label = "音频" else: diff --git a/tauri-app/src-tauri/src/commands/voice.rs b/tauri-app/src-tauri/src/commands/voice.rs index 0b71f1b..a7a5200 100644 --- a/tauri-app/src-tauri/src/commands/voice.rs +++ b/tauri-app/src-tauri/src/commands/voice.rs @@ -92,6 +92,7 @@ pub struct SaveAudioArgs { pub name: String, pub voice_id: String, pub duration: f64, + pub skip_list: Option, } /// 保存音频文件(前端传入 base64 编码) @@ -118,6 +119,7 @@ pub async fn save_audio( &args.name, &args.voice_id, args.duration, + args.skip_list.unwrap_or(false), ) { Ok(meta) => ApiResponse { code: 200, diff --git a/tauri-app/src-tauri/src/storage/voice.rs b/tauri-app/src-tauri/src/storage/voice.rs index e91a9b5..b792ff8 100644 --- a/tauri-app/src-tauri/src/storage/voice.rs +++ b/tauri-app/src-tauri/src/storage/voice.rs @@ -41,6 +41,7 @@ pub fn save_audio_file( name: &str, voice_id: &str, duration: f64, + skip_list: bool, ) -> Result { let project_dir = get_project_dir(project_id)?; let audios_dir = project_dir.join("audios"); @@ -63,23 +64,25 @@ pub fn save_audio_file( segment_id: None, }; - // 更新音频列表元数据 - let list = load_audio_list_meta(project_id)?; - let mut list = list.unwrap_or(AudioListMeta { - project_id: project_id.to_string(), - audios: vec![], - updated_at: String::new(), - }); + // 仅在非 skip_list 模式下更新音频列表元数据(audios.json 用于素材库) + if !skip_list { + let list = load_audio_list_meta(project_id)?; + let mut list = list.unwrap_or(AudioListMeta { + project_id: project_id.to_string(), + audios: vec![], + updated_at: String::new(), + }); - // 替换已存在的同名音频 - if let Some(pos) = list.audios.iter().position(|a| a.id == audio_id) { - list.audios[pos] = meta.clone(); - } else { - list.audios.push(meta.clone()); + // 替换已存在的同名音频 + if let Some(pos) = list.audios.iter().position(|a| a.id == audio_id) { + list.audios[pos] = meta.clone(); + } else { + list.audios.push(meta.clone()); + } + list.updated_at = chrono_lite_now(); + + save_audio_list_meta(project_id, &list)?; } - list.updated_at = chrono_lite_now(); - - save_audio_list_meta(project_id, &list)?; Ok(meta) } diff --git a/tauri-app/src/api/modules/localStorage.ts b/tauri-app/src/api/modules/localStorage.ts index 57c1d33..3e3418a 100644 --- a/tauri-app/src/api/modules/localStorage.ts +++ b/tauri-app/src/api/modules/localStorage.ts @@ -55,6 +55,9 @@ export interface ProjectMeta { selectedHumanId?: string; // 选中的形象 humanId selectedElementId?: number; // 选中的形象 elementId(Kling) selectedVoiceId?: string; // 选中的形象 voiceId + dubbingAudioUrl?: string; // 生成后的配音音频七牛云URL + dubbingAudioPath?: string; // 生成后的配音音频本地路径 + dubbingVoiceId?: string; // 生成配音使用的音色ID coverConfig?: { caption?: string; coverStyle?: { @@ -125,6 +128,9 @@ export const localProjectApi = { selectedHumanId: meta.selectedHumanId, selectedElementId: meta.selectedElementId, selectedVoiceId: meta.selectedVoiceId, + dubbingAudioUrl: meta.dubbingAudioUrl, + dubbingAudioPath: meta.dubbingAudioPath, + dubbingVoiceId: meta.dubbingVoiceId, coverConfig: meta.coverConfig, scriptDuration: meta.scriptDuration, scriptType: meta.scriptType, diff --git a/tauri-app/src/api/modules/voice.ts b/tauri-app/src/api/modules/voice.ts index 9ca6613..1c86311 100644 --- a/tauri-app/src/api/modules/voice.ts +++ b/tauri-app/src/api/modules/voice.ts @@ -248,6 +248,7 @@ export async function saveAudio(args: { name: string; voiceId: string; duration: number; + skipList?: boolean; }): Promise { const result = await invoke<{ code: number; data?: AudioMeta; message: string }>('save_audio', { args }); if (result.code !== 200 || !result.data) { diff --git a/tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx b/tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx index 0238bd2..e3fb149 100644 --- a/tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx +++ b/tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx @@ -96,11 +96,17 @@ export default function VoiceMaterialLibrary() { // 音频文件验证 const validateAudioFile = (file: File): Promise<{ valid: boolean; error?: string }> => { return new Promise(resolve => { - const allowedExts = ['.mp3', '.wav']; + const maxSize = 20 * 1024 * 1024; // 20MB + if (file.size > maxSize) { + resolve({ valid: false, error: `文件大小 ${(file.size / 1024 / 1024).toFixed(1)}MB,要求不超过 20MB` }); + return; + } + + const allowedExts = ['.mp3', '.m4a', '.wav']; const ext = file.name.substring(file.name.lastIndexOf('.')).toLowerCase(); if (!allowedExts.includes(ext)) { - resolve({ valid: false, error: '仅支持 MP3、WAV 格式' }); + resolve({ valid: false, error: '仅支持 MP3、M4A、WAV 格式' }); return; } @@ -110,12 +116,12 @@ export default function VoiceMaterialLibrary() { audio.onloadedmetadata = () => { const duration = audio.duration; URL.revokeObjectURL(audio.src); - if (duration < 5) { - resolve({ valid: false, error: `音频时长 ${duration.toFixed(1)} 秒,要求至少 5 秒` }); + if (duration < 10) { + resolve({ valid: false, error: `音频时长 ${duration.toFixed(1)} 秒,要求至少 10 秒` }); return; } - if (duration > 30) { - resolve({ valid: false, error: `音频时长 ${duration.toFixed(1)} 秒,要求不超过 30 秒` }); + if (duration > 300) { + resolve({ valid: false, error: `音频时长 ${duration.toFixed(1)} 秒,要求不超过 5 分钟` }); return; } resolve({ valid: true }); diff --git a/tauri-app/src/pages/VideoCreation/VoiceDubbing.css b/tauri-app/src/pages/VideoCreation/VoiceDubbing.css index af39e1d..a62cc78 100644 --- a/tauri-app/src/pages/VideoCreation/VoiceDubbing.css +++ b/tauri-app/src/pages/VideoCreation/VoiceDubbing.css @@ -263,6 +263,15 @@ width: 100%; } +.voice-generate-btns { + display: flex; + gap: var(--spacing-sm); +} + +.voice-generate-btns .btn { + flex: 1; +} + /* ========== 右侧 ========== */ .script-content { diff --git a/tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx b/tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx index e18a264..50919d8 100644 --- a/tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx +++ b/tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx @@ -9,6 +9,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { useProjectStore } from '../../store'; import { useVoiceStore } from '../../store/voiceStore'; import { getCurrentProjectId } from '../../api/modules/localStorage'; +import { saveMetaToLocalFile } from '../../store/projectStore'; import { synthesizeTTS, saveAudio, uploadAudio } from '../../api/modules/voice'; import { toast } from '../../store/uiStore'; import { useProgressStore } from '../../store/progressStore'; @@ -40,6 +41,17 @@ export default function VoiceDubbing() { const [activeVoiceTab, setActiveVoiceTab] = useState<'preset' | 'clone'>('preset'); const [activePreviewVoiceId, setActivePreviewVoiceId] = useState(null); + // 判断当前是否已有生成的配音 + const hasGeneratedAudio = useMemo( + () => segments.some(s => s.audioUrl), + [segments] + ); + // 取第一个有 audioUrl 的分镜作为播放源 + const generatedAudioUrl = useMemo( + () => segments.find(s => s.audioUrl)?.audioUrl || null, + [segments] + ); + useEffect(() => { loadPresetVoices(); loadVoiceMaterials(); @@ -108,13 +120,21 @@ export default function VoiceDubbing() { const meta = await saveAudio({ projectId, audioId, audioData: base64, name: `配音-${segments.length}段`, voiceId: selectedVoiceId, duration: 0, + skipList: true, }); + // 更新 meta.json(项目级配音信息) + await saveMetaToLocalFile({ + dubbingAudioUrl: qiniuUrl, + dubbingAudioPath: meta.filePath, + dubbingVoiceId: selectedVoiceId, + }); + + // 配音音频是项目级别的,不写入各分镜 for (const seg of segments) { const segId = seg.id; if (segId) { setAudioMapping(segId.toString(), meta.id); - updateSegment(segId, { audioPath: meta.filePath, audioUrl: qiniuUrl }); } } progress.success('配音生成完成'); @@ -270,9 +290,28 @@ export default function VoiceDubbing() { {/* 底部生成按钮 */}
- + {!hasGeneratedAudio ? ( + + ) : ( +
+ + {generatedAudioUrl && ( + + )} +
+ )}
diff --git a/tauri-app/src/store/projectStore.ts b/tauri-app/src/store/projectStore.ts index fd98cbd..4ff6283 100644 --- a/tauri-app/src/store/projectStore.ts +++ b/tauri-app/src/store/projectStore.ts @@ -134,6 +134,9 @@ export async function saveMetaToLocalFile(overrides: Partial()( // 1. 上传七牛云 const sourceUrl = await voiceApi.uploadAudio(file); - // 2. 提交 Kling 克隆任务 + // 2. 提交 Vidu 同步克隆 const cloneResult = await voiceApi.submitCloneTask({ sourceAudioUrl: sourceUrl, voiceName: name, }); - // 3. 创建本地记录 + // 3. Vidu 同步返回,直接 ready const material: VoiceMaterial = { - id: cloneResult.taskId, + id: cloneResult.voiceId || cloneResult.taskId, name, - voiceId: '', + voiceId: cloneResult.voiceId || '', sourceUrl, - trialUrl: undefined, - status: 'pending', + trialUrl: cloneResult.trialUrl, + status: 'ready', createdAt: new Date().toISOString(), };