feat: 用户数据隔离、动态分辨率、字幕缩放、多项体验优化

- 用户数据隔离:所有用户数据按 users/{user_id}/ 隔离,Rust IPC 命令自治读取 auth.json
- 安全加固:delete_local_product/rename_local_product/export_product 增加前缀校验
- 移除音调(pitch)功能:从 VoiceSynthesis、projectStore、types 等完全移除
- 动态视频分辨率:根据素材最小高度自动选择 720p/1080p,9:16 比例强校验
- ASS 字幕按目标分辨率等比例缩放(720p 和 1080p 视觉一致)
- Canvas 预览支持参数化 playResY,预览与压制效果一致
- 配音合成增加台词字数校验弹窗(语速>1.0时要求更多字)
- BGM 默认音量从 25% 调至 15%
- 素材选择提示文案更新(9:16 比例,5-60秒)
- 视频校验从严格 1080x1920 改为 9:16 比例判断
- 背景图片弹窗宽度从 440px 放大到 560px
This commit is contained in:
小鱼开发
2026-06-04 17:30:54 +08:00
parent 3e94013d2b
commit ca4a0b1303
34 changed files with 1419 additions and 256 deletions
+183
View File
@@ -0,0 +1,183 @@
# 用户数据隔离改造方案
## 背景
当前所有本地数据存储在全局路径下,切换账号后 A 用户的数据对 B 用户可见。涉及隐私(音色素材)和商业数据(项目文件),需要按 `user_id` 隔离。
## 目标
- 按登录用户的 `user_id` 隔离本地数据
- 未登录时使用匿名沙盒或禁止写入
- 兼容旧数据,首次启动自动迁移
## 改动范围
### 1. Rust 层 — 路径改造(`src/storage/paths.rs`
新增用户隔离根目录,用户相关数据全部迁移:
```
{app_local_data_dir}/
users/
{user_id}/
voices.json ← 音色素材库
cover_avatars.json ← 封面头像库
projects/ ← 项目目录
config.json ← 用户级配置(可选)
bgm_cache/ ← 全局共享(缓存无隐私问题)
config.json ← 全局配置(窗口状态、偏好设置等)
temp/ ← 临时文件(定时清理)
```
**需要修改的函数:**
| 当前路径 | 改造后 |
|---|---|
| `{data}/voices.json` | `{data}/users/{user_id}/voices.json` |
| `{data}/cover_avatars.json` | `{data}/users/{user_id}/cover_avatars.json` |
| `{data}/cover_avatars/` | `{data}/users/{user_id}/cover_avatars/` |
| `{data}/projects/` | `{data}/users/{user_id}/projects/` |
| `{data}/config.json` | 保持全局(或拆分为全局+用户级) |
| `{data}/bgm_cache/` | 保持全局 |
| `{config}/auth.json` | 保持全局(只存当前登录态) |
**新增:**
- `get_current_user_id() -> Option<String>`:从全局状态或调用方获取当前 user_id
- `get_user_data_dir(user_id: &str)`:返回用户隔离目录
### 2. Rust 层 — 存储命令改造
**受影响的命令文件:**
- `src/commands/voice.rs` — load/save/delete voice materials
- `src/commands/project.rs` — 项目 CRUD
- `src/commands/avatar.rs` — 封面头像
- `src/commands/config.rs` — 配置(决定全局 or 用户级)
**改造方式:**
方式 A(推荐):命令函数签名增加 `user_id: String` 参数
```rust
#[tauri::command]
pub async fn load_voice_materials(user_id: String) -> ApiResponse<Vec<VoiceMaterial>> {
let path = get_user_voices_json_path(&user_id)?;
// ...
}
```
方式 B:命令内部从 AppHandle 全局状态读取当前 user_id
```rust
let user_id = app.state::<CurrentUser>().id.clone();
```
**推荐方式 A**,前端调用时传入 `user.id`,减少 Rust 层对全局状态的依赖,更明确。
### 3. 前端 — AuthStore 改造(`src/store/authStore.ts`
**新增:**
- 登录成功后,写入一个全局标记 `CURRENT_USER_ID = user.id`
- 登出/被踢时,清除该标记
- 所有调用 Rust 存储命令的地方,带上 `userId` 参数
**受影响的 Store**
- `voiceStore.ts``loadVoiceMaterials()``addVoiceMaterial()` 等需要传 `userId`
- `projectStore.ts` — 项目操作需要传 `userId`
- `avatarStore.ts` — 封面头像操作需要传 `userId`
### 4. 前端 — API 调用改造
所有 `invoke('xxx', { ... })` 调用用户相关命令时,增加 `userId`
```typescript
// 改造前
await invoke('load_voice_materials')
// 改造后
const userId = useAuthStore.getState().user?.id;
await invoke('load_voice_materials', { userId })
```
### 5. 数据迁移策略
**检测时机:** 应用启动时,`init_app_data_dir` 之后
**迁移逻辑(Rust):**
```rust
pub fn migrate_legacy_data(data_dir: &Path) -> Result<(), StorageError> {
// 1. 检查是否存在旧数据(全局 voices.json / projects / cover_avatars.json
let legacy_voices = data_dir.join("voices.json");
if !legacy_voices.exists() {
return Ok(()); // 无旧数据,跳过
}
// 2. 读取 auth.json 获取当前登录的 user_id
let auth_path = get_auth_state_path(app)?;
let auth: Option<AuthState> = read_json(&auth_path)?;
let user_id = match auth.and_then(|a| a.user).map(|u| u.id) {
Some(id) => id,
None => {
// 未登录但有旧数据:移到 anonymous/ 目录,提示用户登录后迁移
return move_to_anonymous(data_dir);
}
};
// 3. 创建用户目录并迁移
let user_dir = data_dir.join("users").join(&user_id);
ensure_dir(&user_dir)?;
// 迁移 voices.json
if legacy_voices.exists() {
fs::rename(&legacy_voices, user_dir.join("voices.json"))?;
}
// 迁移 cover_avatars.json + cover_avatars/
let legacy_avatars = data_dir.join("cover_avatars.json");
if legacy_avatars.exists() {
fs::rename(&legacy_avatars, user_dir.join("cover_avatars.json"))?;
}
let legacy_avatars_dir = data_dir.join("cover_avatars");
if legacy_avatars_dir.exists() {
fs::rename(&legacy_avatars_dir, user_dir.join("cover_avatars"))?;
}
// 迁移 projects/
let legacy_projects = data_dir.join("projects");
if legacy_projects.exists() {
fs::rename(&legacy_projects, user_dir.join("projects"))?;
}
// 4. 写迁移标记,避免重复迁移
let flag = data_dir.join(".migration_v1_done");
fs::write(&flag, "done")?;
Ok(())
}
```
**迁移顺序:**
1. 启动应用
2. 初始化 `app_local_data_dir`
3. 检测 `.migration_v1_done` 标记,不存在则执行迁移
4. 迁移完成后继续正常启动
## 工作量评估
| 模块 | 文件数 | 预估工作量 |
|---|---|---|
| paths.rs 改造 | 1 | 2h |
| Rust 存储命令加 user_id 参数 | ~8 个文件 | 4h |
| lib.rs 注册新签名 | 1 | 0.5h |
| 前端 Store 改造 | ~4 个文件 | 3h |
| 数据迁移逻辑 | 1 个新文件 | 3h |
| 测试验证 | — | 2h |
| **总计** | | **~14.5h** |
## 风险点
1. **多设备登录同一账号**:数据只在本地隔离,不同设备间数据不互通。如果需要跨设备同步,需要后端支持。
2. **匿名数据**:未登录时产生的数据(理论上目前不存在,因为功能都需要登录),需要决定是禁止未登录操作还是存到 `anonymous/` 目录。
3. **回滚**:迁移是单向的(移动文件),回滚需要手动恢复。建议迁移前做备份副本而非直接 move。
## 建议实施时机
- **v1.8.0** 或 **v1.9.0** 版本中实施
- 最好在素材库数据量还不大的早期做,迁移成本低
+259
View File
@@ -0,0 +1,259 @@
# 用户数据隔离方案评审报告
## 结论
**方案大方向正确,但存在 3 个关键设计缺陷必须修正**,修正后可用。
---
## 关键缺陷
### ❌ 缺陷 1:前端传 userId 到 Rust 命令 — 安全漏洞
**方案原文:**
> 方式 A(推荐):命令函数签名增加 `user_id: String` 参数
> 前端调用时传入 `user.id`
**问题:** Tauri `#[tauri::command]` 的参数由前端传入,前端可以伪造任意 `userId`
```typescript
// 恶意前端代码:A 用户传入 B 的 userId,即可读取 B 的本地数据
await invoke('load_voice_materials', { userId: 'B的userId' })
```
**这完全破坏了隔离的安全性。**
**修正方案(推荐方式 B):**
Rust 命令从已认证的 `auth.json` 中读取当前 `user.id`,不接收前端传来的 `userId`
```rust
use tauri::State;
// 全局状态存储当前用户
pub struct CurrentUser {
pub id: std::sync::Mutex<Option<String>>,
}
#[tauri::command]
pub async fn load_voice_materials(
app: tauri::AppHandle,
) -> ApiResponse<Vec<VoiceMaterial>> {
// 从 auth.json 读取已认证的 user.id
let auth: Option<AuthState> = crate::storage::auth::load_auth_state(&app)?;
let user_id = match auth.and_then(|a| a.user).map(|u| u.id) {
Some(id) => id,
None => return ApiResponse::err(401, "未登录"),
};
let path = get_user_voices_json_path(&user_id)?;
// ...
}
```
或更简洁:在 `lib.rs``setup` 中读取 `auth.json`,将 `user_id` 存入 `ManagedState`,所有命令通过 `app.state::<CurrentUser>()` 获取。
**前端完全不需要传 `userId`Rust 层自治。**
---
### ❌ 缺陷 2:迁移用 `fs::rename`move)— 不可逆且易崩溃
**方案原文:**
```rust
fs::rename(&legacy_voices, user_dir.join("voices.json"))?;
```
**问题:**
- `rename` 是原子操作,但**多个文件的迁移不是原子的**
- 如果迁移 `voices.json` 后、迁移 `projects/` 前进程崩溃,会留下半迁移状态
- 不可逆,回滚困难
**修正方案:**
```rust
pub fn migrate_legacy_data(data_dir: &Path, app: &AppHandle) -> Result<(), StorageError> {
// 1. 检查迁移标记
let flag = data_dir.join(".migration_v1_done");
if flag.exists() { return Ok(()); }
// 2. 检查是否有旧数据
let legacy_items = vec![
("voices.json", false),
("cover_avatars.json", false),
("cover_avatars/", true),
("projects/", true),
];
let has_legacy = legacy_items.iter().any(|(name, _)| data_dir.join(name).exists());
if !has_legacy {
// 无旧数据,直接写标记跳过
fs::write(&flag, "none")?;
return Ok(());
}
// 3. 获取当前登录用户
let auth: Option<AuthState> = crate::storage::auth::load_auth_state(app)?;
let user_id = match auth.and_then(|a| a.user).map(|u| u.id) {
Some(id) => id,
None => {
// 未登录但有旧数据:复制到 anonymous/,登录后再手动迁移
let anon_dir = data_dir.join("anonymous");
ensure_dir(&anon_dir)?;
for (name, is_dir) in &legacy_items {
let src = data_dir.join(name);
if src.exists() {
let dest = anon_dir.join(name);
if *is_dir {
copy_dir_all(&src, &dest)?;
} else {
fs::copy(&src, &dest)?;
}
}
}
fs::write(&flag, "anonymous")?;
return Ok(());
}
};
// 4. 创建用户目录,用 copy 而非 move
let user_dir = data_dir.join("users").join(&user_id);
ensure_dir(&user_dir)?;
for (name, is_dir) in &legacy_items {
let src = data_dir.join(name);
let dest = user_dir.join(name);
if src.exists() {
if *is_dir {
copy_dir_all(&src, &dest)?;
} else {
fs::copy(&src, &dest)?;
}
}
}
// 5. copy 成功后,再写标记,然后延迟清理旧数据
fs::write(&flag, user_id)?;
// 可选:启动后台线程,延迟 7 天后删除旧数据
// 给用户一个回滚窗口
Ok(())
}
```
---
### ⚠️ 缺陷 3:缺少切换用户时的热切换设计
**当前问题:**
- 切换账号后,前端 Zustand store 中仍残留上一个用户的数据
- 需要页面刷新或重启应用才能加载新用户的数据
**修正方案:**
`authStore.logout()``authStore.login()` 中增加**数据刷新契约**
```typescript
// authStore.ts
logout: async () => {
// ... 原有逻辑 ...
// 新增:清空所有用户相关的内存状态
useVoiceStore.getState().reset();
useProjectStore.getState().reset();
useAvatarStore.getState().reset();
// 通知 Rust 层清空缓存
await invoke('switch_user', { userId: null });
},
login: async (phone, code) => {
// ... 原有登录逻辑 ...
// 新增:通知 Rust 层切换数据目录
await invoke('switch_user', { userId: data.user.id });
// 重新加载当前用户的数据
await useVoiceStore.getState().loadMaterials();
await useProjectStore.getState().loadProjects();
},
```
Rust 层 `switch_user` 命令:
```rust
#[tauri::command]
pub async fn switch_user(
app: tauri::AppHandle,
user_id: Option<String>,
) -> Result<(), String> {
// 更新 ManagedState 中的当前 user_id
let state = app.state::<CurrentUser>();
let mut guard = state.id.lock().map_err(|e| e.to_string())?;
*guard = user_id;
Ok(())
}
```
---
## 次要问题
### ⚠️ `sanitize_id` 对 user_id 的兼容性
当前 `sanitize_id` 只允许 `a-zA-Z0-9_-`。测试确认:
- UUID 格式(`550e8400-e29b-41d4-a716-446655440000`):✅ 通过
- 邮箱格式(`user@example.com`):❌ 失败
- 含点号(`user.name`):❌ 失败
**建议:** 确认后端 `user.id` 的格式。如果是 UUID,无需改动;如果是邮箱或其他格式,需要对 `user_id` 单独做路径净化(如用 `sanitize_filename` 或直接 base64/urlencode)。
### ⚠️ `temp/` 目录也应隔离
当前 `extract_audio_from_video` 写到 `app_local_data_dir/temp/`,如果多用户并发操作会冲突。建议:
```
{app_local_data_dir}/temp/{user_id}/
```
### ⚠️ 全局 `config.json` 的拆分
当前 `config.json` 存的是 API 地址和环境配置(`production`/`debug`),属于设备级配置,保持全局合理。
但如果以后增加用户偏好(主题、音量、快捷键),需要拆分为:
- `config.json` — 设备级(全局)
- `users/{user_id}/preferences.json` — 用户级
---
## 与主流规范对比
| 应用 | 隔离方式 | 说明 |
|---|---|---|
| **微信/QQ 桌面端** | ✅ 按账号隔离目录 | 每个账号独立数据目录,切换账号需重启 |
| **Discord/Slack** | ❌ 不隔离 | 完全云端,本地只有缓存 |
| **VS Code** | ⚠️ 按 workspace | 不严格按用户,但 settings.json 可放用户目录 |
| **Figma** | ❌ 不隔离 | 完全云端 |
| **Adobe Creative Cloud** | ✅ 按账号隔离 | 登录后数据按 Creative Cloud 账号隔离 |
**本项目定位:** 本地创作工具,数据以本地为主,且支持多设备登录(手机号+验证码)。
**结论:**`user_id` 隔离是**合理且必要的**,尤其在涉及**声音复刻素材**这种高敏感隐私数据时。
---
## 修正后的工作量
| 模块 | 修正后工作量 | 说明 |
|---|---|---|
| `paths.rs` 改造 | 2h | 加 `users/{user_id}/` 前缀 |
| `ManagedState` + `switch_user` | 2h | 新增当前用户状态管理 |
| Rust 命令读 auth.json 获取 user_id | 4h | 替代前端传参方案 |
| 前端 Store 增加 `reset()` + 登录后重载 | 2h | 热切换体验 |
| 数据迁移(copy + 标记) | 3h | 安全迁移 |
| 测试验证 | 2h | |
| **总计** | **~15h** | 比原方案多 0.5h(主要在 State 管理) |
---
## 建议实施时机
**v1.8.0** 实施,趁数据量小、迁移成本低时做。
+3 -3
View File
@@ -11,11 +11,11 @@
DO $$
DECLARE
v_mobile TEXT := '18860016386'; -- ← 修改:手机号
v_nickname TEXT := '18860016386'; -- ← 修改:昵称(可为空)
v_mobile TEXT := '15359215971'; -- ← 修改:手机号
v_nickname TEXT := '家迪装饰'; -- ← 修改:昵称(可为空)
v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion
v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL
v_gift_points INT := 2000; -- ← 修改:赠送初始积分(0 表示不赠送)
v_gift_points INT := 10000; -- ← 修改:赠送初始积分(0 表示不赠送)
v_gift_days INT := 365; -- ← 修改:赠送积分有效期(天)
v_user_id UUID;
v_batch_id BIGINT;
+14 -2
View File
@@ -6,11 +6,17 @@ use crate::storage::project as project_storage;
#[tauri::command]
pub async fn save_project_asset(
app: tauri::AppHandle,
project_id: String,
filename: String,
base64_data: String,
) -> ApiResponse<String> {
match project_storage::save_project_asset(&project_id, &filename, &base64_data).map_err_string() {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None },
};
match project_storage::save_project_asset(&user_id, &project_id, &filename, &base64_data).map_err_string() {
Ok(path) => ApiResponse {
code: 200,
message: "资源保存成功".to_string(),
@@ -26,10 +32,16 @@ pub async fn save_project_asset(
#[tauri::command]
pub async fn get_video_save_path(
app: tauri::AppHandle,
project_id: String,
filename: String,
) -> ApiResponse<String> {
match project_storage::get_video_save_path(&project_id, &filename).map_err_string() {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None },
};
match project_storage::get_video_save_path(&user_id, &project_id, &filename).map_err_string() {
Ok(path) => ApiResponse {
code: 200,
message: "获取路径成功".to_string(),
+16 -5
View File
@@ -3,6 +3,7 @@
use crate::ApiResponse;
use crate::StringResultExt;
use crate::storage::auth as auth_storage;
use tauri::Manager;
#[tauri::command]
pub async fn load_auth_state(app: tauri::AppHandle) -> ApiResponse<Option<serde_json::Value>> {
@@ -26,11 +27,21 @@ pub async fn save_auth_state(
state: serde_json::Value,
) -> ApiResponse<bool> {
match auth_storage::save_auth_state(&app, &state).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Auth state saved successfully".to_string(),
data: Some(true),
},
Ok(_) => {
// 保存 auth 成功后,检查是否需要迁移旧全局数据
// 场景:用户在退出状态下升级应用,setup 阶段跳过迁移;
// 重新登录时触发迁移,确保旧数据正确归入当前用户目录
if let Ok(data_dir) = app.path().app_local_data_dir() {
if let Err(e) = crate::migrate_legacy_data(&data_dir, &app) {
eprintln!("[save_auth_state] 数据迁移失败: {}", e);
}
}
ApiResponse {
code: 200,
message: "Auth state saved successfully".to_string(),
data: Some(true),
}
}
Err(e) => ApiResponse {
code: 500,
message: format!("Failed to save auth state: {}", e),
@@ -17,8 +17,15 @@ pub struct CoverAvatarArgs {
/// 加载封面形象库
#[tauri::command]
pub async fn load_cover_avatars() -> ApiResponse<Vec<cover_avatar_storage::CoverAvatar>> {
match cover_avatar_storage::load_cover_avatars() {
pub async fn load_cover_avatars(
app: tauri::AppHandle,
) -> ApiResponse<Vec<cover_avatar_storage::CoverAvatar>> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(vec![]) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(vec![]) },
};
match cover_avatar_storage::load_cover_avatars(&user_id) {
Ok(list) => ApiResponse {
code: 200,
message: "封面形象库加载成功".to_string(),
@@ -35,8 +42,14 @@ pub async fn load_cover_avatars() -> ApiResponse<Vec<cover_avatar_storage::Cover
/// 保存封面形象
#[tauri::command]
pub async fn save_cover_avatar(
app: tauri::AppHandle,
args: CoverAvatarArgs,
) -> ApiResponse<bool> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) },
};
let avatar = cover_avatar_storage::CoverAvatar {
id: args.id,
name: args.name,
@@ -44,7 +57,7 @@ pub async fn save_cover_avatar(
local_path: args.local_path,
created_at: args.created_at,
};
match cover_avatar_storage::add_cover_avatar(avatar) {
match cover_avatar_storage::add_cover_avatar(&user_id, avatar) {
Ok(_) => ApiResponse {
code: 200,
message: "封面形象保存成功".to_string(),
@@ -61,9 +74,15 @@ pub async fn save_cover_avatar(
/// 删除封面形象
#[tauri::command]
pub async fn delete_cover_avatar_cmd(
app: tauri::AppHandle,
id: String,
) -> ApiResponse<bool> {
match cover_avatar_storage::delete_cover_avatar(&id) {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) },
};
match cover_avatar_storage::delete_cover_avatar(&user_id, &id) {
Ok(_) => ApiResponse {
code: 200,
message: "封面形象删除成功".to_string(),
@@ -90,8 +109,15 @@ pub struct SaveCoverAvatarImageArgs {
/// 保存封面形象图片文件(前端传入 base64 编码)
#[tauri::command]
pub async fn save_cover_avatar_image(
app: tauri::AppHandle,
args: SaveCoverAvatarImageArgs,
) -> ApiResponse<String> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None },
};
let image_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&args.image_data,
@@ -105,6 +131,7 @@ pub async fn save_cover_avatar_image(
};
match cover_avatar_storage::save_cover_avatar_image(
&user_id,
&args.avatar_id,
&image_bytes,
&args.ext,
+84 -8
View File
@@ -23,8 +23,17 @@ pub struct ProductItem {
/// 获取成品保存路径(项目成品目录)
#[tauri::command]
pub async fn get_product_save_path(project_id: String, filename: String) -> ApiResponse<String> {
match get_project_products_dir(&project_id) {
pub async fn get_product_save_path(
app: tauri::AppHandle,
project_id: String,
filename: String,
) -> ApiResponse<String> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None },
};
match get_project_products_dir(&user_id, &project_id) {
Ok(products_dir) => {
let safe_filename = match sanitize_filename(&filename) {
Ok(name) => name,
@@ -52,8 +61,13 @@ pub async fn get_product_save_path(project_id: String, filename: String) -> ApiR
}
#[tauri::command]
pub async fn list_local_products(_app: AppHandle) -> ApiResponse<Vec<ProductItem>> {
let projects_root = match crate::storage::get_projects_root_dir() {
pub async fn list_local_products(app: AppHandle) -> ApiResponse<Vec<ProductItem>> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(vec![]) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(vec![]) },
};
let projects_root = match crate::storage::get_projects_root_dir(&user_id) {
Ok(dir) => dir,
Err(e) => return ApiResponse {
code: 500,
@@ -191,10 +205,19 @@ pub async fn list_local_products(_app: AppHandle) -> ApiResponse<Vec<ProductItem
}
#[tauri::command]
pub async fn delete_local_product(path: String) -> ApiResponse<()> {
pub async fn delete_local_product(
app: tauri::AppHandle,
path: String,
) -> ApiResponse<()> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None },
};
let target = PathBuf::from(&path);
// 安全检查:确保路径在应用数据目录内
// 安全检查:确保路径在应用数据目录内,且在当前用户目录下
let canonical = match target.canonicalize() {
Ok(p) => p,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
@@ -229,6 +252,17 @@ pub async fn delete_local_product(path: String) -> ApiResponse<()> {
};
}
// 用户隔离检查:路径必须在当前用户的 users/{id}/ 目录下
let user_prefix = app_data.join("users").join(&user_id);
let user_prefix_canonical = user_prefix.canonicalize().unwrap_or_else(|_| user_prefix.clone());
if !canonical.starts_with(&user_prefix_canonical) {
return ApiResponse {
code: 403,
message: "无权操作其他用户的数据".to_string(),
data: None,
};
}
if target.exists() && target.is_file() {
match fs::remove_file(&target) {
Ok(_) => ApiResponse {
@@ -252,7 +286,17 @@ pub async fn delete_local_product(path: String) -> ApiResponse<()> {
}
#[tauri::command]
pub async fn rename_local_product(path: String, new_filename: String) -> ApiResponse<()> {
pub async fn rename_local_product(
app: tauri::AppHandle,
path: String,
new_filename: String,
) -> ApiResponse<()> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None },
};
let old_path = PathBuf::from(&path);
// 安全检查
@@ -290,6 +334,17 @@ pub async fn rename_local_product(path: String, new_filename: String) -> ApiResp
};
}
// 用户隔离检查
let user_prefix = app_data.join("users").join(&user_id);
let user_prefix_canonical = user_prefix.canonicalize().unwrap_or_else(|_| user_prefix.clone());
if !canonical.starts_with(&user_prefix_canonical) {
return ApiResponse {
code: 403,
message: "无权操作其他用户的数据".to_string(),
data: None,
};
}
let parent = match old_path.parent() {
Some(p) => p.to_path_buf(),
None => return ApiResponse {
@@ -339,7 +394,17 @@ pub async fn rename_local_product(path: String, new_filename: String) -> ApiResp
/// 导出成品到用户指定位置
#[tauri::command]
pub async fn export_product(source_path: String, target_path: String) -> ApiResponse<String> {
pub async fn export_product(
app: tauri::AppHandle,
source_path: String,
target_path: String,
) -> ApiResponse<String> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: None },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: None },
};
let source = PathBuf::from(&source_path);
let target = PathBuf::from(&target_path);
@@ -371,6 +436,17 @@ pub async fn export_product(source_path: String, target_path: String) -> ApiResp
};
}
// 用户隔离检查:source 必须在当前用户目录下
let user_prefix = app_data.join("users").join(&user_id);
let user_prefix_canonical = user_prefix.canonicalize().unwrap_or_else(|_| user_prefix.clone());
if !source_canonical.starts_with(&user_prefix_canonical) {
return ApiResponse {
code: 403,
message: "无权导出其他用户的数据".to_string(),
data: None,
};
}
if !source.exists() || !source.is_file() {
return ApiResponse {
code: 404,
+73 -14
View File
@@ -9,8 +9,17 @@ use crate::storage::project as project_storage;
// ============================================================
#[tauri::command]
pub async fn save_project_meta_raw(project_id: String, json_content: String) -> ApiResponse<bool> {
match project_storage::save_project_meta_raw(&project_id, &json_content).map_err_string() {
pub async fn save_project_meta_raw(
app: tauri::AppHandle,
project_id: String,
json_content: String,
) -> ApiResponse<bool> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) },
};
match project_storage::save_project_meta_raw(&user_id, &project_id, &json_content).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Project saved successfully".to_string(),
@@ -25,8 +34,16 @@ pub async fn save_project_meta_raw(project_id: String, json_content: String) ->
}
#[tauri::command]
pub async fn load_project_meta(project_id: String) -> ApiResponse<serde_json::Value> {
match project_storage::load_project_meta(&project_id).map_err_string() {
pub async fn load_project_meta(
app: tauri::AppHandle,
project_id: String,
) -> ApiResponse<serde_json::Value> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(serde_json::Value::Null) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(serde_json::Value::Null) },
};
match project_storage::load_project_meta(&user_id, &project_id).map_err_string() {
Ok(data) => ApiResponse {
code: 200,
message: "Project loaded successfully".to_string(),
@@ -41,8 +58,17 @@ pub async fn load_project_meta(project_id: String) -> ApiResponse<serde_json::Va
}
#[tauri::command]
pub async fn save_project_segments_raw(project_id: String, json_content: String) -> ApiResponse<bool> {
match project_storage::save_project_segments_raw(&project_id, &json_content).map_err_string() {
pub async fn save_project_segments_raw(
app: tauri::AppHandle,
project_id: String,
json_content: String,
) -> ApiResponse<bool> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) },
};
match project_storage::save_project_segments_raw(&user_id, &project_id, &json_content).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Segments saved successfully".to_string(),
@@ -57,8 +83,16 @@ pub async fn save_project_segments_raw(project_id: String, json_content: String)
}
#[tauri::command]
pub async fn load_project_segments(project_id: String) -> ApiResponse<serde_json::Value> {
match project_storage::load_project_segments(&project_id).map_err_string() {
pub async fn load_project_segments(
app: tauri::AppHandle,
project_id: String,
) -> ApiResponse<serde_json::Value> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(serde_json::Value::Array(vec![])) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(serde_json::Value::Array(vec![])) },
};
match project_storage::load_project_segments(&user_id, &project_id).map_err_string() {
Ok(data) => ApiResponse {
code: 200,
message: "Segments loaded successfully".to_string(),
@@ -73,8 +107,15 @@ pub async fn load_project_segments(project_id: String) -> ApiResponse<serde_json
}
#[tauri::command]
pub async fn list_local_projects() -> ApiResponse<Vec<serde_json::Value>> {
match project_storage::list_projects().map_err_string() {
pub async fn list_local_projects(
app: tauri::AppHandle,
) -> ApiResponse<Vec<serde_json::Value>> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(vec![]) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(vec![]) },
};
match project_storage::list_projects(&user_id).map_err_string() {
Ok(projects) => ApiResponse {
code: 200,
message: "Projects listed successfully".to_string(),
@@ -89,8 +130,16 @@ pub async fn list_local_projects() -> ApiResponse<Vec<serde_json::Value>> {
}
#[tauri::command]
pub async fn delete_local_project(project_id: String) -> ApiResponse<bool> {
match project_storage::delete_project(&project_id).map_err_string() {
pub async fn delete_local_project(
app: tauri::AppHandle,
project_id: String,
) -> ApiResponse<bool> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) },
};
match project_storage::delete_project(&user_id, &project_id).map_err_string() {
Ok(_) => ApiResponse {
code: 200,
message: "Project deleted successfully".to_string(),
@@ -105,10 +154,20 @@ pub async fn delete_local_project(project_id: String) -> ApiResponse<bool> {
}
#[tauri::command]
pub async fn delete_project_file(project_id: String, file_path: String) -> ApiResponse<bool> {
pub async fn delete_project_file(
app: tauri::AppHandle,
project_id: String,
file_path: String,
) -> ApiResponse<bool> {
use crate::storage::get_project_dir_path;
let project_dir: std::path::PathBuf = match get_project_dir_path(&project_id) {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse { code: 401, message: "未登录".to_string(), data: Some(false) },
Err(e) => return ApiResponse { code: 500, message: format!("获取用户信息失败: {}", e), data: Some(false) },
};
let project_dir: std::path::PathBuf = match get_project_dir_path(&user_id, &project_id) {
Ok(d) => d,
Err(e) => return ApiResponse {
code: 500,
+112 -26
View File
@@ -28,21 +28,55 @@ pub struct UploadResult {
/// 兼容旧命名
pub type UploadVideoResult = UploadResult;
/// 获取项目视频目录
fn get_project_video_dir(project_id: &str) -> Result<std::path::PathBuf, String> {
crate::storage::get_project_videos_dir(project_id)
/// 获取项目视频目录(内部辅助函数,从 auth.json 读取当前用户)
fn get_project_video_dir(app: &AppHandle, project_id: &str) -> Result<std::path::PathBuf, String> {
let user_id = match crate::storage::auth::get_current_user_id(app) {
Ok(Some(id)) => id,
Ok(None) => return Err("未登录".to_string()),
Err(e) => return Err(format!("获取用户信息失败: {}", e)),
};
crate::storage::get_project_videos_dir(&user_id, project_id)
.map_err(|e| format!("获取项目视频目录失败: {}", e))
}
/// 校验视频比例是否为 9:16(允许 1% 误差)
fn validate_nine_sixteen_aspect(width: u32, height: u32) -> Result<(), String> {
if width == 0 || height == 0 {
return Err("无法读取视频分辨率".to_string());
}
let ratio = width as f64 / height as f64;
let expected = 9.0 / 16.0;
if (ratio - expected).abs() > 0.01 {
return Err(format!(
"视频比例必须是 9:16,当前为 {}×{}(比例 {:.3}",
width, height, ratio
));
}
Ok(())
}
/// 直接拼接已标准化的视频片段(不做裁剪,只做 concat)
/// 根据素材最小高度决定目标输出分辨率
fn resolve_target_resolution(heights: &[u32]) -> Option<(u32, u32)> {
let min_height = heights.iter().min()?;
if *min_height <= 1280 {
Some((720, 1280))
} else {
Some((1080, 1920))
}
}
/// 拼接视频片段
///
/// 1. 探测所有片段分辨率并校验 9:16 比例
/// 2. 按最小高度决定目标输出分辨率
/// 3. 统一标准化后拼接
#[tauri::command]
pub async fn concat_video_clips(
app: AppHandle,
project_id: String,
clip_paths: Vec<String>,
) -> ApiResponse<ComposeVideoResult> {
let video_dir = match get_project_video_dir(&project_id) {
let video_dir = match get_project_video_dir(&app, &project_id) {
Ok(dir) => dir,
Err(e) => {
return ApiResponse {
@@ -58,29 +92,49 @@ pub async fn concat_video_clips(
.unwrap()
.as_millis();
// 生成 concat 列表文件
let list_path = video_dir.join(format!("concat_list_{}.txt", timestamp));
let mut list_content = String::new();
// 1. 探测所有片段分辨率并校验 9:16
let mut heights = Vec::new();
for path in &clip_paths {
list_content.push_str(&format!("file '{}'\n", ffmpeg_cmd::escape_ffmpeg_path(path)));
}
if let Err(e) = std::fs::write(&list_path, list_content) {
return ApiResponse {
code: 500,
message: format!("创建拼接列表失败: {}", e),
data: None,
};
match ffmpeg_cmd::get_video_metadata(&app, path).await {
Ok(meta) => {
if let Err(e) = validate_nine_sixteen_aspect(meta.width, meta.height) {
return ApiResponse { code: 400, message: e, data: None };
}
heights.push(meta.height);
}
Err(e) => {
return ApiResponse {
code: 500,
message: format!("读取视频元数据失败: {}", e),
data: None,
};
}
}
}
// 执行拼接(所有片段已标准化,编码一致,直接用 copy)
let (target_width, target_height) = match resolve_target_resolution(&heights) {
Some(res) => res,
None => {
return ApiResponse {
code: 400,
message: "没有可拼接的视频片段".to_string(),
data: None,
};
}
};
// 2. 执行拼接(内部会先标准化到目标分辨率)
let output_filename = format!("composed_{}.mp4", timestamp);
let output_path = video_dir.join(&output_filename);
let output_path_str = output_path.to_string_lossy().to_string();
let concat_res = ffmpeg_cmd::concat_videos_copy(&app, list_path.to_str().unwrap(), &output_path_str).await;
// 清理列表文件
let _ = std::fs::remove_file(&list_path);
let concat_res = ffmpeg_cmd::concat_videos_robust(
&app,
clip_paths,
&output_path_str,
target_width,
target_height,
).await;
if let Err(e) = concat_res {
return ApiResponse {
@@ -91,7 +145,6 @@ pub async fn concat_video_clips(
}
// 总时长由前端提供(各片段时长的精确累加)
// 此处不通过 ffprobe 读取,避免依赖
let total_duration = 0.0;
ApiResponse {
@@ -103,7 +156,6 @@ pub async fn concat_video_clips(
}),
}
}
/// 空镜片段生成请求参数
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -134,8 +186,25 @@ pub async fn generate_empty_shot_clip(
let _ = std::fs::remove_file(&temp_audio);
};
// 1. 截取空镜视频并标准化
if let Err(e) = ffmpeg_cmd::clip_video(&app, &args.video_source, 0.0, args.duration, &temp_video).await {
// 1. 探测输入分辨率并校验 9:16 比例
let (video_width, video_height) = match ffmpeg_cmd::get_video_metadata(&app, &args.video_source).await {
Ok(meta) => {
if let Err(e) = validate_nine_sixteen_aspect(meta.width, meta.height) {
return ApiResponse { code: 400, message: e, data: None };
}
(meta.width, meta.height)
}
Err(e) => {
return ApiResponse {
code: 500,
message: format!("读取视频元数据失败: {}", e),
data: None,
};
}
};
// 2. 截取空镜视频并标准化(保持输入分辨率)
if let Err(e) = ffmpeg_cmd::clip_video(&app, &args.video_source, 0.0, args.duration, &temp_video, video_width, video_height).await {
cleanup();
return ApiResponse {
code: 500,
@@ -278,7 +347,24 @@ pub async fn extract_video_segment(
};
};
match ffmpeg_cmd::clip_video(&app, &safe_input, args.start, args.duration, &safe_output).await {
// 探测输入分辨率并校验 9:16 比例
let (video_width, video_height) = match ffmpeg_cmd::get_video_metadata(&app, &safe_input).await {
Ok(meta) => {
if let Err(e) = validate_nine_sixteen_aspect(meta.width, meta.height) {
return ApiResponse { code: 400, message: e, data: None };
}
(meta.width, meta.height)
}
Err(e) => {
return ApiResponse {
code: 500,
message: format!("读取视频元数据失败: {}", e),
data: None,
};
}
};
match ffmpeg_cmd::clip_video(&app, &safe_input, args.start, args.duration, &safe_output, video_width, video_height).await {
Ok(_) => ApiResponse {
code: 200,
message: "视频片段截取成功".to_string(),
+66 -4
View File
@@ -20,8 +20,24 @@ pub struct VoiceMaterialArgs {
/// 加载音色素材库
#[tauri::command]
pub async fn load_voice_materials() -> ApiResponse<Vec<voice_storage::VoiceMaterial>> {
match voice_storage::load_voice_materials() {
pub async fn load_voice_materials(
app: tauri::AppHandle,
) -> ApiResponse<Vec<voice_storage::VoiceMaterial>> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse {
code: 401,
message: "未登录".to_string(),
data: Some(vec![]),
},
Err(e) => return ApiResponse {
code: 500,
message: format!("获取用户信息失败: {}", e),
data: Some(vec![]),
},
};
match voice_storage::load_voice_materials(&user_id) {
Ok(list) => ApiResponse {
code: 200,
message: "素材库加载成功".to_string(),
@@ -38,8 +54,23 @@ pub async fn load_voice_materials() -> ApiResponse<Vec<voice_storage::VoiceMater
/// 保存音色素材
#[tauri::command]
pub async fn save_voice_material(
app: tauri::AppHandle,
args: VoiceMaterialArgs,
) -> ApiResponse<bool> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse {
code: 401,
message: "未登录".to_string(),
data: Some(false),
},
Err(e) => return ApiResponse {
code: 500,
message: format!("获取用户信息失败: {}", e),
data: Some(false),
},
};
let material = voice_storage::VoiceMaterial {
id: args.id,
name: args.name,
@@ -49,7 +80,7 @@ pub async fn save_voice_material(
status: args.status,
created_at: args.created_at,
};
match voice_storage::add_voice_material(material) {
match voice_storage::add_voice_material(&user_id, material) {
Ok(_) => ApiResponse {
code: 200,
message: "素材保存成功".to_string(),
@@ -66,9 +97,24 @@ pub async fn save_voice_material(
/// 删除音色素材
#[tauri::command]
pub async fn delete_voice_material_cmd(
app: tauri::AppHandle,
id: String,
) -> ApiResponse<bool> {
match voice_storage::delete_voice_material(&id) {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse {
code: 401,
message: "未登录".to_string(),
data: Some(false),
},
Err(e) => return ApiResponse {
code: 500,
message: format!("获取用户信息失败: {}", e),
data: Some(false),
},
};
match voice_storage::delete_voice_material(&user_id, &id) {
Ok(_) => ApiResponse {
code: 200,
message: "素材删除成功".to_string(),
@@ -98,8 +144,23 @@ pub struct SaveAudioArgs {
/// 保存音频文件(前端传入 base64 编码)
#[tauri::command]
pub async fn save_audio(
app: tauri::AppHandle,
args: SaveAudioArgs,
) -> ApiResponse<voice_storage::AudioMeta> {
let user_id = match crate::storage::auth::get_current_user_id(&app) {
Ok(Some(id)) => id,
Ok(None) => return ApiResponse {
code: 401,
message: "未登录".to_string(),
data: None,
},
Err(e) => return ApiResponse {
code: 500,
message: format!("获取用户信息失败: {}", e),
data: None,
},
};
let audio_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
&args.audio_data,
@@ -113,6 +174,7 @@ pub async fn save_audio(
};
match voice_storage::save_audio_file(
&user_id,
&args.project_id,
&args.audio_id,
&audio_bytes,
+49 -13
View File
@@ -206,16 +206,27 @@ pub async fn run_ffmpeg_in_dir(
/**
* ( 1080:1920, 25fps, libx264, aac 44100Hz stereo)
* (, 25fps, libx264, aac 44100Hz stereo)
*/
pub async fn standardize_video(app: &AppHandle, input_path: &str, output_path: &str) -> Result<(), String> {
pub async fn standardize_video(
app: &AppHandle,
input_path: &str,
output_path: &str,
target_width: u32,
target_height: u32,
) -> Result<(), String> {
// 验证路径安全
let safe_input = validate_safe_path(input_path)?;
let safe_output = sanitize_output_path(output_path)?;
let vf = format!(
"fps=25,scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
target_width, target_height, target_width, target_height
);
let args = vec![
"-i".to_string(), safe_input,
"-vf".to_string(), "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(),
"-vf".to_string(), vf,
"-c:v".to_string(), "libx264".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-ar".to_string(), "44100".to_string(),
@@ -251,7 +262,13 @@ pub async fn concat_videos_copy(app: &AppHandle, list_path: &str, output_path: &
/**
* - ( -> )
*/
pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec<String>, output_path: &str) -> Result<(), String> {
pub async fn concat_videos_robust(
app: &AppHandle,
video_paths: Vec<String>,
output_path: &str,
target_width: u32,
target_height: u32,
) -> Result<(), String> {
// 使用输出路径的父目录作为临时目录(确保在安全目录内)
let output_parent = std::path::Path::new(output_path)
.parent()
@@ -261,13 +278,13 @@ pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec<String>, out
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
let mut standardized_paths = Vec::new();
// 1. 标准化每个片段(内部已验证路径)
for (i, path) in video_paths.iter().enumerate() {
let std_path = output_parent.join(format!("std_{}_{}.mp4", timestamp, i));
match standardize_video(app, path, std_path.to_str().unwrap()).await {
match standardize_video(app, path, std_path.to_str().unwrap(), target_width, target_height).await {
Ok(()) => standardized_paths.push(std_path),
Err(e) => {
// 清理本轮已创建的标准化文件
@@ -325,14 +342,26 @@ pub async fn add_audio_to_video(app: &AppHandle, video_path: &str, audio_path: &
}
/**
* (0.5s, 1080x1920, 25fps)
* (0.5s, , 25fps)
* concat
*/
pub async fn create_cover_video(app: &AppHandle, input_path: &str, output_path: &str, duration: &str) -> Result<(), String> {
pub async fn create_cover_video(
app: &AppHandle,
input_path: &str,
output_path: &str,
duration: &str,
target_width: u32,
target_height: u32,
) -> Result<(), String> {
// 验证路径安全
let safe_input = validate_safe_path(input_path)?;
let safe_output = sanitize_output_path(output_path)?;
let vf = format!(
"scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,setsar=1",
target_width, target_height, target_width, target_height
);
let args = vec![
"-loop".to_string(), "1".to_string(),
"-i".to_string(), safe_input,
@@ -342,7 +371,7 @@ pub async fn create_cover_video(app: &AppHandle, input_path: &str, output_path:
"-c:a".to_string(), "aac".to_string(),
"-t".to_string(), duration.to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-vf".to_string(), "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1".to_string(),
"-vf".to_string(), vf,
"-r".to_string(), "25".to_string(),
"-shortest".to_string(),
"-y".to_string(),
@@ -745,7 +774,7 @@ pub async fn get_video_metadata(app: &AppHandle, input_path: &str) -> Result<Vid
/**
* HTTP URL
*
* 1080x1920, 25fps, libx264, aac
* , 25fps, libx264, aac
*
*/
pub async fn clip_video(
@@ -754,6 +783,8 @@ pub async fn clip_video(
start: f64,
duration: f64,
output_path: &str,
target_width: u32,
target_height: u32,
) -> Result<(), String> {
let safe_output = sanitize_output_path(output_path)?;
@@ -770,11 +801,16 @@ pub async fn clip_video(
let start_str = format!("{:.3}", start);
let duration_str = format!("{:.3}", duration);
let vf = format!(
"fps=25,scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
target_width, target_height, target_width, target_height
);
let args = vec![
"-ss".to_string(), start_str,
"-t".to_string(), duration_str,
"-i".to_string(), safe_input,
"-vf".to_string(), "fps=25,scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2,format=yuv420p".to_string(),
"-vf".to_string(), vf,
"-c:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "23".to_string(),
+190 -1
View File
@@ -292,6 +292,135 @@ fn get_bgm_cache_dir() -> Result<String, String> {
.map_err(|e| e.to_string())
}
// ============================================================
// 数据迁移:旧全局数据 → 用户隔离目录
// ============================================================
pub fn migrate_legacy_data(
data_dir: &std::path::Path,
app: &tauri::AppHandle,
) -> Result<(), crate::storage::StorageError> {
// 1. 检查迁移标记
let flag = data_dir.join(".migration_v1_done");
if flag.exists() {
return Ok(());
}
// 2. 检查是否有旧数据
let legacy_items = vec![
"voices.json",
"cover_avatars.json",
"cover_avatars",
"projects",
];
let has_legacy = legacy_items.iter().any(|name| data_dir.join(name).exists());
if !has_legacy {
std::fs::write(&flag, "none")?;
return Ok(());
}
// 3. 获取当前登录用户
let raw_user_id = crate::storage::auth::get_current_user_id(app)?;
let target_dir = match raw_user_id {
Some(id) => {
let safe_id = crate::storage::sanitize_id(&id)?;
data_dir.join("users").join(&safe_id)
}
None => {
// 未登录:暂不迁移,等登录后由 save_auth_state 触发
return Ok(());
}
};
crate::storage::ensure_dir(&target_dir)?;
// 4. 逐个 move(rename 在同一文件系统内是原子的)
// 若目标已存在(如之前部分迁移过),跳过该项,确保不丢数据
let mut any_skipped = false;
for name in &legacy_items {
let src = data_dir.join(name);
let dest = target_dir.join(name);
if src.exists() {
if dest.exists() {
eprintln!("[migrate] 目标已存在,跳过: {}", dest.display());
any_skipped = true;
continue;
}
if let Err(e) = std::fs::rename(&src, &dest) {
eprintln!("[migrate] 移动 {} 失败: {}", name, e);
return Err(e.into());
}
}
}
// 5. 修复项目 JSON 中的绝对路径引用(meta.json / segments.json
// 旧数据中的 coverPath、videoPath 等存的是绝对路径,move 后前缀变了
let old_prefix = data_dir.to_string_lossy().to_string();
let new_prefix = target_dir.to_string_lossy().to_string();
let projects_dir = target_dir.join("projects");
if projects_dir.exists() {
for entry in std::fs::read_dir(&projects_dir)? {
let entry = entry?;
let project_dir = entry.path();
if !project_dir.is_dir() { continue; }
for filename in &["meta.json", "segments.json"] {
let path = project_dir.join(filename);
if path.exists() {
if let Err(e) = fix_legacy_paths_in_file(&path, &old_prefix, &new_prefix) {
eprintln!("[migrate] 修复路径失败 {:?}: {}", path, e);
}
}
}
}
}
// 6. 写迁移标记
let flag_content = if any_skipped {
format!("skipped:{}", target_dir.file_name().unwrap_or_default().to_string_lossy())
} else {
target_dir.file_name().unwrap_or_default().to_string_lossy().to_string()
};
std::fs::write(&flag, flag_content)?;
Ok(())
}
/// 递归替换 JSON 字符串值中的旧路径前缀为新路径前缀
fn fix_legacy_paths_in_json(value: &mut serde_json::Value, old_prefix: &str, new_prefix: &str) {
match value {
serde_json::Value::String(s) => {
if s.starts_with(old_prefix) {
*s = new_prefix.to_string() + &s[old_prefix.len()..];
}
}
serde_json::Value::Array(arr) => {
for item in arr {
fix_legacy_paths_in_json(item, old_prefix, new_prefix);
}
}
serde_json::Value::Object(obj) => {
for (_, v) in obj {
fix_legacy_paths_in_json(v, old_prefix, new_prefix);
}
}
_ => {}
}
}
/// 读取 JSON 文件,替换其中的旧路径前缀,写回文件
fn fix_legacy_paths_in_file(
path: &std::path::Path,
old_prefix: &str,
new_prefix: &str,
) -> Result<(), crate::storage::StorageError> {
let content = std::fs::read_to_string(path)?;
let mut value: serde_json::Value = serde_json::from_str(&content)?;
fix_legacy_paths_in_json(&mut value, old_prefix, new_prefix);
std::fs::write(path, serde_json::to_string_pretty(&value)?)?;
Ok(())
}
// ============================================================
// 应用入口
// ============================================================
@@ -316,6 +445,12 @@ pub fn run() {
// 初始化应用数据目录(所有业务数据的根目录)
if let Ok(app_data_dir) = app.path().app_local_data_dir() {
crate::storage::init_app_data_dir(app_data_dir.clone());
// 数据迁移:将旧的全局数据按当前登录用户隔离到 users/{user_id}/ 目录
if let Err(e) = migrate_legacy_data(&app_data_dir, &app.handle()) {
eprintln!("[setup] 数据迁移失败: {}", e);
}
// 后台清理过期视频缓存和 BGM 缓存,不阻塞首屏
let app_data_dir_clone = app_data_dir.clone();
std::thread::spawn(move || {
@@ -538,13 +673,67 @@ async fn video_composite_synthesis(
};
}
};
// 探测所有视频素材分辨率,校验 9:16 并决定目标输出分辨率
let mut heights = Vec::new();
for path in &request.video_paths {
match ffmpeg_cmd::get_video_metadata(&app, path).await {
Ok(meta) => {
if meta.width == 0 || meta.height == 0 {
return ApiResponse {
code: 400,
message: format!("无法读取视频分辨率: {}", path),
data: None,
};
}
let ratio = meta.width as f64 / meta.height as f64;
let expected = 9.0 / 16.0;
if (ratio - expected).abs() > 0.01 {
return ApiResponse {
code: 400,
message: format!(
"视频比例必须是 9:16,当前为 {}×{}(比例 {:.3}: {}",
meta.width, meta.height, ratio, path
),
data: None,
};
}
heights.push(meta.height);
}
Err(e) => {
return ApiResponse {
code: 500,
message: format!("读取视频元数据失败 {}: {}", path, e),
data: None,
};
}
}
}
if heights.is_empty() {
return ApiResponse {
code: 400,
message: "没有可处理的视频片段".to_string(),
data: None,
};
}
let min_height = *heights.iter().min().unwrap();
let (target_width, target_height) = if min_height <= 1280 {
(720, 1280)
} else {
(1080, 1920)
};
let payload = serde_json::json!({
"videoPaths": request.video_paths,
"audioPath": request.audio_path.unwrap_or_default(),
"coverPath": request.cover_path,
"outputPath": request.output_path,
});
let response = video_processing::handle_video_synthesis(&app, &project_dir, payload).await;
let response = video_processing::handle_video_synthesis(
&app, &project_dir, payload, target_width, target_height
).await;
if response.code == 200 {
if let Some(data) = response.data {
if let Some(path) = data.as_object()
+13
View File
@@ -43,3 +43,16 @@ pub fn clear_auth_state(app: &AppHandle) -> Result<(), StorageError> {
Err(e) => Err(e.into()),
}
}
/// 获取当前登录用户的 user_id
///
/// 从 auth.json 中读取已认证的 user.id,Rust 层自治,不依赖前端传参。
pub fn get_current_user_id(app: &AppHandle) -> Result<Option<String>, StorageError> {
#[derive(serde::Deserialize)]
struct UserInfo { id: String }
#[derive(serde::Deserialize)]
struct AuthState { user: Option<UserInfo> }
let auth: Option<AuthState> = load_auth_state(app)?;
Ok(auth.and_then(|a| a.user).map(|u| u.id))
}
+12 -11
View File
@@ -29,20 +29,20 @@ pub struct CoverAvatarsList {
}
/// 加载封面形象库
pub fn load_cover_avatars() -> Result<CoverAvatarsList, StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?;
pub fn load_cover_avatars(user_id: &str) -> Result<CoverAvatarsList, StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path(user_id)?;
Ok(read_json(&path)?.unwrap_or_default())
}
/// 保存封面形象库
pub fn save_cover_avatars(list: &CoverAvatarsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?;
pub fn save_cover_avatars(user_id: &str, list: &CoverAvatarsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path(user_id)?;
atomic_write_json(&path, list)
}
/// 添加封面形象
pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?;
pub fn add_cover_avatar(user_id: &str, avatar: CoverAvatar) -> Result<(), StorageError> {
let mut list = load_cover_avatars(user_id)?;
// 去重:相同 id 替换
if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) {
list.avatars[pos] = avatar;
@@ -50,12 +50,12 @@ pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> {
list.avatars.push(avatar);
}
list.updated_at = chrono_lite_now();
save_cover_avatars(&list)
save_cover_avatars(user_id, &list)
}
/// 删除封面形象
pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?;
pub fn delete_cover_avatar(user_id: &str, id: &str) -> Result<(), StorageError> {
let mut list = load_cover_avatars(user_id)?;
let pos = list.avatars.iter().position(|a| a.id == id)
.ok_or_else(|| StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
@@ -63,18 +63,19 @@ pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> {
)))?;
list.avatars.remove(pos);
list.updated_at = chrono_lite_now();
save_cover_avatars(&list)
save_cover_avatars(user_id, &list)
}
/// 保存封面形象图片文件到本地
///
/// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。
pub fn save_cover_avatar_image(
user_id: &str,
avatar_id: &str,
data: &[u8],
ext: &str,
) -> Result<String, StorageError> {
let avatars_dir = get_cover_avatars_dir()?;
let avatars_dir = get_cover_avatars_dir(user_id)?;
ensure_dir(&avatars_dir)?;
// 净化扩展名:只允许字母数字,防止路径遍历
+7 -5
View File
@@ -16,12 +16,14 @@ pub mod cover_avatar;
pub use engine::{
atomic_write_json, atomic_write_bytes,
with_file_lock, read_json, sanitize_filename, StorageError,
with_file_lock, read_json, sanitize_filename, sanitize_id, ensure_dir, StorageError,
};
pub use paths::{
init_app_data_dir, get_app_data_dir, get_projects_root_dir,
get_project_dir, get_project_dir_path, get_project_assets_dir,
get_project_videos_dir, get_project_products_dir, get_voices_json_path,
get_app_config_json_path, get_cover_avatars_dir, get_cover_avatars_json_path,
init_app_data_dir, get_app_data_dir, get_user_data_dir,
get_projects_root_dir, get_project_dir, get_project_dir_path,
get_project_assets_dir, get_project_videos_dir, get_project_products_dir,
get_voices_json_path, get_app_config_json_path,
get_cover_avatars_dir, get_cover_avatars_json_path,
get_bgm_cache_dir, get_temp_dir,
};
+87 -40
View File
@@ -4,6 +4,11 @@
//! 所有返回 PathBuf 的函数内部已做路径净化。
//!
//! 存储根目录为 app_local_data_dir,在应用启动时通过 init_app_data_dir 初始化。
//!
//! 【用户数据隔离说明】
//! 用户相关数据(项目、音色素材、封面头像)按 user_id 隔离:
//! {app_local_data_dir}/users/{user_id}/
//! 全局共享数据(BGM 缓存、设备级配置)保持在根目录。
use std::path::PathBuf;
use std::sync::OnceLock;
@@ -26,68 +31,114 @@ pub fn get_app_data_dir() -> Result<&'static PathBuf, StorageError> {
)))
}
/// 获取项目根目录(所有项目的父目录)
/// {app_local_data_dir}/projects/
pub fn get_projects_root_dir() -> Result<PathBuf, StorageError> {
// ============================================================
// 用户隔离根目录
// ============================================================
/// 获取用户数据根目录
/// {app_local_data_dir}/users/{user_id}/
pub fn get_user_data_dir(user_id: &str) -> Result<PathBuf, StorageError> {
let safe_id = sanitize_id(user_id)?;
let base = get_app_data_dir()?;
let path = base.join("users").join(&safe_id);
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
// ============================================================
// 项目相关路径(用户隔离)
// ============================================================
/// 获取项目根目录(所有项目的父目录)
/// {app_local_data_dir}/users/{user_id}/projects/
pub fn get_projects_root_dir(user_id: &str) -> Result<PathBuf, StorageError> {
let base = get_user_data_dir(user_id)?;
let path = base.join("projects");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
/// 获取项目目录路径(不自动创建)
/// {app_local_data_dir}/projects/{project_id}/
pub fn get_project_dir_path(project_id: &str) -> Result<PathBuf, StorageError> {
/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/
pub fn get_project_dir_path(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let safe_id = sanitize_id(project_id)?;
let base = get_app_data_dir()?;
Ok(base.join("projects").join(&safe_id))
let base = get_projects_root_dir(user_id)?;
Ok(base.join(&safe_id))
}
/// 获取项目目录(自动创建)
/// {app_local_data_dir}/projects/{project_id}/
pub fn get_project_dir(project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir_path(project_id)?;
/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/
pub fn get_project_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir_path(user_id, project_id)?;
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
/// 获取项目内的 assets 目录
/// {app_local_data_dir}/projects/{project_id}/assets/
pub fn get_project_assets_dir(project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir(project_id)?;
/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/assets/
pub fn get_project_assets_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir(user_id, project_id)?;
let assets = path.join("assets");
crate::storage::engine::ensure_dir(&assets)?;
Ok(assets)
}
/// 获取项目内的 videos 目录
/// {app_local_data_dir}/projects/{project_id}/videos/
pub fn get_project_videos_dir(project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir(project_id)?;
/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/videos/
pub fn get_project_videos_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir(user_id, project_id)?;
let videos = path.join("videos");
crate::storage::engine::ensure_dir(&videos)?;
Ok(videos)
}
/// 获取项目成品目录
/// {app_local_data_dir}/projects/{project_id}/products/
pub fn get_project_products_dir(project_id: &str) -> Result<PathBuf, StorageError> {
let safe_id = sanitize_id(project_id)?;
let base = get_app_data_dir()?;
let path = base.join("projects").join(&safe_id).join("products");
/// {app_local_data_dir}/users/{user_id}/projects/{project_id}/products/
pub fn get_project_products_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let base = get_project_dir(user_id, project_id)?;
let path = base.join("products");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
// ============================================================
// 音色素材库路径(用户隔离)
// ============================================================
/// 获取私有音色素材库 JSON 路径
/// {app_local_data_dir}/voices.json
pub fn get_voices_json_path() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
/// {app_local_data_dir}/users/{user_id}/voices.json
pub fn get_voices_json_path(user_id: &str) -> Result<PathBuf, StorageError> {
let base = get_user_data_dir(user_id)?;
Ok(base.join("voices.json"))
}
// ============================================================
// 封面头像路径(用户隔离)
// ============================================================
/// 获取封面形象图片存储目录
/// {app_local_data_dir}/users/{user_id}/cover_avatars/
pub fn get_cover_avatars_dir(user_id: &str) -> Result<PathBuf, StorageError> {
let base = get_user_data_dir(user_id)?;
let path = base.join("cover_avatars");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
/// 获取封面形象库 JSON 路径
/// {app_local_data_dir}/users/{user_id}/cover_avatars.json
pub fn get_cover_avatars_json_path(user_id: &str) -> Result<PathBuf, StorageError> {
let base = get_user_data_dir(user_id)?;
Ok(base.join("cover_avatars.json"))
}
// ============================================================
// 全局共享路径(不隔离)
// ============================================================
/// 获取应用配置路径
/// {app_local_data_dir}/config.json
/// 注意:config.json 存的是设备级配置(API 地址、运行环境),保持全局共享。
pub fn get_app_config_json_path() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
Ok(base.join("config.json"))
@@ -95,6 +146,7 @@ pub fn get_app_config_json_path() -> Result<PathBuf, StorageError> {
/// 获取认证状态文件路径
/// {app_config_dir}/auth.json
/// 注意:auth.json 只存当前登录态,全局唯一,切换账号时覆盖。
pub fn get_auth_state_path(app: &AppHandle) -> Result<PathBuf, StorageError> {
let path = app.path().app_config_dir()
.map_err(|e| StorageError::Io(std::io::Error::new(
@@ -105,27 +157,22 @@ pub fn get_auth_state_path(app: &AppHandle) -> Result<PathBuf, StorageError> {
Ok(path.join("auth.json"))
}
/// 获取封面形象图片存储目录
/// {app_local_data_dir}/cover_avatars/
pub fn get_cover_avatars_dir() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
let path = base.join("cover_avatars");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
/// 获取封面形象库 JSON 路径
/// {app_local_data_dir}/cover_avatars.json
pub fn get_cover_avatars_json_path() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
Ok(base.join("cover_avatars.json"))
}
/// 获取 BGM 缓存目录
/// {app_local_data_dir}/bgm_cache/
/// 注意:BGM 缓存是公共资源,无需按用户隔离。
pub fn get_bgm_cache_dir() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
let path = base.join("bgm_cache");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
/// 获取临时目录
/// {app_local_data_dir}/temp/
#[allow(dead_code)]
pub fn get_temp_dir() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?;
let path = base.join("temp");
crate::storage::engine::ensure_dir(&path)?;
Ok(path)
}
+20 -19
View File
@@ -21,10 +21,11 @@ use crate::storage::paths::{
/// 保存项目元数据
pub fn save_project_meta(
user_id: &str,
project_id: &str,
data: &Value,
) -> Result<(), StorageError> {
let dir = get_project_dir(project_id)?;
let dir = get_project_dir(user_id, project_id)?;
let path = dir.join("meta.json");
with_file_lock(&path, || {
atomic_write_json(&path, data)
@@ -33,18 +34,20 @@ pub fn save_project_meta(
/// 保存项目元数据(原始 JSON 字符串)
pub fn save_project_meta_raw(
user_id: &str,
project_id: &str,
json_content: &str,
) -> Result<(), StorageError> {
let data: Value = serde_json::from_str(json_content)?;
save_project_meta(project_id, &data)
save_project_meta(user_id, project_id, &data)
}
/// 加载项目元数据
pub fn load_project_meta(
user_id: &str,
project_id: &str,
) -> Result<Value, StorageError> {
let dir = get_project_dir_path(project_id)?;
let dir = get_project_dir_path(user_id, project_id)?;
let path = dir.join("meta.json");
match read_json(&path)? {
Some(data) => Ok(data),
@@ -58,10 +61,11 @@ pub fn load_project_meta(
/// 保存分镜数据
pub fn save_project_segments(
user_id: &str,
project_id: &str,
segments: &Value,
) -> Result<(), StorageError> {
let dir = get_project_dir(project_id)?;
let dir = get_project_dir(user_id, project_id)?;
let path = dir.join("segments.json");
with_file_lock(&path, || {
atomic_write_json(&path, segments)
@@ -70,18 +74,20 @@ pub fn save_project_segments(
/// 保存分镜数据(原始 JSON 字符串)
pub fn save_project_segments_raw(
user_id: &str,
project_id: &str,
json_content: &str,
) -> Result<(), StorageError> {
let data: Value = serde_json::from_str(json_content)?;
save_project_segments(project_id, &data)
save_project_segments(user_id, project_id, &data)
}
/// 加载分镜数据
pub fn load_project_segments(
user_id: &str,
project_id: &str,
) -> Result<Value, StorageError> {
let dir = get_project_dir_path(project_id)?;
let dir = get_project_dir_path(user_id, project_id)?;
let path = dir.join("segments.json");
match read_json(&path)? {
Some(data) => Ok(data),
@@ -97,9 +103,8 @@ pub fn load_project_segments(
///
/// 遍历 projects 目录,读取每个项目的 meta.json。
/// 读取失败的项目会被跳过并记录警告,不会导致整个列表失败。
pub fn list_projects() -> Result<Vec<Value>, StorageError> {
let base = get_meijiaka_dir()?;
let projects_dir = base.join("projects");
pub fn list_projects(user_id: &str) -> Result<Vec<Value>, StorageError> {
let projects_dir = crate::storage::paths::get_projects_root_dir(user_id)?;
if !projects_dir.exists() {
return Ok(vec![]);
@@ -143,8 +148,8 @@ pub fn list_projects() -> Result<Vec<Value>, StorageError> {
///
/// 使用 `get_project_dir_path`(不自动创建目录),
/// 避免删除不存在项目时先创建空目录的 bug。
pub fn delete_project(project_id: &str) -> Result<(), StorageError> {
let dir = get_project_dir_path(project_id)?;
pub fn delete_project(user_id: &str, project_id: &str) -> Result<(), StorageError> {
let dir = get_project_dir_path(user_id, project_id)?;
if dir.exists() {
fs::remove_dir_all(&dir)?;
}
@@ -157,12 +162,13 @@ pub fn delete_project(project_id: &str) -> Result<(), StorageError> {
/// 保存项目资源(Base64 解码后写入)
pub fn save_project_asset(
user_id: &str,
project_id: &str,
filename: &str,
base64_data: &str,
) -> Result<String, StorageError> {
let safe_filename = sanitize_filename(filename)?;
let dir = get_project_assets_dir(project_id)?;
let dir = get_project_assets_dir(user_id, project_id)?;
let path = dir.join(&safe_filename);
let decoded = base64::engine::general_purpose::STANDARD
@@ -177,16 +183,11 @@ pub fn save_project_asset(
/// 获取视频保存路径(不写入文件)
/// 保存到项目 videos 目录下,与后端下载路径保持一致
pub fn get_video_save_path(
user_id: &str,
project_id: &str,
filename: &str,
) -> Result<PathBuf, StorageError> {
let safe_filename = sanitize_filename(filename)?;
let dir = get_project_videos_dir(project_id)?;
let dir = get_project_videos_dir(user_id, project_id)?;
Ok(dir.join(&safe_filename))
}
// 兼容旧接口:从 persistence.rs 迁移时需要的 get_meijiaka_dir
fn get_meijiaka_dir() -> Result<std::path::PathBuf, StorageError> {
crate::storage::paths::get_app_data_dir()
.map(|p| p.to_path_buf())
}
+12 -11
View File
@@ -26,6 +26,7 @@ pub struct AudioMeta {
///
/// 将 base64 编码的音频数据写入 `audios/` 子目录。
pub fn save_audio_file(
user_id: &str,
project_id: &str,
audio_id: &str,
data: &[u8],
@@ -33,7 +34,7 @@ pub fn save_audio_file(
voice_id: &str,
duration: f64,
) -> Result<AudioMeta, StorageError> {
let project_dir = get_project_dir(project_id)?;
let project_dir = get_project_dir(user_id, project_id)?;
let audios_dir = project_dir.join("audios");
ensure_dir(&audios_dir)?;
@@ -87,20 +88,20 @@ pub struct VoiceMaterialsList {
}
/// 加载音色素材库
pub fn load_voice_materials() -> Result<VoiceMaterialsList, StorageError> {
let path = crate::storage::paths::get_voices_json_path()?;
pub fn load_voice_materials(user_id: &str) -> Result<VoiceMaterialsList, StorageError> {
let path = crate::storage::paths::get_voices_json_path(user_id)?;
Ok(read_json(&path)?.unwrap_or_default())
}
/// 保存音色素材库
pub fn save_voice_materials(list: &VoiceMaterialsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_voices_json_path()?;
pub fn save_voice_materials(user_id: &str, list: &VoiceMaterialsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_voices_json_path(user_id)?;
crate::storage::engine::atomic_write_json(&path, list)
}
/// 添加音色素材
pub fn add_voice_material(material: VoiceMaterial) -> Result<(), StorageError> {
let mut list = load_voice_materials()?;
pub fn add_voice_material(user_id: &str, material: VoiceMaterial) -> Result<(), StorageError> {
let mut list = load_voice_materials(user_id)?;
// 去重:相同 id 替换
if let Some(pos) = list.materials.iter().position(|m| m.id == material.id) {
list.materials[pos] = material;
@@ -108,12 +109,12 @@ pub fn add_voice_material(material: VoiceMaterial) -> Result<(), StorageError> {
list.materials.push(material);
}
list.updated_at = chrono_lite_now();
save_voice_materials(&list)
save_voice_materials(user_id, &list)
}
/// 删除音色素材
pub fn delete_voice_material(id: &str) -> Result<(), StorageError> {
let mut list = load_voice_materials()?;
pub fn delete_voice_material(user_id: &str, id: &str) -> Result<(), StorageError> {
let mut list = load_voice_materials(user_id)?;
let pos = list.materials.iter().position(|m| m.id == id)
.ok_or_else(|| StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
@@ -121,5 +122,5 @@ pub fn delete_voice_material(id: &str) -> Result<(), StorageError> {
)))?;
list.materials.remove(pos);
list.updated_at = chrono_lite_now();
save_voice_materials(&list)
save_voice_materials(user_id, &list)
}
+4 -2
View File
@@ -9,6 +9,8 @@ pub async fn handle_video_synthesis(
app: &tauri::AppHandle,
project_dir: &std::path::PathBuf,
payload: serde_json::Value,
target_width: u32,
target_height: u32,
) -> ApiResponse<serde_json::Value> {
let payload_obj = match payload.as_object() {
Some(p) => p,
@@ -59,7 +61,7 @@ pub async fn handle_video_synthesis(
cover_v_path.push(format!("temp_cover_{}.mp4", utils::uuid_v4()));
let cover_v_path_str = cover_v_path.to_string_lossy().to_string();
match crate::ffmpeg_cmd::create_cover_video(app, c_path, &cover_v_path_str, "0.5").await {
match crate::ffmpeg_cmd::create_cover_video(app, c_path, &cover_v_path_str, "0.5", target_width, target_height).await {
Ok(_) => {
paths_vec.insert(0, cover_v_path_str);
temp_cover_video = Some(cover_v_path);
@@ -75,7 +77,7 @@ pub async fn handle_video_synthesis(
}
// 执行视频拼接
match crate::ffmpeg_cmd::concat_videos_robust(app, paths_vec, concat_output.to_string_lossy().as_ref()).await {
match crate::ffmpeg_cmd::concat_videos_robust(app, paths_vec, concat_output.to_string_lossy().as_ref(), target_width, target_height).await {
Ok(_) => {
// 如果有音频,合并音频;否则直接使用拼接结果
let has_audio = audio_path.map(|p| !p.is_empty()).unwrap_or(false);
@@ -115,7 +115,6 @@ export const localProjectApi = {
dubbingAudioDuration: meta.dubbingAudioDuration,
voiceSpeed: meta.voiceSpeed,
voiceVolume: meta.voiceVolume,
voicePitch: meta.voicePitch,
avatarMaterialPath: meta.avatarMaterialPath,
avatarMaterialName: meta.avatarMaterialName,
avatarMaterialDuration: meta.avatarMaterialDuration,
@@ -36,6 +36,8 @@ interface UseCanvasSubtitleRendererOptions {
mainTitleStyle?: Partial<AssStyle>;
subTitleStyle?: Partial<AssStyle>;
enabled: boolean;
/** ASS 坐标系基准高度,默认 1920720p 视频应传入 1280 */
playResY?: number;
}
export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOptions) {
@@ -49,6 +51,7 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti
mainTitleStyle,
subTitleStyle,
enabled,
playResY,
} = options;
const rafRef = useRef<number | null>(null);
@@ -84,7 +87,8 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti
// 计算缩放比例(基于高度)
// 扣除 <video controls> 控制条高度(约 40px),确保预览与视频画面比例一致
const VIDEO_CONTROLS_HEIGHT = 40;
const scale = Math.max(1, displayHeight - VIDEO_CONTROLS_HEIGHT) / PLAY_RES_Y;
const playResYValue = playResY ?? PLAY_RES_Y;
const scale = Math.max(1, displayHeight - VIDEO_CONTROLS_HEIGHT) / playResYValue;
const currentTimeMs = video.currentTime * 1000;
@@ -139,7 +143,7 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti
}
ctx.restore();
}, [enabled, utterances, mainTitle, subTitle, subtitleStyle, mainTitleStyle, subTitleStyle, videoRef, canvasRef]);
}, [enabled, utterances, mainTitle, subTitle, subtitleStyle, mainTitleStyle, subTitleStyle, videoRef, canvasRef, playResY]);
useEffect(() => {
if (!enabled) {return;}
@@ -399,7 +399,7 @@ export default function CoverDesign() {
open={bgModalOpen}
onClose={() => setBgModalOpen(false)}
title="选择背景图片"
width="440px"
width="560px"
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="modal-bg-grid">
@@ -80,6 +80,8 @@ export default function SubtitleBurning() {
const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false);
const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false);
const [showPointsModal, setShowPointsModal] = useState(false);
// 视频原始分辨率(用于字幕缩放和 Canvas 预览基准)
const [videoResolution, setVideoResolution] = useState<{ width: number; height: number } | null>(null);
const showRechargeModal = usePointStore(state => state.showRechargeModal);
const setShowRechargeModal = usePointStore(state => state.setShowRechargeModal);
const fetchBalance = usePointStore(state => state.fetchBalance);
@@ -96,6 +98,10 @@ export default function SubtitleBurning() {
const { videoUrl: burnedVideoUrl } = useLocalVideo(burnedVideoPath || undefined);
const hasBurnedVideo = !!burnedVideoPath;
// 探测视频分辨率,决定字幕缩放比例和 Canvas 预览基准
const subtitleScale = videoResolution ? videoResolution.width / 1080 : 1;
const playResY = videoResolution ? videoResolution.height : 1920;
// 大标题 / 小标题(从 store 读取,非必填,无默认值)
const storeMainTitle = useProjectStore(state => state.mainTitle);
const storeSubTitle = useProjectStore(state => state.subTitle);
@@ -182,46 +188,46 @@ export default function SubtitleBurning() {
// 构建 ASS Style 参数
const videoDurationMs = (alignment?.duration || 0) * 1000;
const buildSubtitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({
fontSize: 64,
const buildSubtitleStyle = (preset: TitlePreset, scale: number = 1): Partial<AssStyle> => ({
fontSize: Math.round(64 * scale),
primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle,
outline: preset.outline,
outline: Math.round(preset.outline * scale),
shadow: 0,
alignment: 2,
marginV: 480,
marginL: 160,
marginR: 160,
marginV: Math.round(480 * scale),
marginL: Math.round(160 * scale),
marginR: Math.round(160 * scale),
});
const buildMainTitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({
fontSize: 96,
const buildMainTitleStyle = (preset: TitlePreset, scale: number = 1): Partial<AssStyle> => ({
fontSize: Math.round(96 * scale),
primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle,
outline: preset.outline,
outline: Math.round(preset.outline * scale),
shadow: 0,
alignment: 8,
marginV: 320,
marginL: 160,
marginR: 160,
marginV: Math.round(320 * scale),
marginL: Math.round(160 * scale),
marginR: Math.round(160 * scale),
});
const buildSubTitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({
fontSize: 80,
const buildSubTitleStyle = (preset: TitlePreset, scale: number = 1): Partial<AssStyle> => ({
fontSize: Math.round(80 * scale),
primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle,
outline: preset.outline,
outline: Math.round(preset.outline * scale),
shadow: 0,
alignment: 8,
marginV: 440,
marginL: 160,
marginR: 160,
marginV: Math.round(440 * scale),
marginL: Math.round(160 * scale),
marginR: Math.round(160 * scale),
});
// 用 text-shadow 模拟 ASS 外描边(比 WebkitTextStroke 的居中描边更准确)
@@ -230,6 +236,20 @@ export default function SubtitleBurning() {
return `${-w}px ${-w}px 0 ${color}, ${w}px ${-w}px 0 ${color}, ${-w}px ${w}px 0 ${color}, ${w}px ${w}px 0 ${color}, ${-w}px 0px 0 ${color}, ${w}px 0px 0 ${color}, 0px ${-w}px 0 ${color}, 0px ${w}px 0 ${color}`;
};
// 探测视频分辨率
useEffect(() => {
if (!actualVideoPath) {return;}
invoke<{ code: number; data?: { width: number; height: number; duration: number; fps: number }; message: string }>('get_video_metadata_cmd', {
request: { path: actualVideoPath },
}).then((res) => {
if (res.code === 200 && res.data) {
setVideoResolution({ width: res.data.width, height: res.data.height });
}
}).catch(() => {
// 探测失败不阻断,使用默认 1080p 比例
});
}, [actualVideoPath]);
// Canvas 2D 统一字幕渲染(替代 assjs + CSS 标题叠加)
// 统一去除字幕末尾标点(预览和压制共用)
const processedUtterances = alignment?.utterances?.map(u => ({
@@ -242,18 +262,18 @@ export default function SubtitleBurning() {
const subtitleStyle = useMemo(() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {};
}, [captionPreset]);
return preset ? buildSubtitleStyle(preset, subtitleScale) : {};
}, [captionPreset, subtitleScale]);
const mainTitleStyle = useMemo(() => {
const preset = MAIN_TITLE_PRESETS.find(p => p.id === mainTitlePreset);
return preset ? buildMainTitleStyle(preset) : undefined;
}, [mainTitlePreset]);
return preset ? buildMainTitleStyle(preset, subtitleScale) : undefined;
}, [mainTitlePreset, subtitleScale]);
const subTitleStyle = useMemo(() => {
const preset = SUB_TITLE_PRESETS.find(p => p.id === subTitlePreset);
return preset ? buildSubTitleStyle(preset) : undefined;
}, [subTitlePreset]);
return preset ? buildSubTitleStyle(preset, subtitleScale) : undefined;
}, [subTitlePreset, subtitleScale]);
useCanvasSubtitleRenderer({
videoRef,
@@ -265,6 +285,7 @@ export default function SubtitleBurning() {
mainTitleStyle,
subTitleStyle,
enabled: previewMode === 'style' && subtitleEnabled,
playResY,
});
// 监听 FFmpeg 进度事件(支持多步分段)
@@ -385,16 +406,31 @@ export default function SubtitleBurning() {
overlayImagePath = pngSaveRes.data;
}
// 2. 生成 ASS 内容(不含大标题/小标题,它们走 PNG overlay
// 2. 确定 ASS 字幕目标分辨率(优先复用组件 state 中已探测的结果
let targetWidth = videoResolution?.width ?? 1080;
let targetHeight = videoResolution?.height ?? 1920;
if (!videoResolution) {
const metaRes = await invoke<{ code: number; data?: { width: number; height: number; duration: number; fps: number }; message: string }>('get_video_metadata_cmd', {
request: { path: actualVideoPath },
});
if (metaRes.code === 200 && metaRes.data) {
targetWidth = metaRes.data.width;
targetHeight = metaRes.data.height;
}
}
// 3. 生成 ASS 内容(不含大标题/小标题,它们走 PNG overlay
const assForBurn = generateAssFromAlignment(
processedUtterances,
{
subtitleStyle: (() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {};
return preset ? buildSubtitleStyle(preset, subtitleScale) : {};
})(),
videoDurationMs,
includeTitlesInAss: false,
targetWidth,
targetHeight,
}
);
@@ -292,7 +292,7 @@ export default function VideoCompose() {
bgmPath: finalBgmPath,
outputPath: result,
videoVolume: 1.0,
bgmVolume: (bgmVolume ?? 0.25),
bgmVolume: (bgmVolume ?? 0.15),
},
});
if (mixRes.code !== 200) {
@@ -415,7 +415,7 @@ export default function VideoCompose() {
<div>
<div style={{ fontSize: 'var(--font-sm)', fontWeight: 500 }}>{bgmMusicTitle || '背景音乐'}</div>
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)' }}>
{Math.round((bgmVolume ?? 0.25) * 100)}%
{Math.round((bgmVolume ?? 0.15) * 100)}%
</div>
</div>
</div>
@@ -465,13 +465,13 @@ export default function VideoCompose() {
min="0"
max="1"
step="0.05"
value={bgmVolume ?? 0.25}
value={bgmVolume ?? 0.15}
onChange={e => setBgmVolume(parseFloat(e.target.value))}
disabled={compositing}
style={{ flex: 1, '--slider-percent': `${((bgmVolume ?? 0.25) / 1) * 100}%` } as React.CSSProperties}
style={{ flex: 1, '--slider-percent': `${((bgmVolume ?? 0.15) / 1) * 100}%` } as React.CSSProperties}
/>
<span style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)', minWidth: '36px', textAlign: 'right' }}>
{Math.round(((bgmVolume ?? 0.25)) * 100)}%
{Math.round(((bgmVolume ?? 0.15)) * 100)}%
</span>
</div>
)}
@@ -12,6 +12,7 @@ import { getCurrentProjectId } from '../../api/modules/localStorage';
import { saveMetaToLocalFile } from '../../store/projectStore';
import { synthesizeTTS, saveAudio, uploadAudio, extractAudioSegment } from '../../api/modules/voice';
import { toast } from '../../store/uiStore';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import type { AlignmentResult } from '../../api/types';
import { useProgressStore } from '../../store/progressStore';
import { usePointsCheck } from '../../hooks/usePointsCheck';
@@ -30,11 +31,10 @@ export default function VoiceSynthesis() {
const selectedVoiceId = useProjectStore(state => state.selectedVoiceId);
const speed = useProjectStore(state => state.voiceSpeed);
const volume = useProjectStore(state => state.voiceVolume);
const pitch = useProjectStore(state => state.voicePitch);
const setSelectedVoiceId = useProjectStore(state => state.setSelectedVoiceId);
const setSpeed = useProjectStore(state => state.setVoiceSpeed);
const setVolume = useProjectStore(state => state.setVoiceVolume);
const setPitch = useProjectStore(state => state.setVoicePitch);
const {
presetVoices,
@@ -56,6 +56,14 @@ export default function VoiceSynthesis() {
const [isPlayingGenerated, setIsPlayingGenerated] = useState(false);
const generatedAudioRef = useRef<HTMLAudioElement | null>(null);
// 台词字数不足弹窗
const [scriptCheckModal, setScriptCheckModal] = useState<{
open: boolean;
segId?: number;
required?: number;
actual?: number;
}>({ open: false });
const hasGeneratedAudio = !!dubbingAudioUrl;
useEffect(() => {
@@ -285,6 +293,18 @@ export default function VoiceSynthesis() {
const handleGenerate = useCallback(async () => {
if (!projectId) { toast.warning('请先创建项目'); return; }
// 检查每个镜头的台词字数(剔除标点),当前语速下需满足 Math.ceil(4 * speed) 个字
const minChars = Math.ceil(4 * (speed || 1));
for (const seg of segments) {
if (!seg.voiceover?.trim()) continue;
const pureChars = seg.voiceover.trim().replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, '').length;
if (pureChars < minChars) {
setScriptCheckModal({ open: true, segId: seg.id, required: minChars, actual: pureChars });
return;
}
}
// 拼接 TTS 文本,根据镜头切换类型插入停顿标记
const realText = segments
.filter(s => s.voiceover?.trim())
@@ -308,7 +328,6 @@ export default function VoiceSynthesis() {
const currentVoiceId = useProjectStore.getState().selectedVoiceId;
const currentSpeed = useProjectStore.getState().voiceSpeed;
const currentVolume = useProjectStore.getState().voiceVolume;
const currentPitch = useProjectStore.getState().voicePitch;
if (!currentVoiceId) { toast.warning('请先选择音色'); setIsGenerating(false); return; }
// 前置积分检查(宽松模式:余额为正即可执行,TTS 实际消耗不确定,允许欠费)
@@ -321,7 +340,7 @@ export default function VoiceSynthesis() {
try {
progress.update('正在合成配音...');
const result = await synthesizeTTS({ text: truncatedText, voiceId: currentVoiceId, speed: currentSpeed, volume: currentVolume, pitch: currentPitch });
const result = await synthesizeTTS({ text: truncatedText, voiceId: currentVoiceId, speed: currentSpeed, volume: currentVolume });
if (!result.audioUrl) {throw new Error('未返回音频 URL');}
progress.update('正在处理音频...');
@@ -550,28 +569,6 @@ export default function VoiceSynthesis() {
</div>
</div>
{/* 音调 */}
<div className="voice-section">
<div className="voice-section-header">
<span className="voice-section-title"></span>
<span className="speed-value">{pitch}</span>
</div>
<div className="speed-slider-wrap">
<span>-12</span>
<input
type="range"
className="slider-input"
min={-12}
max={12}
step={1}
value={pitch}
onChange={e => { const v = parseInt(e.target.value); setPitch(v); saveMetaToLocalFile({ voicePitch: v }); }}
style={{ '--slider-percent': `${((pitch + 12) / 24) * 100}%` } as React.CSSProperties}
/>
<span>12</span>
</div>
</div>
{/* 底部生成按钮 */}
<div className="voice-generate-wrap">
{!hasGeneratedAudio ? (
@@ -604,6 +601,23 @@ export default function VoiceSynthesis() {
</div>
</div>
<PointsModal />
{/* 台词字数不足提示弹窗 */}
<ConfirmModal
open={scriptCheckModal.open}
type="warning"
title={<> {scriptCheckModal.segId} </>}
description={
scriptCheckModal.required !== undefined && scriptCheckModal.actual !== undefined
? `当前 ${speed}x 语速要求至少 ${scriptCheckModal.required} 个字,实际仅 ${scriptCheckModal.actual} 个字。\n请补充台词或降低语速后重试。`
: undefined
}
confirmText="知道了"
hideCancel
onConfirm={() => setScriptCheckModal({ open: false })}
onCancel={() => setScriptCheckModal({ open: false })}
onClose={() => setScriptCheckModal({ open: false })}
/>
</div>
);
}
@@ -65,7 +65,6 @@ function VideoCreationContent() {
if (meta.dubbingAudioDuration !== undefined) {updates.dubbingAudioDuration = meta.dubbingAudioDuration;}
if (meta.voiceSpeed !== undefined) {updates.voiceSpeed = meta.voiceSpeed;}
if (meta.voiceVolume !== undefined) {updates.voiceVolume = meta.voiceVolume;}
if (meta.voicePitch !== undefined) {updates.voicePitch = meta.voicePitch;}
if (meta.subtitleAlignment !== undefined) {updates.subtitleAlignment = meta.subtitleAlignment;}
// Step 3 视频生成
@@ -80,7 +80,7 @@ const AvatarMaterialSelector: React.FC<AvatarMaterialSelectorProps> = ({
</p>
<p style={{ marginTop: '4px', fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)' }}>
MP4 / MOV · 5-20 · 1080×1920
MP4 / MOV · 5-60 · 9:16720×1280 1080×1920
</p>
<button
className="btn btn-primary btn-sm"
@@ -196,7 +196,7 @@ export function useEmptyShotMaterials(
const result = await validateLocalVideo(selected, {
minDuration,
maxDuration: 20,
maxDuration: 60,
requireResolution: true,
});
if (!result.valid) {
@@ -25,7 +25,7 @@ export async function validateLocalVideo(
filePath: string,
options?: { minDuration?: number; maxDuration?: number; requireResolution?: boolean }
): Promise<VideoValidationResult> {
const { minDuration = 5, maxDuration = 20, requireResolution = true } = options || {};
const { minDuration = 5, maxDuration = 60, requireResolution = true } = options || {};
try {
const resp = await invoke<{
@@ -56,14 +56,19 @@ export async function validateLocalVideo(
};
}
if (requireResolution && (width !== 1080 || height !== 1920)) {
return {
valid: false,
duration,
width,
height,
error: `视频分辨率 ${width}x${height},要求 1080x1920`,
};
// 判断 9:16 比例(允许 ±3% 容差),支持 720×1280 与 1080×1920
if (requireResolution) {
const ratio = width / height;
const expected = 9 / 16;
if (Math.abs(ratio - expected) / expected > 0.03) {
return {
valid: false,
duration,
width,
height,
error: `视频比例必须是 9:16,当前为 ${width}×${height}`,
};
}
}
return { valid: true, duration, width, height };
+23
View File
@@ -281,6 +281,15 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
closeKickModal: () => {
clearAuthState();
set({ ...initialState, isLoading: false, showKickModal: false, kickMessage: '' });
// 清除其他 Store 的内存状态,防止被踢后重新登录时数据残留
try {
import('./voiceStore').then(m => m.useVoiceStore.getState().reset());
import('./coverAvatarStore').then(m => m.useCoverAvatarStore.getState().reset());
import('./progressStore').then(m => m.useProgressStore.getState().reset());
import('./projectStore').then(m => m.useProjectStore.getState().reset());
} catch (e) {
console.error('[Auth] 清除其他 Store 状态失败:', e);
}
},
logout: async () => {
@@ -308,6 +317,20 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
} catch (e) {
console.error('[Auth] 清除认证状态失败:', e);
}
// 清除其他 Store 的内存状态,防止切换账号后数据残留
try {
const { useVoiceStore } = await import('./voiceStore');
const { useCoverAvatarStore } = await import('./coverAvatarStore');
const { useProgressStore } = await import('./progressStore');
const { useProjectStore } = await import('./projectStore');
useVoiceStore.getState().reset();
useCoverAvatarStore.getState().reset();
useProgressStore.getState().reset();
useProjectStore.getState().reset();
} catch (e) {
console.error('[Auth] 清除其他 Store 状态失败:', e);
}
},
checkAuth: () => {
+12 -7
View File
@@ -30,7 +30,6 @@ interface ProjectActions {
setDubbingAudioDuration: (_duration: number | undefined) => void;
setVoiceSpeed: (_speed: number) => void;
setVoiceVolume: (_volume: number) => void;
setVoicePitch: (_pitch: number) => void;
setBgmMusic: (_id: number | undefined, _title: string | undefined, _path: string | undefined) => void;
setBgmVolume: (_volume: number) => void;
setCategoryCode: (_code: string) => void;
@@ -39,6 +38,7 @@ interface ProjectActions {
setHasHydrated: (_hydrated: boolean) => void;
markStepsDirty: (_fromStep: number) => void;
clearStepDirty: (_stepId: number) => void;
reset: () => void;
}
@@ -65,7 +65,6 @@ const initialState: Omit<
_isLoading: false,
voiceSpeed: 1.0,
voiceVolume: 0,
voicePitch: 0,
_hasHydrated: false,
};
@@ -232,11 +231,6 @@ export const useProjectStore = create<ProjectStore>()(
state.voiceVolume = volume;
state.updatedAt = Date.now();
}),
setVoicePitch: pitch =>
set(state => {
state.voicePitch = pitch;
state.updatedAt = Date.now();
}),
setBgmMusic: (id, title, path) => {
set(state => {
state.bgmMusicId = id;
@@ -297,6 +291,17 @@ export const useProjectStore = create<ProjectStore>()(
saveMetaToLocalFile({ stepDirtyFlags: { ...flags, [stepId]: false } });
},
reset: () => {
currentProjectId = '';
setCurrentProjectId('');
set({
...initialState,
...BLANK_META_OVERRIDES,
segments: [],
_hasHydrated: true,
});
},
}),
{
name: 'ai-video-project-config',
-2
View File
@@ -98,7 +98,6 @@ export interface ProjectMeta {
dubbingAudioDuration?: number;
voiceSpeed?: number;
voiceVolume?: number;
voicePitch?: number;
// === 字幕 ===
subtitleAlignment?: AlignmentResult;
@@ -150,7 +149,6 @@ export interface ProjectState extends ProjectMeta {
// 以下字段在 ProjectMeta 中为可选,但在 Store 中始终有默认值,故覆盖为必需
voiceSpeed: number;
voiceVolume: number;
voicePitch: number;
}
// ------------------------------------------------------------------
+19 -5
View File
@@ -178,11 +178,25 @@ export function generateAssFromAlignment(
videoDurationMs?: number;
/** 是否将大标题/小标题包含在 ASS 中(默认 true) */
includeTitlesInAss?: boolean;
/** 目标视频分辨率,默认 1080×1920;用于等比例缩放字幕样式 */
targetWidth?: number;
targetHeight?: number;
} = {}
): string {
const { subtitleStyle, mainTitle, subTitle, mainTitleStyle, subTitleStyle, videoDurationMs, includeTitlesInAss = true } = options;
const { subtitleStyle, mainTitle, subTitle, mainTitleStyle, subTitleStyle, videoDurationMs, includeTitlesInAss = true, targetWidth = 1080, targetHeight = 1920 } = options;
const mergedSubtitleStyle = { ...DEFAULT_ASS_STYLE, ...subtitleStyle, name: 'Default' };
// 根据目标分辨率等比例缩放默认样式(基于宽度比例,9:16 下宽度/高度比例相同)
const scale = targetWidth / 1080;
const scaledDefaultStyle: AssStyle = {
...DEFAULT_ASS_STYLE,
fontSize: Math.round(DEFAULT_ASS_STYLE.fontSize * scale),
outline: Math.round(DEFAULT_ASS_STYLE.outline * scale),
marginL: Math.round(DEFAULT_ASS_STYLE.marginL * scale),
marginR: Math.round(DEFAULT_ASS_STYLE.marginR * scale),
marginV: Math.round(DEFAULT_ASS_STYLE.marginV * scale),
};
const mergedSubtitleStyle = { ...scaledDefaultStyle, ...subtitleStyle, name: 'Default' };
const styles: AssStyle[] = [mergedSubtitleStyle];
const dialogues: AssDialogue[] = utterances.map(u => ({
@@ -194,7 +208,7 @@ export function generateAssFromAlignment(
// 大标题:覆盖整个视频时长,显示在顶部
if (includeTitlesInAss && mainTitle && mainTitleStyle && videoDurationMs) {
styles.push({ ...DEFAULT_ASS_STYLE, ...mainTitleStyle, name: 'MainTitle' });
styles.push({ ...scaledDefaultStyle, ...mainTitleStyle, name: 'MainTitle' });
dialogues.push({
start: '0:00:00.00',
end: msToAssTime(videoDurationMs),
@@ -205,7 +219,7 @@ export function generateAssFromAlignment(
// 小标题:覆盖整个视频时长,显示在大标题下方
if (includeTitlesInAss && subTitle && subTitleStyle && videoDurationMs) {
styles.push({ ...DEFAULT_ASS_STYLE, ...subTitleStyle, name: 'SubTitle' });
styles.push({ ...scaledDefaultStyle, ...subTitleStyle, name: 'SubTitle' });
dialogues.push({
start: '0:00:00.00',
end: msToAssTime(videoDurationMs),
@@ -214,7 +228,7 @@ export function generateAssFromAlignment(
});
}
return generateAssContent(dialogues, styles);
return generateAssContent(dialogues, styles, targetWidth, targetHeight);
}
/**
-1
View File
@@ -17,7 +17,6 @@ const DEFAULT_META_VALUES: Partial<ProjectMeta> = {
currentStep: 1,
voiceSpeed: 1.0,
voiceVolume: 0,
voicePitch: 0,
status: 'draft',
version: 1,
};