feat: Vidu 语音能力全面接入,音频归属修正至项目级

- 后端 Voice API 全面切换至 Vidu(TTS/克隆/对口型)
- 前端配音页面 UI 优化:重新生成+播放音频双按钮
- 素材库克隆适配:Vidu 同步克隆,前端预校验格式/大小/时长
- 音频数据归属修正:生成配音保存到 meta.json(dubbingAudioUrl/Path/VoiceId)
- 不再写入 audios.json 和 segments.json,统一项目级一份配音
- Rust save_audio 支持 skip_list 参数跳过 audios.json 写入
This commit is contained in:
小鱼开发
2026-04-22 00:17:04 +08:00
parent 67e73b5a51
commit 4795acc367
10 changed files with 102 additions and 33 deletions
+2 -2
View File
@@ -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:
@@ -92,6 +92,7 @@ pub struct SaveAudioArgs {
pub name: String,
pub voice_id: String,
pub duration: f64,
pub skip_list: Option<bool>,
}
/// 保存音频文件(前端传入 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,
+18 -15
View File
@@ -41,6 +41,7 @@ pub fn save_audio_file(
name: &str,
voice_id: &str,
duration: f64,
skip_list: bool,
) -> Result<AudioMeta, StorageError> {
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)
}
@@ -55,6 +55,9 @@ export interface ProjectMeta {
selectedHumanId?: string; // 选中的形象 humanId
selectedElementId?: number; // 选中的形象 elementIdKling
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,
+1
View File
@@ -248,6 +248,7 @@ export async function saveAudio(args: {
name: string;
voiceId: string;
duration: number;
skipList?: boolean;
}): Promise<AudioMeta> {
const result = await invoke<{ code: number; data?: AudioMeta; message: string }>('save_audio', { args });
if (result.code !== 200 || !result.data) {
@@ -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 });
@@ -263,6 +263,15 @@
width: 100%;
}
.voice-generate-btns {
display: flex;
gap: var(--spacing-sm);
}
.voice-generate-btns .btn {
flex: 1;
}
/* ========== 右侧 ========== */
.script-content {
@@ -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<string | null>(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() {
{/* 底部生成按钮 */}
<div className="voice-generate-wrap">
<button className="btn btn-primary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{isGenerating ? '合成中...' : '生成配音'}
</button>
{!hasGeneratedAudio ? (
<button className="btn btn-primary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{isGenerating ? '合成中...' : '生成配音'}
</button>
) : (
<div className="voice-generate-btns">
<button className="btn btn-secondary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{isGenerating ? '合成中...' : '重新生成'}
</button>
{generatedAudioUrl && (
<button
className="btn btn-primary generate-btn"
onClick={() => {
const audio = new Audio(generatedAudioUrl);
audio.play();
}}
>
</button>
)}
</div>
)}
</div>
</div>
+3
View File
@@ -134,6 +134,9 @@ export async function saveMetaToLocalFile(overrides: Partial<Omit<SaveState, 'se
selectedHumanId: 'selectedHumanId' in overrides ? overrides.selectedHumanId : existingMeta?.selectedHumanId,
selectedElementId: 'selectedElementId' in overrides ? overrides.selectedElementId : existingMeta?.selectedElementId,
selectedVoiceId: 'selectedVoiceId' in overrides ? overrides.selectedVoiceId : existingMeta?.selectedVoiceId,
dubbingAudioUrl: 'dubbingAudioUrl' in overrides ? overrides.dubbingAudioUrl : existingMeta?.dubbingAudioUrl,
dubbingAudioPath: 'dubbingAudioPath' in overrides ? overrides.dubbingAudioPath : existingMeta?.dubbingAudioPath,
dubbingVoiceId: 'dubbingVoiceId' in overrides ? overrides.dubbingVoiceId : existingMeta?.dubbingVoiceId,
coverConfig: 'coverConfig' in overrides ? overrides.coverConfig : existingMeta?.coverConfig,
scriptDuration: 'scriptDuration' in overrides ? overrides.scriptDuration : existingMeta?.scriptDuration,
scriptType: 'scriptType' in overrides ? overrides.scriptType : existingMeta?.scriptType,
+6 -6
View File
@@ -211,20 +211,20 @@ export const useVoiceStore = create<VoiceState & VoiceActions>()(
// 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(),
};