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:
@@ -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,
|
||||
|
||||
@@ -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; // 选中的形象 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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user