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 $$ DO $$
DECLARE DECLARE
v_mobile TEXT := '18860016386'; -- ← 修改:手机号 v_mobile TEXT := '15359215971'; -- ← 修改:手机号
v_nickname TEXT := '18860016386'; -- ← 修改:昵称(可为空) v_nickname TEXT := '家迪装饰'; -- ← 修改:昵称(可为空)
v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion
v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL 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_gift_days INT := 365; -- ← 修改:赠送积分有效期(天)
v_user_id UUID; v_user_id UUID;
v_batch_id BIGINT; v_batch_id BIGINT;
+14 -2
View File
@@ -6,11 +6,17 @@ use crate::storage::project as project_storage;
#[tauri::command] #[tauri::command]
pub async fn save_project_asset( pub async fn save_project_asset(
app: tauri::AppHandle,
project_id: String, project_id: String,
filename: String, filename: String,
base64_data: String, base64_data: String,
) -> ApiResponse<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 { Ok(path) => ApiResponse {
code: 200, code: 200,
message: "资源保存成功".to_string(), message: "资源保存成功".to_string(),
@@ -26,10 +32,16 @@ pub async fn save_project_asset(
#[tauri::command] #[tauri::command]
pub async fn get_video_save_path( pub async fn get_video_save_path(
app: tauri::AppHandle,
project_id: String, project_id: String,
filename: String, filename: String,
) -> ApiResponse<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 { Ok(path) => ApiResponse {
code: 200, code: 200,
message: "获取路径成功".to_string(), message: "获取路径成功".to_string(),
+16 -5
View File
@@ -3,6 +3,7 @@
use crate::ApiResponse; use crate::ApiResponse;
use crate::StringResultExt; use crate::StringResultExt;
use crate::storage::auth as auth_storage; use crate::storage::auth as auth_storage;
use tauri::Manager;
#[tauri::command] #[tauri::command]
pub async fn load_auth_state(app: tauri::AppHandle) -> ApiResponse<Option<serde_json::Value>> { 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, state: serde_json::Value,
) -> ApiResponse<bool> { ) -> ApiResponse<bool> {
match auth_storage::save_auth_state(&app, &state).map_err_string() { match auth_storage::save_auth_state(&app, &state).map_err_string() {
Ok(_) => ApiResponse { Ok(_) => {
code: 200, // 保存 auth 成功后,检查是否需要迁移旧全局数据
message: "Auth state saved successfully".to_string(), // 场景:用户在退出状态下升级应用,setup 阶段跳过迁移;
data: Some(true), // 重新登录时触发迁移,确保旧数据正确归入当前用户目录
}, 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 { Err(e) => ApiResponse {
code: 500, code: 500,
message: format!("Failed to save auth state: {}", e), message: format!("Failed to save auth state: {}", e),
@@ -17,8 +17,15 @@ pub struct CoverAvatarArgs {
/// 加载封面形象库 /// 加载封面形象库
#[tauri::command] #[tauri::command]
pub async fn load_cover_avatars() -> ApiResponse<Vec<cover_avatar_storage::CoverAvatar>> { pub async fn load_cover_avatars(
match cover_avatar_storage::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 { Ok(list) => ApiResponse {
code: 200, code: 200,
message: "封面形象库加载成功".to_string(), message: "封面形象库加载成功".to_string(),
@@ -35,8 +42,14 @@ pub async fn load_cover_avatars() -> ApiResponse<Vec<cover_avatar_storage::Cover
/// 保存封面形象 /// 保存封面形象
#[tauri::command] #[tauri::command]
pub async fn save_cover_avatar( pub async fn save_cover_avatar(
app: tauri::AppHandle,
args: CoverAvatarArgs, args: CoverAvatarArgs,
) -> ApiResponse<bool> { ) -> 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 { let avatar = cover_avatar_storage::CoverAvatar {
id: args.id, id: args.id,
name: args.name, name: args.name,
@@ -44,7 +57,7 @@ pub async fn save_cover_avatar(
local_path: args.local_path, local_path: args.local_path,
created_at: args.created_at, 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 { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "封面形象保存成功".to_string(), message: "封面形象保存成功".to_string(),
@@ -61,9 +74,15 @@ pub async fn save_cover_avatar(
/// 删除封面形象 /// 删除封面形象
#[tauri::command] #[tauri::command]
pub async fn delete_cover_avatar_cmd( pub async fn delete_cover_avatar_cmd(
app: tauri::AppHandle,
id: String, id: String,
) -> ApiResponse<bool> { ) -> 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 { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "封面形象删除成功".to_string(), message: "封面形象删除成功".to_string(),
@@ -90,8 +109,15 @@ pub struct SaveCoverAvatarImageArgs {
/// 保存封面形象图片文件(前端传入 base64 编码) /// 保存封面形象图片文件(前端传入 base64 编码)
#[tauri::command] #[tauri::command]
pub async fn save_cover_avatar_image( pub async fn save_cover_avatar_image(
app: tauri::AppHandle,
args: SaveCoverAvatarImageArgs, args: SaveCoverAvatarImageArgs,
) -> ApiResponse<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 image_bytes = match base64::Engine::decode( let image_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD, &base64::engine::general_purpose::STANDARD,
&args.image_data, &args.image_data,
@@ -105,6 +131,7 @@ pub async fn save_cover_avatar_image(
}; };
match cover_avatar_storage::save_cover_avatar_image( match cover_avatar_storage::save_cover_avatar_image(
&user_id,
&args.avatar_id, &args.avatar_id,
&image_bytes, &image_bytes,
&args.ext, &args.ext,
+84 -8
View File
@@ -23,8 +23,17 @@ pub struct ProductItem {
/// 获取成品保存路径(项目成品目录) /// 获取成品保存路径(项目成品目录)
#[tauri::command] #[tauri::command]
pub async fn get_product_save_path(project_id: String, filename: String) -> ApiResponse<String> { pub async fn get_product_save_path(
match get_project_products_dir(&project_id) { 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) => { Ok(products_dir) => {
let safe_filename = match sanitize_filename(&filename) { let safe_filename = match sanitize_filename(&filename) {
Ok(name) => name, Ok(name) => name,
@@ -52,8 +61,13 @@ pub async fn get_product_save_path(project_id: String, filename: String) -> ApiR
} }
#[tauri::command] #[tauri::command]
pub async fn list_local_products(_app: AppHandle) -> ApiResponse<Vec<ProductItem>> { pub async fn list_local_products(app: AppHandle) -> ApiResponse<Vec<ProductItem>> {
let projects_root = match crate::storage::get_projects_root_dir() { 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, Ok(dir) => dir,
Err(e) => return ApiResponse { Err(e) => return ApiResponse {
code: 500, code: 500,
@@ -191,10 +205,19 @@ pub async fn list_local_products(_app: AppHandle) -> ApiResponse<Vec<ProductItem
} }
#[tauri::command] #[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 target = PathBuf::from(&path);
// 安全检查:确保路径在应用数据目录内 // 安全检查:确保路径在应用数据目录内,且在当前用户目录下
let canonical = match target.canonicalize() { let canonical = match target.canonicalize() {
Ok(p) => p, Ok(p) => p,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => { 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() { if target.exists() && target.is_file() {
match fs::remove_file(&target) { match fs::remove_file(&target) {
Ok(_) => ApiResponse { Ok(_) => ApiResponse {
@@ -252,7 +286,17 @@ pub async fn delete_local_product(path: String) -> ApiResponse<()> {
} }
#[tauri::command] #[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); 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() { let parent = match old_path.parent() {
Some(p) => p.to_path_buf(), Some(p) => p.to_path_buf(),
None => return ApiResponse { None => return ApiResponse {
@@ -339,7 +394,17 @@ pub async fn rename_local_product(path: String, new_filename: String) -> ApiResp
/// 导出成品到用户指定位置 /// 导出成品到用户指定位置
#[tauri::command] #[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 source = PathBuf::from(&source_path);
let target = PathBuf::from(&target_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() { if !source.exists() || !source.is_file() {
return ApiResponse { return ApiResponse {
code: 404, code: 404,
+73 -14
View File
@@ -9,8 +9,17 @@ use crate::storage::project as project_storage;
// ============================================================ // ============================================================
#[tauri::command] #[tauri::command]
pub async fn save_project_meta_raw(project_id: String, json_content: String) -> ApiResponse<bool> { pub async fn save_project_meta_raw(
match project_storage::save_project_meta_raw(&project_id, &json_content).map_err_string() { 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 { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "Project saved successfully".to_string(), 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] #[tauri::command]
pub async fn load_project_meta(project_id: String) -> ApiResponse<serde_json::Value> { pub async fn load_project_meta(
match project_storage::load_project_meta(&project_id).map_err_string() { 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 { Ok(data) => ApiResponse {
code: 200, code: 200,
message: "Project loaded successfully".to_string(), 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] #[tauri::command]
pub async fn save_project_segments_raw(project_id: String, json_content: String) -> ApiResponse<bool> { pub async fn save_project_segments_raw(
match project_storage::save_project_segments_raw(&project_id, &json_content).map_err_string() { 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 { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "Segments saved successfully".to_string(), 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] #[tauri::command]
pub async fn load_project_segments(project_id: String) -> ApiResponse<serde_json::Value> { pub async fn load_project_segments(
match project_storage::load_project_segments(&project_id).map_err_string() { 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 { Ok(data) => ApiResponse {
code: 200, code: 200,
message: "Segments loaded successfully".to_string(), message: "Segments loaded successfully".to_string(),
@@ -73,8 +107,15 @@ pub async fn load_project_segments(project_id: String) -> ApiResponse<serde_json
} }
#[tauri::command] #[tauri::command]
pub async fn list_local_projects() -> ApiResponse<Vec<serde_json::Value>> { pub async fn list_local_projects(
match project_storage::list_projects().map_err_string() { 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 { Ok(projects) => ApiResponse {
code: 200, code: 200,
message: "Projects listed successfully".to_string(), message: "Projects listed successfully".to_string(),
@@ -89,8 +130,16 @@ pub async fn list_local_projects() -> ApiResponse<Vec<serde_json::Value>> {
} }
#[tauri::command] #[tauri::command]
pub async fn delete_local_project(project_id: String) -> ApiResponse<bool> { pub async fn delete_local_project(
match project_storage::delete_project(&project_id).map_err_string() { 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 { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "Project deleted successfully".to_string(), message: "Project deleted successfully".to_string(),
@@ -105,10 +154,20 @@ pub async fn delete_local_project(project_id: String) -> ApiResponse<bool> {
} }
#[tauri::command] #[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; 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, Ok(d) => d,
Err(e) => return ApiResponse { Err(e) => return ApiResponse {
code: 500, code: 500,
+112 -26
View File
@@ -28,21 +28,55 @@ pub struct UploadResult {
/// 兼容旧命名 /// 兼容旧命名
pub type UploadVideoResult = UploadResult; pub type UploadVideoResult = UploadResult;
/// 获取项目视频目录 /// 获取项目视频目录(内部辅助函数,从 auth.json 读取当前用户)
fn get_project_video_dir(project_id: &str) -> Result<std::path::PathBuf, String> { fn get_project_video_dir(app: &AppHandle, project_id: &str) -> Result<std::path::PathBuf, String> {
crate::storage::get_project_videos_dir(project_id) 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)) .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] #[tauri::command]
pub async fn concat_video_clips( pub async fn concat_video_clips(
app: AppHandle, app: AppHandle,
project_id: String, project_id: String,
clip_paths: Vec<String>, clip_paths: Vec<String>,
) -> ApiResponse<ComposeVideoResult> { ) -> 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, Ok(dir) => dir,
Err(e) => { Err(e) => {
return ApiResponse { return ApiResponse {
@@ -58,29 +92,49 @@ pub async fn concat_video_clips(
.unwrap() .unwrap()
.as_millis(); .as_millis();
// 生成 concat 列表文件 // 1. 探测所有片段分辨率并校验 9:16
let list_path = video_dir.join(format!("concat_list_{}.txt", timestamp)); let mut heights = Vec::new();
let mut list_content = String::new();
for path in &clip_paths { for path in &clip_paths {
list_content.push_str(&format!("file '{}'\n", ffmpeg_cmd::escape_ffmpeg_path(path))); match ffmpeg_cmd::get_video_metadata(&app, path).await {
} Ok(meta) => {
if let Err(e) = std::fs::write(&list_path, list_content) { if let Err(e) = validate_nine_sixteen_aspect(meta.width, meta.height) {
return ApiResponse { return ApiResponse { code: 400, message: e, data: None };
code: 500, }
message: format!("创建拼接列表失败: {}", e), heights.push(meta.height);
data: None, }
}; 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_filename = format!("composed_{}.mp4", timestamp);
let output_path = video_dir.join(&output_filename); let output_path = video_dir.join(&output_filename);
let output_path_str = output_path.to_string_lossy().to_string(); 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 concat_res = ffmpeg_cmd::concat_videos_robust(
&app,
// 清理列表文件 clip_paths,
let _ = std::fs::remove_file(&list_path); &output_path_str,
target_width,
target_height,
).await;
if let Err(e) = concat_res { if let Err(e) = concat_res {
return ApiResponse { return ApiResponse {
@@ -91,7 +145,6 @@ pub async fn concat_video_clips(
} }
// 总时长由前端提供(各片段时长的精确累加) // 总时长由前端提供(各片段时长的精确累加)
// 此处不通过 ffprobe 读取,避免依赖
let total_duration = 0.0; let total_duration = 0.0;
ApiResponse { ApiResponse {
@@ -103,7 +156,6 @@ pub async fn concat_video_clips(
}), }),
} }
} }
/// 空镜片段生成请求参数 /// 空镜片段生成请求参数
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@@ -134,8 +186,25 @@ pub async fn generate_empty_shot_clip(
let _ = std::fs::remove_file(&temp_audio); let _ = std::fs::remove_file(&temp_audio);
}; };
// 1. 截取空镜视频并标准化 // 1. 探测输入分辨率并校验 9:16 比例
if let Err(e) = ffmpeg_cmd::clip_video(&app, &args.video_source, 0.0, args.duration, &temp_video).await { 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(); cleanup();
return ApiResponse { return ApiResponse {
code: 500, 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 { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "视频片段截取成功".to_string(), message: "视频片段截取成功".to_string(),
+66 -4
View File
@@ -20,8 +20,24 @@ pub struct VoiceMaterialArgs {
/// 加载音色素材库 /// 加载音色素材库
#[tauri::command] #[tauri::command]
pub async fn load_voice_materials() -> ApiResponse<Vec<voice_storage::VoiceMaterial>> { pub async fn load_voice_materials(
match voice_storage::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 { Ok(list) => ApiResponse {
code: 200, code: 200,
message: "素材库加载成功".to_string(), message: "素材库加载成功".to_string(),
@@ -38,8 +54,23 @@ pub async fn load_voice_materials() -> ApiResponse<Vec<voice_storage::VoiceMater
/// 保存音色素材 /// 保存音色素材
#[tauri::command] #[tauri::command]
pub async fn save_voice_material( pub async fn save_voice_material(
app: tauri::AppHandle,
args: VoiceMaterialArgs, args: VoiceMaterialArgs,
) -> ApiResponse<bool> { ) -> 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 { let material = voice_storage::VoiceMaterial {
id: args.id, id: args.id,
name: args.name, name: args.name,
@@ -49,7 +80,7 @@ pub async fn save_voice_material(
status: args.status, status: args.status,
created_at: args.created_at, created_at: args.created_at,
}; };
match voice_storage::add_voice_material(material) { match voice_storage::add_voice_material(&user_id, material) {
Ok(_) => ApiResponse { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "素材保存成功".to_string(), message: "素材保存成功".to_string(),
@@ -66,9 +97,24 @@ pub async fn save_voice_material(
/// 删除音色素材 /// 删除音色素材
#[tauri::command] #[tauri::command]
pub async fn delete_voice_material_cmd( pub async fn delete_voice_material_cmd(
app: tauri::AppHandle,
id: String, id: String,
) -> ApiResponse<bool> { ) -> 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 { Ok(_) => ApiResponse {
code: 200, code: 200,
message: "素材删除成功".to_string(), message: "素材删除成功".to_string(),
@@ -98,8 +144,23 @@ pub struct SaveAudioArgs {
/// 保存音频文件(前端传入 base64 编码) /// 保存音频文件(前端传入 base64 编码)
#[tauri::command] #[tauri::command]
pub async fn save_audio( pub async fn save_audio(
app: tauri::AppHandle,
args: SaveAudioArgs, args: SaveAudioArgs,
) -> ApiResponse<voice_storage::AudioMeta> { ) -> 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( let audio_bytes = match base64::Engine::decode(
&base64::engine::general_purpose::STANDARD, &base64::engine::general_purpose::STANDARD,
&args.audio_data, &args.audio_data,
@@ -113,6 +174,7 @@ pub async fn save_audio(
}; };
match voice_storage::save_audio_file( match voice_storage::save_audio_file(
&user_id,
&args.project_id, &args.project_id,
&args.audio_id, &args.audio_id,
&audio_bytes, &audio_bytes,
+46 -10
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_input = validate_safe_path(input_path)?;
let safe_output = sanitize_output_path(output_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![ let args = vec![
"-i".to_string(), safe_input, "-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:v".to_string(), "libx264".to_string(),
"-c:a".to_string(), "aac".to_string(), "-c:a".to_string(), "aac".to_string(),
"-ar".to_string(), "44100".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) let output_parent = std::path::Path::new(output_path)
.parent() .parent()
@@ -267,7 +284,7 @@ pub async fn concat_videos_robust(app: &AppHandle, video_paths: Vec<String>, out
// 1. 标准化每个片段(内部已验证路径) // 1. 标准化每个片段(内部已验证路径)
for (i, path) in video_paths.iter().enumerate() { for (i, path) in video_paths.iter().enumerate() {
let std_path = output_parent.join(format!("std_{}_{}.mp4", timestamp, i)); 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), Ok(()) => standardized_paths.push(std_path),
Err(e) => { 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 时丢失后续片段音频 * 带静音音频轨道,避免 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_input = validate_safe_path(input_path)?;
let safe_output = sanitize_output_path(output_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![ let args = vec![
"-loop".to_string(), "1".to_string(), "-loop".to_string(), "1".to_string(),
"-i".to_string(), safe_input, "-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(), "-c:a".to_string(), "aac".to_string(),
"-t".to_string(), duration.to_string(), "-t".to_string(), duration.to_string(),
"-pix_fmt".to_string(), "yuv420p".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(), "-r".to_string(), "25".to_string(),
"-shortest".to_string(), "-shortest".to_string(),
"-y".to_string(), "-y".to_string(),
@@ -745,7 +774,7 @@ pub async fn get_video_metadata(app: &AppHandle, input_path: &str) -> Result<Vid
/** /**
* 裁剪视频片段(支持本地文件和 HTTP URL) * 裁剪视频片段(支持本地文件和 HTTP URL)
* *
* 从起始时间裁剪指定时长,同时标准化输出格式(1080x1920, 25fps, libx264, aac)。 * 从起始时间裁剪指定时长,同时标准化输出格式(目标分辨率, 25fps, libx264, aac)。
* 适用于从人物形象素材或空镜素材中提取指定时长的片段。 * 适用于从人物形象素材或空镜素材中提取指定时长的片段。
*/ */
pub async fn clip_video( pub async fn clip_video(
@@ -754,6 +783,8 @@ pub async fn clip_video(
start: f64, start: f64,
duration: f64, duration: f64,
output_path: &str, output_path: &str,
target_width: u32,
target_height: u32,
) -> Result<(), String> { ) -> Result<(), String> {
let safe_output = sanitize_output_path(output_path)?; let safe_output = sanitize_output_path(output_path)?;
@@ -770,11 +801,16 @@ pub async fn clip_video(
let start_str = format!("{:.3}", start); let start_str = format!("{:.3}", start);
let duration_str = format!("{:.3}", duration); 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![ let args = vec![
"-ss".to_string(), start_str, "-ss".to_string(), start_str,
"-t".to_string(), duration_str, "-t".to_string(), duration_str,
"-i".to_string(), safe_input, "-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:v".to_string(), "libx264".to_string(),
"-preset".to_string(), "veryfast".to_string(), "-preset".to_string(), "veryfast".to_string(),
"-crf".to_string(), "23".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()) .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() { if let Ok(app_data_dir) = app.path().app_local_data_dir() {
crate::storage::init_app_data_dir(app_data_dir.clone()); 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 缓存,不阻塞首屏 // 后台清理过期视频缓存和 BGM 缓存,不阻塞首屏
let app_data_dir_clone = app_data_dir.clone(); let app_data_dir_clone = app_data_dir.clone();
std::thread::spawn(move || { 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!({ let payload = serde_json::json!({
"videoPaths": request.video_paths, "videoPaths": request.video_paths,
"audioPath": request.audio_path.unwrap_or_default(), "audioPath": request.audio_path.unwrap_or_default(),
"coverPath": request.cover_path, "coverPath": request.cover_path,
"outputPath": request.output_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 response.code == 200 {
if let Some(data) = response.data { if let Some(data) = response.data {
if let Some(path) = data.as_object() 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()), 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> { pub fn load_cover_avatars(user_id: &str) -> Result<CoverAvatarsList, StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?; let path = crate::storage::paths::get_cover_avatars_json_path(user_id)?;
Ok(read_json(&path)?.unwrap_or_default()) Ok(read_json(&path)?.unwrap_or_default())
} }
/// 保存封面形象库 /// 保存封面形象库
pub fn save_cover_avatars(list: &CoverAvatarsList) -> Result<(), StorageError> { pub fn save_cover_avatars(user_id: &str, list: &CoverAvatarsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_cover_avatars_json_path()?; let path = crate::storage::paths::get_cover_avatars_json_path(user_id)?;
atomic_write_json(&path, list) atomic_write_json(&path, list)
} }
/// 添加封面形象 /// 添加封面形象
pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> { pub fn add_cover_avatar(user_id: &str, avatar: CoverAvatar) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?; let mut list = load_cover_avatars(user_id)?;
// 去重:相同 id 替换 // 去重:相同 id 替换
if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) { if let Some(pos) = list.avatars.iter().position(|a| a.id == avatar.id) {
list.avatars[pos] = avatar; list.avatars[pos] = avatar;
@@ -50,12 +50,12 @@ pub fn add_cover_avatar(avatar: CoverAvatar) -> Result<(), StorageError> {
list.avatars.push(avatar); list.avatars.push(avatar);
} }
list.updated_at = chrono_lite_now(); 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> { pub fn delete_cover_avatar(user_id: &str, id: &str) -> Result<(), StorageError> {
let mut list = load_cover_avatars()?; let mut list = load_cover_avatars(user_id)?;
let pos = list.avatars.iter().position(|a| a.id == id) let pos = list.avatars.iter().position(|a| a.id == id)
.ok_or_else(|| StorageError::Io(std::io::Error::new( .ok_or_else(|| StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::NotFound,
@@ -63,18 +63,19 @@ pub fn delete_cover_avatar(id: &str) -> Result<(), StorageError> {
)))?; )))?;
list.avatars.remove(pos); list.avatars.remove(pos);
list.updated_at = chrono_lite_now(); list.updated_at = chrono_lite_now();
save_cover_avatars(&list) save_cover_avatars(user_id, &list)
} }
/// 保存封面形象图片文件到本地 /// 保存封面形象图片文件到本地
/// ///
/// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。 /// 将 base64 编码的图片数据写入 `cover_avatars/` 子目录。
pub fn save_cover_avatar_image( pub fn save_cover_avatar_image(
user_id: &str,
avatar_id: &str, avatar_id: &str,
data: &[u8], data: &[u8],
ext: &str, ext: &str,
) -> Result<String, StorageError> { ) -> Result<String, StorageError> {
let avatars_dir = get_cover_avatars_dir()?; let avatars_dir = get_cover_avatars_dir(user_id)?;
ensure_dir(&avatars_dir)?; ensure_dir(&avatars_dir)?;
// 净化扩展名:只允许字母数字,防止路径遍历 // 净化扩展名:只允许字母数字,防止路径遍历
+7 -5
View File
@@ -16,12 +16,14 @@ pub mod cover_avatar;
pub use engine::{ pub use engine::{
atomic_write_json, atomic_write_bytes, 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::{ pub use paths::{
init_app_data_dir, get_app_data_dir, get_projects_root_dir, init_app_data_dir, get_app_data_dir, get_user_data_dir,
get_project_dir, get_project_dir_path, get_project_assets_dir, get_projects_root_dir, get_project_dir, get_project_dir_path,
get_project_videos_dir, get_project_products_dir, get_voices_json_path, get_project_assets_dir, get_project_videos_dir, get_project_products_dir,
get_app_config_json_path, get_cover_avatars_dir, get_cover_avatars_json_path, 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 的函数内部已做路径净化。 //! 所有返回 PathBuf 的函数内部已做路径净化。
//! //!
//! 存储根目录为 app_local_data_dir,在应用启动时通过 init_app_data_dir 初始化。 //! 存储根目录为 app_local_data_dir,在应用启动时通过 init_app_data_dir 初始化。
//!
//! 【用户数据隔离说明】
//! 用户相关数据(项目、音色素材、封面头像)按 user_id 隔离:
//! {app_local_data_dir}/users/{user_id}/
//! 全局共享数据(BGM 缓存、设备级配置)保持在根目录。
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; 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 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"); let path = base.join("projects");
crate::storage::engine::ensure_dir(&path)?; crate::storage::engine::ensure_dir(&path)?;
Ok(path) Ok(path)
} }
/// 获取项目目录路径(不自动创建) /// 获取项目目录路径(不自动创建)
/// {app_local_data_dir}/projects/{project_id}/ /// {app_local_data_dir}/users/{user_id}/projects/{project_id}/
pub fn get_project_dir_path(project_id: &str) -> Result<PathBuf, StorageError> { pub fn get_project_dir_path(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let safe_id = sanitize_id(project_id)?; let safe_id = sanitize_id(project_id)?;
let base = get_app_data_dir()?; let base = get_projects_root_dir(user_id)?;
Ok(base.join("projects").join(&safe_id)) Ok(base.join(&safe_id))
} }
/// 获取项目目录(自动创建) /// 获取项目目录(自动创建)
/// {app_local_data_dir}/projects/{project_id}/ /// {app_local_data_dir}/users/{user_id}/projects/{project_id}/
pub fn get_project_dir(project_id: &str) -> Result<PathBuf, StorageError> { pub fn get_project_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir_path(project_id)?; let path = get_project_dir_path(user_id, project_id)?;
crate::storage::engine::ensure_dir(&path)?; crate::storage::engine::ensure_dir(&path)?;
Ok(path) Ok(path)
} }
/// 获取项目内的 assets 目录 /// 获取项目内的 assets 目录
/// {app_local_data_dir}/projects/{project_id}/assets/ /// {app_local_data_dir}/users/{user_id}/projects/{project_id}/assets/
pub fn get_project_assets_dir(project_id: &str) -> Result<PathBuf, StorageError> { pub fn get_project_assets_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir(project_id)?; let path = get_project_dir(user_id, project_id)?;
let assets = path.join("assets"); let assets = path.join("assets");
crate::storage::engine::ensure_dir(&assets)?; crate::storage::engine::ensure_dir(&assets)?;
Ok(assets) Ok(assets)
} }
/// 获取项目内的 videos 目录 /// 获取项目内的 videos 目录
/// {app_local_data_dir}/projects/{project_id}/videos/ /// {app_local_data_dir}/users/{user_id}/projects/{project_id}/videos/
pub fn get_project_videos_dir(project_id: &str) -> Result<PathBuf, StorageError> { pub fn get_project_videos_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let path = get_project_dir(project_id)?; let path = get_project_dir(user_id, project_id)?;
let videos = path.join("videos"); let videos = path.join("videos");
crate::storage::engine::ensure_dir(&videos)?; crate::storage::engine::ensure_dir(&videos)?;
Ok(videos) Ok(videos)
} }
/// 获取项目成品目录 /// 获取项目成品目录
/// {app_local_data_dir}/projects/{project_id}/products/ /// {app_local_data_dir}/users/{user_id}/projects/{project_id}/products/
pub fn get_project_products_dir(project_id: &str) -> Result<PathBuf, StorageError> { pub fn get_project_products_dir(user_id: &str, project_id: &str) -> Result<PathBuf, StorageError> {
let safe_id = sanitize_id(project_id)?; let base = get_project_dir(user_id, project_id)?;
let base = get_app_data_dir()?; let path = base.join("products");
let path = base.join("projects").join(&safe_id).join("products");
crate::storage::engine::ensure_dir(&path)?; crate::storage::engine::ensure_dir(&path)?;
Ok(path) Ok(path)
} }
// ============================================================
// 音色素材库路径(用户隔离)
// ============================================================
/// 获取私有音色素材库 JSON 路径 /// 获取私有音色素材库 JSON 路径
/// {app_local_data_dir}/voices.json /// {app_local_data_dir}/users/{user_id}/voices.json
pub fn get_voices_json_path() -> Result<PathBuf, StorageError> { pub fn get_voices_json_path(user_id: &str) -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?; let base = get_user_data_dir(user_id)?;
Ok(base.join("voices.json")) 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 /// {app_local_data_dir}/config.json
/// 注意:config.json 存的是设备级配置(API 地址、运行环境),保持全局共享。
pub fn get_app_config_json_path() -> Result<PathBuf, StorageError> { pub fn get_app_config_json_path() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?; let base = get_app_data_dir()?;
Ok(base.join("config.json")) Ok(base.join("config.json"))
@@ -95,6 +146,7 @@ pub fn get_app_config_json_path() -> Result<PathBuf, StorageError> {
/// 获取认证状态文件路径 /// 获取认证状态文件路径
/// {app_config_dir}/auth.json /// {app_config_dir}/auth.json
/// 注意:auth.json 只存当前登录态,全局唯一,切换账号时覆盖。
pub fn get_auth_state_path(app: &AppHandle) -> Result<PathBuf, StorageError> { pub fn get_auth_state_path(app: &AppHandle) -> Result<PathBuf, StorageError> {
let path = app.path().app_config_dir() let path = app.path().app_config_dir()
.map_err(|e| StorageError::Io(std::io::Error::new( .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")) 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 缓存目录 /// 获取 BGM 缓存目录
/// {app_local_data_dir}/bgm_cache/ /// {app_local_data_dir}/bgm_cache/
/// 注意:BGM 缓存是公共资源,无需按用户隔离。
pub fn get_bgm_cache_dir() -> Result<PathBuf, StorageError> { pub fn get_bgm_cache_dir() -> Result<PathBuf, StorageError> {
let base = get_app_data_dir()?; let base = get_app_data_dir()?;
let path = base.join("bgm_cache"); let path = base.join("bgm_cache");
crate::storage::engine::ensure_dir(&path)?; crate::storage::engine::ensure_dir(&path)?;
Ok(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( pub fn save_project_meta(
user_id: &str,
project_id: &str, project_id: &str,
data: &Value, data: &Value,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
let dir = get_project_dir(project_id)?; let dir = get_project_dir(user_id, project_id)?;
let path = dir.join("meta.json"); let path = dir.join("meta.json");
with_file_lock(&path, || { with_file_lock(&path, || {
atomic_write_json(&path, data) atomic_write_json(&path, data)
@@ -33,18 +34,20 @@ pub fn save_project_meta(
/// 保存项目元数据(原始 JSON 字符串) /// 保存项目元数据(原始 JSON 字符串)
pub fn save_project_meta_raw( pub fn save_project_meta_raw(
user_id: &str,
project_id: &str, project_id: &str,
json_content: &str, json_content: &str,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
let data: Value = serde_json::from_str(json_content)?; 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( pub fn load_project_meta(
user_id: &str,
project_id: &str, project_id: &str,
) -> Result<Value, StorageError> { ) -> 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"); let path = dir.join("meta.json");
match read_json(&path)? { match read_json(&path)? {
Some(data) => Ok(data), Some(data) => Ok(data),
@@ -58,10 +61,11 @@ pub fn load_project_meta(
/// 保存分镜数据 /// 保存分镜数据
pub fn save_project_segments( pub fn save_project_segments(
user_id: &str,
project_id: &str, project_id: &str,
segments: &Value, segments: &Value,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
let dir = get_project_dir(project_id)?; let dir = get_project_dir(user_id, project_id)?;
let path = dir.join("segments.json"); let path = dir.join("segments.json");
with_file_lock(&path, || { with_file_lock(&path, || {
atomic_write_json(&path, segments) atomic_write_json(&path, segments)
@@ -70,18 +74,20 @@ pub fn save_project_segments(
/// 保存分镜数据(原始 JSON 字符串) /// 保存分镜数据(原始 JSON 字符串)
pub fn save_project_segments_raw( pub fn save_project_segments_raw(
user_id: &str,
project_id: &str, project_id: &str,
json_content: &str, json_content: &str,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
let data: Value = serde_json::from_str(json_content)?; 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( pub fn load_project_segments(
user_id: &str,
project_id: &str, project_id: &str,
) -> Result<Value, StorageError> { ) -> 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"); let path = dir.join("segments.json");
match read_json(&path)? { match read_json(&path)? {
Some(data) => Ok(data), Some(data) => Ok(data),
@@ -97,9 +103,8 @@ pub fn load_project_segments(
/// ///
/// 遍历 projects 目录,读取每个项目的 meta.json。 /// 遍历 projects 目录,读取每个项目的 meta.json。
/// 读取失败的项目会被跳过并记录警告,不会导致整个列表失败。 /// 读取失败的项目会被跳过并记录警告,不会导致整个列表失败。
pub fn list_projects() -> Result<Vec<Value>, StorageError> { pub fn list_projects(user_id: &str) -> Result<Vec<Value>, StorageError> {
let base = get_meijiaka_dir()?; let projects_dir = crate::storage::paths::get_projects_root_dir(user_id)?;
let projects_dir = base.join("projects");
if !projects_dir.exists() { if !projects_dir.exists() {
return Ok(vec![]); return Ok(vec![]);
@@ -143,8 +148,8 @@ pub fn list_projects() -> Result<Vec<Value>, StorageError> {
/// ///
/// 使用 `get_project_dir_path`(不自动创建目录), /// 使用 `get_project_dir_path`(不自动创建目录),
/// 避免删除不存在项目时先创建空目录的 bug。 /// 避免删除不存在项目时先创建空目录的 bug。
pub fn delete_project(project_id: &str) -> Result<(), StorageError> { pub fn delete_project(user_id: &str, project_id: &str) -> Result<(), StorageError> {
let dir = get_project_dir_path(project_id)?; let dir = get_project_dir_path(user_id, project_id)?;
if dir.exists() { if dir.exists() {
fs::remove_dir_all(&dir)?; fs::remove_dir_all(&dir)?;
} }
@@ -157,12 +162,13 @@ pub fn delete_project(project_id: &str) -> Result<(), StorageError> {
/// 保存项目资源(Base64 解码后写入) /// 保存项目资源(Base64 解码后写入)
pub fn save_project_asset( pub fn save_project_asset(
user_id: &str,
project_id: &str, project_id: &str,
filename: &str, filename: &str,
base64_data: &str, base64_data: &str,
) -> Result<String, StorageError> { ) -> Result<String, StorageError> {
let safe_filename = sanitize_filename(filename)?; 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 path = dir.join(&safe_filename);
let decoded = base64::engine::general_purpose::STANDARD let decoded = base64::engine::general_purpose::STANDARD
@@ -177,16 +183,11 @@ pub fn save_project_asset(
/// 获取视频保存路径(不写入文件) /// 获取视频保存路径(不写入文件)
/// 保存到项目 videos 目录下,与后端下载路径保持一致 /// 保存到项目 videos 目录下,与后端下载路径保持一致
pub fn get_video_save_path( pub fn get_video_save_path(
user_id: &str,
project_id: &str, project_id: &str,
filename: &str, filename: &str,
) -> Result<PathBuf, StorageError> { ) -> Result<PathBuf, StorageError> {
let safe_filename = sanitize_filename(filename)?; 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)) 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/` 子目录。 /// 将 base64 编码的音频数据写入 `audios/` 子目录。
pub fn save_audio_file( pub fn save_audio_file(
user_id: &str,
project_id: &str, project_id: &str,
audio_id: &str, audio_id: &str,
data: &[u8], data: &[u8],
@@ -33,7 +34,7 @@ pub fn save_audio_file(
voice_id: &str, voice_id: &str,
duration: f64, duration: f64,
) -> Result<AudioMeta, StorageError> { ) -> 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"); let audios_dir = project_dir.join("audios");
ensure_dir(&audios_dir)?; ensure_dir(&audios_dir)?;
@@ -87,20 +88,20 @@ pub struct VoiceMaterialsList {
} }
/// 加载音色素材库 /// 加载音色素材库
pub fn load_voice_materials() -> Result<VoiceMaterialsList, StorageError> { pub fn load_voice_materials(user_id: &str) -> Result<VoiceMaterialsList, StorageError> {
let path = crate::storage::paths::get_voices_json_path()?; let path = crate::storage::paths::get_voices_json_path(user_id)?;
Ok(read_json(&path)?.unwrap_or_default()) Ok(read_json(&path)?.unwrap_or_default())
} }
/// 保存音色素材库 /// 保存音色素材库
pub fn save_voice_materials(list: &VoiceMaterialsList) -> Result<(), StorageError> { pub fn save_voice_materials(user_id: &str, list: &VoiceMaterialsList) -> Result<(), StorageError> {
let path = crate::storage::paths::get_voices_json_path()?; let path = crate::storage::paths::get_voices_json_path(user_id)?;
crate::storage::engine::atomic_write_json(&path, list) crate::storage::engine::atomic_write_json(&path, list)
} }
/// 添加音色素材 /// 添加音色素材
pub fn add_voice_material(material: VoiceMaterial) -> Result<(), StorageError> { pub fn add_voice_material(user_id: &str, material: VoiceMaterial) -> Result<(), StorageError> {
let mut list = load_voice_materials()?; let mut list = load_voice_materials(user_id)?;
// 去重:相同 id 替换 // 去重:相同 id 替换
if let Some(pos) = list.materials.iter().position(|m| m.id == material.id) { if let Some(pos) = list.materials.iter().position(|m| m.id == material.id) {
list.materials[pos] = material; list.materials[pos] = material;
@@ -108,12 +109,12 @@ pub fn add_voice_material(material: VoiceMaterial) -> Result<(), StorageError> {
list.materials.push(material); list.materials.push(material);
} }
list.updated_at = chrono_lite_now(); 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> { pub fn delete_voice_material(user_id: &str, id: &str) -> Result<(), StorageError> {
let mut list = load_voice_materials()?; let mut list = load_voice_materials(user_id)?;
let pos = list.materials.iter().position(|m| m.id == id) let pos = list.materials.iter().position(|m| m.id == id)
.ok_or_else(|| StorageError::Io(std::io::Error::new( .ok_or_else(|| StorageError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound, std::io::ErrorKind::NotFound,
@@ -121,5 +122,5 @@ pub fn delete_voice_material(id: &str) -> Result<(), StorageError> {
)))?; )))?;
list.materials.remove(pos); list.materials.remove(pos);
list.updated_at = chrono_lite_now(); 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, app: &tauri::AppHandle,
project_dir: &std::path::PathBuf, project_dir: &std::path::PathBuf,
payload: serde_json::Value, payload: serde_json::Value,
target_width: u32,
target_height: u32,
) -> ApiResponse<serde_json::Value> { ) -> ApiResponse<serde_json::Value> {
let payload_obj = match payload.as_object() { let payload_obj = match payload.as_object() {
Some(p) => p, Some(p) => p,
@@ -59,7 +61,7 @@ pub async fn handle_video_synthesis(
cover_v_path.push(format!("temp_cover_{}.mp4", utils::uuid_v4())); cover_v_path.push(format!("temp_cover_{}.mp4", utils::uuid_v4()));
let cover_v_path_str = cover_v_path.to_string_lossy().to_string(); 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(_) => { Ok(_) => {
paths_vec.insert(0, cover_v_path_str); paths_vec.insert(0, cover_v_path_str);
temp_cover_video = Some(cover_v_path); 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(_) => { Ok(_) => {
// 如果有音频,合并音频;否则直接使用拼接结果 // 如果有音频,合并音频;否则直接使用拼接结果
let has_audio = audio_path.map(|p| !p.is_empty()).unwrap_or(false); let has_audio = audio_path.map(|p| !p.is_empty()).unwrap_or(false);
@@ -115,7 +115,6 @@ export const localProjectApi = {
dubbingAudioDuration: meta.dubbingAudioDuration, dubbingAudioDuration: meta.dubbingAudioDuration,
voiceSpeed: meta.voiceSpeed, voiceSpeed: meta.voiceSpeed,
voiceVolume: meta.voiceVolume, voiceVolume: meta.voiceVolume,
voicePitch: meta.voicePitch,
avatarMaterialPath: meta.avatarMaterialPath, avatarMaterialPath: meta.avatarMaterialPath,
avatarMaterialName: meta.avatarMaterialName, avatarMaterialName: meta.avatarMaterialName,
avatarMaterialDuration: meta.avatarMaterialDuration, avatarMaterialDuration: meta.avatarMaterialDuration,
@@ -36,6 +36,8 @@ interface UseCanvasSubtitleRendererOptions {
mainTitleStyle?: Partial<AssStyle>; mainTitleStyle?: Partial<AssStyle>;
subTitleStyle?: Partial<AssStyle>; subTitleStyle?: Partial<AssStyle>;
enabled: boolean; enabled: boolean;
/** ASS 坐标系基准高度,默认 1920720p 视频应传入 1280 */
playResY?: number;
} }
export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOptions) { export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOptions) {
@@ -49,6 +51,7 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti
mainTitleStyle, mainTitleStyle,
subTitleStyle, subTitleStyle,
enabled, enabled,
playResY,
} = options; } = options;
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
@@ -84,7 +87,8 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti
// 计算缩放比例(基于高度) // 计算缩放比例(基于高度)
// 扣除 <video controls> 控制条高度(约 40px),确保预览与视频画面比例一致 // 扣除 <video controls> 控制条高度(约 40px),确保预览与视频画面比例一致
const VIDEO_CONTROLS_HEIGHT = 40; 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; const currentTimeMs = video.currentTime * 1000;
@@ -139,7 +143,7 @@ export function useCanvasSubtitleRenderer(options: UseCanvasSubtitleRendererOpti
} }
ctx.restore(); ctx.restore();
}, [enabled, utterances, mainTitle, subTitle, subtitleStyle, mainTitleStyle, subTitleStyle, videoRef, canvasRef]); }, [enabled, utterances, mainTitle, subTitle, subtitleStyle, mainTitleStyle, subTitleStyle, videoRef, canvasRef, playResY]);
useEffect(() => { useEffect(() => {
if (!enabled) {return;} if (!enabled) {return;}
@@ -399,7 +399,7 @@ export default function CoverDesign() {
open={bgModalOpen} open={bgModalOpen}
onClose={() => setBgModalOpen(false)} onClose={() => setBgModalOpen(false)}
title="选择背景图片" title="选择背景图片"
width="440px" width="560px"
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="modal-bg-grid"> <div className="modal-bg-grid">
@@ -80,6 +80,8 @@ export default function SubtitleBurning() {
const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false); const [isGeneratingMainTitle, setIsGeneratingMainTitle] = useState(false);
const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false); const [isGeneratingSubTitle, setIsGeneratingSubTitle] = useState(false);
const [showPointsModal, setShowPointsModal] = 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 showRechargeModal = usePointStore(state => state.showRechargeModal);
const setShowRechargeModal = usePointStore(state => state.setShowRechargeModal); const setShowRechargeModal = usePointStore(state => state.setShowRechargeModal);
const fetchBalance = usePointStore(state => state.fetchBalance); const fetchBalance = usePointStore(state => state.fetchBalance);
@@ -96,6 +98,10 @@ export default function SubtitleBurning() {
const { videoUrl: burnedVideoUrl } = useLocalVideo(burnedVideoPath || undefined); const { videoUrl: burnedVideoUrl } = useLocalVideo(burnedVideoPath || undefined);
const hasBurnedVideo = !!burnedVideoPath; const hasBurnedVideo = !!burnedVideoPath;
// 探测视频分辨率,决定字幕缩放比例和 Canvas 预览基准
const subtitleScale = videoResolution ? videoResolution.width / 1080 : 1;
const playResY = videoResolution ? videoResolution.height : 1920;
// 大标题 / 小标题(从 store 读取,非必填,无默认值) // 大标题 / 小标题(从 store 读取,非必填,无默认值)
const storeMainTitle = useProjectStore(state => state.mainTitle); const storeMainTitle = useProjectStore(state => state.mainTitle);
const storeSubTitle = useProjectStore(state => state.subTitle); const storeSubTitle = useProjectStore(state => state.subTitle);
@@ -182,46 +188,46 @@ export default function SubtitleBurning() {
// 构建 ASS Style 参数 // 构建 ASS Style 参数
const videoDurationMs = (alignment?.duration || 0) * 1000; const videoDurationMs = (alignment?.duration || 0) * 1000;
const buildSubtitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({ const buildSubtitleStyle = (preset: TitlePreset, scale: number = 1): Partial<AssStyle> => ({
fontSize: 64, fontSize: Math.round(64 * scale),
primaryColor: htmlColorToAss(preset.primaryColor), primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor), outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor), backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle, borderStyle: preset.borderStyle,
outline: preset.outline, outline: Math.round(preset.outline * scale),
shadow: 0, shadow: 0,
alignment: 2, alignment: 2,
marginV: 480, marginV: Math.round(480 * scale),
marginL: 160, marginL: Math.round(160 * scale),
marginR: 160, marginR: Math.round(160 * scale),
}); });
const buildMainTitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({ const buildMainTitleStyle = (preset: TitlePreset, scale: number = 1): Partial<AssStyle> => ({
fontSize: 96, fontSize: Math.round(96 * scale),
primaryColor: htmlColorToAss(preset.primaryColor), primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor), outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor), backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle, borderStyle: preset.borderStyle,
outline: preset.outline, outline: Math.round(preset.outline * scale),
shadow: 0, shadow: 0,
alignment: 8, alignment: 8,
marginV: 320, marginV: Math.round(320 * scale),
marginL: 160, marginL: Math.round(160 * scale),
marginR: 160, marginR: Math.round(160 * scale),
}); });
const buildSubTitleStyle = (preset: TitlePreset): Partial<AssStyle> => ({ const buildSubTitleStyle = (preset: TitlePreset, scale: number = 1): Partial<AssStyle> => ({
fontSize: 80, fontSize: Math.round(80 * scale),
primaryColor: htmlColorToAss(preset.primaryColor), primaryColor: htmlColorToAss(preset.primaryColor),
outlineColor: htmlColorToAss(preset.outlineColor), outlineColor: htmlColorToAss(preset.outlineColor),
backColor: htmlColorToAss(preset.backColor), backColor: htmlColorToAss(preset.backColor),
borderStyle: preset.borderStyle, borderStyle: preset.borderStyle,
outline: preset.outline, outline: Math.round(preset.outline * scale),
shadow: 0, shadow: 0,
alignment: 8, alignment: 8,
marginV: 440, marginV: Math.round(440 * scale),
marginL: 160, marginL: Math.round(160 * scale),
marginR: 160, marginR: Math.round(160 * scale),
}); });
// 用 text-shadow 模拟 ASS 外描边(比 WebkitTextStroke 的居中描边更准确) // 用 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}`; 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 标题叠加) // Canvas 2D 统一字幕渲染(替代 assjs + CSS 标题叠加)
// 统一去除字幕末尾标点(预览和压制共用) // 统一去除字幕末尾标点(预览和压制共用)
const processedUtterances = alignment?.utterances?.map(u => ({ const processedUtterances = alignment?.utterances?.map(u => ({
@@ -242,18 +262,18 @@ export default function SubtitleBurning() {
const subtitleStyle = useMemo(() => { const subtitleStyle = useMemo(() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset); const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {}; return preset ? buildSubtitleStyle(preset, subtitleScale) : {};
}, [captionPreset]); }, [captionPreset, subtitleScale]);
const mainTitleStyle = useMemo(() => { const mainTitleStyle = useMemo(() => {
const preset = MAIN_TITLE_PRESETS.find(p => p.id === mainTitlePreset); const preset = MAIN_TITLE_PRESETS.find(p => p.id === mainTitlePreset);
return preset ? buildMainTitleStyle(preset) : undefined; return preset ? buildMainTitleStyle(preset, subtitleScale) : undefined;
}, [mainTitlePreset]); }, [mainTitlePreset, subtitleScale]);
const subTitleStyle = useMemo(() => { const subTitleStyle = useMemo(() => {
const preset = SUB_TITLE_PRESETS.find(p => p.id === subTitlePreset); const preset = SUB_TITLE_PRESETS.find(p => p.id === subTitlePreset);
return preset ? buildSubTitleStyle(preset) : undefined; return preset ? buildSubTitleStyle(preset, subtitleScale) : undefined;
}, [subTitlePreset]); }, [subTitlePreset, subtitleScale]);
useCanvasSubtitleRenderer({ useCanvasSubtitleRenderer({
videoRef, videoRef,
@@ -265,6 +285,7 @@ export default function SubtitleBurning() {
mainTitleStyle, mainTitleStyle,
subTitleStyle, subTitleStyle,
enabled: previewMode === 'style' && subtitleEnabled, enabled: previewMode === 'style' && subtitleEnabled,
playResY,
}); });
// 监听 FFmpeg 进度事件(支持多步分段) // 监听 FFmpeg 进度事件(支持多步分段)
@@ -385,16 +406,31 @@ export default function SubtitleBurning() {
overlayImagePath = pngSaveRes.data; 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( const assForBurn = generateAssFromAlignment(
processedUtterances, processedUtterances,
{ {
subtitleStyle: (() => { subtitleStyle: (() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset); const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {}; return preset ? buildSubtitleStyle(preset, subtitleScale) : {};
})(), })(),
videoDurationMs, videoDurationMs,
includeTitlesInAss: false, includeTitlesInAss: false,
targetWidth,
targetHeight,
} }
); );
@@ -292,7 +292,7 @@ export default function VideoCompose() {
bgmPath: finalBgmPath, bgmPath: finalBgmPath,
outputPath: result, outputPath: result,
videoVolume: 1.0, videoVolume: 1.0,
bgmVolume: (bgmVolume ?? 0.25), bgmVolume: (bgmVolume ?? 0.15),
}, },
}); });
if (mixRes.code !== 200) { if (mixRes.code !== 200) {
@@ -415,7 +415,7 @@ export default function VideoCompose() {
<div> <div>
<div style={{ fontSize: 'var(--font-sm)', fontWeight: 500 }}>{bgmMusicTitle || '背景音乐'}</div> <div style={{ fontSize: 'var(--font-sm)', fontWeight: 500 }}>{bgmMusicTitle || '背景音乐'}</div>
<div style={{ fontSize: 'var(--font-xs)', color: 'var(--text-secondary)' }}> <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> </div>
</div> </div>
@@ -465,13 +465,13 @@ export default function VideoCompose() {
min="0" min="0"
max="1" max="1"
step="0.05" step="0.05"
value={bgmVolume ?? 0.25} value={bgmVolume ?? 0.15}
onChange={e => setBgmVolume(parseFloat(e.target.value))} onChange={e => setBgmVolume(parseFloat(e.target.value))}
disabled={compositing} 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' }}> <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> </span>
</div> </div>
)} )}
@@ -12,6 +12,7 @@ import { getCurrentProjectId } from '../../api/modules/localStorage';
import { saveMetaToLocalFile } from '../../store/projectStore'; import { saveMetaToLocalFile } from '../../store/projectStore';
import { synthesizeTTS, saveAudio, uploadAudio, extractAudioSegment } from '../../api/modules/voice'; import { synthesizeTTS, saveAudio, uploadAudio, extractAudioSegment } from '../../api/modules/voice';
import { toast } from '../../store/uiStore'; import { toast } from '../../store/uiStore';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import type { AlignmentResult } from '../../api/types'; import type { AlignmentResult } from '../../api/types';
import { useProgressStore } from '../../store/progressStore'; import { useProgressStore } from '../../store/progressStore';
import { usePointsCheck } from '../../hooks/usePointsCheck'; import { usePointsCheck } from '../../hooks/usePointsCheck';
@@ -30,11 +31,10 @@ export default function VoiceSynthesis() {
const selectedVoiceId = useProjectStore(state => state.selectedVoiceId); const selectedVoiceId = useProjectStore(state => state.selectedVoiceId);
const speed = useProjectStore(state => state.voiceSpeed); const speed = useProjectStore(state => state.voiceSpeed);
const volume = useProjectStore(state => state.voiceVolume); const volume = useProjectStore(state => state.voiceVolume);
const pitch = useProjectStore(state => state.voicePitch);
const setSelectedVoiceId = useProjectStore(state => state.setSelectedVoiceId); const setSelectedVoiceId = useProjectStore(state => state.setSelectedVoiceId);
const setSpeed = useProjectStore(state => state.setVoiceSpeed); const setSpeed = useProjectStore(state => state.setVoiceSpeed);
const setVolume = useProjectStore(state => state.setVoiceVolume); const setVolume = useProjectStore(state => state.setVoiceVolume);
const setPitch = useProjectStore(state => state.setVoicePitch);
const { const {
presetVoices, presetVoices,
@@ -56,6 +56,14 @@ export default function VoiceSynthesis() {
const [isPlayingGenerated, setIsPlayingGenerated] = useState(false); const [isPlayingGenerated, setIsPlayingGenerated] = useState(false);
const generatedAudioRef = useRef<HTMLAudioElement | null>(null); const generatedAudioRef = useRef<HTMLAudioElement | null>(null);
// 台词字数不足弹窗
const [scriptCheckModal, setScriptCheckModal] = useState<{
open: boolean;
segId?: number;
required?: number;
actual?: number;
}>({ open: false });
const hasGeneratedAudio = !!dubbingAudioUrl; const hasGeneratedAudio = !!dubbingAudioUrl;
useEffect(() => { useEffect(() => {
@@ -285,6 +293,18 @@ export default function VoiceSynthesis() {
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!projectId) { toast.warning('请先创建项目'); return; } 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 文本,根据镜头切换类型插入停顿标记 // 拼接 TTS 文本,根据镜头切换类型插入停顿标记
const realText = segments const realText = segments
.filter(s => s.voiceover?.trim()) .filter(s => s.voiceover?.trim())
@@ -308,7 +328,6 @@ export default function VoiceSynthesis() {
const currentVoiceId = useProjectStore.getState().selectedVoiceId; const currentVoiceId = useProjectStore.getState().selectedVoiceId;
const currentSpeed = useProjectStore.getState().voiceSpeed; const currentSpeed = useProjectStore.getState().voiceSpeed;
const currentVolume = useProjectStore.getState().voiceVolume; const currentVolume = useProjectStore.getState().voiceVolume;
const currentPitch = useProjectStore.getState().voicePitch;
if (!currentVoiceId) { toast.warning('请先选择音色'); setIsGenerating(false); return; } if (!currentVoiceId) { toast.warning('请先选择音色'); setIsGenerating(false); return; }
// 前置积分检查(宽松模式:余额为正即可执行,TTS 实际消耗不确定,允许欠费) // 前置积分检查(宽松模式:余额为正即可执行,TTS 实际消耗不确定,允许欠费)
@@ -321,7 +340,7 @@ export default function VoiceSynthesis() {
try { try {
progress.update('正在合成配音...'); 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');} if (!result.audioUrl) {throw new Error('未返回音频 URL');}
progress.update('正在处理音频...'); progress.update('正在处理音频...');
@@ -550,28 +569,6 @@ export default function VoiceSynthesis() {
</div> </div>
</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"> <div className="voice-generate-wrap">
{!hasGeneratedAudio ? ( {!hasGeneratedAudio ? (
@@ -604,6 +601,23 @@ export default function VoiceSynthesis() {
</div> </div>
</div> </div>
<PointsModal /> <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> </div>
); );
} }
@@ -65,7 +65,6 @@ function VideoCreationContent() {
if (meta.dubbingAudioDuration !== undefined) {updates.dubbingAudioDuration = meta.dubbingAudioDuration;} if (meta.dubbingAudioDuration !== undefined) {updates.dubbingAudioDuration = meta.dubbingAudioDuration;}
if (meta.voiceSpeed !== undefined) {updates.voiceSpeed = meta.voiceSpeed;} if (meta.voiceSpeed !== undefined) {updates.voiceSpeed = meta.voiceSpeed;}
if (meta.voiceVolume !== undefined) {updates.voiceVolume = meta.voiceVolume;} if (meta.voiceVolume !== undefined) {updates.voiceVolume = meta.voiceVolume;}
if (meta.voicePitch !== undefined) {updates.voicePitch = meta.voicePitch;}
if (meta.subtitleAlignment !== undefined) {updates.subtitleAlignment = meta.subtitleAlignment;} if (meta.subtitleAlignment !== undefined) {updates.subtitleAlignment = meta.subtitleAlignment;}
// Step 3 视频生成 // Step 3 视频生成
@@ -80,7 +80,7 @@ const AvatarMaterialSelector: React.FC<AvatarMaterialSelectorProps> = ({
</p> </p>
<p style={{ marginTop: '4px', fontSize: 'var(--font-xs)', color: 'var(--text-tertiary)' }}> <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> </p>
<button <button
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
@@ -196,7 +196,7 @@ export function useEmptyShotMaterials(
const result = await validateLocalVideo(selected, { const result = await validateLocalVideo(selected, {
minDuration, minDuration,
maxDuration: 20, maxDuration: 60,
requireResolution: true, requireResolution: true,
}); });
if (!result.valid) { if (!result.valid) {
@@ -25,7 +25,7 @@ export async function validateLocalVideo(
filePath: string, filePath: string,
options?: { minDuration?: number; maxDuration?: number; requireResolution?: boolean } options?: { minDuration?: number; maxDuration?: number; requireResolution?: boolean }
): Promise<VideoValidationResult> { ): Promise<VideoValidationResult> {
const { minDuration = 5, maxDuration = 20, requireResolution = true } = options || {}; const { minDuration = 5, maxDuration = 60, requireResolution = true } = options || {};
try { try {
const resp = await invoke<{ const resp = await invoke<{
@@ -56,14 +56,19 @@ export async function validateLocalVideo(
}; };
} }
if (requireResolution && (width !== 1080 || height !== 1920)) { // 判断 9:16 比例(允许 ±3% 容差),支持 720×1280 与 1080×1920
return { if (requireResolution) {
valid: false, const ratio = width / height;
duration, const expected = 9 / 16;
width, if (Math.abs(ratio - expected) / expected > 0.03) {
height, return {
error: `视频分辨率 ${width}x${height},要求 1080x1920`, valid: false,
}; duration,
width,
height,
error: `视频比例必须是 9:16,当前为 ${width}×${height}`,
};
}
} }
return { valid: true, duration, width, height }; return { valid: true, duration, width, height };
+23
View File
@@ -281,6 +281,15 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
closeKickModal: () => { closeKickModal: () => {
clearAuthState(); clearAuthState();
set({ ...initialState, isLoading: false, showKickModal: false, kickMessage: '' }); 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 () => { logout: async () => {
@@ -308,6 +317,20 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
} catch (e) { } catch (e) {
console.error('[Auth] 清除认证状态失败:', 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: () => { checkAuth: () => {
+12 -7
View File
@@ -30,7 +30,6 @@ interface ProjectActions {
setDubbingAudioDuration: (_duration: number | undefined) => void; setDubbingAudioDuration: (_duration: number | undefined) => void;
setVoiceSpeed: (_speed: number) => void; setVoiceSpeed: (_speed: number) => void;
setVoiceVolume: (_volume: number) => void; setVoiceVolume: (_volume: number) => void;
setVoicePitch: (_pitch: number) => void;
setBgmMusic: (_id: number | undefined, _title: string | undefined, _path: string | undefined) => void; setBgmMusic: (_id: number | undefined, _title: string | undefined, _path: string | undefined) => void;
setBgmVolume: (_volume: number) => void; setBgmVolume: (_volume: number) => void;
setCategoryCode: (_code: string) => void; setCategoryCode: (_code: string) => void;
@@ -39,6 +38,7 @@ interface ProjectActions {
setHasHydrated: (_hydrated: boolean) => void; setHasHydrated: (_hydrated: boolean) => void;
markStepsDirty: (_fromStep: number) => void; markStepsDirty: (_fromStep: number) => void;
clearStepDirty: (_stepId: number) => void; clearStepDirty: (_stepId: number) => void;
reset: () => void;
} }
@@ -65,7 +65,6 @@ const initialState: Omit<
_isLoading: false, _isLoading: false,
voiceSpeed: 1.0, voiceSpeed: 1.0,
voiceVolume: 0, voiceVolume: 0,
voicePitch: 0,
_hasHydrated: false, _hasHydrated: false,
}; };
@@ -232,11 +231,6 @@ export const useProjectStore = create<ProjectStore>()(
state.voiceVolume = volume; state.voiceVolume = volume;
state.updatedAt = Date.now(); state.updatedAt = Date.now();
}), }),
setVoicePitch: pitch =>
set(state => {
state.voicePitch = pitch;
state.updatedAt = Date.now();
}),
setBgmMusic: (id, title, path) => { setBgmMusic: (id, title, path) => {
set(state => { set(state => {
state.bgmMusicId = id; state.bgmMusicId = id;
@@ -297,6 +291,17 @@ export const useProjectStore = create<ProjectStore>()(
saveMetaToLocalFile({ stepDirtyFlags: { ...flags, [stepId]: false } }); saveMetaToLocalFile({ stepDirtyFlags: { ...flags, [stepId]: false } });
}, },
reset: () => {
currentProjectId = '';
setCurrentProjectId('');
set({
...initialState,
...BLANK_META_OVERRIDES,
segments: [],
_hasHydrated: true,
});
},
}), }),
{ {
name: 'ai-video-project-config', name: 'ai-video-project-config',
-2
View File
@@ -98,7 +98,6 @@ export interface ProjectMeta {
dubbingAudioDuration?: number; dubbingAudioDuration?: number;
voiceSpeed?: number; voiceSpeed?: number;
voiceVolume?: number; voiceVolume?: number;
voicePitch?: number;
// === 字幕 === // === 字幕 ===
subtitleAlignment?: AlignmentResult; subtitleAlignment?: AlignmentResult;
@@ -150,7 +149,6 @@ export interface ProjectState extends ProjectMeta {
// 以下字段在 ProjectMeta 中为可选,但在 Store 中始终有默认值,故覆盖为必需 // 以下字段在 ProjectMeta 中为可选,但在 Store 中始终有默认值,故覆盖为必需
voiceSpeed: number; voiceSpeed: number;
voiceVolume: number; voiceVolume: number;
voicePitch: number;
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
+19 -5
View File
@@ -178,11 +178,25 @@ export function generateAssFromAlignment(
videoDurationMs?: number; videoDurationMs?: number;
/** 是否将大标题/小标题包含在 ASS 中(默认 true) */ /** 是否将大标题/小标题包含在 ASS 中(默认 true) */
includeTitlesInAss?: boolean; includeTitlesInAss?: boolean;
/** 目标视频分辨率,默认 1080×1920;用于等比例缩放字幕样式 */
targetWidth?: number;
targetHeight?: number;
} = {} } = {}
): string { ): 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 styles: AssStyle[] = [mergedSubtitleStyle];
const dialogues: AssDialogue[] = utterances.map(u => ({ const dialogues: AssDialogue[] = utterances.map(u => ({
@@ -194,7 +208,7 @@ export function generateAssFromAlignment(
// 大标题:覆盖整个视频时长,显示在顶部 // 大标题:覆盖整个视频时长,显示在顶部
if (includeTitlesInAss && mainTitle && mainTitleStyle && videoDurationMs) { if (includeTitlesInAss && mainTitle && mainTitleStyle && videoDurationMs) {
styles.push({ ...DEFAULT_ASS_STYLE, ...mainTitleStyle, name: 'MainTitle' }); styles.push({ ...scaledDefaultStyle, ...mainTitleStyle, name: 'MainTitle' });
dialogues.push({ dialogues.push({
start: '0:00:00.00', start: '0:00:00.00',
end: msToAssTime(videoDurationMs), end: msToAssTime(videoDurationMs),
@@ -205,7 +219,7 @@ export function generateAssFromAlignment(
// 小标题:覆盖整个视频时长,显示在大标题下方 // 小标题:覆盖整个视频时长,显示在大标题下方
if (includeTitlesInAss && subTitle && subTitleStyle && videoDurationMs) { if (includeTitlesInAss && subTitle && subTitleStyle && videoDurationMs) {
styles.push({ ...DEFAULT_ASS_STYLE, ...subTitleStyle, name: 'SubTitle' }); styles.push({ ...scaledDefaultStyle, ...subTitleStyle, name: 'SubTitle' });
dialogues.push({ dialogues.push({
start: '0:00:00.00', start: '0:00:00.00',
end: msToAssTime(videoDurationMs), 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, currentStep: 1,
voiceSpeed: 1.0, voiceSpeed: 1.0,
voiceVolume: 0, voiceVolume: 0,
voicePitch: 0,
status: 'draft', status: 'draft',
version: 1, version: 1,
}; };