diff --git a/AGENTS.md b/AGENTS.md index c60a730..cb45123 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ **美家卡智影**是一款面向桌面端的 AI 视频创作应用,采用"Python 后端 API + Tauri 桌面前端"的混合架构。 -- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-jian` +- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-zy` - **版本**: `0.1.0` - **核心功能**: AI 脚本生成、AI 配音合成(TTS)、声音复刻、视频生成(Vidu)、视频字幕生成、压制成片(FFmpeg)、项目本地持久化 diff --git a/tauri-app/src-tauri/src/commands/voice.rs b/tauri-app/src-tauri/src/commands/voice.rs index 77faae5..f6a85aa 100644 --- a/tauri-app/src-tauri/src/commands/voice.rs +++ b/tauri-app/src-tauri/src/commands/voice.rs @@ -92,7 +92,6 @@ pub struct SaveAudioArgs { pub name: String, pub voice_id: String, pub duration: f64, - pub skip_list: Option, } /// 保存音频文件(前端传入 base64 编码) @@ -119,7 +118,6 @@ pub async fn save_audio( &args.name, &args.voice_id, args.duration, - args.skip_list.unwrap_or(false), ) { Ok(meta) => ApiResponse { code: 200, @@ -134,45 +132,6 @@ pub async fn save_audio( } } -/// 列出项目所有音频文件 -#[tauri::command] -pub async fn list_project_audios( - project_id: String, -) -> ApiResponse> { - match voice_storage::list_project_audios(&project_id) { - Ok(audios) => ApiResponse { - code: 200, - message: "Audio list retrieved".to_string(), - data: Some(audios), - }, - Err(e) => ApiResponse { - code: 500, - message: format!("Failed to list audios: {}", e), - data: Some(vec![]), - }, - } -} - -/// 删除音频文件 -#[tauri::command] -pub async fn delete_audio( - project_id: String, - audio_id: String, -) -> ApiResponse { - match voice_storage::delete_audio_file(&project_id, &audio_id) { - Ok(_) => ApiResponse { - code: 200, - message: "Audio deleted successfully".to_string(), - data: Some(true), - }, - Err(e) => ApiResponse { - code: 500, - message: format!("Failed to delete audio: {}", e), - data: None, - }, - } -} - /// 获取项目音频目录(供前端 FFmpeg 调用) #[tauri::command] pub async fn get_project_audios_dir( diff --git a/tauri-app/src-tauri/src/ffmpeg_cmd.rs b/tauri-app/src-tauri/src/ffmpeg_cmd.rs index 51cec94..bf30fee 100644 --- a/tauri-app/src-tauri/src/ffmpeg_cmd.rs +++ b/tauri-app/src-tauri/src/ffmpeg_cmd.rs @@ -196,8 +196,16 @@ pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec, out // 1. 标准化每个片段(内部已验证路径) for (i, path) in video_paths.iter().enumerate() { let std_path = output_parent.join(format!("std_{}_{}.mp4", timestamp, i)); - standardize_video(app, path, std_path.to_str().unwrap()).await?; - standardized_paths.push(std_path); + match standardize_video(app, path, std_path.to_str().unwrap()).await { + Ok(()) => standardized_paths.push(std_path), + Err(e) => { + // 清理本轮已创建的标准化文件 + for p in &standardized_paths { + let _ = std::fs::remove_file(p); + } + return Err(e); + } + } } // 2. 生成 concat 列表 @@ -443,7 +451,8 @@ pub async fn burn_ass_subtitle( match run_ffmpeg(app, args).await { Ok(_) => return Ok(()), - Err(_e) => { + Err(e) => { + eprintln!("[ffmpeg] 带 fontsdir 的 ASS 烧录失败,尝试回退: {}", e); } } diff --git a/tauri-app/src-tauri/src/lib.rs b/tauri-app/src-tauri/src/lib.rs index 48ebce1..7b5b361 100644 --- a/tauri-app/src-tauri/src/lib.rs +++ b/tauri-app/src-tauri/src/lib.rs @@ -107,8 +107,6 @@ pub fn run() { commands::product::export_product, // 音频管理 commands::voice::save_audio, - commands::voice::list_project_audios, - commands::voice::delete_audio, commands::voice::get_project_audios_dir, commands::voice::extract_audio_segment, commands::voice::upload_audio_file, @@ -356,13 +354,17 @@ async fn video_composite_synthesis( app: tauri::AppHandle, request: VideoCompositeRequest, ) -> ApiResponse { - let project_dir = match crate::storage::get_projects_root_dir() { - Ok(dir) => dir, - Err(e) => return ApiResponse { - code: 500, - message: format!("获取项目目录失败: {}", e), - data: None, - }, + // 从 output_path 解析项目目录(格式:.../projects/{project_id}/products/{filename}) + let output_path = std::path::PathBuf::from(&request.output_path); + let project_dir = match output_path.parent().and_then(|p| p.parent()) { + Some(dir) => dir.to_path_buf(), + None => { + return ApiResponse { + code: 500, + message: "无法从输出路径解析项目目录".to_string(), + data: None, + }; + } }; let payload = serde_json::json!({ "videoPaths": request.video_paths, diff --git a/tauri-app/src-tauri/src/storage/voice.rs b/tauri-app/src-tauri/src/storage/voice.rs index ee4c743..ab3707a 100644 --- a/tauri-app/src-tauri/src/storage/voice.rs +++ b/tauri-app/src-tauri/src/storage/voice.rs @@ -1,12 +1,12 @@ //! Voice 音频文件存储模块 //! -//! 管理 TTS 合成音频和配音文件的本地存储。 +//! 管理 TTS 合成音频文件的本地存储。 use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; -use crate::storage::engine::{atomic_write_bytes, atomic_write_json, read_json, ensure_dir, StorageError}; -use crate::storage::paths::{get_project_dir, get_voices_json_path}; +use crate::storage::engine::{atomic_write_bytes, read_json, ensure_dir, StorageError}; +use crate::storage::paths::get_project_dir; /// 音频文件元数据 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -22,15 +22,6 @@ pub struct AudioMeta { pub segment_id: Option, } -/// 音频列表元数据文件 -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct AudioListMeta { - pub project_id: String, - pub audios: Vec, - pub updated_at: String, -} - /// 保存音频文件到项目目录 /// /// 将 base64 编码的音频数据写入 `audios/` 子目录。 @@ -41,7 +32,6 @@ 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"); @@ -64,75 +54,9 @@ pub fn save_audio_file( segment_id: None, }; - // 仅在非 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()); - } - list.updated_at = chrono_lite_now(); - - save_audio_list_meta(project_id, &list)?; - } - Ok(meta) } -/// 列出项目所有音频文件 -pub fn list_project_audios(project_id: &str) -> Result, StorageError> { - let list = load_audio_list_meta(project_id)?; - Ok(list.map(|l| l.audios).unwrap_or_default()) -} - -/// 删除项目中的音频文件 -pub fn delete_audio_file(project_id: &str, audio_id: &str) -> Result<(), StorageError> { - let list = load_audio_list_meta(project_id)?; - let mut list = list.ok_or_else(|| StorageError::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - "音频列表不存在", - )))?; - - let pos = list.audios.iter().position(|a| a.id == audio_id) - .ok_or_else(|| StorageError::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("音频 {} 不存在", audio_id), - )))?; - - let meta = list.audios.remove(pos); - let path = Path::new(&meta.file_path); - if path.exists() { - std::fs::remove_file(path)?; - } - - list.updated_at = chrono_lite_now(); - save_audio_list_meta(project_id, &list) -} - -/// 获取音频列表元数据文件路径 -fn get_audio_list_path(project_id: &str) -> Result { - let project_dir = get_project_dir(project_id)?; - Ok(project_dir.join("audios.json")) -} - -fn load_audio_list_meta(project_id: &str) -> Result, StorageError> { - let path = get_audio_list_path(project_id)?; - read_json(&path) -} - -fn save_audio_list_meta(project_id: &str, list: &AudioListMeta) -> Result<(), StorageError> { - let path = get_audio_list_path(project_id)?; - atomic_write_json(&path, list) -} - /// 获取项目音频目录路径(供 FFmpeg 使用) pub fn get_project_audios_dir(project_id: &str) -> Result { let project_dir = get_project_dir(project_id)?; @@ -172,14 +96,14 @@ pub struct VoiceMaterialsList { /// 加载音色素材库 pub fn load_voice_materials() -> Result { - let path = get_voices_json_path()?; + let path = crate::storage::paths::get_voices_json_path()?; Ok(read_json(&path)?.unwrap_or_default()) } /// 保存音色素材库 pub fn save_voice_materials(list: &VoiceMaterialsList) -> Result<(), StorageError> { - let path = get_voices_json_path()?; - atomic_write_json(&path, list) + let path = crate::storage::paths::get_voices_json_path()?; + crate::storage::engine::atomic_write_json(&path, list) } /// 添加音色素材 diff --git a/tauri-app/src-tauri/src/video_processing.rs b/tauri-app/src-tauri/src/video_processing.rs index 5e451e7..5a93080 100644 --- a/tauri-app/src-tauri/src/video_processing.rs +++ b/tauri-app/src-tauri/src/video_processing.rs @@ -96,6 +96,10 @@ pub async fn handle_video_synthesis( let _ = std::fs::remove_file(&final_output); }) }); + // 兜底:如果 rename 和 copy 都失败,强制删除 final_output + if move_result.is_err() { + let _ = std::fs::remove_file(&final_output); + } // 清理临时文件 let _ = std::fs::remove_file(&concat_output); if let Some(ref p) = temp_cover_video { @@ -136,6 +140,10 @@ pub async fn handle_video_synthesis( let _ = std::fs::remove_file(&concat_output); }) }); + // 兜底:如果 rename 和 copy 都失败,强制删除 concat_output + if move_result.is_err() { + let _ = std::fs::remove_file(&concat_output); + } if let Some(ref p) = temp_cover_video { let _ = std::fs::remove_file(p); } diff --git a/tauri-app/src-tauri/tauri.conf.json b/tauri-app/src-tauri/tauri.conf.json index afdfda7..400a3aa 100644 --- a/tauri-app/src-tauri/tauri.conf.json +++ b/tauri-app/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "美家卡智影", "version": "0.1.0", - "identifier": "cn.meijiaka.ai-jian", + "identifier": "cn.meijiaka.ai-zy", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/tauri-app/src/api/modules/voice.ts b/tauri-app/src/api/modules/voice.ts index dda7c01..afdb7ea 100644 --- a/tauri-app/src/api/modules/voice.ts +++ b/tauri-app/src/api/modules/voice.ts @@ -162,7 +162,6 @@ 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) { @@ -171,23 +170,6 @@ export async function saveAudio(args: { return result.data; } -/** 列出项目音频文件 */ -export async function listProjectAudios(projectId: string): Promise { - const result = await invoke<{ code: number; data?: AudioMeta[]; message: string }>('list_project_audios', { projectId }); - if (result.code !== 200) { - throw new Error(result.message || '获取音频列表失败'); - } - return result.data || []; -} - -/** 删除音频文件 */ -export async function deleteAudio(projectId: string, audioId: string): Promise { - const result = await invoke<{ code: number; message: string }>('delete_audio', { projectId, audioId }); - if (result.code !== 200) { - throw new Error(result.message || '删除音频失败'); - } -} - // ====================== 音频处理命令(Tauri IPC) ====================== interface ExtractAudioSegmentRequest { diff --git a/tauri-app/src/pages/VideoCreation/VideoGeneration.tsx b/tauri-app/src/pages/VideoCreation/VideoGeneration.tsx index 38d08f8..5c44a10 100644 --- a/tauri-app/src/pages/VideoCreation/VideoGeneration.tsx +++ b/tauri-app/src/pages/VideoCreation/VideoGeneration.tsx @@ -633,6 +633,16 @@ export default function VideoGeneration() { // 1c. 上传切割后的视频到七牛云 const clipUrl = await uploadVideoFile(clipPath); + // 上传成功后删除本地临时截取片段 + try { + await invoke<{ code: number; message: string }>('delete_project_file', { + projectId, + filePath: clipPath, + }); + } catch (e) { + console.warn(`[VideoGeneration] 删除临时截取片段失败: ${clipPath}`, e); + } + // 1d. 提交视频生成任务(仅当该分镜有 clipAudioUrl 时) if (!shot.clipAudioUrl) { console.warn(`[VideoGeneration] Segment ${shot.id} 无 clipAudioUrl,跳过视频生成`); diff --git a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx index 3880b65..37c1ec1 100644 --- a/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx +++ b/tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx @@ -40,8 +40,6 @@ export default function VoiceSynthesis() { voiceMaterials, loadPresetVoices, loadVoiceMaterials, - loadProjectAudios, - setAudioMapping, } = useVoiceStore(); const [isGenerating, setIsGenerating] = useState(false); @@ -59,8 +57,7 @@ export default function VoiceSynthesis() { useEffect(() => { loadPresetVoices(); loadVoiceMaterials(); - if (projectId) {loadProjectAudios(projectId);} - }, [loadPresetVoices, loadProjectAudios, loadVoiceMaterials, projectId]); + }, [loadPresetVoices, loadVoiceMaterials, projectId]); // 组件卸载时清理音频播放 useEffect(() => { @@ -314,7 +311,6 @@ export default function VoiceSynthesis() { const meta = await saveAudio({ projectId, audioId, audioData: base64, name: `配音合成-${segments.length}段`, voiceId: currentVoiceId || 'tianxin_xiaoling', duration: 0, - skipList: true, }); // 更新 projectStore 和 meta.json(项目级配音信息) @@ -327,14 +323,6 @@ export default function VoiceSynthesis() { dubbingAudioPath: meta.filePath, }); - // 配音合成音频是项目级别的,不写入各分镜 - for (const seg of segments) { - const segId = seg.id; - if (segId) { - setAudioMapping(segId.toString(), meta.id); - } - } - // dubbingAudioUrl 已通过 store 持久化,无需再存到组件本地 state // 生成完成后自动执行打轴+截取 @@ -350,7 +338,7 @@ export default function VoiceSynthesis() { } finally { setIsGenerating(false); } - }, [projectId, segments, setAudioMapping, handleAlignAndClip, checkBalance, handleError]); + }, [projectId, segments, handleAlignAndClip, checkBalance, handleError]); const handleToggleGeneratedAudio = useCallback(() => { if (!dubbingAudioUrl) {return;} diff --git a/tauri-app/src/store/voiceStore.ts b/tauri-app/src/store/voiceStore.ts index 65744b5..aba8462 100644 --- a/tauri-app/src/store/voiceStore.ts +++ b/tauri-app/src/store/voiceStore.ts @@ -2,30 +2,19 @@ * Voice Store - Zustand * ====================== * - * 管理 TTS 合成、音频文件、本地音色库的全局状态。 + * 管理 TTS 合成、本地音色库的全局状态。 */ import { create } from 'zustand'; -import type { VoiceInfo, AudioMeta, VoiceMaterial } from '../api/modules/voice'; +import type { VoiceInfo, VoiceMaterial } from '../api/modules/voice'; import * as voiceApi from '../api/modules/voice'; interface VoiceState { // 预设音色列表 presetVoices: VoiceInfo[]; - // 项目音频文件列表 - projectAudios: AudioMeta[]; - - // 音频替换映射(segmentId → audioId) - // 记录每个分镜使用了哪个音频文件 - audioMapping: Record; // segmentId → audioId - - // 当前项目 ID - currentProjectId: string | null; - // 加载状态 isLoadingVoices: boolean; - isLoadingAudios: boolean; // 素材库(用户上传的克隆音色) voiceMaterials: VoiceMaterial[]; @@ -43,41 +32,19 @@ interface VoiceActions { renameVoiceMaterial: (_id: string, _name: string) => Promise; deleteVoiceMaterial: (_materialId: string) => Promise; - // 项目音频操作 - loadProjectAudios: (_projectId: string) => Promise; - saveAudio: (_args: { - projectId: string; - audioId: string; - audioData: string; - name: string; - voiceId: string; - duration: number; - segmentId?: string; - }) => Promise; - deleteAudio: (_projectId: string, _audioId: string) => Promise; - - // 音频映射操作 - setAudioMapping: (_segmentId: string, _audioId: string | undefined) => void; - getAudioForSegment: (_segmentId: string) => AudioMeta | undefined; - // 重置 reset: () => void; } - const initialState: VoiceState = { presetVoices: [], - projectAudios: [], - audioMapping: {}, - currentProjectId: null, isLoadingVoices: false, - isLoadingAudios: false, voiceMaterials: [], isLoadingMaterials: false, }; export const useVoiceStore = create()( - (set, get) => ({ + (set) => ({ ...initialState, // ====================== 音色操作 ====================== @@ -230,92 +197,8 @@ export const useVoiceStore = create()( })); }, - // ====================== 项目音频操作 ====================== - - loadProjectAudios: async (projectId) => { - set({ isLoadingAudios: true, currentProjectId: projectId }); - try { - const audios = await voiceApi.listProjectAudios(projectId); - set({ projectAudios: audios }); - - // 从现有音频恢复 audioMapping - const mapping: Record = {}; - for (const audio of audios) { - if (audio.segmentId) { - mapping[audio.segmentId] = audio.id; - } - } - set({ audioMapping: mapping }); - } catch (err) { - console.error('[VoiceStore] 加载项目音频失败:', err); - set({ projectAudios: [] }); - } finally { - set({ isLoadingAudios: false }); - } - }, - - saveAudio: async (args) => { - const meta = await voiceApi.saveAudio(args); - const state = get(); - - // 更新 projectAudios 列表 - const existing = state.projectAudios.findIndex(a => a.id === meta.id); - const updatedAudios = existing >= 0 - ? state.projectAudios.map((a, i) => i === existing ? meta : a) - : [...state.projectAudios, meta]; - set({ projectAudios: updatedAudios }); - - // 如果有 segmentId,更新 audioMapping - if (args.segmentId) { - set(state => ({ - audioMapping: { ...state.audioMapping, [args.segmentId!]: meta.id }, - })); - } - - return meta; - }, - - deleteAudio: async (projectId, audioId) => { - await voiceApi.deleteAudio(projectId, audioId); - - const state = get(); - const updatedAudios = state.projectAudios.filter(a => a.id !== audioId); - - // 从 audioMapping 中移除 - const updatedMapping = { ...state.audioMapping }; - for (const [segId, audioId2] of Object.entries(updatedMapping)) { - if (audioId2 === audioId) { - delete updatedMapping[segId]; - } - } - - set({ projectAudios: updatedAudios, audioMapping: updatedMapping }); - }, - - // ====================== 音频映射操作 ====================== - - setAudioMapping: (segmentId, audioId) => { - set(state => { - if (audioId === undefined) { - const updated = { ...state.audioMapping }; - delete updated[segmentId]; - return { audioMapping: updated }; - } - return { audioMapping: { ...state.audioMapping, [segmentId]: audioId } }; - }); - }, - - getAudioForSegment: (segmentId) => { - const state = get(); - const audioId = state.audioMapping[segmentId]; - if (!audioId) {return undefined;} - return state.projectAudios.find(a => a.id === audioId); - }, - // ====================== 重置 ====================== reset: () => set(initialState), }) ); - -