release: bump version to 1.6.1

Frontend fixes:
- fix(VideoCompose): clear step dirty flag after compose success
- refactor(MyWorks): play product videos directly without transcode cache
- feat(CoverDesign): swap main/subtitle positions in cover preview
- fix(SubtitleBurning): charge points after burn success instead of before
- fix(VoiceSynthesis/VideoGeneration/SubtitleBurning/CoverDesign): mark downstream steps dirty on re-generation
- fix(MyWorks): bind video event listeners after async videoUrl load
- fix(CoverDesign): revoke Blob URLs on upload/unmount to prevent memory leak
This commit is contained in:
小鱼开发
2026-05-25 22:35:35 +08:00
parent 33265df299
commit 915339d42a
22 changed files with 1755 additions and 132 deletions
+1 -1
View File
@@ -1 +1 @@
1.6.0
1.6.1
+756
View File
@@ -0,0 +1,756 @@
# 前端系统兼容性审查报告 v2(业务场景驱动)
> 审查范围:`tauri-app/src` 全部源码 + `tauri-app/src-tauri/src` Rust 层命令
> 审查方法:按用户真实使用路径和数据流转分析,非通用技术罗列
> 审查日期:2026-05-21
> 当前版本:v1.6.0
---
## 一、综述
本次审查以**用户真实操作流程**为主线,从数据持久化、媒体处理、第三方服务、版本升级、跨设备迁移五个业务维度展开,共发现 **14 项与业务直接相关的兼容性问题**
**核心结论**
1. **BGM 云端化改造存在链路缺口**:前端存储了 URL,但混音时直接传给 FFmpeg,未做本地缓存,网络波动或 URL 过期会导致合成失败或产生"无声成片"。
2. **积分消费存在 TOCTOU 竞态**:预检通过→合成完成→扣费失败之间有时间窗口,可能导致用户白嫖或重复扣费。
3. **项目数据完全不可迁移**:所有本地路径为绝对路径,无导出/导入功能,换设备后项目报废。
4. **磁盘满等大文件场景缺乏保护**:合成成果可能直接丢失,大视频上传/下载全量读内存。
5. **多处"静默失败"**:保存项目、分段配音、BGM 混音等关键环节出错时不提示用户,导致用户以为成功实际数据残缺。
---
## 二、🔴 严重问题(影响功能、数据或资金)
### 1. BGM 云端化后混音链路断裂——"无声成片"与合成失败
**业务场景**
1. 用户在 BGM 弹窗中选择一首云端 BGM(七牛云 URL)
2. 保存项目(`bgmMusicPath` 写入 `meta.json`,值为 `https://media.liche.cn/.../xxx.mp3`
3. 几天后点击「合成视频」,FFmpeg 混音时直接拉取该 URL
4. 网络波动或 URL 签名过期 → FFmpeg HTTP 输入超时 → 混音失败
**实际代码路径**
```typescript
// VideoCompose.tsx:265-276
if (bgmMusicPath) {
const mixRes = await invoke('mix_bgm_to_video', {
args: {
videoPath: result,
bgmPath: bgmFullPath, // <-- 这里是七牛云 URL,不是本地路径
outputPath: result,
bgmVolume: (bgmVolume ?? 0.25),
},
});
if (mixRes.code !== 200) {
console.warn('BGM 混合失败,使用无 BGM 版本:', mixRes.message);
}
}
```
```rust
// ffmpeg_cmd.rs:509-546
pub async fn mix_bgm_to_video(...) {
let safe_video = validate_safe_path(video_path)?; // 只校验视频路径
let safe_bgm = bgm_path.to_string(); // <-- 直接透传 URL,无校验
run_ffmpeg(app, vec!["-i", safe_video, "-i", safe_bgm, ...])
}
```
**用户实际看到**
- 界面提示「压制成片完成」✅
- 播放视频发现**没有背景音乐** ❌
- 用户以为是自己操作问题,反复合成浪费积分和时间
**影响评估**
- 功能完整性受损:选了 BGM 却出无声视频
- 积分浪费:每次合成消耗积分,但产出不符合预期
- 用户信任度下降:无法解释为什么有时有 BGM 有时没有
**修复建议**
`VideoCompose.tsx` `handleStart` 中,混音前确保 BGM 为本地文件:
```typescript
let finalBgmPath = bgmMusicPath;
if (bgmMusicPath?.startsWith('http')) {
// 下载到本地缓存目录
const cacheDir = await invoke<string>('get_bgm_cache_dir');
const cachedPath = `${cacheDir}/bgm_${bgmMusicId}.mp3`;
const exists = await invoke<boolean>('file_exists', { path: cachedPath });
if (!exists) {
useProgressStore.getState().update('正在下载背景音乐...');
await videoComposeApi.downloadFile({ url: bgmMusicPath, outputPath: cachedPath });
}
finalBgmPath = cachedPath;
}
// 然后传给 mix_bgm_to_video
```
Rust 侧 `mix_bgm_to_video` 应恢复 `validate_safe_path` 校验,拒绝 URL
```rust
let safe_bgm = validate_safe_path(bgm_path)?; // 强制要求本地路径
```
---
### 2. 积分消费 TOCTOU 竞态——合成完成扣费失败导致"白嫖"或需重来
**业务场景**
1. 用户点击「合成视频」,预检余额充足(如 50 积分,需扣 5 分)
2. 视频合成耗时 3-5 分钟
3. 期间用户在手机端或其他场景消费了积分,余额降至 3 分
4. 合成完成后调用 `consumePoints`,返回 402 "积分不足"
5. 前端回滚 `finalVideoPath` 状态,但**不删除已生成的视频文件**
**实际代码**
```typescript
// VideoCompose.tsx:287-309
const composePoints = usePointStore.getState().getRule('compose')?.points || 5;
try {
await pointsApi.consumePoints({
points: composePoints,
sourceType: 'compose',
sourceId: `compose_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`,
description: '压制成片',
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
setResultPath('');
setFinalVideoPath(undefined); // 回滚前端状态
setExportedAt(undefined);
// ❌ 没有删除 products/ 目录下已生成的视频文件
if (msg.includes('402') || msg.includes('积分不足')) {
setShowPointsModal(true);
return;
}
}
```
**用户实际看到**
- 提示「积分不足」弹出充值弹窗
-`~/Library/Application Support/cn.meijiaka.ai-zy/projects/xxx/products/` 下已经有一份完整的 `.mp4`
- 用户可以通过 Finder 直接找到并播放该文件,**实际已白嫖成功**
- 或者用户充值后再次点击合成,重复消耗时间
**影响评估**
- 资金损失风险:用户可在不扣积分的情况下拿到成品
- 用户体验差:明明看到"合成完成"的进度条走到 100%,最后说积分不够
- 运营数据失真:成品文件存在但系统无消费记录
**修复建议(方案二选一)**
**方案 A:积分预占/冻结机制(推荐)**
后端新增 "预占积分" API,合成前预占 5 积分,合成完成后确认扣费,失败则释放。消除时间窗口。
**方案 B:扣费前置 + 失败清理**
若无法改后端,至少做到失败时清理文件:
```typescript
} catch (err) {
// 回滚状态
setResultPath('');
setFinalVideoPath(undefined);
// 清理已生成的文件
if (outputPath) {
await invoke('delete_project_file', { projectId, filePath: outputPath })
.catch(() => {}); // 清理失败不阻断错误提示
}
// ... 原有错误处理
}
```
---
### 3. 项目数据绝对路径依赖——换设备后项目完全报废
**业务场景**
1. 用户 Mac A 上创建项目,生成视频,一切正常
2. 用户将 `~/Library/Application Support/cn.meijiaka.ai-zy/projects/` 文件夹复制到 Mac B(或 Time Machine 恢复)
3. 在 Mac B 上打开应用,项目列表显示正常
4. 点击项目进入编辑,视频预览空白、配音无法播放、封面无法加载
**根本原因**
`meta.json``segments.json` 中所有本地文件路径均为**绝对路径**:
```json
{
"avatarMaterialPath": "/Users/alice/Library/Application Support/cn.meijiaka.ai-zy/projects/proj_xxx/assets/voice.mp3",
"burnedVideoPath": "/Users/alice/Library/Application Support/.../burned_xxx.mp4",
"coverPath": "/Users/alice/Library/Application Support/.../cover.png"
}
```
Mac B 上用户名为 `bob`,上述路径全部指向不存在的目录。
**当前代码无修复机制**
- `loadMeta` 直接返回磁盘数据,无路径校验或重映射
- `getLocalFileUrl` 调用 Rust `validate_media_path`,校验通过后会返回 `asset://` URL,但文件不存在时直接抛错
- `useLocalVideo` 抛错后显示空白,无降级提示
**用户实际看到**
- 项目标题、主题等文字信息正常
- 视频预览区域空白或转圈
- 配音试听按钮点击无反应
- 用户以为数据损坏,恐慌
**影响评估**
- 用户换机/重装系统后所有本地项目无法继续编辑
- 与「桌面端本地持久化」的核心卖点相矛盾
- Time Machine 备份恢复后项目数据"假死"
**修复建议**
**短期(最小改动)**:加载项目时检测路径有效性,无效时给出明确提示:
```typescript
// initProjectStore 中
const validatedMeta = await validateProjectPaths(meta);
if (validatedMeta.brokenPaths.length > 0) {
toast.warn(`项目 ${validatedMeta.brokenPaths.join(', ')} 关联的文件已丢失,可能因迁移设备或清理磁盘导致`);
}
```
**长期**
1. 持久化时存储**相对路径**(相对于项目目录)
2. 加载时解析为绝对路径:
```typescript
function resolveProjectPath(projectId: string, relativePath: string): string {
return `${APP_LOCAL_DATA_DIR}/projects/${projectId}/${relativePath}`;
}
```
3. 新增「项目包导出/导入」功能:将 `meta.json` + `segments.json` + `assets/` + `videos/` 打包为 `.zip`
---
### 4. 磁盘空间不足时合成成果直接丢失
**业务场景**
1. 用户 Mac 剩余空间 2GB
2. 合成一个 1.5GB 的视频,临时文件 + 输出文件刚好占满磁盘
3. FFmpeg 合成成功,但 `std::fs::copy` 移动最终文件时因磁盘满失败
4. 临时文件被清理,用户一无所获
**实际代码**
```rust
// video_processing.rs:93
std::fs::rename(&final_output, output_path)
.or_else(|_| {
std::fs::copy(&final_output, output_path)
.and_then(|_| std::fs::remove_file(&final_output))
})
```
`rename` 跨卷时失败,`copy` 在磁盘满时失败,临时文件在 `Drop` 或后续清理中被删除。
**用户实际看到**
- 进度条走到 100%,显示「正在保存...」
- 突然报错「移动最终视频失败」
- 数分钟的等待 + 积分消耗,结果什么都没有
**影响评估**
- 极端挫败感:用户最高预期时刻("马上完成了")直接失败
- 积分和时间双重浪费
**修复建议**
1. 合成前检查磁盘空间:
```rust
// 在 handleStart 调用前
let required_space = estimate_output_size(video_paths) * 2; // 输出 + 临时文件
let available = fs2::available_space(&output_parent)?;
if available < required_space {
return Err("磁盘空间不足,需要至少 {} GB 可用空间".into());
}
```
2. `copy` 失败时保留临时文件,给用户手动恢复的机会:
```rust
if let Err(e) = std::fs::copy(&final_output, output_path) {
return Err(format!("保存失败(磁盘可能已满)。临时文件保留在: {},错误: {}",
final_output.display(), e));
}
```
---
### 5. 大文件上传/下载全量读内存——低配机器 OOM
**业务场景**
1. 用户生成了一段 10 分钟 1080p 视频,文件大小 500MB
2. 点击「上传」或系统自动上传到七牛云/后端
3. Rust 侧 `std::fs::read(local_path)` 将 500MB 全量读入内存
4. 再复制到 reqwest multipart body,峰值内存占用 >1GB
5. 8GB 内存的 MacBook Air 可能触发系统 OOM,应用被杀死
**实际代码**
```rust
// Rust 侧 upload_video_file / upload_audio_file
let file_bytes = match std::fs::read(local_path) { ... };
let form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(file_bytes) ...);
```
```rust
// Rust 侧 download_file
let client = reqwest::Client::new(); // 默认无超时
let bytes = match response.bytes().await { ... }; // 全量入内存
std::fs::write(&safe_output, &bytes);
```
**用户实际看到**
- 上传/下载大文件时应用突然消失(被系统杀死)
- 或进度条卡住很久,没有任何反馈
- 重启后需要重新开始整个流程
**影响评估**
- 长视频用户(核心目标用户群)完全无法使用
- 应用稳定性差,低配置设备体验极差
**修复建议**
上传改用流式:
```rust
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let file = File::open(local_path).await?;
let stream = tokio_util::codec::FramedRead::new(file, tokio_util::codec::BytesCodec::new());
let body = reqwest::Body::wrap_stream(stream);
let part = reqwest::multipart::Part::stream(body)
.file_name(filename)
.mime_str("video/mp4")?;
```
下载改用边下边写 + 超时:
```rust
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(300))
.connect_timeout(std::time::Duration::from_secs(30))
.build()?;
let mut response = client.get(url).send().await?;
let mut file = tokio::fs::File::create(&safe_output).await?;
while let Some(chunk) = response.chunk().await? {
tokio::io::copy(&mut chunk.as_ref(), &mut file).await?;
}
```
---
## 三、🟡 中等问题(影响体验或存在数据风险)
### 6. 保存项目失败用户完全不知情——数据丢失风险
**业务场景**
1. 用户在 CoverDesign 页面调整标题,触发自动保存
2. 磁盘已满(或文件被其他程序锁定)
3. `saveMetaToLocalFile` 抛出 IO 错误
4. 错误被 `.catch` 捕获后只 `console.error`**没有任何 UI 提示**
5. 用户继续编辑,关闭应用
6. 重新打开后发现之前的修改全部丢失
**实际代码**
```typescript
// localStorage.ts 中的 safeInvoke 错误处理
try {
const result = await invoke<T>(cmd, args);
return result;
} catch (error) {
console.error(`Tauri IPC 调用失败 [${cmd}]:`, error);
throw error; // 抛给上层
}
// saveMetaToLocalFile 调用链
metaSavePromise = metaSavePromise.then(task).catch(err => {
console.error('保存项目元数据失败:', err);
throw err; // 继续抛出,但无人处理
});
```
**用户实际看到**
- 没有任何错误提示
- 下次打开项目时数据回到旧状态
- 用户以为是应用 bug,不信任自动保存功能
**修复建议**
`saveMetaToLocalFile` 的 catch 中增加用户可见提示:
```typescript
metaSavePromise = metaSavePromise.then(task).catch(err => {
console.error('保存项目元数据失败:', err);
const message = err instanceof Error ? err.message : String(err);
if (message.includes('磁盘') || message.includes('space') || message.includes('No space')) {
toast.error('项目保存失败:磁盘空间不足,请清理后重试');
} else {
toast.error('项目保存失败,请检查文件权限或重启应用');
}
throw err;
});
```
---
### 7. 配音分段失败静默继续——"部分缺失"的配音
**业务场景**
1. 用户生成 10 段配音,每段对应一个分镜
2. 第 3 段 `extractAudioSegment``uploadAudioFile` 失败(网络抖动、文件被占用)
3. 错误被 catch 后只 `console.error`,循环继续
4. 最终提示「配音合成完成」
5. 用户导出视频后发现第 3 分镜没有声音
**实际代码**
```typescript
// VoiceSynthesis.tsx:243-245(近似逻辑)
for (const segment of segments) {
try {
await extractAudioSegment(...);
await uploadAudioFile(...);
} catch (err) {
console.error('分段处理失败:', err); // ❌ 静默吞掉
// 循环继续...
}
}
toast.success('配音合成完成');
```
**用户实际看到**
- 提示「配音合成完成」✅
- 导出视频后发现部分片段无声 ❌
- 无法定位是哪一段出了问题
**修复建议**
```typescript
const failedSegments: string[] = [];
for (const segment of segments) {
try {
await extractAudioSegment(...);
await uploadAudioFile(...);
} catch (err) {
console.error('分段处理失败:', err);
failedSegments.push(segment.id);
// 继续处理其他段,但记录失败
}
}
if (failedSegments.length > 0) {
toast.warn(`配音合成部分完成,第 ${failedSegments.join(', ')} 段处理失败,请检查网络后重试`);
} else {
toast.success('配音合成完成');
}
```
---
### 8. 轮询任务状态遇到网络闪断直接失败——长任务前功尽弃
**业务场景**
1. 用户提交 Vidu 视频生成任务,进入轮询等待
2. 轮询 3 分钟后,用户 WiFi 短暂断开 5 秒
3. `getTaskStatus` 抛出网络错误
4. `while` 循环无内部 try-catch,整个函数抛出异常
5. 前端提示「视频生成失败」
6. 实际上后端任务仍在执行,用户需重新提交并再次等待
**实际代码**
```typescript
// useVideoGeneration.ts / ScriptCreation.tsx 等处的轮询逻辑
while (status === 'pending' || status === 'running') {
await new Promise(resolve => setTimeout(resolve, pollInterval));
const resp = await taskApi.getTaskStatus(taskId); // ❌ 无 try-catch
status = resp.status;
}
```
**用户实际看到**
- 等待数分钟后突然报错「失败」
- 重新提交后又需等待同样长的时间
- 后端实际上可能已经完成了任务,但前端放弃了
**修复建议**
```typescript
let consecutiveErrors = 0;
const MAX_CONSECUTIVE_ERRORS = 3;
while (status === 'pending' || status === 'running') {
await new Promise(resolve => setTimeout(resolve, pollInterval));
try {
const resp = await taskApi.getTaskStatus(taskId);
status = resp.status;
consecutiveErrors = 0;
} catch (err) {
consecutiveErrors++;
console.warn(`轮询失败 (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, err);
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
throw new Error('网络异常,视频状态获取失败,请稍后重试');
}
// 继续轮询,给用户一个恢复的机会
}
}
```
---
### 9. 余额获取失败 = 误判为余额不足——有积分却被阻止
**业务场景**
1. 用户打开应用,网络较差
2. `fetchBalance` 调用失败(`console.error` 后静默)
3. `balance` 保持默认值 `0`
4. 用户点击「合成视频」
5. 预检:`currentBalance < requiredPoints``0 < 5`**阻止**
6. 用户明明有积分,却无法使用
**实际代码**
```typescript
// pointStore.ts
fetchBalance: async () => {
try {
const data = await pointsApi.getBalance();
set({ balance: data.balance, rules: data.rules });
} catch (e) {
console.error('获取积分余额失败:', e); // ❌ 静默失败
// balance 保持旧值或 0
}
},
```
**用户实际看到**
- 点击合成按钮后弹出「积分不足」充值弹窗
- 用户去「我的」页面查看,发现余额显示为 0 或旧值
- 刷新页面后余额恢复正常
**修复建议**
```typescript
fetchBalance: async () => {
try {
const data = await pointsApi.getBalance();
set({ balance: data.balance, rules: data.rules, balanceError: null });
} catch (e) {
console.error('获取积分余额失败:', e);
set({ balanceError: '获取余额失败,请检查网络' });
// balance 保持旧值,不要变成 0
}
},
// 预检时
if (balanceError) {
// 无法确认余额,允许操作但提示风险
toast.warn('余额获取失败,将尝试扣费,若余额不足会提示充值');
return true;
}
```
---
### 10. 多应用实例并发修改导致数据覆盖
**业务场景**
1. 用户双击应用图标,意外打开两个窗口(或命令行启动第二个实例)
2. 实例 A 在 CoverDesign 修改标题为「现代简约风」
3. 实例 B 在 ScriptCreation 修改主题为「奶油风」
4. 两个实例同时点击保存
5. 实例 A 的保存覆盖了实例 B 的修改(或反之)
**根因分析**
`saveMetaToLocalFile` 使用 Read-Modify-Write 模式:
1.`meta.json`
2. 内存合并
3.`meta.json`(带文件锁,保证单写)
但文件锁只保护"写"操作,两个实例可以同时读取同一个文件,然后各自基于旧版本修改并写入,导致后写入的覆盖前者。
**用户实际看到**
- 在一个窗口里明明保存了修改
- 切到另一个窗口再切回来,发现修改消失了
- 用户以为是应用不稳定
**修复建议**
1. **应用层单实例锁**:启动时检查是否已有实例在运行
```rust
// main.rs
let single = single_instance::SingleInstance::new("cn.meijiaka.ai-zy").unwrap();
if !single.is_single() {
// 已有实例,唤起旧实例窗口并退出
return;
}
```
2. **或文件锁扩展为读写锁**:读取时也加共享锁,防止并发读-改-写
---
### 11. BGM 预览硬编码开发者路径——正式包无法预览系统 BGM
**业务场景**
1. 用户安装正式版应用
2. 进入 BGM 选择弹窗
3. 点击任意系统 BGM 的试听按钮
4. 无声音,或报错
**实际代码**
```typescript
// VideoCompose.tsx:113
const audioSrc = item.url || (item.filePath ? `/Users/0fun/work/meijiaka-zy/mixkit_bgm/${item.filePath}` : '');
```
`item.url` 为空且 `item.filePath` 存在时,构造的路径是开发者本机绝对路径 `/Users/0fun/...`,正式包用户机器上不存在此目录。
**影响评估**
- 虽然云端化后 `item.url` 应始终有值,但如果 API 返回异常或旧数据残留,会回退到硬编码路径
- 开发环境测试时「正常」的功能,正式包上直接失效
**修复建议**
直接移除硬编码回退,若 `item.url` 为空则禁用试听:
```typescript
const audioSrc = item.url;
if (!audioSrc) {
toast.warn('该音乐暂无可用的试听链接');
return;
}
```
---
### 12. 封面 Fabric.js 跨域加载失败无用户提示
**业务场景**
1. 用户选择一张网络图片作为封面背景
2. 该图片服务器未配置 CORS 头
3. `image.crossOrigin = 'anonymous'` 加载失败
4. `useCoverFabric.ts` 中 catch 静默吞掉错误
5. Canvas 上背景为空白,用户不知道为什么
**实际代码**
```typescript
// useCoverFabric.ts:192-196
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = (e) => reject(e);
image.src = imagePath;
// ...
} catch {
// no-op: 背景图加载失败已在内部处理
}
```
**用户实际看到**
- 选了背景图,但 Canvas 预览为纯色背景
- 不知道是因为图片跨域、链接失效还是其他原因
**修复建议**
在 catch 中区分错误类型并提示:
```typescript
} catch (err) {
console.error('封面背景加载失败:', err);
if (imagePath.startsWith('http')) {
toast.error('封面图片加载失败,可能是跨域限制或链接失效,请尝试本地上传');
} else {
toast.error('封面图片加载失败,文件可能已被移动或删除');
}
}
```
---
## 四、🟢 低风险/建议(5 项)
### 13. 自动更新后无数据迁移逻辑
**业务场景**
1. v1.6.0 用户自动更新到 v1.7.0
2. v1.7.0 新增了某个必填字段(如 `videoCodec`
3. 旧项目加载后该字段为 `undefined`
4. 如果新功能直接读取此字段不做防御,可能崩溃
**现状**
- `migrateMeta` 只处理了 `v0 → v1`(添加 `version` 字段)
- 注释预留了 `v1 → v2` 的扩展点,但无实际实现
- Tauri updater 安装后只是重启应用,不触发任何数据迁移
**建议**
在应用启动时(`bootstrap``App.tsx` useEffect)增加一次性的全局迁移检查:
```typescript
async function runGlobalMigrations() {
const appVersion = await getVersion();
const lastMigratedVersion = localStorage.getItem('last_migrated_version');
if (lastMigratedVersion === appVersion) return;
// 遍历所有本地项目,执行迁移
const projects = await localProjectApi.listProjects();
for (const project of projects) {
const meta = await localProjectApi.loadMeta(project.id);
if (meta) {
const migrated = migrateMeta(meta); // 扩展此函数
await localProjectApi.saveMeta(project.id, migrated);
}
}
localStorage.setItem('last_migrated_version', appVersion);
}
```
---
### 14. 旧字段删除无运行时降级处理
**现状**
Git 历史中有多个字段被删除/重命名:
- `subtitlePreset``captionPreset`
- `dubbingVoiceId``selectedVoiceId`
- `selectedHumanId` / `selectedElementId` 被移除
- `caption``mainTitle`CoverDesign 中有 fallback
**当前行为**
旧项目加载后,旧字段保留在 `meta.json` 中但被忽略,对应功能降级为默认状态。对用户来说,打开旧项目后发现某些设置"复位"了,但不明白为什么。
**建议**
`migrateMeta` 中增加字段映射:
```typescript
function migrateMeta(raw: Record<string, unknown>): Partial<ProjectMeta> {
// v0 → v1
if ((raw.version as number) < 1) {
raw.version = 1;
}
// 字段重命名映射
if (raw.subtitlePreset && !raw.captionPreset) {
raw.captionPreset = raw.subtitlePreset;
delete raw.subtitlePreset;
}
if (raw.dubbingVoiceId && !raw.selectedVoiceId) {
raw.selectedVoiceId = raw.dubbingVoiceId;
delete raw.dubbingVoiceId;
}
return raw as Partial<ProjectMeta>;
}
```
---
## 五、按业务维度汇总
| 业务维度 | 问题编号 | 核心风险 | 用户感知 |
|----------|----------|----------|----------|
| **BGM/音频** | 1, 11 | 合成无声、预览失效 | "为什么选了音乐却没有声音" |
| **积分/资金** | 2, 9 | 白嫖可能、误判余额不足 | "明明有积分却说不让用" |
| **数据持久化** | 3, 6, 10, 13, 14 | 换机报废、保存失败无感知、多实例覆盖 | "修改保存后怎么没了" |
| **视频合成** | 4, 5, 7, 8 | 磁盘满丢失、OOM、分段缺失、长任务闪断 | "等了5分钟结果什么都没有" |
| **封面/视觉** | 12 | 跨域图片加载失败无提示 | "选了图片但封面是空的" |
---
## 六、修复优先级(按业务影响排序)
### P0(立即修复,影响核心功能或资金)
1. **#1 BGM 混音链路缺口**:混音前下载 URL 到本地缓存
2. **#2 积分 TOCTOU**:扣费失败时清理已生成文件,或推动后端预占机制
3. **#5 大文件 OOM**:上传/下载改用流式传输
### P1(本轮迭代修复,影响体验)
4. **#4 磁盘满保护**:合成前检查空间,`copy` 失败保留临时文件
5. **#6 保存失败无提示**`saveMetaToLocalFile` 错误 toast 提示
6. **#7 分段配音失败静默**:记录失败段并提示用户
7. **#8 轮询闪断**:增加网络错误容忍和重试
8. **#9 余额误判**:余额获取失败时不阻断用户
### P2(后续排期,架构改进)
9. **#3 项目跨设备迁移**:路径相对化 + 导出/导入功能
10. **#10 多实例并发**:应用层单实例锁
11. **#11 BGM 预览硬编码**:移除开发者路径
12. **#12 封面跨域提示**:增加错误提示
13. **#13 自动更新迁移**:全局迁移框架
14. **#14 旧字段映射**`migrateMeta` 扩展
+676
View File
@@ -0,0 +1,676 @@
# 前端系统兼容性审查报告
> 审查范围:`tauri-app/src` 全部源码 + `tauri-app/src-tauri/src` Rust 层命令
> 审查维度:跨平台(macOS/Windows)、Tauri API、媒体/音频、CSS、网络、文件系统
> 审查日期:2026-05-21
---
## 一、综述
本次审查共发现 **28 项兼容性问题**,其中:
| 级别 | 数量 | 说明 |
|------|------|------|
| 🔴 严重 | 6 | 可能导致功能失效、安全漏洞或数据损坏 |
| 🟡 中等 | 11 | 潜在风险,特定场景下会触发问题 |
| 🟢 低风险 | 11 | 建议优化,影响较小或仅存在于边缘场景 |
**关键结论**
1. **Windows 路径处理是最大隐患**:多处 Rust 代码对 Windows 路径的反斜杠、大小写、UNC 前缀处理不完善,可能导致 FFmpeg 调用失败或安全检查被绕过。
2. **前端内存泄漏已确认 1 处**`CoverDesign.tsx``URL.createObjectURL` 未释放。
3. **Asset Protocol 过度授权**`tauri.conf.json``"scope": "/**"` 允许 WebView 读取整个文件系统。
4. **CSS/Web API 兼容性良好**:项目运行在 Tauri 封装的 WebViewEdge/WebKit)中,现代 CSS 特性和 Web API 支持度较高,未发现严重兼容性问题。
---
## 二、🔴 严重问题(6 项)
### 1. `URL.createObjectURL` 内存泄漏 — 背景图上传
**位置**`tauri-app/src/pages/VideoCreation/CoverDesign.tsx:181`
```typescript
const url = URL.createObjectURL(file);
setConfig(prev => ({ ...prev, backgroundImage: url }));
```
**问题**:本地上传背景图时创建 Blob URL,但**从未调用 `URL.revokeObjectURL(url)`**。用户多次上传不同背景图时,旧的 Blob URL 会一直占用内存,直到页面刷新。
**影响**:内存泄漏,长时间使用后可能导致应用卡顿或崩溃。
**修复建议**
```typescript
const prevUrl = config.backgroundImage;
const url = URL.createObjectURL(file);
setConfig(prev => ({ ...prev, backgroundImage: url }));
// 释放旧的 Blob URL
if (prevUrl?.startsWith('blob:')) {
URL.revokeObjectURL(prevUrl);
}
// 组件卸载时也要清理
useEffect(() => {
return () => {
if (config.backgroundImage?.startsWith('blob:')) {
URL.revokeObjectURL(config.backgroundImage);
}
};
}, []);
```
---
### 2. Windows 敏感路径检查大小写不敏感问题
**位置**`tauri-app/src-tauri/src/commands/file.rs:47`
```rust
let windows_denied = vec![
r"c:\windows\",
r"c:\program files\",
r"c:\program files (x86)\",
r"c:\users\all users\",
];
```
**问题**Windows 文件系统(NTFS)是**大小写保留但大小写不敏感**的。用户传入 `C:\Windows\``C:\WINDOWS\` 会**完全绕过**上述安全检查。
**影响**:攻击者可通过大小写变体访问系统敏感目录。
**修复建议**
```rust
let path_lower = path.to_lowercase();
let windows_denied = vec![
r"c:\windows\",
r"c:\program files\",
r"c:\program files (x86)\",
r"c:\users\all users\",
];
for denied in &windows_denied {
if path_lower.starts_with(denied) {
return Err(...);
}
}
```
---
### 3. Asset Protocol 范围过度授权
**位置**`tauri-app/src-tauri/tauri.conf.json`
```json
"assetProtocol": {
"enable": true,
"scope": ["$APPLOCALDATA/**", "$APPDATA/**", "$APPCONFIG/**", "/**"]
}
```
**问题**`/**` 允许 WebView 通过 `asset://` 协议读取**整个文件系统的任何文件**。这意味着前端 JavaScript 可以构造 URL 访问用户的任何本地文件(如 `asset:///etc/passwd``asset://C:/Users/xxx/Documents/`)。
**影响**:严重安全漏洞。即使需要配合路径遍历,也极大扩大了攻击面。
**修复建议**:移除 `/**`,仅保留应用数据目录:
```json
"assetProtocol": {
"enable": true,
"scope": ["$APPLOCALDATA/**", "$APPDATA/**", "$APPCONFIG/**"]
}
```
> 注:如果确有需要访问用户选择的文件,应通过 Tauri Dialog API 让用户主动选择,而非开放全局文件系统。
---
### 4. `escape_ffmpeg_path` 不支持 Windows 路径格式
**位置**`tauri-app/src-tauri/src/ffmpeg_cmd.rs:23`
```rust
fn escape_ffmpeg_path(path: &str) -> String {
path.replace("'", "'\\''")
}
```
**问题**:该函数仅转义单引号,但**不处理 Windows 反斜杠 `\` 和盘符冒号 `:`**。在 FFmpeg 的 `ass='{}':fontsdir='{}'` filter 语法或 concat demuxer 的 `file 'path'` 格式中,Windows 路径如 `C:\Users\name\video.mp4` 中的反斜杠可能被 FFmpeg 解析为转义序列。
**影响**:Windows 用户在字幕压制、字体加载、视频合成时,FFmpeg 可能因路径解析错误而失败。
**修复建议**
```rust
fn escape_ffmpeg_path(path: &str) -> String {
// 1. 统一使用正斜杠(FFmpeg 支持跨平台路径分隔符)
let normalized = path.replace('\\', "/");
// 2. 转义单引号(用于 FFmpeg filter 语法中的引号包裹)
normalized.replace("'", "'\\''")
}
```
> 注意:Windows 上 `C:/Users/...` 这种正斜杠路径 FFmpeg 完全支持,这是最简单的跨平台方案。
---
### 5. `canonicalize()` 在 Windows 上返回 UNC 路径导致下游问题
**位置**:多处使用 `std::fs::canonicalize`
- `tauri-app/src-tauri/src/commands/product.rs:198,258,345`
- `tauri-app/src-tauri/src/commands/project.rs:124,140`
- `tauri-app/src-tauri/src/ffmpeg_cmd.rs:46,792`
**问题**:在 Windows 上,`std::fs::canonicalize()` 返回 UNC 路径格式 `\\?\C:\Users\...`。这种路径格式:
1. **FFmpeg 某些版本不支持**,可能导致命令执行失败
2. **与 `starts_with` 比较时行为异常**,如果比较路径不是 UNC 格式
3. **序列化到 JSON 传给前端时**,前端可能无法正确理解这种路径
**影响**:Windows 上的文件校验、路径比较、FFmpeg 调用可能全部受影响。
**修复建议**:封装一个跨平台的 `normalize_path` 函数,替代 `canonicalize`
```rust
use std::path::{Path, PathBuf};
fn normalize_path(path: &Path) -> PathBuf {
// 使用 dunce::simplified() 消除 UNC 前缀,同时保持路径有效性
dunce::simplified(path).to_path_buf()
}
```
> 需要添加 `dune` crate 依赖,这是 Rust 社区处理 UNC 路径的标准方案。
---
### 6. `atob()` 解析 JWT 存在 base64url 兼容性问题
**位置**`tauri-app/src/api/client.ts:116`
```typescript
const payload = JSON.parse(atob(token.split('.')[1]));
```
**问题**JWT 使用 **base64url** 编码(将 `+``-``/``_`,去掉 padding `=`),而 `atob()` 是标准 **base64** 解码器。如果 JWT payload 中包含 `-``_` 或需要 padding 的字符,`atob()` 会抛出 `DOMException`
**当前影响有限**:因为 `exp` 字段通常是纯数字时间戳,但理论上如果用户 ID 或其他 claim 包含这些字符就会失败。
**修复建议**
```typescript
function base64UrlDecode(str: string): string {
// base64url → base64
let padding = '';
const padLen = 4 - (str.length % 4);
if (padLen !== 4) {
padding = '='.repeat(padLen);
}
const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding;
return atob(base64);
}
// 使用
const payload = JSON.parse(base64UrlDecode(token.split('.')[1]));
```
---
## 三、🟡 中等问题(11 项)
### 7. `crossOrigin = 'anonymous'` 跨域图片污染 Canvas
**位置**
- `tauri-app/src/hooks/useCoverFabric.ts:192,235`
```typescript
image.crossOrigin = 'anonymous';
image.src = imagePath;
```
**问题**:当加载远程 HTTP(S) 图片时,如果服务器未配置 `Access-Control-Allow-Origin` 响应头,Canvas 会被**污染(tainted**。被污染的 Canvas 调用 `toDataURL()` 会抛出 `SecurityError: The canvas has been tainted by cross-origin data`
当前代码用 try-catch 静默吞掉了错误,用户会看到空白封面,但不知道原因。
**修复建议**:捕获错误并向用户提示:
```typescript
try {
// ...加载图片...
} catch (err) {
console.error('封面图片加载失败:', err);
toast.error('封面图片加载失败,可能是跨域限制或图片链接失效');
}
```
---
### 8. `requestAnimationFrame` + Video 字幕同步在后台标签页节流
**位置**`tauri-app/src/hooks/useCanvasSubtitleRenderer.ts:158-168`
```typescript
const onFrame = () => {
drawFrame();
if (!video.paused) {
rafRef.current = requestAnimationFrame(onFrame);
}
};
```
**问题**:当应用窗口不在前台或标签页在后台时,浏览器会**节流 `requestAnimationFrame`**(通常降到 1fps 或完全暂停)。这会导致 Canvas 字幕与视频画面不同步。
**影响**:用户切出应用再切回时,字幕可能短暂错位。
**修复建议**:使用 `video.requestVideoFrameCallback()`(如果支持)作为更精确的同步机制,或在 `visibilitychange` 事件触发时强制重绘:
```typescript
useEffect(() => {
const onVisibilityChange = () => {
if (!document.hidden) drawFrame();
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
}, [drawFrame]);
```
---
### 9. `video` 控制条高度硬编码导致字幕定位偏移
**位置**`tauri-app/src/hooks/useCanvasSubtitleRenderer.ts:86`
```typescript
const VIDEO_CONTROLS_HEIGHT = 40;
```
**问题**`<video controls>` 的控制条高度在不同浏览器/OS 上不同(macOS Safari 约 30pxWindows 约 40-50px,全屏模式约 0px)。硬编码 40px 会导致字幕在预览时的垂直位置与压制输出不完全一致。
**修复建议**:在视频元数据加载后动态计算:
```typescript
const video = videoRef.current;
if (video) {
const rect = video.getBoundingClientRect();
const videoRect = video.videoWidth / video.videoHeight;
// 实际视频画面高度 = 容器宽度 / 宽高比
const actualVideoHeight = rect.width / videoRect;
const controlsHeight = rect.height - actualVideoHeight;
}
```
---
### 10. `audio.duration` 可能返回 `NaN`/`Infinity` 未处理
**位置**
- `tauri-app/src/pages/VideoCreation/VoiceSynthesis.tsx:360-367`
- `tauri-app/src/pages/VideoGeneration/hooks/useVideoGeneration.ts:371-378`
- `tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx:163-178`
```typescript
audio.onloadedmetadata = () => {
clearTimeout(timeoutId);
resolve(audio.duration);
};
```
**问题**:如果音频文件损坏、格式不支持或元数据缺失,`audio.duration` 可能返回 `NaN``Infinity`。直接 resolve 这个值会导致下游计算错误。
**修复建议**
```typescript
audio.onloadedmetadata = () => {
clearTimeout(timeoutId);
if (!isFinite(audio.duration) || audio.duration <= 0) {
reject(new Error('音频时长无效,文件可能损坏或格式不支持'));
} else {
resolve(audio.duration);
}
};
```
---
### 11. `rename` 在 Windows 上目标已存在时失败
**位置**
- `tauri-app/src-tauri/src/video_processing.rs:93`
- `tauri-app/src-tauri/src/commands/video_compose.rs:222`
**问题**`std::fs::rename()` 在 Windows 上**如果目标文件已存在会直接失败**(Unix 是原子替换)。代码虽然有 copy 回退,但逻辑可能留下残留文件。
**影响**:Windows 上如果输出路径已存在(如用户重复合成),操作可能失败或留下临时文件。
**修复建议**:在 `rename` 前先删除目标文件(如果存在):
```rust
if output_path.exists() {
std::fs::remove_file(&output_path)?;
}
std::fs::rename(&temp_output, &output_path)?;
```
---
### 12. `to_str().unwrap()` 在非 UTF-8 路径上会 panic
**位置**`tauri-app/src-tauri/src/commands/video_compose.rs:80`
```rust
concat_videos_copy(&app, list_path.to_str().unwrap(), ...)
```
**问题**Windows 允许非 UTF-8 编码的文件路径(历史 OEM code page 文件)。`to_str()` 返回 `None``unwrap()` 会**直接 panic**。
**影响**:极少数 Windows 用户(使用中文 Windows 95/XP 时代遗留文件系统编码)可能导致应用崩溃。
**修复建议**:使用 `to_string_lossy()``as_os_str()` 传递路径:
```rust
// 如果需要传给 FFmpeg,使用 to_string_lossy()
let path_str = list_path.to_string_lossy();
```
---
### 13. `get_fonts_dir` 开发模式路径探测在 Windows 上可能失效
**位置**`tauri-app/src-tauri/src/ffmpeg_cmd.rs:298-328`
```rust
cwd.join("fonts"),
parent.join("src-tauri/fonts"),
grandparent.join("tauri-app/src-tauri/fonts"),
```
**问题**:开发模式下的字体目录探测使用 `/` 路径拼接。虽然 `PathBuf::join` 会处理分隔符,但如果开发时的**当前工作目录**与预期不同(如从 IDE 以不同路径启动),探测会失败。
**影响**:开发环境下 Windows 开发者可能遇到字体加载失败。
**修复建议**:添加环境变量覆盖或更健壮的探测逻辑:
```rust
// 优先从环境变量读取
if let Ok(font_dir) = std::env::var("MEIJIAKA_FONTS_DIR") {
let p = PathBuf::from(font_dir);
if p.exists() { return Some(p); }
}
```
---
### 14. `concat` demuxer 列表中的 Windows 反斜杠问题
**位置**`tauri-app/src-tauri/src/commands/video_compose.rs:65`
```rust
format!("file '{}'\n", ffmpeg_cmd::escape_ffmpeg_path(path))
```
**问题**FFmpeg concat demuxer 的列表文件格式中,`file 'path'` 语法在 Windows 上如果路径包含反斜杠,反斜杠可能被 FFmpeg 解释为转义字符。
**影响**:与问题 #4 类似,Windows 路径导致 FFmpeg 解析错误。
**修复建议**:在写入 concat 列表前统一将路径中的 `\` 替换为 `/`
```rust
let normalized = path.replace('\\', "/");
format!("file '{}'\n", normalized)
```
---
### 15. `load_app_config` 失败时无降级配置
**位置**`tauri-app/src/main.tsx:10-16`
```typescript
try {
const config = await loadAppConfig();
appEnvironment = config.environment;
} catch {
// 加载失败时默认为生产模式
}
```
**问题**:如果 `load_app_config`(Tauri IPC 调用)失败,应用降级为生产模式。这是合理的,但**生产模式会禁用右键菜单和 F12 DevTools**。开发者在调试时如果 IPC 调用失败,会突然失去所有调试能力,且不知道原因。
**修复建议**:在降级时输出警告日志:
```typescript
} catch (e) {
console.warn('[bootstrap] 加载应用配置失败,降级为生产模式:', e);
appEnvironment = 'production';
}
```
---
### 16. `window.location.reload()` 在 Tauri 中行为不确定
**位置**`tauri-app/src/pages/Settings/Settings.tsx:178`
```typescript
setTimeout(() => { window.location.reload(); }, 500);
```
**问题**Tauri 应用中的 `window.location.reload()` 行为与浏览器不同。在某些 Tauri 版本中可能导致:
- 白屏而非正常刷新
- WebView 进程崩溃
- 状态丢失但窗口不重新加载
**修复建议**:使用 Tauri 的 `relaunch()` 命令重启整个应用,或重新挂载 React 根组件:
```typescript
import { relaunch } from '@tauri-apps/plugin-process';
// 重启应用
await relaunch();
```
---
### 17. `Math.random()` 用于缓存清除参数(安全性)
**位置**`tauri-app/src/api/client.ts:334`
```typescript
const cacheBuster = `_t=${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
```
**问题**`Math.random()` 不是加密安全的随机数生成器。虽然这里只是用于缓存清除,但如果未来用于其他安全相关场景会有风险。
**修复建议**:使用 `crypto.randomUUID()``crypto.getRandomValues()`
```typescript
const cacheBuster = `_t=${Date.now()}_${crypto.randomUUID().slice(0, 8)}`;
```
---
## 四、🟢 低风险/建议(11 项)
### 18. `backdrop-filter` 无标准前缀回退
**位置**`tauri-app/src/pages/VideoCreation/CoverDesign.css:444-445`
```css
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
```
**评估**:当前代码同时有标准和 WebKit 前缀版本,在 Tauri WebViewEdge/WebKit)中支持良好。Firefox 不支持,但项目不面向 Firefox。**无需修复**。
---
### 19. `::-webkit-scrollbar` 在 Firefox 中无效
**位置**:多处(`global.css``CoverDesign.css` 等)
**评估**:项目运行在 Tauri WebView 中(基于系统浏览器引擎),不是 Firefox。在 Windows 上基于 WebView2Edge),macOS 上基于 WKWebViewSafari),均支持 WebKit 滚动条样式。**无需修复**。
---
### 20. `aspect-ratio` 在旧版 Safari 中可能不支持
**位置**:多处使用 `aspect-ratio: 9 / 16`
**评估**macOS 12+ 的 Safari 支持 `aspect-ratio`。如果目标用户可能使用较旧的 macOS 版本,可能需要 `padding-top: 177.77%` 回退。但鉴于这是 Tauri 桌面应用,可以控制最低系统版本。**建议确认 `tauri.conf.json``macOS.minimumSystemVersion` 是否要求 12.0+**。
---
### 21. `requestIdleCallback` 缺失回退不完整
**位置**`tauri-app/src/main.tsx:75-79`
```typescript
if ('requestIdleCallback' in window) {
requestIdleCallback(showWindow, { timeout: 500 });
} else {
setTimeout(showWindow, 100);
}
```
**评估**Tauri WebView2Edge)和 WKWebViewSafari)均支持 `requestIdleCallback`。回退逻辑也已实现。**无需修复**。
---
### 22. `navigator.userAgent` 已被冻结
**位置**
- `tauri-app/src/store/authStore.ts:254`
- `tauri-app/src/api/client.ts:259`
**评估**:虽然现代浏览器正在限制 `navigator.userAgent`,但 Tauri WebView 不受此限制。且当前用法仅为日志和登录信息上报,不影响功能。**无需修复**。
---
### 23. `document.fonts.check()` 参数格式兼容性
**位置**`tauri-app/src/utils/canvasSubtitleDrawer.ts:209`
```typescript
if (document.fonts.check(`bold 16px ${fontName}`)) {
```
**评估**`document.fonts.check()` 的参数格式在不同浏览器中实现有细微差异,但 Tauri WebView2/WKWebView 均支持此用法。**风险极低**。
---
### 24. `Date.now()` 连续调用可能冲突
**位置**:多处使用 `Date.now()` 生成文件名
**评估**:仅在极快速连续调用时(<1ms)可能冲突。当前场景下不太可能。**风险极低**。
---
### 25. `autoPlay` 视频可能被浏览器阻止
**位置**:多处 `<video autoPlay>`
**评估**:桌面应用中的 WebView 通常不受浏览器自动播放策略限制。但如果用户操作系统设置了辅助功能限制,仍可能被阻止。**建议添加 `muted` 属性作为后备**(如果需要自动播放且带声音)。
---
### 26. `file.path` 是非标准 Chromium 属性
**位置**`tauri-app/src/pages/VideoCreation/CoverDesign.tsx:178`
```typescript
const path = (file as any).path || (file as any).webkitRelativePath || '';
```
**评估**`File.path` 是 Chromium 的私有属性,在标准浏览器(Firefox)中不存在。但由于项目运行在 TauriChromium/WebView2)中,这**当前是可行的**。但如果未来需要支持 Web 端部署,需要改用 Tauri Dialog API 获取路径。**建议添加注释说明此依赖**。
---
### 27. `storage/engine.rs` 无 Windows 文件权限设置
**位置**`tauri-app/src-tauri/src/storage/engine.rs:161`
```rust
#[cfg(unix)]
fn set_restrictive_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
Ok(())
}
```
**评估**Unix 上设置了 0o600 权限,Windows 上跳过。Windows 上文件默认对用户可读可写,其他用户也可读(取决于 ACL)。虽然这不是严重安全问题(应用数据存储在用户目录),但**建议在 Windows 上设置等效的 ACL 限制**。
**修复建议**
```rust
#[cfg(windows)]
fn set_restrictive_permissions(path: &Path) -> Result<()> {
// 使用 windows crate 或 fs_extra 设置 ACL
// 简化为仅当前用户可读写
// 这是可选优化,优先级低
Ok(())
}
```
---
### 28. `Slider.css` 和 `CoverDesign.css` 中 `appearance` 重复声明
**位置**
- `tauri-app/src/components/Slider/Slider.css:32-33`
- `tauri-app/src/pages/VideoCreation/CoverDesign.css:79-80`
```css
appearance: none;
appearance: none;
```
**评估**:纯代码质量问题,不影响兼容性。**建议移除重复行**。
---
## 五、按维度汇总表
| 维度 | 严重 | 中等 | 低风险 | 主要文件 |
|------|------|------|--------|----------|
| **内存/资源管理** | 1 (#1) | 1 (#10) | 1 (#24) | CoverDesign.tsx, VoiceSynthesis.tsx |
| **Windows 路径** | 2 (#4, #5) | 3 (#11, #12, #14) | 1 (#27) | ffmpeg_cmd.rs, file.rs, product.rs |
| **安全检查** | 2 (#2, #3) | 0 | 0 | file.rs, tauri.conf.json |
| **Canvas/媒体** | 0 | 3 (#7, #8, #9) | 2 (#20, #25) | useCoverFabric.ts, useCanvasSubtitleRenderer.ts |
| **网络/API** | 1 (#6) | 1 (#17) | 2 (#22, #23) | client.ts |
| **Tauri 原生** | 0 | 1 (#16) | 1 (#26) | Settings.tsx, CoverDesign.tsx |
| **CSS** | 0 | 0 | 3 (#18, #19, #28) | CoverDesign.css, global.css |
| **启动/配置** | 0 | 1 (#15) | 1 (#21) | main.tsx |
| **字体加载** | 0 | 1 (#13) | 0 | ffmpeg_cmd.rs |
---
## 六、修复优先级建议
### 立即修复(影响功能/安全)
1. **#3 Asset Protocol 过度授权** — 安全漏洞,一行配置修改
2. **#2 Windows 敏感路径大小写** — 安全检查被绕过
3. **#1 URL.createObjectURL 泄漏** — 内存泄漏,用户可见
4. **#4 escape_ffmpeg_path Windows 支持** — Windows 功能失效
5. **#5 canonicalize() UNC 路径** — Windows 文件操作异常
### 本轮迭代修复(影响体验)
6. **#6 atob() base64url 兼容性** — Token 解析潜在失败
7. **#7 crossOrigin 图片污染提示** — 用户友好性
8. **#8 RAF 后台节流** — 字幕同步
9. **#10 audio.duration NaN 处理** — 音频处理健壮性
10. **#9 控制条高度硬编码** — 预览准确性
11. **#11 Windows rename 已存在** — 文件操作健壮性
### 后续排期(优化/边缘场景)
12-28. 其余低风险项
---
## 七、特别说明:Tauri 环境 vs 浏览器环境的兼容性差异
本项目同时支持两种运行模式:
| 特性 | Tauri 桌面模式 | 浏览器模式(开发调试用) |
|------|---------------|------------------------|
| `invoke()` | ✅ Tauri IPC | ❌ 会 catch 失败 |
| `convertFileSrc()` | ✅ `asset://` | ❌ 会 catch 失败 |
| `localStorage` | ✅ 可用 | ✅ 可用 |
| File 系统 API | ✅ Tauri 插件 | ❌ 不可用 |
| `__TAURI_INTERNALS__` | ✅ 存在 | ❌ 不存在 |
**当前代码对浏览器模式有降级处理**`isTauri()` 检查 + catch 错误),这是好的实践。但以下功能在浏览器模式下完全不可用,需要评估是否影响开发调试:
- 本地视频预览(依赖 `asset://` + FFmpeg 转码)
- 文件保存/导出(依赖 Tauri Dialog
- 项目本地持久化(依赖 Tauri IPC)
- 自动更新(依赖 Tauri Updater
**建议**:在 `README` 或开发文档中明确列出浏览器模式的功能限制,避免开发者困惑。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "tauri-app",
"private": true,
"version": "1.6.0",
"version": "1.6.1",
"type": "module",
"scripts": {
"dev": "vite",
+2 -1
View File
@@ -4219,12 +4219,13 @@ dependencies = [
[[package]]
name = "tauri-app"
version = "1.6.0"
version = "1.6.1"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"fs2",
"log",
"reqwest 0.12.28",
"serde",
"serde_json",
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "tauri-app"
version = "1.6.0"
version = "1.6.1"
description = "美家卡智影 - AI 视频创作桌面应用"
authors = ["美家卡科技"]
edition = "2021"
@@ -27,6 +27,7 @@ tauri-plugin-updater = "2"
tauri-plugin-process = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
log = "0.4"
dirs = "5"
# HTTP 客户端: 精简功能
# 使用 default-tls(默认)+ json,无需额外 features
@@ -43,6 +44,6 @@ thiserror = "1"
# 文件锁(跨平台)
fs2 = "0.4"
# 异步运行时定时器(FFmpeg 超时保护)
tokio = { version = "1", features = ["time"] }
tokio = { version = "1", features = ["time", "sync"] }
tauri-plugin-single-instance = "2"
+123 -18
View File
@@ -3,6 +3,11 @@ use crate::StringResultExt;
use tauri_plugin_shell::process::CommandEvent;
use tauri::{AppHandle, Emitter};
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use tokio::sync::Semaphore;
/// 预览转码并发锁:保证同时只有一个 FFmpeg 进程在转码,避免 CPU/磁盘争抢
static PREVIEW_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(1));
/// 视频元数据(由 ffprobe 解析)
#[derive(Debug, Serialize, Deserialize)]
@@ -100,6 +105,7 @@ pub async fn run_ffmpeg(app: &AppHandle, args: Vec<String>) -> Result<String, St
}
CommandEvent::Stderr(line) => {
let log = String::from_utf8_lossy(&line);
log::debug!("[ffmpeg stderr] {}", log.trim());
stderr_output.push_str(&log);
stderr_output.push('\n');
@@ -114,7 +120,12 @@ pub async fn run_ffmpeg(app: &AppHandle, args: Vec<String>) -> Result<String, St
}
CommandEvent::Terminated(status) => {
match status.code {
Some(0) => return Ok(()),
Some(0) => {
if !stderr_output.is_empty() {
log::debug!("[ffmpeg] stderr:\n{}", stderr_output.trim());
}
return Ok(());
}
Some(code) => {
let err_detail = if stderr_output.is_empty() {
"(no stderr output)".to_string()
@@ -435,7 +446,7 @@ pub async fn burn_ass_subtitle(
match run_ffmpeg(app, args).await {
Ok(_) => return Ok(()),
Err(e) => {
eprintln!("[ffmpeg] 带 fontsdir 的 ASS 烧录失败,尝试回退: {}", e);
log::warn!("[ffmpeg] 带 fontsdir 的 ASS 烧录失败,尝试回退: {}", e);
}
}
@@ -775,11 +786,50 @@ pub async fn extract_audio_segment(
/**
* 解析 ffprobe 返回的帧率字符串(如 "30000/1001" 或 "30/1"
*/
fn parse_frame_rate(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
if let Some(idx) = s.find('/') {
let num: f64 = s[..idx].parse().unwrap_or(0.0);
let den: f64 = s[idx + 1..].parse().unwrap_or(1.0);
if den > 0.0 {
return num / den;
}
}
s.parse().unwrap_or(0.0)
}
/**
* 为预览转码视频(统一为浏览器兼容格式)
*
* 将任意格式的视频转码为 H.264 Baseline + YUV420p 540p
* 确保在所有平台的浏览器/WebView 中都能正常预览
* 快速通过检测:只有满足全部兼容性条件的视频才直接返回原路径
* 任一不满足则强制转码为 H.264 Baseline + YUV420p 540p 代理
*
* 检测项(基于主流浏览器/WebView 兼容性最佳实践):
* - codec: H.264
* - pix_fmt: YUV420p
* - profile: Baseline / Constrained Baseline / Main
* - level: <= 4.1
* - color_range: limited (tv) — macOS VideoToolbox 对 full range 支持不完整
* - has_b_frames: 0 — macOS WKWebView 对 B-frames 支持不完整
* - refs: <= 4 — 参考帧数过多可能导致解码失败
* - r_frame_rate: <= 60fps — 高帧率可能异常
* - width/height: <= 1920x1920
*
* 转码参数参考 Mux/Transloadit 主流推荐,针对预览场景优化:
* - preset ultrafast: 编码速度优先(预览不追求极致压缩率)
* - crf 28: 预览画质够用
* - g 30 / keyint_min 30: 每秒一个关键帧,seek 更精确
* - 保留音频(人物形象视频有配音)
* - +faststart: moov atom 前置,快速开始播放
*
* 并发控制:tokio Semaphore(1),保证同时只有一个 FFmpeg 进程在跑,
* 避免多视频同时预览时 CPU/磁盘争抢。
*
* 转码结果按(文件路径 + 大小 + 修改时间)缓存,避免重复处理。
*/
pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<String, String> {
@@ -793,11 +843,13 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
.to_string_lossy()
.to_string();
// 快速检测:如果已经是 H.264 + YUV420p,直接返回原始路径(避免应用自己生成的成品重复转码)
// ============================================================
// 1. 快速通过检测:用 ffprobe 全面检查视频兼容性
// ============================================================
let probe_args = vec![
"-v".to_string(), "error".to_string(),
"-select_streams".to_string(), "v:0".to_string(),
"-show_entries".to_string(), "stream=codec_name,pix_fmt,width,height".to_string(),
"-show_entries".to_string(), "stream=codec_name,pix_fmt,width,height,profile,level,color_range,has_b_frames,refs,r_frame_rate".to_string(),
"-of".to_string(), "json".to_string(),
path_str.clone(),
];
@@ -822,10 +874,37 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
if let Some(stream) = parsed.get("streams").and_then(|s| s.as_array()).and_then(|a| a.first()) {
let codec = stream.get("codec_name").and_then(|v| v.as_str()).unwrap_or("");
let pix_fmt = stream.get("pix_fmt").and_then(|v| v.as_str()).unwrap_or("");
// 应用生成的视频通常是 1080p 及以下;若用户上传了 4K H.264,仍转码为 540p 代理以保证预览流畅
let profile = stream.get("profile").and_then(|v| v.as_str()).unwrap_or("");
let level = stream.get("level").and_then(|v| v.as_i64()).unwrap_or(0);
let color_range = stream.get("color_range").and_then(|v| v.as_str()).unwrap_or("tv");
let has_b_frames = stream.get("has_b_frames").and_then(|v| v.as_i64()).unwrap_or(0);
let refs = stream.get("refs").and_then(|v| v.as_i64()).unwrap_or(0);
let r_frame_rate = stream.get("r_frame_rate").and_then(|v| v.as_str()).unwrap_or("");
let width = stream.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
let height = stream.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
if codec == "h264" && pix_fmt == "yuv420p" && width <= 1920 && height <= 1920 {
let is_safe_profile = profile == "Baseline"
|| profile == "Constrained Baseline"
|| profile == "Main";
let is_limited_range = color_range == "tv" || color_range.is_empty();
let no_b_frames = has_b_frames == 0;
let refs_ok = refs <= 4;
let level_ok = level <= 41;
let fps = parse_frame_rate(r_frame_rate);
let fps_ok = fps > 0.0 && fps <= 60.0;
let resolution_ok = width <= 1920 && height <= 1920;
let can_skip = codec == "h264"
&& pix_fmt == "yuv420p"
&& is_safe_profile
&& is_limited_range
&& no_b_frames
&& refs_ok
&& level_ok
&& fps_ok
&& resolution_ok;
if can_skip {
return Ok(path_str);
}
}
@@ -838,10 +917,11 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
}
}
// 获取文件元数据用于缓存 key
// ============================================================
// 2. 缓存检查
// ============================================================
let metadata = std::fs::metadata(input_path)
.map_err(|e| format!("无法读取文件元数据: {}", e))?;
// 某些文件系统(如 FAT32)不支持修改时间,失败时回退为 0
let mtime = metadata.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
@@ -849,7 +929,6 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
.as_secs();
let file_size = metadata.len();
// 计算缓存路径(基于文件路径 hash + 大小 + 修改时间)
let path_hash = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
@@ -863,9 +942,10 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
let cache_dir = app_data_dir.join("video_cache");
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("无法创建缓存目录: {}", e))?;
let cache_path = cache_dir.join(format!("preview_{}_{}_{}.mp4", path_hash, file_size, mtime));
// 缓存版本号:每次修改转码参数后递增,确保旧缓存自动失效
const CACHE_VERSION: &str = "v3";
let cache_path = cache_dir.join(format!("preview_{}_{}_{}_{}.mp4", path_hash, file_size, mtime, CACHE_VERSION));
// 缓存命中且文件完整,直接返回
if cache_path.exists() {
let cache_meta = std::fs::metadata(&cache_path)
.map_err(|e| format!("无法读取缓存文件: {}", e))?;
@@ -874,23 +954,48 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
}
}
// FFmpeg 转码:H.264 Baseline + YUV420p540p,无音频,faststart
// ============================================================
// 3. FFmpeg 转码(带并发锁)
// ============================================================
let _permit = PREVIEW_SEMAPHORE.acquire().await
.map_err(|e| format!("无法获取转码锁: {}", e))?;
// 先写入临时文件,避免 FFmpeg 写入过程中被 WebKit 读取到不完整文件
let tmp_path = cache_path.with_extension("mp4.tmp");
let args = vec![
"-i".to_string(), path_str,
"-i".to_string(), path_str.clone(),
"-map".to_string(), "0:v:0".to_string(),
"-map".to_string(), "0:a:0?".to_string(),
"-c:v".to_string(), "libx264".to_string(),
"-profile:v".to_string(), "baseline".to_string(),
"-level".to_string(), "3.0".to_string(),
"-pix_fmt".to_string(), "yuv420p".to_string(),
"-preset".to_string(), "ultrafast".to_string(),
"-crf".to_string(), "28".to_string(),
"-crf".to_string(), "23".to_string(),
"-vf".to_string(), "scale=540:-2:force_original_aspect_ratio=decrease".to_string(),
"-an".to_string(),
"-c:a".to_string(), "aac".to_string(),
"-b:a".to_string(), "128k".to_string(),
"-movflags".to_string(), "+faststart".to_string(),
"-f".to_string(), "mp4".to_string(),
"-y".to_string(),
cache_path.to_string_lossy().to_string(),
tmp_path.to_string_lossy().to_string(),
];
run_ffmpeg(app, args).await?;
// 转码完成后验证临时文件大小
let output_meta = std::fs::metadata(&tmp_path)
.map_err(|e| format!("转码后无法读取临时文件: {}", e))?;
if output_meta.len() < 1024 {
let _ = std::fs::remove_file(&tmp_path);
return Err(format!("FFmpeg 转码输出文件过小: {} bytes", output_meta.len()));
}
log::info!("[transcode_for_preview] 转码完成: {} ({} bytes)", tmp_path.display(), output_meta.len());
// 原子重命名:确保 WebKit 只读到完整文件
std::fs::rename(&tmp_path, &cache_path)
.map_err(|e| format!("重命名缓存文件失败: {}", e))?;
Ok(cache_path.to_string_lossy().to_string())
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "美家卡智影",
"version": "1.6.0",
"version": "1.6.1",
"identifier": "cn.meijiaka.ai-zy",
"build": {
"beforeDevCommand": "npm run dev",
@@ -23,7 +23,7 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' https: data: blob: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; connect-src 'self' https: ws://localhost:*;",
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' asset: http://asset.localhost; img-src 'self' https: data: blob: asset: http://asset.localhost; media-src 'self' https: blob: asset: http://asset.localhost; connect-src 'self' https: ws://localhost:* wss://localhost:* ipc://localhost http://ipc.localhost;",
"capabilities": [
"default"
],
+3 -4
View File
@@ -101,17 +101,16 @@
cursor: pointer;
padding: 0;
margin-left: auto;
margin-right: 0;
transform: translateY(1px);
margin-right: -4px;
transition: all 0.2s ease;
}
.nav-new-project:hover {
transform: translateY(1px) scale(1.08);
transform: scale(1.08);
}
.nav-new-project:active {
transform: translateY(1px) scale(0.96);
transform: scale(0.96);
}
.nav-chevron {
@@ -82,6 +82,9 @@ export default function Sidebar({ currentPath, onNavigate }: SidebarProps) {
const handleClick = (item: NavItem) => {
if (item.children) {
toggleExpand(item.id);
} else if (item.id === 'recharge') {
window.dispatchEvent(new CustomEvent('open-recharge-modal'));
onNavigate('profile');
} else {
onNavigate(item.id);
}
@@ -1,48 +1,44 @@
interface PointsCardProps {
balance: number;
todayConsumed: number;
onRecharge: () => void;
onViewDetail: () => void;
onViewPricing: () => void;
onViewUsage: () => void;
}
export default function PointsCard({
balance,
todayConsumed,
onRecharge,
onViewDetail,
onViewPricing,
onViewUsage,
}: PointsCardProps) {
return (
<div className="profile-points-section">
<div className="profile-points-grid">
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-card-header">
<span className="profile-points-label"></span>
<span className="profile-points-corner-link" onClick={onViewDetail}>
</span>
</div>
<div className="profile-points-value-row">
<span className="profile-points-value primary">{balance}</span>
<span className="profile-points-unit"></span>
</div>
</div>
<div className="profile-points-card">
<div className="profile-points-label"></div>
<div className="profile-points-card-header">
<span className="profile-points-label"></span>
<span className="profile-points-corner-link" onClick={onViewUsage}>
</span>
</div>
<div className="profile-points-value-row">
<span className="profile-points-value danger">{todayConsumed}</span>
<span className="profile-points-unit"></span>
</div>
</div>
</div>
<div className="profile-points-actions-row">
<button className="btn btn-primary btn-sm" onClick={onRecharge}>
</button>
<button className="btn btn-ghost btn-sm" onClick={onViewDetail}>
</button>
<button className="btn btn-ghost btn-sm" onClick={onViewPricing}>
</button>
</div>
</div>
);
}
+30 -30
View File
@@ -317,19 +317,40 @@ export function useCoverFabric() {
const subtitleHeight = subtitleLines.length * subtitleLineHeight;
// 间距配置
const gapAvatarToMain = 50; // 人物与标题间距
const gapMainToSub = 24; // 标题与标题间距
const gapAvatarToSub = 50; // 人物与标题间距
const gapSubToMain = 24; // 标题与标题间距
// 从人物头顶往上计算文字位置
// 标题底部 = 人物顶部 - 间距
const mainTitleBottom = avatarTop - gapAvatarToMain;
const mainTitleTop = mainTitleBottom - mainTitleHeight;
// 副标题底部 = 主标题顶部 - 间距
const subtitleBottom = mainTitleTop - gapMainToSub;
// 标题底部 = 人物顶部 - 间距
const subtitleBottom = avatarTop - gapAvatarToSub;
const subtitleTop = subtitleBottom - subtitleHeight;
// 3. 副标题(放在人物上方,主标题上方)
// 主标题底部 = 副标题顶部 - 间距
const mainTitleBottom = subtitleTop - gapSubToMain;
const mainTitleTop = mainTitleBottom - mainTitleHeight;
// 3. 主标题(放在人物上方最外侧)
if (mainTitleLines.length > 0) {
mainTitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: mainTitleTop + i * mainTitleLineHeight,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
});
canvas.add(text);
});
}
// 4. 副标题(放在人物上方,主标题下方)
if (subtitleLines.length > 0) {
subtitleLines.forEach((line, i) => {
const text = new FabricText(line, {
@@ -353,27 +374,6 @@ export function useCoverFabric() {
});
}
// 4. 主标题(放在人物上方,副标题下方)
if (mainTitleLines.length > 0) {
mainTitleLines.forEach((line, i) => {
const text = new FabricText(line, {
left: CANVAS_WIDTH / 2,
top: mainTitleTop + i * mainTitleLineHeight,
fontSize: template.mainTitle.fontSize,
fill: template.mainTitle.fill,
fontWeight: template.mainTitle.fontWeight,
fontFamily: FONT_FAMILY,
textAlign: 'center',
originX: 'center',
originY: 'top',
selectable: false,
evented: false,
shadow: template.mainTitle.shadow,
});
canvas.add(text);
});
}
canvas.renderAll();
},
[loadBackground]
@@ -344,6 +344,7 @@
.settings-section h2 {
font-size: var(--font-lg);
font-weight: 600;
padding-left: var(--spacing-lg);
}
.settings-row {
@@ -526,16 +527,51 @@
color: var(--text-tertiary);
}
.profile-points-actions-row {
.profile-points-card-header {
display: flex;
gap: var(--spacing-sm);
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
}
.profile-points-actions-row .btn {
padding: 10px 20px;
.profile-points-corner-link {
font-size: var(--font-xs);
color: var(--text-tertiary);
cursor: pointer;
transition: color 0.2s ease;
}
.profile-points-corner-link:hover {
color: var(--primary);
}
.profile-pricing-link {
display: inline-flex;
align-items: center;
font-size: var(--font-sm);
white-space: nowrap;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s ease;
}
.profile-pricing-link:hover {
color: var(--primary);
}
.profile-recharge-header-btn {
padding: 6px 14px;
font-size: var(--font-sm);
border-radius: var(--radius-md);
border: 1px solid var(--primary);
background: var(--bg-primary);
color: var(--primary);
cursor: pointer;
transition: all 0.2s ease;
margin-right: var(--spacing-lg);
}
.profile-recharge-header-btn:hover {
background: var(--primary);
color: var(--text-inverse);
}
.profile-section-title {
@@ -8,7 +8,7 @@ import { switchProject } from '../../store/projectStore';
import { localProjectApi } from '../../api/modules/localStorage';
import type { ProjectMeta } from '../../types/project';
import ConfirmModal from '../../components/Modal/ConfirmModal';
import { useLocalVideo } from '../../hooks/useLocalVideo';
import type { ApiResponse } from '../../api/types';
import './ContentManagement.css';
@@ -99,7 +99,20 @@ function ProductCard({ product, onDelete, onRename }: {
const [editName, setEditName] = useState(getBaseName(product.filename));
const [coverUrl, setCoverUrl] = useState('');
const [isCoverLoading, setIsCoverLoading] = useState(false);
const { videoUrl } = useLocalVideo(product.path);
const [videoUrl, setVideoUrl] = useState('');
// 成品视频直接读取原文件,不经过转码缓存(成品已是 H.264 + yuv420p 标准格式)
useEffect(() => {
let canceled = false;
getLocalFileUrl(product.path)
.then(url => {
if (!canceled) setVideoUrl(url);
})
.catch(err => {
console.error('[ProductCard] 加载视频失败:', err);
});
return () => { canceled = true; };
}, [product.path]);
useEffect(() => {
if (!product.poster_path) {
@@ -140,7 +153,7 @@ function ProductCard({ product, onDelete, onRename }: {
video.removeEventListener('pause', handlePause);
video.removeEventListener('ended', handleEnded);
};
}, []);
}, [videoUrl]);
const togglePlay = (e: React.MouseEvent) => {
e.stopPropagation();
+25 -9
View File
@@ -127,7 +127,14 @@ export default function Profile() {
return (
<div className="settings-page">
<AppHeader title="我的账户" />
<AppHeader
title="我的账户"
rightActions={
<button className="profile-recharge-header-btn" onClick={() => setShowRechargeModal(true)}>
</button>
}
/>
{/* 个人信息 + 积分 */}
<div className="card profile-card-flat">
@@ -192,22 +199,31 @@ export default function Profile() {
<PointsCard
balance={balance?.balance ?? 0}
todayConsumed={todayConsumed}
onRecharge={() => setShowRechargeModal(true)}
onViewDetail={() => {
localStorage.setItem('usage-detail-initial-tab', 'recharge');
navigate('usage-detail');
}}
onViewPricing={handleOpenPricing}
onViewUsage={() => {
localStorage.setItem('usage-detail-initial-tab', 'usage');
navigate('usage-detail');
}}
/>
</div>
{/* 最近记录 */}
<div className="profile-section-spaced">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)' }}>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600, margin: 0 }}></h3>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('usage-detail')}>
</button>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--spacing-md)', paddingRight: 'var(--spacing-lg)' }}>
<h3 style={{ fontSize: 'var(--font-md)', fontWeight: 600, margin: 0, paddingLeft: 'var(--spacing-lg)' }}></h3>
<span className="profile-pricing-link" onClick={handleOpenPricing}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '4px', verticalAlign: 'text-bottom' }}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
</span>
</div>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
<table className="usage-table">
@@ -233,7 +249,7 @@ export default function Profile() {
</td>
</tr>
) : (
recentTx.map((tx) => (
recentTx.slice(0, 10).map((tx) => (
<tr key={tx.id}>
<td>
<span
@@ -152,6 +152,7 @@ export default function CoverDesign() {
// 加载背景图 — 系统图库 + 本地上传
const localBgRef = useRef<HTMLInputElement | null>(null);
const blobUrlsRef = useRef<Set<string>>(new Set());
const [bgList, setBgList] = useState<BgImage[]>([]);
useEffect(() => {
@@ -175,10 +176,19 @@ export default function CoverDesign() {
const handleLocalBgUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 释放旧的 Blob URL
const oldBg = config.backgroundImage;
if (oldBg && oldBg.startsWith('blob:')) {
URL.revokeObjectURL(oldBg);
blobUrlsRef.current.delete(oldBg);
}
const path = (file as any).path || (file as any).webkitRelativePath || '';
if (!path) {
// Tauri 文件选择器通常有 path,如果没有则使用 Object URL
const url = URL.createObjectURL(file);
blobUrlsRef.current.add(url);
setConfig(prev => ({ ...prev, backgroundImage: url }));
} else {
setConfig(prev => ({ ...prev, backgroundImage: path }));
@@ -203,6 +213,14 @@ export default function CoverDesign() {
setLastModalBgIds(new Set(picked.map(b => b.id)));
};
// 组件卸载时释放所有 Blob URL
useEffect(() => {
return () => {
blobUrlsRef.current.forEach(url => URL.revokeObjectURL(url));
blobUrlsRef.current.clear();
};
}, []);
// 初始化 Canvas
useEffect(() => {
initCanvas();
@@ -314,6 +332,7 @@ export default function CoverDesign() {
});
useProjectStore.getState().clearStepDirty(5);
useProjectStore.getState().markStepsDirty(5);
useProgressStore.getState().success('封面设计完成', coverDesignPoints);
} catch (error: unknown) {
const message = (error instanceof Error ? error.message : String(error)) || '封面设计失败';
@@ -414,16 +414,6 @@ export default function SubtitleBurning() {
// 5. 准备视频路径
const resolvedVideoPath = actualVideoPath;
// 扣费:字幕烧录
const subtitleBurnPoints = usePointStore.getState().getRule('subtitle_burn')?.points || 2;
await pointsApi.consumePoints({
points: subtitleBurnPoints,
sourceType: 'subtitle_burn',
sourceId: `subtitle_burn_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`,
description: '字幕烧录',
});
await usePointStore.getState().fetchBalance();
// 6. 调用 Rust 压制字幕(带可选的 overlay 图片)
const burnResult = await invoke<{ code: number; data?: string; message: string }>('burn_subtitle', {
request: {
@@ -441,6 +431,18 @@ export default function SubtitleBurning() {
useProjectStore.setState({ burnedVideoPath: outputPath });
await saveMetaToLocalFile({ burnedVideoPath: outputPath });
useProjectStore.getState().clearStepDirty(4);
useProjectStore.getState().markStepsDirty(4);
// 8. 扣费:字幕烧录(压制成功后才扣费)
const subtitleBurnPoints = usePointStore.getState().getRule('subtitle_burn')?.points || 2;
await pointsApi.consumePoints({
points: subtitleBurnPoints,
sourceType: 'subtitle_burn',
sourceId: `subtitle_burn_${useAuthStore.getState().user?.id || 'unknown'}_${Date.now()}`,
description: '字幕烧录',
});
await usePointStore.getState().fetchBalance();
useProgressStore.getState().success('字幕烧录完成', subtitleBurnPoints);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
@@ -337,6 +337,7 @@ export default function VideoCompose() {
coverPath,
});
useProjectStore.getState().clearStepDirty(6);
setDone(true);
useProgressStore.getState().success('压制成片完成', composePoints);
} catch (error: unknown) {
@@ -385,6 +385,7 @@ export default function VoiceSynthesis() {
await handleAlignAndClip(qiniuUrl, meta.filePath);
clearStepDirty(2);
useProjectStore.getState().markStepsDirty(2);
progress.success('配音合成完成', result.consumedPoints);
} catch (err) {
if (handleError(err, '配音合成', estimatedTtsPoints.max)) {
@@ -438,6 +438,7 @@ export function useVideoGeneration({
});
useProjectStore.getState().clearStepDirty(3);
useProjectStore.getState().markStepsDirty(3);
onSuccess();
progress.success('视频生成完成', actualVideoPoints);
+24 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { open } from '@tauri-apps/plugin-dialog';
import { exists } from '@tauri-apps/plugin-fs';
import { getPreviewVideoUrl } from '../../utils/videoPreview';
@@ -70,7 +70,6 @@ export default function VideoGeneration() {
// 预览视频
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const previewBlobRef = useRef<string | null>(null);
// 页面刷新后恢复组件本地 statestore 字段由 VideoCreation/index.tsx 统一恢复)
useEffect(() => {
@@ -183,6 +182,16 @@ export default function VideoGeneration() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// loading 兜底:视频 URL 设置后最多显示 5 秒 loading,防止 loadeddata 不触发时卡住
useEffect(() => {
if (previewVideoUrl) {
const timer = setTimeout(() => {
setIsPreviewLoading(false);
}, 5000);
return () => clearTimeout(timer);
}
}, [previewVideoUrl]);
// 点击卡片后自动预览对应素材/形象
useEffect(() => {
const activeShot = shots.find((s) => Number(s.id) === activeScene);
@@ -190,29 +199,24 @@ export default function VideoGeneration() {
return;
}
if (previewBlobRef.current) {
previewBlobRef.current = null;
setPreviewVideoUrl(null);
}
setPreviewVideoUrl(null);
if (activeShot.type === 'empty_shot') {
const matched = materialMatchMap[String(activeShot.id)];
if (matched) {
setPreviewVideoUrl(matched.url);
} else {
setPreviewVideoUrl(null);
}
} else if (selectedAvatarMaterial) {
setIsPreviewLoading(true);
(async () => {
try {
const url = await getPreviewVideoUrl(selectedAvatarMaterial.path);
setPreviewVideoUrl(url);
} catch (e) {
console.error('[VideoGeneration] 预览形象视频失败:', e);
setIsPreviewLoading(false);
}
})();
} else {
setPreviewVideoUrl(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeScene]);
@@ -234,27 +238,23 @@ export default function VideoGeneration() {
*
*/
const handlePreview = async (urlOrPath: string) => {
if (previewBlobRef.current) {
previewBlobRef.current = null;
setPreviewVideoUrl(null);
}
setPreviewVideoUrl(null);
setIsPreviewLoading(true);
// 线上视频:直接设 src,不走 blob
// 线上视频:直接设 src
if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
setPreviewVideoUrl(urlOrPath);
setIsPreviewLoading(false);
return;
}
// 本地视频
// 本地视频:转码期间保持 loading,转码完成后由 video onLoadedData/onError 或兜底 timer 关闭
try {
const url = await getPreviewVideoUrl(urlOrPath);
setPreviewVideoUrl(url);
} catch (e) {
console.error('[VideoGeneration] 预览本地视频失败:', e);
toast.error('预览失败');
} finally {
setIsPreviewLoading(false);
}
};
@@ -305,17 +305,9 @@ export default function VideoGeneration() {
};
// 处理场景点击
const handleSceneClick = (id: number, shot: ScriptShot) => {
const handleSceneClick = (id: number, _shot: ScriptShot) => {
setIsComposedPreview(false);
setActiveScene(id);
if (shot.type === 'empty_shot') {
const matched = materialMatchMap[String(id)];
if (matched) {
handlePreview(matched.url);
}
} else if (selectedAvatarMaterial) {
handlePreview(selectedAvatarMaterial.path);
}
};
// 处理素材切换
@@ -349,6 +341,8 @@ export default function VideoGeneration() {
const activeShot = shots.find((s) => Number(s.id) === activeScene);
return (
<>
{isStepDirty && (
@@ -422,8 +416,12 @@ export default function VideoGeneration() {
className="preview-video"
controls
autoPlay
muted
playsInline
preload="none"
onLoadStart={() => setIsPreviewLoading(true)}
onLoadedData={() => setIsPreviewLoading(false)}
onError={() => setIsPreviewLoading(false)}
/>
{isPreviewLoading && (
<div
-1
View File
@@ -24,6 +24,5 @@ export async function getPreviewVideoUrl(path: string): Promise<string> {
throw new Error(resp.message || '预览视频处理失败');
}
// Rust 返回本地文件系统路径,转为 asset:// 协议 URL
return convertFileSrc(resp.data);
}