fix: identifier改为cn.meijiaka.ai-zy + 清理audios.json + 修复临时文件残留

- 应用标识符从 cn.meijiaka.ai-jian 改为 cn.meijiaka.ai-zy
- 删除 audios.json 相关代码(前后端 IPC/存储/状态)
- 修复 video_processing.rs 临时文件路径:从 projects/ 根目录改为项目目录
- 修复 video_processing.rs rename/copy 双失败后文件残留(有音频/无音频两分支)
- 修复 ffmpeg concat_videos_robust 标准化失败后 std_*.mp4 残留
- 修复 ffmpeg burn_ass_subtitle 首次失败原因被静默丢弃
- VideoGeneration Step1 截取片段上传后立即删除
This commit is contained in:
小鱼开发
2026-05-12 22:58:05 +08:00
parent b3c279f4fc
commit 2b319bc42d
11 changed files with 55 additions and 290 deletions
+1 -1
View File
@@ -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)、项目本地持久化
-41
View File
@@ -92,7 +92,6 @@ pub struct SaveAudioArgs {
pub name: String,
pub voice_id: String,
pub duration: f64,
pub skip_list: Option<bool>,
}
/// 保存音频文件(前端传入 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<Vec<voice_storage::AudioMeta>> {
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<bool> {
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(
+12 -3
View File
@@ -196,8 +196,16 @@ pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec<String>, 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);
}
}
+11 -9
View File
@@ -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<String> {
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,
+7 -83
View File
@@ -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<String>,
}
/// 音频列表元数据文件
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct AudioListMeta {
pub project_id: String,
pub audios: Vec<AudioMeta>,
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<AudioMeta, StorageError> {
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<Vec<AudioMeta>, 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<PathBuf, StorageError> {
let project_dir = get_project_dir(project_id)?;
Ok(project_dir.join("audios.json"))
}
fn load_audio_list_meta(project_id: &str) -> Result<Option<AudioListMeta>, 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<PathBuf, StorageError> {
let project_dir = get_project_dir(project_id)?;
@@ -172,14 +96,14 @@ pub struct VoiceMaterialsList {
/// 加载音色素材库
pub fn load_voice_materials() -> Result<VoiceMaterialsList, StorageError> {
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)
}
/// 添加音色素材
@@ -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);
}
+1 -1
View File
@@ -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",
-18
View File
@@ -162,7 +162,6 @@ 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) {
@@ -171,23 +170,6 @@ export async function saveAudio(args: {
return result.data;
}
/** 列出项目音频文件 */
export async function listProjectAudios(projectId: string): Promise<AudioMeta[]> {
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<void> {
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 {
@@ -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,跳过视频生成`);
@@ -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;}
+3 -120
View File
@@ -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<string, string>; // segmentId → audioId
// 当前项目 ID
currentProjectId: string | null;
// 加载状态
isLoadingVoices: boolean;
isLoadingAudios: boolean;
// 素材库(用户上传的克隆音色)
voiceMaterials: VoiceMaterial[];
@@ -43,41 +32,19 @@ interface VoiceActions {
renameVoiceMaterial: (_id: string, _name: string) => Promise<void>;
deleteVoiceMaterial: (_materialId: string) => Promise<void>;
// 项目音频操作
loadProjectAudios: (_projectId: string) => Promise<void>;
saveAudio: (_args: {
projectId: string;
audioId: string;
audioData: string;
name: string;
voiceId: string;
duration: number;
segmentId?: string;
}) => Promise<AudioMeta>;
deleteAudio: (_projectId: string, _audioId: string) => Promise<void>;
// 音频映射操作
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<VoiceState & VoiceActions>()(
(set, get) => ({
(set) => ({
...initialState,
// ====================== 音色操作 ======================
@@ -230,92 +197,8 @@ export const useVoiceStore = create<VoiceState & VoiceActions>()(
}));
},
// ====================== 项目音频操作 ======================
loadProjectAudios: async (projectId) => {
set({ isLoadingAudios: true, currentProjectId: projectId });
try {
const audios = await voiceApi.listProjectAudios(projectId);
set({ projectAudios: audios });
// 从现有音频恢复 audioMapping
const mapping: Record<string, string> = {};
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),
})
);