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:
@@ -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** 版本中实施
|
||||||
|
- 最好在素材库数据量还不大的早期做,迁移成本低
|
||||||
@@ -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** 实施,趁数据量小、迁移成本低时做。
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|
||||||
// 净化扩展名:只允许字母数字,防止路径遍历
|
// 净化扩展名:只允许字母数字,防止路径遍历
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 坐标系基准高度,默认 1920;720p 视频应传入 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:16(720×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 };
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user