From 915339d42aa8d530e439241f1b4e851d8ef1ac78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B1=BC=E5=BC=80=E5=8F=91?= Date: Mon, 25 May 2026 22:35:35 +0800 Subject: [PATCH] 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 --- VERSION | 2 +- docs/frontend-compatibility-audit-v2.md | 756 ++++++++++++++++++ docs/frontend-compatibility-audit.md | 676 ++++++++++++++++ tauri-app/package.json | 2 +- tauri-app/src-tauri/Cargo.lock | 3 +- tauri-app/src-tauri/Cargo.toml | 5 +- tauri-app/src-tauri/src/ffmpeg_cmd.rs | 141 +++- tauri-app/src-tauri/tauri.conf.json | 4 +- tauri-app/src/components/Layout/Sidebar.css | 7 +- tauri-app/src/components/Layout/Sidebar.tsx | 3 + .../src/components/PointsCard/PointsCard.tsx | 32 +- tauri-app/src/hooks/useCoverFabric.ts | 60 +- .../ContentManagement/ContentManagement.css | 48 +- .../src/pages/ContentManagement/MyWorks.tsx | 19 +- tauri-app/src/pages/Profile/Profile.tsx | 34 +- .../src/pages/VideoCreation/CoverDesign.tsx | 19 + .../pages/VideoCreation/SubtitleBurning.tsx | 22 +- .../src/pages/VideoCreation/VideoCompose.tsx | 1 + .../pages/VideoCreation/VoiceSynthesis.tsx | 1 + .../hooks/useVideoGeneration.ts | 1 + tauri-app/src/pages/VideoGeneration/index.tsx | 50 +- tauri-app/src/utils/videoPreview.ts | 1 - 22 files changed, 1755 insertions(+), 132 deletions(-) create mode 100644 docs/frontend-compatibility-audit-v2.md create mode 100644 docs/frontend-compatibility-audit.md diff --git a/VERSION b/VERSION index dc1e644..2eda823 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0 +1.6.1 \ No newline at end of file diff --git a/docs/frontend-compatibility-audit-v2.md b/docs/frontend-compatibility-audit-v2.md new file mode 100644 index 0000000..09e4206 --- /dev/null +++ b/docs/frontend-compatibility-audit-v2.md @@ -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('get_bgm_cache_dir'); + const cachedPath = `${cacheDir}/bgm_${bgmMusicId}.mp3`; + const exists = await invoke('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(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): Partial { + // 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; +} +``` + +--- + +## 五、按业务维度汇总 + +| 业务维度 | 问题编号 | 核心风险 | 用户感知 | +|----------|----------|----------|----------| +| **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` 扩展 diff --git a/docs/frontend-compatibility-audit.md b/docs/frontend-compatibility-audit.md new file mode 100644 index 0000000..00334dd --- /dev/null +++ b/docs/frontend-compatibility-audit.md @@ -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 封装的 WebView(Edge/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; +``` + +**问题**:`