Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 603650cfb3 | |||
| 15dc5df12c | |||
| 4659f4536e | |||
| 784c4faa55 | |||
| 5b804e9d79 | |||
| 00f0088c2a | |||
| 4a295e6e0d | |||
| 63e0ffeaea | |||
| 2797583d81 | |||
| 10fc4092b2 | |||
| cc2e3f639c | |||
| 6c64189c70 | |||
| d84a4e9d65 | |||
| 7f522f5b83 | |||
| d2220ac176 | |||
| 790cf3a7fb | |||
| 943358bafc | |||
| 9ca07ff571 | |||
| 9df8572512 | |||
| 7b53abf37b | |||
| cf3ea8d619 | |||
| af734eb6ca | |||
| 2b35a9ced0 | |||
| 993d6e0c78 | |||
| e35b0f0bbb | |||
| 8cddaec70e | |||
| 915339d42a | |||
| 33265df299 | |||
| 4af42c157e |
@@ -25,6 +25,8 @@ jobs:
|
||||
name: Build macOS (Universal)
|
||||
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'macos' }}
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -121,6 +123,8 @@ jobs:
|
||||
name: Build Windows (x64)
|
||||
if: ${{ github.event_name == 'push' || inputs.platform == 'all' || inputs.platform == 'windows' }}
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
**美家卡智影**是一款面向桌面端的 AI 视频创作应用,采用"Python 后端 API + Tauri 桌面前端"的混合架构。
|
||||
|
||||
- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-zy`
|
||||
- **版本**: `1.5.18`
|
||||
- **版本**: `1.6.6`
|
||||
- **核心功能**: AI 脚本生成、AI 配音合成(TTS)、声音复刻、视频生成(Vidu)、视频字幕生成、压制成片(FFmpeg)、项目本地持久化
|
||||
|
||||
### 技术栈总览
|
||||
|
||||
@@ -71,7 +71,7 @@ INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES
|
||||
('0.2.0', '2026-04-20 10:00:00', '新增:批量导出功能\n优化:性能提升 30%', FALSE);
|
||||
```
|
||||
|
||||
#### 2.1.2 release_packages - 平台包信息
|
||||
#### 2.1.2 mjk_app_release_packages - 平台包信息
|
||||
|
||||
| 字段 | 类型 | 说明 | 约束 |
|
||||
|------|------|------|------|
|
||||
@@ -97,7 +97,7 @@ INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES
|
||||
|
||||
**示例数据**:
|
||||
```sql
|
||||
INSERT INTO release_packages (release_id, platform, architecture, filename, file_url, file_size, file_hash) VALUES
|
||||
INSERT INTO mjk_app_release_packages (release_id, platform, architecture, filename, file_url, file_size, file_hash) VALUES
|
||||
(2, 'darwin', 'x86_64', 'meijiaka_0.1.1_darwin_x86_64.dmg',
|
||||
'https://cdn.meijiaka.com/releases/meijiaka_0.1.1_darwin_x86_64.dmg',
|
||||
102400000, 'sha256:abc123...'),
|
||||
@@ -129,7 +129,7 @@ INSERT INTO release_packages (release_id, platform, architecture, filename, file
|
||||
CREATE INDEX idx_releases_release_date ON app_releases(release_date DESC);
|
||||
|
||||
-- 平台包复合索引
|
||||
CREATE INDEX idx_packages_platform_arch ON release_packages(platform, architecture);
|
||||
CREATE INDEX idx_packages_platform_arch ON mjk_app_release_packages(platform, architecture);
|
||||
|
||||
-- 下载统计
|
||||
CREATE INDEX idx_downloads_release_id ON update_downloads(release_id);
|
||||
@@ -151,7 +151,7 @@ CREATE TABLE IF NOT EXISTS app_releases (
|
||||
);
|
||||
|
||||
-- 创建平台包信息表
|
||||
CREATE TABLE IF NOT EXISTS release_packages (
|
||||
CREATE TABLE IF NOT EXISTS mjk_app_release_packages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
release_id INTEGER NOT NULL REFERENCES app_releases(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(20) NOT NULL,
|
||||
@@ -177,8 +177,8 @@ CREATE TABLE IF NOT EXISTS update_downloads (
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_version ON app_releases(version);
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_release_date ON app_releases(release_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_platform_arch ON release_packages(platform, architecture);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_release_id ON release_packages(release_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_platform_arch ON mjk_app_release_packages(platform, architecture);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_release_id ON mjk_app_release_packages(release_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_release_id ON update_downloads(release_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloads_download_at ON update_downloads(download_at);
|
||||
|
||||
@@ -240,7 +240,7 @@ class AppRelease(Base):
|
||||
|
||||
class ReleasePackage(Base):
|
||||
"""平台包信息"""
|
||||
__tablename__ = "release_packages"
|
||||
__tablename__ = "mjk_app_release_packages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
release_id: Mapped[int] = mapped_column(
|
||||
|
||||
@@ -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` 扩展
|
||||
@@ -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;
|
||||
```
|
||||
|
||||
**问题**:`<video controls>` 的控制条高度在不同浏览器/OS 上不同(macOS Safari 约 30px,Windows 约 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 WebView(Edge/WebKit)中支持良好。Firefox 不支持,但项目不面向 Firefox。**无需修复**。
|
||||
|
||||
---
|
||||
|
||||
### 19. `::-webkit-scrollbar` 在 Firefox 中无效
|
||||
|
||||
**位置**:多处(`global.css`、`CoverDesign.css` 等)
|
||||
|
||||
**评估**:项目运行在 Tauri WebView 中(基于系统浏览器引擎),不是 Firefox。在 Windows 上基于 WebView2(Edge),macOS 上基于 WKWebView(Safari),均支持 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 WebView2(Edge)和 WKWebView(Safari)均支持 `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)中不存在。但由于项目运行在 Tauri(Chromium/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` 或开发文档中明确列出浏览器模式的功能限制,避免开发者困惑。
|
||||
@@ -170,4 +170,4 @@ Tauri updater 插件已内置跨平台安装逻辑,前端代码无需区分平
|
||||
| `tauri-app/src-tauri/tauri.conf.json` | updater 配置:公钥 + endpoint URL |
|
||||
| `python-api/scripts/publish_release.py` | 发版脚本(扫描 .sig → 上传七牛云 → 写数据库) |
|
||||
| `python-api/app/api/v1/update.py` | 后端更新检查 API |
|
||||
| `python-api/app/models/update.py` | 数据库模型(`app_releases` / `release_packages`) |
|
||||
| `python-api/app/models/update.py` | 数据库模型(`mjk_app_releases` / `mjk_app_release_packages`) |
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Windows 11 开发环境搭建指南
|
||||
|
||||
> 适用场景:全新重装系统后的 Windows 11,国内网络环境。
|
||||
|
||||
---
|
||||
|
||||
## 前置说明
|
||||
|
||||
- **WebView2**:Windows 11 自带,无需安装。
|
||||
- **WSL2**:Windows 11 默认支持,Docker Desktop 会自动启用。
|
||||
- **全程使用 cmd + 官网 .exe 安装包**,不依赖 PowerShell 脚本。
|
||||
|
||||
---
|
||||
|
||||
## 一、基础工具安装(图形界面,双击下一步)
|
||||
|
||||
按顺序安装,装完一个再装下一个。
|
||||
|
||||
### 1. Git
|
||||
|
||||
- 下载:https://git-scm.com/download/win
|
||||
- 安装:全默认,一路 Next。
|
||||
|
||||
### 2. Node.js 22 LTS
|
||||
|
||||
- 下载:https://nodejs.org/
|
||||
- 安装:勾选 **"Automatically install necessary tools"**(会自动装 Python 2.7 等构建工具)。
|
||||
|
||||
### 3. Visual Studio Build Tools 2022
|
||||
|
||||
- 下载:https://aka.ms/vs/17/release/vs_BuildTools.exe
|
||||
- 安装:只勾选 **"使用 C++ 的桌面开发"**(约 8GB),其他全取消。
|
||||
|
||||
### 4. Rust
|
||||
|
||||
- 下载:https://rustup.rs/ → 点击 `rustup-init.exe (64-bit)`
|
||||
- 安装:选 **1) Proceed with default installation**(默认 MSVC 工具链)。
|
||||
|
||||
---
|
||||
|
||||
## 二、国内镜像配置(cmd 执行)
|
||||
|
||||
打开 **cmd(Win+R → cmd)**,逐行执行:
|
||||
|
||||
```cmd
|
||||
:: ========== npm 镜像 ==========
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
:: ========== Rust 镜像 ==========
|
||||
mkdir "%USERPROFILE%\.cargo" 2>nul
|
||||
|
||||
echo [source.crates-io] > "%USERPROFILE%\.cargo\config.toml"
|
||||
echo replace-with = 'ustc' >> "%USERPROFILE%\.cargo\config.toml"
|
||||
echo [source.ustc] >> "%USERPROFILE%\.cargo\config.toml"
|
||||
echo registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/" >> "%USERPROFILE%\.cargo\config.toml"
|
||||
|
||||
setx RUSTUP_UPDATE_ROOT https://mirrors.ustc.edu.cn/rust-static/rustup
|
||||
setx RUSTUP_DIST_SERVER https://mirrors.ustc.edu.cn/rust-static
|
||||
|
||||
:: ========== Python 镜像(预留,方案 B 用到) ==========
|
||||
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
pip config set global.trusted-host pypi.tuna.tsinghua.edu.cn
|
||||
|
||||
echo 镜像配置完成,请关闭并重新打开 cmd
|
||||
```
|
||||
|
||||
**执行完后,关闭 cmd,重新打开**,再执行验证:
|
||||
|
||||
```cmd
|
||||
npm config get registry
|
||||
cargo --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、方案 A:只跑前端(连测试环境后端)
|
||||
|
||||
### 1. 拉代码
|
||||
|
||||
```cmd
|
||||
git clone <你的仓库地址>
|
||||
cd meijiaka-zy\tauri-app
|
||||
```
|
||||
|
||||
### 2. 装依赖
|
||||
|
||||
```cmd
|
||||
npm ci
|
||||
```
|
||||
|
||||
### 3. 启动
|
||||
|
||||
```cmd
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
前端默认连接 `https://dev.tapi.meijiaka.cn/api/v1`,无需本地后端。
|
||||
|
||||
---
|
||||
|
||||
## 四、方案 B:前后端都本地跑
|
||||
|
||||
在方案 A 基础上继续。
|
||||
|
||||
### 1. Python 3.13
|
||||
|
||||
- 下载:https://www.python.org/ftp/python/3.13.0/python-3.13.0-amd64.exe
|
||||
- 安装:**务必勾选 "Add python.exe to PATH"**,然后 Install Now。
|
||||
|
||||
### 2. 安装 uv
|
||||
|
||||
```cmd
|
||||
pip install uv
|
||||
```
|
||||
|
||||
### 3. Docker Desktop
|
||||
|
||||
- 下载:https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe
|
||||
- 安装:默认,装完**重启电脑**。
|
||||
- 重启后打开 Docker Desktop,等左下角状态变绿。
|
||||
|
||||
### 4. 后端启动
|
||||
|
||||
```cmd
|
||||
cd meijiaka-zy\python-api
|
||||
|
||||
:: 安装依赖
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
:: 复制环境变量
|
||||
copy .env.example .env
|
||||
|
||||
:: 启动数据库
|
||||
docker compose -f docker-compose.test.yml up -d db redis
|
||||
|
||||
:: 数据库迁移
|
||||
alembic upgrade head
|
||||
|
||||
:: 启动 API(终端 1)
|
||||
make run
|
||||
:: 或:uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
如果需要异步调度器(脚本/TTS/字幕生成等),另开终端:
|
||||
|
||||
```cmd
|
||||
cd meijiaka-zy\python-api
|
||||
make scheduler
|
||||
:: 或:python -m app.scheduler.main
|
||||
```
|
||||
|
||||
### 5. 前端启动(连本地后端)
|
||||
|
||||
```cmd
|
||||
cd meijiaka-zy\tauri-app
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
前端 Vite 开发服务器会代理 API 请求到 `localhost:8000`。如果代理异常,检查 `tauri-app/src/api/client.ts` 中的 `PYTHON_API_BASE_URL`。
|
||||
|
||||
---
|
||||
|
||||
## 五、验证清单
|
||||
|
||||
全部装完后,在 cmd 里执行:
|
||||
|
||||
```cmd
|
||||
git --version
|
||||
node -v
|
||||
npm -v
|
||||
rustc --version
|
||||
cargo --version
|
||||
python --version
|
||||
uv --version
|
||||
docker --version
|
||||
```
|
||||
|
||||
每个都要有版本号输出。
|
||||
|
||||
---
|
||||
|
||||
## 六、常见问题
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|------|------|------|
|
||||
| `npm ci` 卡住 | 镜像没配好 | 检查 `npm config get registry` 是否为 `registry.npmmirror.com` |
|
||||
| `cargo build` 卡住 | Rust 镜像没生效 | 关闭 cmd 重新打开,或检查 `%USERPROFILE%\.cargo\config.toml` |
|
||||
| Tauri 编译报错 `link.exe not found` | VS Build Tools 没装 C++ 桌面开发 | 重装,确保勾选了该工作负载 |
|
||||
| `tauri dev` 白屏 | 前端代理地址错误 | 检查 `client.ts` 里的 base URL |
|
||||
| Docker 启动失败 | WSL2 未启用 | 控制面板 → 程序和功能 → 启用 Windows 功能 → 勾选 **适用于 Linux 的 Windows 子系统** |
|
||||
| `python` 命令找不到 | 安装时没勾选 Add to PATH | 重装 Python,务必勾选 |
|
||||
| `alembic` 命令找不到 | 没在虚拟环境里 | 确保在 `python-api` 目录下执行,`uv pip install` 已经装了 |
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
# === 基础配置 ===
|
||||
APP_NAME=美家卡智影 API
|
||||
APP_VERSION=1.5.18
|
||||
APP_VERSION=1.6.6
|
||||
# ⚠️ 生产环境必须设为 false
|
||||
DEBUG=true
|
||||
ENV=development
|
||||
@@ -79,4 +79,4 @@ SMS_BASE_URL=https://bjksmtn.b2m.cn/inter/sendSingleSMS
|
||||
|
||||
# === 日志配置 ===
|
||||
# 生产环境建议 INFO
|
||||
LOG_LEVEL=DEBUG
|
||||
LOG_LEVEL=ERROR
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:media-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1778077071}, "http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:img-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776433218}}
|
||||
{"http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:media-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1779898302}, "http:Pn60lJXcaOGKvMjn5qv-OMr7wR1lp1p8QG7Ul6NK:img-liche": {"upHosts": ["http://upload-z2.qiniup.com", "http://up-z2.qiniup.com"], "ioHosts": ["http://iovip-z2.qbox.me"], "rsHosts": ["http://rs-z2.qbox.me"], "rsfHosts": ["http://rsf-z2.qbox.me"], "apiHosts": ["http://api-z2.qiniu.com"], "deadline": 1776433218}}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""make_bgm_music_url_non_nullable
|
||||
|
||||
Revision ID: 7149f61a2f9c
|
||||
Revises: 7172a476e5b2
|
||||
Create Date: 2026-05-21 10:45:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7149f61a2f9c'
|
||||
down_revision: Union[str, Sequence[str], None] = '100366516fbd'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# BGM 云端化改造后,url 字段为必填(七牛云 CDN 地址)
|
||||
op.alter_column(
|
||||
'mjk_bgm_musics',
|
||||
'url',
|
||||
existing_type=sa.String(length=1024),
|
||||
nullable=False,
|
||||
comment='七牛云 URL',
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.alter_column(
|
||||
'mjk_bgm_musics',
|
||||
'url',
|
||||
existing_type=sa.String(length=1024),
|
||||
nullable=True,
|
||||
comment='七牛云 URL',
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add filename to release_package unique constraint
|
||||
|
||||
Revision ID: 7d855b38fe83
|
||||
Revises: 8d901bc90e67
|
||||
Create Date: 2026-05-26 22:55:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7d855b38fe83'
|
||||
down_revision: Union[str, Sequence[str], None] = '8d901bc90e67'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# 删除旧约束(release_id + platform + architecture)
|
||||
op.drop_constraint('uix_app_pkg_platform_arch', 'mjk_app_release_packages', type_='unique')
|
||||
# 创建新约束(release_id + platform + architecture + filename)
|
||||
op.create_unique_constraint(
|
||||
'uix_app_pkg_platform_arch_filename',
|
||||
'mjk_app_release_packages',
|
||||
['release_id', 'platform', 'architecture', 'filename']
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# 删除新约束
|
||||
op.drop_constraint('uix_app_pkg_platform_arch_filename', 'mjk_app_release_packages', type_='unique')
|
||||
# 恢复旧约束
|
||||
op.create_unique_constraint(
|
||||
'uix_app_pkg_platform_arch',
|
||||
'mjk_app_release_packages',
|
||||
['release_id', 'platform', 'architecture']
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""rename mjk_release_packages to mjk_app_release_packages
|
||||
|
||||
Revision ID: 8d901bc90e67
|
||||
Revises: 7149f61a2f9c
|
||||
Create Date: 2026-05-26 10:05:16.921079
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '8d901bc90e67'
|
||||
down_revision: Union[str, Sequence[str], None] = '7149f61a2f9c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.execute("ALTER TABLE IF EXISTS mjk_release_packages RENAME TO mjk_app_release_packages")
|
||||
op.execute("ALTER INDEX IF EXISTS uix_pkg_platform_arch RENAME TO uix_app_pkg_platform_arch")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.execute("ALTER INDEX IF EXISTS uix_app_pkg_platform_arch RENAME TO uix_pkg_platform_arch")
|
||||
op.execute("ALTER TABLE IF EXISTS mjk_app_release_packages RENAME TO mjk_release_packages")
|
||||
@@ -218,24 +218,35 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
|
||||
callback_url: str | None = None,
|
||||
) -> bool:
|
||||
"""验证 Vidu 回调 HMAC-SHA256 签名"""
|
||||
signature = headers.get("X-HMAC-SIGNATURE")
|
||||
algorithm = headers.get("X-HMAC-ALGORITHM")
|
||||
access_key = headers.get("X-HMAC-ACCESS-KEY")
|
||||
signed_headers_str = headers.get("X-HMAC-SIGNED-HEADERS")
|
||||
date = headers.get("Date")
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# HTTP 头大小写不敏感:建立小写 key 的查找表
|
||||
headers_lower = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
signature = headers_lower.get("x-hmac-signature")
|
||||
algorithm = headers_lower.get("x-hmac-algorithm")
|
||||
access_key = headers_lower.get("x-hmac-access-key")
|
||||
signed_headers_str = headers_lower.get("x-hmac-signed-headers")
|
||||
date = headers_lower.get("date")
|
||||
|
||||
if not all([signature, algorithm, access_key, signed_headers_str, date]):
|
||||
logger.warning(f"[Vidu] 签名验证失败: 缺少必要头, headers={list(headers.keys())}")
|
||||
return False
|
||||
if algorithm != "hmac-sha256":
|
||||
logger.warning(f"[Vidu] 签名验证失败: 不支持的算法 {algorithm}")
|
||||
return False
|
||||
if access_key != "vidu":
|
||||
logger.warning(f"[Vidu] 签名验证失败: access_key 不匹配 {access_key}")
|
||||
return False
|
||||
|
||||
header_names = [h.strip() for h in signed_headers_str.split(";") if h.strip()]
|
||||
header_values: dict[str, str] = {}
|
||||
for name in header_names:
|
||||
value = headers.get(name)
|
||||
# 签名头名也可能大小写不一致,统一用小写查找
|
||||
value = headers_lower.get(name.lower())
|
||||
if value is None:
|
||||
logger.warning(f"[Vidu] 签名验证失败: 缺少签名头 {name}")
|
||||
return False
|
||||
header_values[name] = value
|
||||
|
||||
@@ -258,7 +269,15 @@ class ViduAdapter(PlatformAdapter, SyncCapable, TaskCapable, CallbackCapable):
|
||||
hmac.new(secret.encode("utf-8"), signing_string.encode("utf-8"), hashlib.sha256).digest()
|
||||
).decode("utf-8")
|
||||
|
||||
return hmac.compare_digest(signature, expected)
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
logger.warning(
|
||||
f"[Vidu] 签名验证失败: callback_url={callback_url}, "
|
||||
f"signing_string={repr(signing_string)}, "
|
||||
f"expected={expected[:20]}..., received={signature[:20]}..."
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def verify_nonce(
|
||||
self,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把书面提问话术改成抖音接地气口播大白话,不改变每个环节询问的项目、品牌、工艺、收费、责任划分等核心信息,全部细节原样保留。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解环节完整、节奏适中,不啰嗦不拖沓,适配短视频完播习惯。
|
||||
内容适配性:十大问题衔接自然,每个施工环节独立成段适配空镜分镜,直击半包业主不会询价、容易被低价套路、后期增项扯皮的核心痛点,逐条给到可直接照着问的实用话术。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修报价注意事项、评论区扣关键词领资料的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修报价注意事项、评论区回复关键词领资料的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇沿用原文真实吐槽语气,3 秒抓眼球,点破半包业主盲目报面积询价、被装修公司当成新手宰割的现状,瞬间引发准备半包装修业主共鸣。
|
||||
全程口语化大白话,小白一听就懂、可直接照搬拿去问装修公司,站业主立场拆解半包询价所有关键点,条理清晰干货满满,不生硬说教,贴合口播传播节奏。
|
||||
@@ -38,7 +38,7 @@
|
||||
第四,吊顶,问用的是木龙骨还是轻钢龙骨?石膏板是什么牌子的?做单层还是做双层?七字拐、八字缝有没有做?
|
||||
第五,砌墙,问墙固用什么牌子,是油工刷还是开工就刷?挂网是局部还是全屋挂网?全挂要不要加钱?腻子的话,我只认国产一线品牌,其他我都不要。墙顶面我只要顺平就好,柜子后面、踢脚线、门口、窗口局部都要找平就行。乳胶漆用的是什么牌子,有没有刷底漆?是刷几遍,都要给我备注上。
|
||||
最后,装修用的材料,如果发现是以次充好,该怎么赔?工人安全是谁来负责?工期耽误了又该怎么赔?施工不达标,要不要整改?整改费用谁出?
|
||||
这些问题你不搞清楚,后期肯定扯皮。我整理了装修报价注意事项,评论区抠报价,拿去用
|
||||
这些问题你不搞清楚,后期肯定扯皮。我整理了装修报价注意事项,评论区回复报价,拿去用
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
文案调整要求:仅做口语化精简优化,把直白问句改成顺口口播表述,不替换、不删减任何品牌,保持每类主材三个推荐品牌完整不变,原意丝毫不改。
|
||||
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 170-190 字,按每秒 4 个字计算,对应时长 42.5-47.5s,内容精炼、节奏紧凑,适配短平快知识口播。
|
||||
内容适配性:打乱顺序后语句依然衔接自然,每条独立清晰,直接给到可照搬的主材品牌清单,解决业主选材纠结、怕踩坑的核心痛点,实用性拉满。
|
||||
结尾范式:完整保留原文结尾引导原话,仅可轻微优化口语流畅度,不改动评论区扣关键词、领取材料推荐清单的核心引流逻辑。
|
||||
结尾范式:完整保留原文结尾引导原话,仅可轻微优化口语流畅度,不改动评论区回复关键词、领取材料推荐清单的核心引流逻辑。
|
||||
【开篇 & 语言要求】
|
||||
无开篇铺垫,直接切入主材品牌推荐干货;全程短句口语化、接地气,直白罗列品牌,简单好记、业主可直接收藏对照选材。
|
||||
可微调句式语序,严禁替换、删减任意主材品牌,不改变推荐逻辑和原意,语句简短利落,适配短时长口播节奏。
|
||||
@@ -44,7 +44,7 @@
|
||||
瓷砖胶买谁家?德高、大禹、神工。
|
||||
乳胶漆买谁家?立邦、多乐士、三棵树。
|
||||
玻璃胶买谁家?瓦克、西卡、百得。
|
||||
记不住的,我这里有材料推荐清单,评论区扣材料,直接拿走。
|
||||
记不住的,我这里有材料推荐清单,评论区回复材料,直接拿走。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
文案调整要求:仅做口语化精简优化,把问句改成顺口口播表述,不删减、不替换任何一个品牌名称,完整保留每品类三大推荐品牌,原意丝毫不变。
|
||||
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 220-240 字,按每秒 4 个字核算,对应时长 55-60s,内容精炼紧凑、节奏适中,适配短平快知识口播。
|
||||
内容适配性:打乱顺序后语句衔接自然,逐条清晰罗列,业主可直接对照抄作业选品牌,解决选材纠结、怕踩坑、不会分辨好坏的核心痛点,实用性极强。
|
||||
结尾范式:完整保留原文结尾引导原话,仅轻微优化口语流畅度,不改动新房装修人群定位、评论区扣关键词领取装修避坑手册的核心引流逻辑。
|
||||
结尾范式:完整保留原文结尾引导原话,仅轻微优化口语流畅度,不改动新房装修人群定位、评论区回复关键词领取装修避坑手册的核心引流逻辑。
|
||||
【开篇 & 语言要求】
|
||||
无开篇铺垫,直接切入品牌推荐干货;全程短句大白话、接地气,直白罗列靠谱品牌,简单好记、装修可直接照搬参考。
|
||||
可微调句式语序,严禁改动、删减、替换任意品类及对应品牌,不改变推荐逻辑与原意,语句简短利落,适配中短时长口播节奏。
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
第六,材料假一罚十,品牌型号对好,你确认后再施工。防止装修公司以次充好,偷换材料。
|
||||
第七,甲醛检测不合格,装修公司整改并承担所有费用。避免入住后甲醛超标,维权无门。
|
||||
第八,违约责任划清楚,违约金和逾期赔付金额写明白。保障自己权益,让装修公司不敢随意违约。
|
||||
准备装修的,我整理了合同模板,评论区扣装修就能领!帮你装修少踩坑、省麻烦!
|
||||
准备装修的,我整理了合同模板,评论区回复装修就能领!帮你装修少踩坑、省麻烦!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
全程口语化大白话,小白易懂,不生硬说教,站业主共情立场,贴合原文口语化风格。
|
||||
可微调句式,不得篡改原文中工期、赔偿金比例、付款节点、材料条款等核心数字数据,每句必须带标点断句。
|
||||
【细节固定要求】
|
||||
结尾必须固定话术:我整理了装修全流程避坑指南,抠合同直接拿走。同时保留原文结尾“记不住的,我整理了装修合同样本,评论区抠合同,直接拿着对照检查,少踩坑!”
|
||||
结尾必须固定话术:我整理了装修全流程避坑指南,抠合同直接拿走。同时保留原文结尾“记不住的,我整理了装修合同样本,评论区回复合同,直接拿着对照检查,少踩坑!”
|
||||
总分镜数量固定12–20个,每个分镜时长3–8秒,可保留两位小数。
|
||||
【内置固定原文案】
|
||||
新房装修签合同千万注意这6个点,玩的都是文字游戏,耐心听我讲完,少踩一个坑等于多赚一笔钱。
|
||||
@@ -31,7 +31,7 @@
|
||||
第四,材料调换坑。很多公司条款上面写着,当材料断货时,可用同等价钱调换,但有这条,偷工减料就成了理所当然。同价产品很难界定,同价的杂牌你敢用吗?这条必须划掉。
|
||||
第五,安全责任。有80%的公司只写按安全标准施工,但别不提出事谁负责?一旦发生安全事故,就是扯不完的皮。合同里必须注明工人人身安全及财产损失全部由装修公司承担。
|
||||
第六,也是最恶心的一点,很多公司把单方面解约违约金写得很高,他们根本不会主动解约,这条就是为了绑死你。违约金超过20%,你发现问题也不敢换人,所以超过20%直接拉黑,别犹豫。
|
||||
记不住的,我整理了装修合同样本,评论区抠合同,直接拿走对照检查,少踩坑!
|
||||
记不住的,我整理了装修合同样本,评论区回复合同,直接拿走对照检查,少踩坑!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把书面合同话术改成抖音口播接地气大白话,不改变违约金比例、付款节点金额、备注 5 条硬性约定等所有核心数字和规则,完整保留原文原意。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解条款细致不啰嗦,节奏适中,适配短视频完播率。
|
||||
内容适配性:三大要点及备注条款衔接自然,每部分独立适配空镜分镜,直击业主签约被套路、后期加价维权难的核心痛点,每一条都讲清陷阱、整改方法和保障作用,实用性极强。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修合同模板、评论区扣关键词引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修合同模板、评论区回复关键词引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇沿用原文扎心吐槽语气,3 秒抓眼球,点破装修签合同前后身份反差、低价全包套路深坑,瞬间引发准备装修业主共鸣。
|
||||
全程口语化大白话,通俗易懂、接地气,站业主立场拆解合同陷阱,条理清晰、干货满满,不生硬说教,适配口播传播节奏。
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
第五,贴砖时要在场,检查平整度空鼓率,阴阳角方正、缝隙均匀才合格。
|
||||
第六,木工吊顶必在场,拐角整板、接缝做 V 型槽,杜绝后期乳胶漆开裂。
|
||||
第七,刮腻子一定要在场,严禁往腻子加胶水,不然甲醛超标变毒气房。
|
||||
准备装修的朋友,我整理了避坑手册,评论区扣避坑直接领取参考!
|
||||
准备装修的朋友,我整理了避坑手册,评论区回复避坑直接领取参考!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把书面表述改成抖音接地气口播大白话,不改变每个节点的施工要求、到场必要性、后期隐患,所有细节完整保留。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 360-440 字,按每秒 4 个纯文字计算,对应时长 90-110s,内容精炼不啰嗦,节奏适中符合短视频完播习惯。
|
||||
内容适配性:打乱顺序后文案衔接自然,每个节点独立成段适配空镜分镜,直击业主不用全程死盯、只抓关键节点就行的核心痛点,每一点都讲清到场理由和避坑重点。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区扣关键词、福利引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区回复关键词、福利引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇完整沿用原文开头朴实话术,3 秒抓眼球,点破全程监工又累又没用的现实,引出只盯关键节点的核心观点。
|
||||
全程口语化大白话,小白易懂、接地气实在,站普通业主视角共情讲解,不生硬说教,语气真诚接地气。
|
||||
@@ -34,7 +34,7 @@
|
||||
第四,吊顶时,你必须在场,确认好使用的是轻钢龙骨,别让师傅偷换用木龙骨,再直接封上石膏板,后期变形发霉,等你发现那就晚了。
|
||||
第五,全屋定制安装,你必须在场,通过五金孔检查板材品质,还要叮嘱师傅做好封边,少做一步,你家都可能甲醛超标。
|
||||
第六,房子做完闭水试验,你必须亲自去楼下邻居家看看有没有漏水,如果只让师傅拍照片,你根本不知道他是什么时候拍的。真出了问题还得你来赔付。
|
||||
记不住的,我整理了装修全流程避坑手册。评论区抠避坑,拿去用。
|
||||
记不住的,我整理了装修全流程避坑手册。评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,改成抖音口播接地气大白话,不改变每个要点的施工场景、业主行为、带来的影响,完整保留原意不变。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 400-480 字,按每秒 4 个纯文字计算,对应时长 100-120s,讲解饱满不拖沓,符合短视频完播习惯。
|
||||
内容适配性:6 个要点讲解衔接自然,每点独立成段适配空镜分镜,聚焦业主不懂行乱指挥、盲目加活的通病,既讲做法又讲背后利弊,真实接地气、容易引发共鸣。
|
||||
结尾范式:以 “如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区抠避坑,拿去用。” 为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动核心逻辑。
|
||||
结尾范式:以 “如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区回复避坑,拿去用。” 为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇严格遵循核心强制规则原句,3 秒抓眼球不拖沓,用真实行业视角吐槽业主盲目干预施工的通病,贴合装修受众共情点,不偏离范式结构。
|
||||
全程口语化大白话,小白易懂、不生硬说教,站客观中立角度讲解,语气接地气有真实感,贴合口播传播特点。
|
||||
@@ -34,7 +34,7 @@
|
||||
第四,木工师傅高高兴兴来了,你却告诉他,所有接缝处都要做 V 字型槽,转角处要做到 T 字型。师傅一听就知道你是懂行的。后期墙面是不容易开裂了,又给师傅增加好多活儿。
|
||||
第五,瓦工师傅来了,懂行的业主要求把卫生间先找坡度,地漏做成回形地漏,这样不仅下水快,还好看,可这又得浪费师傅半天时间,重新找坡度。
|
||||
第六,瓦工还没结束,部分业主已经提前买好了地漏和油烟止逆阀,要求师傅一并装上。这下好了,之后安装电器的师傅想赚点外快都不行。
|
||||
如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区抠避坑,拿去用。
|
||||
如果你也准备新房装修,我整理了一份装修全流程避坑手册。评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
【核心强制规则】
|
||||
开头范式:保留原文完整开头结构与核心原意,仅微调口语语气,不篡改句意,直击全屋定制合同签完仍乱加价、套路多的痛点,引出3个必看避坑要点。
|
||||
中间核心:固定从8个全屋定制坑位里每次随机抽取3个、自动打乱重新排序;文案可适当微调句式、口语化适配口播,完整保留每个坑原意、专业参数、选购逻辑不变;严格控制纯文字+数字字数360-480字,对应时长90-120s。
|
||||
结尾范式:完整保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区扣关键词的核心逻辑。
|
||||
结尾范式:完整保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区回复关键词的核心逻辑。
|
||||
【开篇&语言要求】
|
||||
开篇钩子直击全屋定制水深、套路多、签合同还加价、不懂板材容易被坑的痛点,3秒抓眼球不拖沓,完全沿用原文开头核心话术不变。
|
||||
全程口语化大白话,小白易懂、不生硬说教,站业主共情立场,贴合原文接地气口播风格。
|
||||
@@ -24,7 +24,7 @@
|
||||
第六就是铰链,你问他什么品牌,但凡跟你说是他们自有品牌,直接让他有多远滚多远。他又不是生产队的驴,啥都能生产。多半是找小工厂代工的,别为了省那点钱,铰链就认准汉高、东泰、德蒂,每天都要开关,咱们可不能马虎。
|
||||
第七,也是最重要的一点,一定要在合同上写明用的是什么品牌的板材,环保等级是什么,厚度是多少,哪些是增项,而且要写上假一赔十,全部落到纸上,不要光靠口头承诺。
|
||||
第八,全屋定制,不管是橱柜也好,衣柜也好,一线品牌和六线品牌做出来都是一模一样的。说白了,所有全屋定制都是板材的二道贩子,咱们就找本地工厂,关键看设计和安装。
|
||||
要是还有不懂的、近期准备新房装修的朋友,我整理了一份装修避坑手册供你参考,评论区抠避坑,拿去用。
|
||||
要是还有不懂的、近期准备新房装修的朋友,我整理了一份装修避坑手册供你参考,评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
3. 厨卫水电必走顶,漏水易发现好维修,其他地方走地省材料。
|
||||
4. 验收必做水管打压30分钟无渗漏,电路测通断再签字。
|
||||
水电是隐蔽工程,紧盯施工别偷懒,别等返工才追悔莫及!
|
||||
近期准备装修的可以找我领装修避坑手册,评论区扣避坑,直接拿走。
|
||||
近期准备装修的可以找我领装修避坑手册,评论区回复避坑,直接拿走。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
文案调整要求:仅做口语化句式微调,把书面表述改成接地气口播大白话,不改动任何施工细节、工艺要求、禁忌标准,完整保留 10 条话术核心原意。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 440-480 字,按每秒 4 个纯文字计算,对应时长 110-120s,讲解饱满不拖沓,符合短视频用户完播习惯。
|
||||
内容适配性:打乱顺序后文案衔接自然,每条话术独立成点、逻辑通顺,贴合业主瓦工进场监工刚需,直击无效送礼不如专业话术管用的核心痛点,每一条都明确施工标准和避坑要点。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区扣关键词、福利引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领资料、评论区回复关键词、福利引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇完整沿用原文开头句式和吐槽语气,3 秒抓眼球,直击业主花钱送礼无效监工的通病,引出专业监工话术。
|
||||
全程口语化大白话,接地气、通俗易懂,站装修业主视角共情讲解,不生硬说教。
|
||||
@@ -42,7 +42,7 @@
|
||||
第八句,师傅,所有的转角都要海棠角,后期我要做美缝,千万别给我做阳角条。
|
||||
第九句,师傅需要贴止逆阀的地方一定要帮我贴一块整砖。我的止逆阀也买回来,你按这个开孔以后,顺手帮我装上吧。
|
||||
第十句,师傅,我家橱柜和浴室柜不打算装挡水条,所以对墙面阴阳角的垂直度要求比较高,麻烦你上点心啊。
|
||||
准备新房装修的朋友,我整理了装修全流程避坑手册。评论区抠避坑,拿去用。
|
||||
准备新房装修的朋友,我整理了装修全流程避坑手册。评论区回复避坑,拿去用。
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
2. 文案调整要求:微调仅针对句式口语化优化,比如将书面化表述改为抖音/视频号口播常用的接地气语气,补充轻微危害提示(结合美缝反碱脱落、水电标识撕毁的隐患),不改变每个坑的核心信息——如验收等待五六天、美缝等待一周、禁止洒水、保留水电标识、定制复尺周期一个月等核心时间节点和禁忌,所有细节完全保留,贴合原文原意。
|
||||
3. 字数与时长控制:纯文字+数字(扣除标点)严格控制在400-480字,按每秒4个纯文字计算,对应时长100-120s,既保证每个避坑点讲解透彻,补充必要危害提示,又不拖沓,符合短视频用户观看习惯,避免用户划走。
|
||||
4. 内容适配性:5个避坑要点讲解时需衔接自然,每个坑独立成段(分镜对应空镜),不重复、不冗余,重点突出“停工避坑”核心,贴合业主担心被装修公司催促、怕后期出问题自己担责、想合理利用停工时间的核心痛点,每段讲解都紧扣“为什么不能做、怎么做才对”的逻辑,与原文保持一致,结合参考内容完善危害提示,增强说服力。
|
||||
结尾范式:以“如果你们也在准备新房装修,不知道还有哪些坑要避,评论区扣 ‘装修’,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!”为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区扣关键词、关注引导的核心逻辑。
|
||||
结尾范式:以“如果你们也在准备新房装修,不知道还有哪些坑要避,评论区回复 ‘装修’,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!”为核心句式,保留原文结尾结构和领资料引导话术,仅可轻微优化口语流畅度,不改动领福利、评论区回复关键词、关注引导的核心逻辑。
|
||||
【开篇&语言要求】
|
||||
开篇严格遵循核心强制规则的警示性句式,3秒抓眼球不拖沓,用犀利语气点出瓷砖铺贴后被催工期、盲目施工后期担责的痛点,贴合装修业主避坑需求,不偏离范式结构。
|
||||
全程口语化大白话,小白易懂、不生硬说教,站业主共情立场,用警示性语气讲解,贴合口播传播特点,增强代入感,补充的危害提示通俗易懂,让业主清晰了解违规操作的后果。
|
||||
@@ -32,7 +32,7 @@
|
||||
第三,瓷砖铺完后千万不要洒水,你洒水养护的是下面的水泥砂浆,那活儿,瓦工铺的时候就应该把墙面地面打湿再贴,铺完了再打扫干净,盖好保护膜就可以了,别多此一举。
|
||||
第四,墙面的水电标识贴不要撕,这是给后期安装师傅看的。你一撕,人家打孔打到水管电线,你就等着哭吧,不仅维修麻烦,还可能引发安全隐患。
|
||||
最后,停工这几天也别闲着。闲着你就可以让定制商家上门复尺,提前下单,定制周期差不多一个月,到时候你家油工结束了,这些东西正好能装,一点儿不耽误工期。
|
||||
如果你们也在准备新房装修,不知道还有哪些坑要避,评论区扣 “装修”,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!
|
||||
如果你们也在准备新房装修,不知道还有哪些坑要避,评论区回复 “装修”,我把整理好的装修避坑手册,免费发给你们,帮你们省时间、省钱!记得关注我,装修不踩坑!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
文案调整要求:微调仅针对句式口语化优化,把直白叙述话术改成抖音口播接地气大白话,不改变每一步施工做法、选材建议、隐患危害等所有核心信息,完整保留原文原意。
|
||||
字数与时长控制:纯文字 + 数字(扣除标点)严格控制在 440-480 字,按每秒 4 个纯文字计算,对应时长 110-120s,讲解收尾细节细致不啰嗦,节奏适中,适配短视频完播率。
|
||||
内容适配性:7 个收尾要点衔接自然,每一条独立适配空镜分镜,直击业主硬装完工急于入住、忽略隐蔽收尾细节,后期返工闹心的核心痛点,每一条都讲清做法、原因和避坑作用,实用性极强。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修全流程避坑手册、评论区扣关键词引导的核心逻辑。
|
||||
结尾范式:完整保留原文结尾原话,仅可轻微优化口语流畅度,不改动领取装修全流程避坑手册、评论区回复关键词引导的核心逻辑。
|
||||
【开篇 & 语言要求】
|
||||
开篇沿用原文警示吐槽语气,3 秒抓眼球,点破硬装刚结束着急搬软装、忽略收尾细节入住就留隐患闹矛盾的真实痛点,瞬间引发装修完工业主共鸣。
|
||||
全程口语化大白话,通俗易懂、接地气,站业主立场拆解装修收尾细节,条理清晰、干货满满,不生硬说教,适配口播传播节奏。
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
第四、门口、踢脚线、衣柜周围重点找平,别留难看缝隙。
|
||||
第五、吊顶钉子眼一定要人工刷防锈漆,防止后期生锈难看。
|
||||
第六、油工验收合格再给钱,面子工程必须把好质量关。
|
||||
准备装修的朋友,评论区扣避坑直接领取装修流程避坑手册!直接拿着对照参考,少踩坑!
|
||||
准备装修的朋友,评论区回复避坑直接领取装修流程避坑手册!直接拿着对照参考,少踩坑!
|
||||
【内置完整素材库标题】
|
||||
合同签署
|
||||
卧室原始结构-毛坯基础
|
||||
|
||||
@@ -50,15 +50,20 @@ async def check_update(
|
||||
if latest.version == version:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
# 查询对应平台的包
|
||||
# 查询对应平台的包(优先返回 updater 用的包:有 signature 的 .app.tar.gz / .exe)
|
||||
result = await db.execute(
|
||||
select(ReleasePackage).where(
|
||||
ReleasePackage.release_id == latest.id,
|
||||
ReleasePackage.platform == target,
|
||||
ReleasePackage.architecture == arch,
|
||||
).order_by(
|
||||
# 有 signature 的排前面(updater 包),空 signature 的排后面(dmg 安装包)
|
||||
ReleasePackage.signature.desc()
|
||||
)
|
||||
)
|
||||
pkg: ReleasePackage | None = result.scalar_one_or_none()
|
||||
pkgs = result.scalars().all()
|
||||
# 取第一个:优先有 signature 的 updater 包;如果没有则取任意一个
|
||||
pkg: ReleasePackage | None = pkgs[0] if pkgs else None
|
||||
|
||||
if not pkg:
|
||||
# 该平台无包,返回 204(避免报错阻断用户)
|
||||
|
||||
@@ -43,7 +43,11 @@ async def vidu_callback(request: Request):
|
||||
body_bytes = await request.body()
|
||||
headers_dict = dict(request.headers)
|
||||
|
||||
logger.info(f"[Vidu] 收到回调: url={request.url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}")
|
||||
# 使用 APP_BASE_URL 构建 callback_url,确保与提交任务时传给 Vidu 的一致
|
||||
#(Nginx 反向代理可能导致 request.url 的 scheme 为 http,与 Vidu 签名时的 https 不一致)
|
||||
app_base_url = get_settings().app_base_url
|
||||
callback_url = f"{app_base_url}/api/v1/vidu/callback" if app_base_url else str(request.url)
|
||||
logger.info(f"[Vidu] 收到回调: request_url={request.url}, callback_url={callback_url}, body={body_bytes.decode('utf-8', errors='replace')[:500]}")
|
||||
|
||||
try:
|
||||
task_status = await gateway.handle_webhook(
|
||||
@@ -51,7 +55,7 @@ async def vidu_callback(request: Request):
|
||||
headers=headers_dict,
|
||||
body=body_bytes,
|
||||
secret=get_settings().VIDU_API_KEY,
|
||||
callback_url=str(request.url),
|
||||
callback_url=callback_url,
|
||||
)
|
||||
except PlatformError as e:
|
||||
logger.warning(f"[Vidu] 回调验证失败: {e}")
|
||||
|
||||
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
|
||||
|
||||
# 应用基础配置
|
||||
APP_NAME: str = Field(default="美家卡智影 API", description="应用名称")
|
||||
APP_VERSION: str = Field(default="1.5.18", description="应用版本")
|
||||
APP_VERSION: str = Field(default="1.6.6", description="应用版本")
|
||||
DEBUG: bool = Field(default=False, description="调试模式")
|
||||
ENV: Literal["development", "staging", "production"] = Field(
|
||||
default="development", description="运行环境"
|
||||
|
||||
@@ -361,9 +361,9 @@ def main():
|
||||
workers=settings.WORKERS if not settings.DEBUG else 1,
|
||||
reload=settings.DEBUG,
|
||||
log_level=settings.LOG_LEVEL.lower(),
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
# test
|
||||
|
||||
@@ -36,8 +36,8 @@ class BgmMusic(BaseModelBigInt):
|
||||
file_path: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False, comment="相对文件路径"
|
||||
)
|
||||
url: Mapped[str | None] = mapped_column(
|
||||
String(1024), nullable=True, comment="七牛云 URL"
|
||||
url: Mapped[str] = mapped_column(
|
||||
String(1024), nullable=False, comment="七牛云 URL"
|
||||
)
|
||||
duration: Mapped[float] = mapped_column(
|
||||
Float, nullable=True, comment="时长(秒)"
|
||||
|
||||
@@ -38,7 +38,7 @@ class AppRelease(Base):
|
||||
class ReleasePackage(Base):
|
||||
"""平台安装包信息"""
|
||||
|
||||
__tablename__ = "mjk_release_packages"
|
||||
__tablename__ = "mjk_app_release_packages"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
release_id: Mapped[int] = mapped_column(
|
||||
@@ -60,5 +60,5 @@ class ReleasePackage(Base):
|
||||
release: Mapped["AppRelease"] = relationship("AppRelease", back_populates="packages")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("release_id", "platform", "architecture", name="uix_pkg_platform_arch"),
|
||||
UniqueConstraint("release_id", "platform", "architecture", "filename", name="uix_app_pkg_platform_arch_filename"),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ class BgmMusicItem(BaseModel):
|
||||
artist: str | None = Field(default=None, description="艺术家")
|
||||
category: str = Field(description="场景分类")
|
||||
file_path: str = Field(description="相对文件路径")
|
||||
url: str | None = Field(default=None, description="七牛云 URL")
|
||||
url: str = Field(description="七牛云 URL")
|
||||
duration: float | None = Field(default=None, description="时长(秒)")
|
||||
sort_order: int = Field(default=0, description="排序权重")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "meijiaka-ai-api"
|
||||
version = "1.5.18"
|
||||
version = "1.6.6"
|
||||
description = "美家卡智影 - AI 视频创作后端 API"
|
||||
authors = [{ name = "Meijiaka Team" }]
|
||||
readme = "README.md"
|
||||
|
||||
@@ -24,6 +24,9 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def find_packages(bundle_dir: Path) -> list[dict]:
|
||||
@@ -37,21 +40,30 @@ def find_packages(bundle_dir: Path) -> list[dict]:
|
||||
print(f"警告: 签名文件存在但安装包缺失: {pkg_file}")
|
||||
continue
|
||||
|
||||
# 解析文件名:{name}_{version}_{target}_{arch}.app.tar.gz
|
||||
parts = pkg_file.stem.split("_")
|
||||
# 最后一个部分可能是 arch,需要处理
|
||||
arch = "aarch64" if "aarch64" in pkg_file.name or "arm64" in pkg_file.name else "x86_64"
|
||||
|
||||
signature = sig_file.read_text().strip()
|
||||
file_size = pkg_file.stat().st_size
|
||||
filename = pkg_file.name
|
||||
|
||||
packages.append({
|
||||
"platform": "darwin",
|
||||
"architecture": arch,
|
||||
"filename": pkg_file.name,
|
||||
"local_path": str(pkg_file),
|
||||
"signature": signature,
|
||||
"file_size": pkg_file.stat().st_size,
|
||||
})
|
||||
# 判断文件名是否包含架构标识
|
||||
has_arch_marker = any(marker in filename for marker in ["aarch64", "arm64", "x86_64"])
|
||||
|
||||
if has_arch_marker:
|
||||
# 文件名含架构标识,按标识解析
|
||||
arch = "aarch64" if "aarch64" in filename or "arm64" in filename else "x86_64"
|
||||
archs = [arch]
|
||||
else:
|
||||
# Universal Binary:同时支持 x86_64 和 aarch64
|
||||
archs = ["x86_64", "aarch64"]
|
||||
|
||||
for arch in archs:
|
||||
packages.append({
|
||||
"platform": "darwin",
|
||||
"architecture": arch,
|
||||
"filename": filename,
|
||||
"local_path": str(pkg_file),
|
||||
"signature": signature,
|
||||
"file_size": file_size,
|
||||
})
|
||||
|
||||
# Windows: .exe + .exe.sig
|
||||
for sig_file in bundle_dir.rglob("*.exe.sig"):
|
||||
@@ -93,6 +105,29 @@ def find_packages(bundle_dir: Path) -> list[dict]:
|
||||
"file_size": pkg_file.stat().st_size,
|
||||
})
|
||||
|
||||
# macOS DMG: 给新用户首次安装用(无签名文件)
|
||||
for dmg_file in bundle_dir.rglob("*.dmg"):
|
||||
filename = dmg_file.name
|
||||
|
||||
# 判断文件名是否包含架构标识
|
||||
has_arch_marker = any(marker in filename for marker in ["aarch64", "arm64", "x86_64"])
|
||||
if has_arch_marker:
|
||||
arch = "aarch64" if "aarch64" in filename or "arm64" in filename else "x86_64"
|
||||
archs = [arch]
|
||||
else:
|
||||
# Universal DMG:同时支持 x86_64 和 aarch64
|
||||
archs = ["x86_64", "aarch64"]
|
||||
|
||||
for arch in archs:
|
||||
packages.append({
|
||||
"platform": "darwin",
|
||||
"architecture": arch,
|
||||
"filename": filename,
|
||||
"local_path": str(dmg_file),
|
||||
"signature": "", # DMG 无签名(非 updater 包)
|
||||
"file_size": dmg_file.stat().st_size,
|
||||
})
|
||||
|
||||
return packages
|
||||
|
||||
|
||||
@@ -165,16 +200,21 @@ def main():
|
||||
print(f" - {p['platform']}-{p['architecture']}: {p['filename']} ({p['file_size'] / 1024 / 1024:.1f} MB)")
|
||||
|
||||
# 2. 上传到七牛云(或构造 URL)
|
||||
# 按 local_path 去重,同一个文件只上传一次
|
||||
uploaded = {} # local_path -> file_url
|
||||
for p in packages:
|
||||
if args.skip_upload:
|
||||
if not args.base_url:
|
||||
print("错误: --skip-upload 时必须提供 --base-url")
|
||||
sys.exit(1)
|
||||
p["file_url"] = f"{args.base_url.rstrip('/')}/{p['filename']}"
|
||||
p["file_url"] = f"{args.base_url.rstrip('/')}/{p['platform']}/{p['filename']}"
|
||||
else:
|
||||
key = f"releases/{args.version}/{p['filename']}"
|
||||
print(f"上传 {p['filename']} 到七牛云...")
|
||||
p["file_url"] = upload_to_qiniu(p["local_path"], key)
|
||||
local_path = p["local_path"]
|
||||
if local_path not in uploaded:
|
||||
key = f"meijiaka-zy/releases/{args.version}/{p['platform']}/{p['filename']}"
|
||||
print(f"上传 {p['filename']} ({p['platform']}) 到七牛云...")
|
||||
uploaded[local_path] = upload_to_qiniu(local_path, key)
|
||||
p["file_url"] = uploaded[local_path]
|
||||
|
||||
# 删除临时字段
|
||||
del p["local_path"]
|
||||
|
||||
@@ -56,7 +56,7 @@ async def seed_bgm():
|
||||
artist=item.get("artist"),
|
||||
category=item["category"],
|
||||
file_path=item["file_path"],
|
||||
url=item.get("url"),
|
||||
url=item["url"],
|
||||
duration=item.get("duration"),
|
||||
status=item.get("status", "active"),
|
||||
sort_order=item.get("sort_order", idx),
|
||||
|
||||
Generated
+1
-1
@@ -944,7 +944,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meijiaka-ai-api"
|
||||
version = "1.5.18"
|
||||
version = "1.6.6"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_mobile TEXT := '13800138000'; -- ← 修改:手机号
|
||||
v_nickname TEXT := '新用户昵称'; -- ← 修改:昵称(可为空)
|
||||
v_mobile TEXT := '13950003857'; -- ← 修改:手机号
|
||||
v_nickname TEXT := '怀松'; -- ← 修改:昵称(可为空)
|
||||
v_source TEXT := 'manual'; -- ← 修改:注册来源:manual / invite / promotion
|
||||
v_invited_by UUID := NULL; -- ← 修改:邀请人 user_id(没有则留 NULL)
|
||||
v_gift_points INT := 2000; -- ← 修改:赠送初始积分(0 表示不赠送)
|
||||
@@ -108,8 +108,8 @@ END $$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_mobile TEXT := '13800138000'; -- ← 修改:目标用户手机号
|
||||
v_gift_points INT := 500; -- ← 修改:赠送积分数量
|
||||
v_mobile TEXT := '13860199646'; -- ← 修改:目标用户手机号
|
||||
v_gift_points INT := 5000; -- ← 修改:赠送积分数量
|
||||
v_gift_days INT := 180; -- ← 修改:有效期(天)
|
||||
v_reason TEXT := '运营活动赠送'; -- ← 修改:赠送原因(写入流水描述)
|
||||
v_user_id UUID;
|
||||
|
||||
+16
-1
@@ -224,10 +224,25 @@ def main():
|
||||
print(" - python-api/.env.example")
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
# 自动提交版本更新,确保 tag 落在正确的 commit 上
|
||||
subprocess.run(
|
||||
["git", "add", "-A"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", f"bump version to {version}"],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
print(f"✅ 已提交: bump version to {version}")
|
||||
create_git_tag(version)
|
||||
print("\n下一步:")
|
||||
print(f" git add -A && git commit -m 'bump version to {version}'")
|
||||
print(f" git push && git push origin v{version}")
|
||||
print(f" # 如果使用 GitHub Actions,同时推送到 GitHub remote:")
|
||||
print(f" git push github-new && git push github-new v{version}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
**美家卡智影**(产品名)是一款基于 Tauri v2 + React 19 + TypeScript 的桌面端 AI 视频创作应用。
|
||||
|
||||
- **产品标识**: `cn.meijiaka.ai-video`
|
||||
- **版本**: `1.5.18`
|
||||
- **版本**: `1.6.6`
|
||||
- **窗口尺寸**: 1200×800,不可缩放(`resizable: false`)
|
||||
- **核心功能**: AI 脚本生成、AI 配音合成、视频生成、压制成片(FFmpeg)、项目本地持久化
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"version": "1.5.18",
|
||||
"version": "1.6.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tauri-app",
|
||||
"version": "1.5.18",
|
||||
"version": "1.6.6",
|
||||
"dependencies": {
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"@tanstack/react-virtual": "^3.13.23",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "tauri-app",
|
||||
"private": true,
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -5,3 +5,6 @@
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
|
||||
# Sidecar binaries (FFmpeg / ffprobe) — large platform-specific executables
|
||||
/binaries/
|
||||
|
||||
Generated
+2
-1
@@ -4219,12 +4219,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-app"
|
||||
version = "1.6.0"
|
||||
version = "1.6.6"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"fs2",
|
||||
"log",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "tauri-app"
|
||||
version = "1.6.0"
|
||||
version = "1.6.6"
|
||||
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"
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"dialog:allow-open",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"updater:default"
|
||||
"updater:default",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -219,8 +219,9 @@ pub async fn delete_local_product(path: String) -> ApiResponse<()> {
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
let app_data_canonical = app_data.canonicalize().unwrap_or_else(|_| app_data.clone());
|
||||
|
||||
if !canonical.starts_with(app_data) {
|
||||
if !canonical.starts_with(&app_data_canonical) {
|
||||
return ApiResponse {
|
||||
code: 400,
|
||||
message: "路径不在允许范围内".to_string(),
|
||||
@@ -279,8 +280,9 @@ pub async fn rename_local_product(path: String, new_filename: String) -> ApiResp
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
let app_data_canonical = app_data.canonicalize().unwrap_or_else(|_| app_data.clone());
|
||||
|
||||
if !canonical.starts_with(app_data) {
|
||||
if !canonical.starts_with(&app_data_canonical) {
|
||||
return ApiResponse {
|
||||
code: 400,
|
||||
message: "路径不在允许范围内".to_string(),
|
||||
@@ -359,8 +361,9 @@ pub async fn export_product(source_path: String, target_path: String) -> ApiResp
|
||||
data: None,
|
||||
},
|
||||
};
|
||||
let app_data_canonical = app_data.canonicalize().unwrap_or_else(|_| app_data.clone());
|
||||
|
||||
if !source_canonical.starts_with(app_data) {
|
||||
if !source_canonical.starts_with(&app_data_canonical) {
|
||||
return ApiResponse {
|
||||
code: 400,
|
||||
message: "源文件不在允许范围内".to_string(),
|
||||
|
||||
@@ -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)]
|
||||
@@ -19,11 +24,42 @@ struct PhaseInfo {
|
||||
total: u8,
|
||||
}
|
||||
|
||||
/// FFmpeg 路径转义(替换 `:` 为 `\:`,替换 `'` 为 `'\''`)
|
||||
/// FFmpeg concat 列表文件中的路径转义(单引号用 `'\''` 包裹转义)
|
||||
pub fn escape_ffmpeg_path(path: &str) -> String {
|
||||
// 去掉 Windows UNC 前缀(\\?\)
|
||||
let path = if path.starts_with(r"\\?\") {
|
||||
&path[4..]
|
||||
} else {
|
||||
path
|
||||
};
|
||||
// concat 列表中路径被单引号包裹,需要将单引号转义为 '\''
|
||||
path.replace("'", "'\\''")
|
||||
}
|
||||
|
||||
/// FFmpeg filter 字符串中的路径转义(用于单引号包裹的字符串内部)
|
||||
fn escape_ffmpeg_filter_path(path: &str) -> String {
|
||||
// 去掉 Windows UNC 前缀(\\?\)
|
||||
let path = if path.starts_with(r"\\?\") {
|
||||
&path[4..]
|
||||
} else {
|
||||
path
|
||||
};
|
||||
// 使用正斜杠,避免反斜杠在 filter 字符串中被当作转义符
|
||||
let path = path.replace('\\', "/");
|
||||
// 单引号内需要将 ' 转义为 \'
|
||||
path.replace("'", "\\'")
|
||||
}
|
||||
|
||||
/// 去掉 Windows UNC 前缀(\\?\),使 convertFileSrc 能正确生成 asset:// URL
|
||||
fn normalize_path(path: &std::path::Path) -> String {
|
||||
let s = path.to_string_lossy().to_string();
|
||||
if s.starts_with(r"\\?\") {
|
||||
std::path::PathBuf::from(&s[4..]).to_string_lossy().to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证路径在允许的目录内,防止路径遍历攻击
|
||||
/// 允许的目录:应用数据目录(app_local_data_dir)
|
||||
fn validate_safe_path(path: &str) -> Result<String, String> {
|
||||
@@ -46,8 +82,13 @@ fn validate_safe_path(path: &str) -> Result<String, String> {
|
||||
let canonical = abs_path.canonicalize()
|
||||
.unwrap_or(abs_path.clone());
|
||||
|
||||
// 同时规范化允许目录(Windows 上 canonicalize 会添加 \\?\ 前缀,
|
||||
// 必须与输入路径保持一致的规范化格式才能正确比较)
|
||||
let allowed_canonical = allowed_dir.canonicalize()
|
||||
.unwrap_or(allowed_dir.clone());
|
||||
|
||||
// 检查是否在允许目录下
|
||||
if !canonical.starts_with(allowed_dir) {
|
||||
if !canonical.starts_with(&allowed_canonical) {
|
||||
return Err(format!("路径不在允许目录内: {}", path.display()));
|
||||
}
|
||||
|
||||
@@ -82,9 +123,23 @@ pub fn sanitize_output_path(path: &str) -> Result<String, String> {
|
||||
* 使用 Tauri 官方 sidecar API,自动处理开发/生产环境的 sidecar 查找。
|
||||
*/
|
||||
pub async fn run_ffmpeg(app: &AppHandle, args: Vec<String>) -> Result<String, String> {
|
||||
let (mut rx, child) = app.shell()
|
||||
run_ffmpeg_in_dir(app, args, None).await
|
||||
}
|
||||
|
||||
pub async fn run_ffmpeg_in_dir(
|
||||
app: &AppHandle,
|
||||
args: Vec<String>,
|
||||
current_dir: Option<&std::path::Path>,
|
||||
) -> Result<String, String> {
|
||||
let cmd = app.shell()
|
||||
.sidecar("ffmpeg")
|
||||
.map_err(|e| format!("Failed to find ffmpeg sidecar: {e}"))?
|
||||
.map_err(|e| format!("Failed to find ffmpeg sidecar: {e}"))?;
|
||||
let cmd = if let Some(dir) = current_dir {
|
||||
cmd.current_dir(dir)
|
||||
} else {
|
||||
cmd
|
||||
};
|
||||
let (mut rx, child) = cmd
|
||||
.args(args)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to spawn ffmpeg: {e}"))?;
|
||||
@@ -100,6 +155,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 +170,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()
|
||||
@@ -342,6 +403,58 @@ fn get_fonts_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
|
||||
* 使用抖音美好体 Bold (DouyinSans Bold) 作为默认字体。
|
||||
* 支持可选的 PNG overlay(用于大标题/小标题,效果与前端 Canvas 预览一致)。
|
||||
*/
|
||||
/// 为 FFmpeg 字幕压制准备临时工作目录,将 ASS 文件和字体复制到该目录,
|
||||
/// 返回 (临时目录路径, ASS 相对文件名, filter 字符串)
|
||||
fn prepare_subtitle_work_dir(
|
||||
safe_ass: &str,
|
||||
app: &AppHandle,
|
||||
) -> Result<(std::path::PathBuf, String, String), String> {
|
||||
let ass_path = std::path::Path::new(safe_ass);
|
||||
let ass_file_name = ass_path
|
||||
.file_name()
|
||||
.ok_or("无效的 ASS 文件路径")?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// 创建临时目录(系统临时目录下)
|
||||
let temp_dir = std::env::temp_dir().join(format!(
|
||||
"meijiaka_subtitle_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis()
|
||||
));
|
||||
std::fs::create_dir_all(&temp_dir)
|
||||
.map_err(|e| format!("创建临时目录失败: {}", e))?;
|
||||
|
||||
// 复制 ASS 文件到临时目录
|
||||
let temp_ass = temp_dir.join(&ass_file_name);
|
||||
if let Err(e) = std::fs::copy(safe_ass, &temp_ass) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("复制 ASS 文件到临时目录失败: {}", e));
|
||||
}
|
||||
|
||||
// 复制字体文件到临时目录
|
||||
if let Ok(fonts_dir) = get_fonts_dir(app) {
|
||||
if let Ok(entries) = std::fs::read_dir(&fonts_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(ext) = path.extension() {
|
||||
let ext = ext.to_string_lossy().to_lowercase();
|
||||
if ext == "ttf" || ext == "otf" || ext == "woff" || ext == "woff2" {
|
||||
let _ = std::fs::copy(&path, temp_dir.join(path.file_name().unwrap_or_default()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用相对路径的 filter 字符串(无 C: 盘符问题)
|
||||
let filter = format!("subtitles='{}'", escape_ffmpeg_filter_path(&ass_file_name));
|
||||
|
||||
Ok((temp_dir, ass_file_name, filter))
|
||||
}
|
||||
|
||||
pub async fn burn_ass_subtitle(
|
||||
app: &AppHandle,
|
||||
video_path: &str,
|
||||
@@ -358,18 +471,8 @@ pub async fn burn_ass_subtitle(
|
||||
let safe_ass = validate_safe_path(ass_path)?;
|
||||
let safe_output = sanitize_output_path(output_path)?;
|
||||
|
||||
let ass_path_escaped = escape_ffmpeg_path(&safe_ass);
|
||||
|
||||
// 构建 ASS filter(尝试带 fontsdir)
|
||||
let ass_filter = if let Ok(fonts_dir) = get_fonts_dir(app) {
|
||||
if let Some(fonts_dir_str) = fonts_dir.to_str() {
|
||||
format!("ass='{}':fontsdir='{}'", ass_path_escaped, escape_ffmpeg_path(fonts_dir_str))
|
||||
} else {
|
||||
format!("ass='{}'", ass_path_escaped)
|
||||
}
|
||||
} else {
|
||||
format!("ass='{}'", ass_path_escaped)
|
||||
};
|
||||
// 准备临时工作目录(ASS + 字体),使用相对路径避开 Windows C: 盘符问题
|
||||
let (work_dir, _ass_name, subtitle_filter) = prepare_subtitle_work_dir(&safe_ass, app)?;
|
||||
|
||||
// 如果有 overlay 图片,先 overlay 再 burn 字幕(两步避免 filter_complex 音频映射问题)
|
||||
if let Some(img_path) = overlay_image {
|
||||
@@ -393,6 +496,7 @@ pub async fn burn_ass_subtitle(
|
||||
|
||||
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 2 });
|
||||
if let Err(e) = run_ffmpeg(app, overlay_args).await {
|
||||
let _ = std::fs::remove_dir_all(&work_dir);
|
||||
let _ = std::fs::remove_file(&temp_output);
|
||||
return Err(format!("Overlay 图片失败: {}", e));
|
||||
}
|
||||
@@ -400,7 +504,7 @@ pub async fn burn_ass_subtitle(
|
||||
// Step 2: burn ASS 字幕
|
||||
let burn_args = vec![
|
||||
"-i".to_string(), temp_output.clone(),
|
||||
"-vf".to_string(), ass_filter.clone(),
|
||||
"-vf".to_string(), subtitle_filter.clone(),
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
@@ -411,40 +515,18 @@ pub async fn burn_ass_subtitle(
|
||||
];
|
||||
|
||||
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 2, total: 2 });
|
||||
let result = run_ffmpeg(app, burn_args).await;
|
||||
let result = run_ffmpeg_in_dir(app, burn_args, Some(&work_dir)).await;
|
||||
let _ = std::fs::remove_dir_all(&work_dir);
|
||||
let _ = std::fs::remove_file(&temp_output);
|
||||
return result.map(|_| ());
|
||||
}
|
||||
|
||||
// 无 overlay,走原有单步逻辑(带 fontsdir 回退)
|
||||
let filter = ass_filter.clone();
|
||||
// 无 overlay,单步 burn 字幕
|
||||
let _ = app.emit("ffmpeg-phase-start", PhaseInfo { step: 1, total: 1 });
|
||||
|
||||
let args = vec![
|
||||
"-i".to_string(), safe_video.clone(),
|
||||
"-vf".to_string(), filter,
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-c:a".to_string(), "copy".to_string(),
|
||||
"-y".to_string(),
|
||||
safe_output.clone(),
|
||||
];
|
||||
|
||||
match run_ffmpeg(app, args).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
eprintln!("[ffmpeg] 带 fontsdir 的 ASS 烧录失败,尝试回退: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 回退:不带 fontsdir
|
||||
let filter = format!("ass='{}'", ass_path_escaped);
|
||||
|
||||
let args = vec![
|
||||
"-i".to_string(), safe_video,
|
||||
"-vf".to_string(), filter,
|
||||
"-vf".to_string(), subtitle_filter,
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
@@ -454,49 +536,12 @@ pub async fn burn_ass_subtitle(
|
||||
safe_output,
|
||||
];
|
||||
|
||||
run_ffmpeg(app, args).await.map(|_| ())
|
||||
let result = run_ffmpeg_in_dir(app, args, Some(&work_dir)).await;
|
||||
let _ = std::fs::remove_dir_all(&work_dir);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
/** 压制 ASS 字幕到视频(带自定义字体目录)
|
||||
* 当前未使用,保留为扩展点。
|
||||
*/
|
||||
#[allow(dead_code)]
|
||||
pub async fn burn_ass_subtitle_with_fonts(
|
||||
app: &AppHandle,
|
||||
video_path: &str,
|
||||
ass_path: &str,
|
||||
fonts_dir: &str,
|
||||
output_path: &str,
|
||||
) -> Result<(), String> {
|
||||
// 输入路径验证:HTTP URL 直接传递,本地文件需要安全检查
|
||||
let safe_video = if video_path.starts_with("http://") || video_path.starts_with("https://") {
|
||||
video_path.to_string()
|
||||
} else {
|
||||
validate_safe_path(video_path)?
|
||||
};
|
||||
let safe_ass = validate_safe_path(ass_path)?;
|
||||
let safe_output = sanitize_output_path(output_path)?;
|
||||
|
||||
let filter = format!(
|
||||
"ass='{}':fontsdir='{}'",
|
||||
escape_ffmpeg_path(&safe_ass),
|
||||
escape_ffmpeg_path(fonts_dir)
|
||||
);
|
||||
|
||||
let args = vec![
|
||||
"-i".to_string(), safe_video,
|
||||
"-vf".to_string(), filter,
|
||||
"-c:v".to_string(), "libx264".to_string(),
|
||||
"-preset".to_string(), "medium".to_string(),
|
||||
"-crf".to_string(), "23".to_string(),
|
||||
"-pix_fmt".to_string(), "yuv420p".to_string(),
|
||||
"-c:a".to_string(), "copy".to_string(),
|
||||
"-y".to_string(),
|
||||
safe_output
|
||||
];
|
||||
|
||||
run_ffmpeg(app, args).await.map(|_| ())
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 混合背景音乐到视频(保留原视频音频)
|
||||
@@ -504,7 +549,7 @@ pub async fn burn_ass_subtitle_with_fonts(
|
||||
* 使用 FFmpeg amix 滤镜将 BGM 与原视频音频混合。
|
||||
* 如果 BGM 短于视频,会自动循环;如果长于视频,会截断到视频长度。
|
||||
*
|
||||
* 注意:bgm_path 来自应用资源目录,不做 validate_safe_path 检查。
|
||||
* BGM 路径必须是本地文件(由前端下载到 bgm_cache 后传入)。
|
||||
*/
|
||||
pub async fn mix_bgm_to_video(
|
||||
app: &AppHandle,
|
||||
@@ -515,8 +560,7 @@ pub async fn mix_bgm_to_video(
|
||||
bgm_volume: f64,
|
||||
) -> Result<(), String> {
|
||||
let safe_video = validate_safe_path(video_path)?;
|
||||
// BGM 来自应用资源目录,直接传递(路径由前端通过 Tauri path API 解析)
|
||||
let safe_bgm = bgm_path.to_string();
|
||||
let safe_bgm = validate_safe_path(bgm_path)?;
|
||||
let safe_output = sanitize_output_path(output_path)?;
|
||||
|
||||
// 构建 filter_complex:
|
||||
@@ -776,29 +820,127 @@ 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> {
|
||||
let input = std::path::Path::new(input_path);
|
||||
if !input.exists() {
|
||||
return Err(format!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
// 网络视频:先下载到本地缓存,再统一走转码/预览流程
|
||||
let path_str = if input_path.starts_with("http://") || input_path.starts_with("https://") {
|
||||
let app_data_dir = crate::storage::paths::get_app_data_dir()
|
||||
.map_err(|e| format!("无法获取应用数据目录: {}", e))?;
|
||||
let cache_dir = app_data_dir.join("video_cache");
|
||||
std::fs::create_dir_all(&cache_dir)
|
||||
.map_err(|e| format!("无法创建缓存目录: {}", e))?;
|
||||
|
||||
let path_str = input.canonicalize()
|
||||
.unwrap_or(input.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let url_hash = {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = DefaultHasher::new();
|
||||
input_path.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
};
|
||||
|
||||
// 快速检测:如果已经是 H.264 + YUV420p,直接返回原始路径(避免应用自己生成的成品重复转码)
|
||||
let download_path = cache_dir.join(format!("download_{}.mp4", url_hash));
|
||||
|
||||
if !download_path.exists() {
|
||||
log::info!("[transcode_for_preview] 下载网络视频: {} -> {}", input_path, download_path.display());
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
||||
let response = client.get(input_path)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("下载网络视频失败: {}", e))?
|
||||
.error_for_status()
|
||||
.map_err(|e| format!("下载网络视频返回错误: {}", e))?;
|
||||
let bytes = response.bytes()
|
||||
.await
|
||||
.map_err(|e| format!("读取网络视频数据失败: {}", e))?;
|
||||
// 用 spawn_blocking 避免同步写文件阻塞 tokio 运行时
|
||||
let download_path_clone = download_path.clone();
|
||||
let bytes_clone = bytes.clone();
|
||||
let write_result = tokio::task::spawn_blocking(move || {
|
||||
std::fs::write(&download_path_clone, &bytes_clone)
|
||||
})
|
||||
.await;
|
||||
if let Err(e) = write_result {
|
||||
let _ = std::fs::remove_file(&download_path);
|
||||
return Err(format!("写入任务失败: {}", e));
|
||||
}
|
||||
if let Err(e) = write_result.unwrap() {
|
||||
let _ = std::fs::remove_file(&download_path);
|
||||
return Err(format!("保存网络视频失败: {}", e));
|
||||
}
|
||||
if let Ok(file) = std::fs::File::open(&download_path) {
|
||||
let _ = file.sync_all();
|
||||
}
|
||||
log::info!("[transcode_for_preview] 下载完成: {} bytes", bytes.len());
|
||||
}
|
||||
|
||||
download_path.to_string_lossy().to_string()
|
||||
} else {
|
||||
let input = std::path::Path::new(input_path);
|
||||
if !input.exists() {
|
||||
return Err(format!("输入文件不存在: {}", input_path));
|
||||
}
|
||||
input.canonicalize()
|
||||
.unwrap_or(input.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 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(),
|
||||
];
|
||||
@@ -823,11 +965,38 @@ 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 {
|
||||
return Ok(path_str);
|
||||
|
||||
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(normalize_path(&std::path::Path::new(&path_str)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -839,10 +1008,11 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件元数据用于缓存 key
|
||||
let metadata = std::fs::metadata(input_path)
|
||||
// ============================================================
|
||||
// 2. 缓存检查
|
||||
// ============================================================
|
||||
let metadata = std::fs::metadata(&path_str)
|
||||
.map_err(|e| format!("无法读取文件元数据: {}", e))?;
|
||||
// 某些文件系统(如 FAT32)不支持修改时间,失败时回退为 0
|
||||
let mtime = metadata.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
@@ -850,7 +1020,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};
|
||||
@@ -864,9 +1033,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))?;
|
||||
@@ -875,23 +1045,58 @@ pub async fn transcode_for_preview(app: &AppHandle, input_path: &str) -> Result<
|
||||
}
|
||||
}
|
||||
|
||||
// FFmpeg 转码:H.264 Baseline + YUV420p,540p,无音频,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))?;
|
||||
|
||||
// 强制刷新文件系统缓存(文件数据 + 目录项),避免 WebKit asset:// 首次读取到不完整数据
|
||||
if let Ok(file) = std::fs::File::open(&cache_path) {
|
||||
let _ = file.sync_all();
|
||||
}
|
||||
if let Some(parent) = cache_path.parent() {
|
||||
if let Ok(dir) = std::fs::OpenOptions::new().read(true).open(parent) {
|
||||
let _ = dir.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cache_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ fn clean_video_cache(app_data_dir: &std::path::Path) {
|
||||
}
|
||||
|
||||
let max_age = std::time::Duration::from_secs(30 * 24 * 60 * 60);
|
||||
let max_total_size: u64 = 500 * 1024 * 1024;
|
||||
let target_size: u64 = 300 * 1024 * 1024;
|
||||
let max_total_size: u64 = 2 * 1024 * 1024 * 1024;
|
||||
let target_size: u64 = 1024 * 1024 * 1024;
|
||||
let now = std::time::SystemTime::now();
|
||||
|
||||
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime, u64)> = Vec::new();
|
||||
@@ -165,6 +165,133 @@ fn get_video_cache_size_cmd() -> Result<u64, String> {
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// BGM 缓存清理
|
||||
// ============================================================
|
||||
|
||||
/// 清理 bgm_cache 目录
|
||||
///
|
||||
/// 策略:和视频缓存一致
|
||||
/// 1. 删除超过 30 天未修改的文件
|
||||
/// 2. 总容量超过 200MB 时,按修改时间删最旧文件直到 100MB
|
||||
fn clean_bgm_cache(app_data_dir: &std::path::Path) {
|
||||
let cache_dir = app_data_dir.join("bgm_cache");
|
||||
if !cache_dir.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let max_age = std::time::Duration::from_secs(30 * 24 * 60 * 60);
|
||||
let max_total_size: u64 = 200 * 1024 * 1024;
|
||||
let target_size: u64 = 100 * 1024 * 1024;
|
||||
let now = std::time::SystemTime::now();
|
||||
|
||||
let mut entries: Vec<(std::path::PathBuf, std::time::SystemTime, u64)> = Vec::new();
|
||||
let mut total_size: u64 = 0;
|
||||
|
||||
let read_dir = match std::fs::read_dir(&cache_dir) {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("[bgm_cache] 无法读取缓存目录: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else { continue };
|
||||
if !metadata.is_file() { continue; }
|
||||
let mtime = metadata.modified().unwrap_or(now);
|
||||
let size = metadata.len();
|
||||
total_size += size;
|
||||
entries.push((entry.path(), mtime, size));
|
||||
}
|
||||
|
||||
// 1. 删除超过 30 天的文件
|
||||
let mut deleted_size: u64 = 0;
|
||||
for (path, mtime, size) in &entries {
|
||||
if now.duration_since(*mtime).unwrap_or_default() > max_age {
|
||||
if std::fs::remove_file(path).is_ok() {
|
||||
deleted_size += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 容量超限,删最旧的
|
||||
let remaining_size = total_size.saturating_sub(deleted_size);
|
||||
if remaining_size > max_total_size {
|
||||
let mut sorted = entries;
|
||||
sorted.sort_by_key(|(_, mtime, _)| *mtime);
|
||||
let mut to_free = remaining_size.saturating_sub(target_size);
|
||||
for (path, _, size) in sorted {
|
||||
if to_free == 0 { break; }
|
||||
if path.exists() && std::fs::remove_file(&path).is_ok() {
|
||||
to_free = to_free.saturating_sub(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 手动清理 bgm_cache 目录,返回释放的字节数
|
||||
#[tauri::command]
|
||||
fn clear_bgm_cache_cmd() -> Result<u64, String> {
|
||||
let app_data_dir = crate::storage::paths::get_app_data_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cache_dir = app_data_dir.join("bgm_cache");
|
||||
if !cache_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut freed: u64 = 0;
|
||||
let read_dir = match std::fs::read_dir(&cache_dir) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
|
||||
};
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else { continue };
|
||||
if !metadata.is_file() { continue; }
|
||||
let size = metadata.len();
|
||||
if std::fs::remove_file(entry.path()).is_ok() {
|
||||
freed += size;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(freed)
|
||||
}
|
||||
|
||||
/// 获取 bgm_cache 目录当前占用大小(字节)
|
||||
#[tauri::command]
|
||||
fn get_bgm_cache_size_cmd() -> Result<u64, String> {
|
||||
let app_data_dir = crate::storage::paths::get_app_data_dir()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let cache_dir = app_data_dir.join("bgm_cache");
|
||||
if !cache_dir.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let mut total: u64 = 0;
|
||||
let read_dir = match std::fs::read_dir(&cache_dir) {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Err(format!("无法读取缓存目录: {}", e)),
|
||||
};
|
||||
|
||||
for entry in read_dir.flatten() {
|
||||
let Ok(metadata) = entry.metadata() else { continue };
|
||||
if metadata.is_file() {
|
||||
total += metadata.len();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// 获取 BGM 缓存目录路径(供前端下载 BGM 时使用)
|
||||
#[tauri::command]
|
||||
fn get_bgm_cache_dir() -> Result<String, String> {
|
||||
crate::storage::paths::get_bgm_cache_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 应用入口
|
||||
// ============================================================
|
||||
@@ -189,8 +316,12 @@ pub fn run() {
|
||||
// 初始化应用数据目录(所有业务数据的根目录)
|
||||
if let Ok(app_data_dir) = app.path().app_local_data_dir() {
|
||||
crate::storage::init_app_data_dir(app_data_dir.clone());
|
||||
// 后台清理过期视频缓存,不阻塞首屏
|
||||
std::thread::spawn(move || clean_video_cache(&app_data_dir));
|
||||
// 后台清理过期视频缓存和 BGM 缓存,不阻塞首屏
|
||||
let app_data_dir_clone = app_data_dir.clone();
|
||||
std::thread::spawn(move || {
|
||||
clean_video_cache(&app_data_dir_clone);
|
||||
clean_bgm_cache(&app_data_dir_clone);
|
||||
});
|
||||
}
|
||||
|
||||
// 窗口初始 visible=false,setup 阶段先显示窗口
|
||||
@@ -330,6 +461,9 @@ pub fn run() {
|
||||
// 缓存清理
|
||||
get_video_cache_size_cmd,
|
||||
clear_video_cache_cmd,
|
||||
get_bgm_cache_size_cmd,
|
||||
clear_bgm_cache_cmd,
|
||||
get_bgm_cache_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -158,6 +158,7 @@ pub fn ensure_dir(path: &Path) -> Result<(), StorageError> {
|
||||
///
|
||||
/// 非 Unix 平台上为无操作。
|
||||
pub fn restrict_file_permissions(path: &Path) -> Result<(), StorageError> {
|
||||
let _ = path; // 非 Unix 平台上避免 unused_variables 警告
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
@@ -120,3 +120,12 @@ pub fn get_cover_avatars_json_path() -> Result<PathBuf, StorageError> {
|
||||
let base = get_app_data_dir()?;
|
||||
Ok(base.join("cover_avatars.json"))
|
||||
}
|
||||
|
||||
/// 获取 BGM 缓存目录
|
||||
/// {app_local_data_dir}/bgm_cache/
|
||||
pub fn get_bgm_cache_dir() -> Result<PathBuf, StorageError> {
|
||||
let base = get_app_data_dir()?;
|
||||
let path = base.join("bgm_cache");
|
||||
crate::storage::engine::ensure_dir(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "美家卡智影",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.6",
|
||||
"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 ipc://localhost/* http://ipc.localhost;",
|
||||
"capabilities": [
|
||||
"default"
|
||||
],
|
||||
|
||||
@@ -18,7 +18,7 @@ import ProgressModal from './components/ProgressModal/ProgressModal';
|
||||
import UpdateDialog from './components/UpdateDialog/UpdateDialog';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import ConfirmModal from './components/Modal/ConfirmModal';
|
||||
import { useAuthStore } from './store';
|
||||
import { useAuthStore, usePointStore } from './store';
|
||||
import { initProjectStore } from './store/projectStore';
|
||||
import { loadAppConfig } from './api/modules/config';
|
||||
import { setApiBaseUrl } from './api/client';
|
||||
@@ -94,6 +94,7 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
initProjectStore().catch(console.error);
|
||||
usePointStore.getState().fetchBalance().catch(console.error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface BgmMusicItem {
|
||||
artist: string | null;
|
||||
category: string;
|
||||
filePath: string;
|
||||
url: string | null;
|
||||
url: string;
|
||||
duration: number | null;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
width?: string;
|
||||
centerTitle?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
@@ -17,6 +18,7 @@ export default function Modal({
|
||||
children,
|
||||
width = '560px',
|
||||
centerTitle = false,
|
||||
maxHeight,
|
||||
}: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -45,7 +47,15 @@ export default function Modal({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={`modal-container animate-slideUp ${!title ? 'modal-container--no-title' : ''}`} style={{ width: '90%', maxWidth: width, position: 'relative' }}>
|
||||
<div
|
||||
className={`modal-container animate-slideUp ${!title ? 'modal-container--no-title' : ''}`}
|
||||
style={{
|
||||
width: '90%',
|
||||
maxWidth: width,
|
||||
position: 'relative',
|
||||
...(maxHeight ? { maxHeight } : {}),
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<div
|
||||
className="modal-header"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
compose: '压制成片',
|
||||
subtitle_burn: '字幕烧录',
|
||||
cover_design: '封面设计',
|
||||
cover_avatar: '封面人物形象',
|
||||
caption: '字幕生成',
|
||||
};
|
||||
|
||||
@@ -40,7 +41,7 @@ export default function PricingModal({ open, onClose }: PricingModalProps) {
|
||||
}, [open, rules.length]);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} width="600px">
|
||||
<Modal open={open} onClose={onClose} width="600px" maxHeight="none">
|
||||
{loading ? (
|
||||
<div className="profile-pricing-loading">
|
||||
加载中...
|
||||
|
||||
@@ -307,6 +307,12 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recharge-modal-footer .btn.btn-ghost {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 订单信息 */
|
||||
.recharge-order-info {
|
||||
display: flex;
|
||||
|
||||
@@ -47,7 +47,7 @@ interface TemplateDef {
|
||||
};
|
||||
}
|
||||
|
||||
const FONT_FAMILY = '"DouyinSans", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||
const FONT_FAMILY = '"DouyinSansBold", "PingFang SC", "Microsoft YaHei", sans-serif';
|
||||
|
||||
/**
|
||||
* 加载本地图片文件为 HTMLImageElement
|
||||
@@ -69,7 +69,7 @@ const TEMPLATES: Record<CoverTemplate, TemplateDef> = {
|
||||
mainTitle: {
|
||||
fontSize: 144,
|
||||
fill: '#FFD700',
|
||||
top: 480, // 1920 * 0.25
|
||||
top: 200,
|
||||
fontWeight: 'bold',
|
||||
shadow: new Shadow({
|
||||
color: 'rgba(0,0,0,0.9)',
|
||||
@@ -81,7 +81,7 @@ const TEMPLATES: Record<CoverTemplate, TemplateDef> = {
|
||||
subtitle: {
|
||||
fontSize: 72,
|
||||
fill: '#FF8C00',
|
||||
top: 1575, // 约 82% 处
|
||||
top: 380,
|
||||
shadow: new Shadow({
|
||||
color: 'rgba(0,0,0,0.85)',
|
||||
blur: 12,
|
||||
@@ -291,19 +291,17 @@ export function useCoverFabric() {
|
||||
}
|
||||
|
||||
// 2. 封面形象(叠加在背景之上)
|
||||
let avatarTop = CANVAS_HEIGHT;
|
||||
if (config.avatarImage) {
|
||||
try {
|
||||
const scaledHeight = await loadAvatarImage(canvas, config.avatarImage);
|
||||
avatarTop = CANVAS_HEIGHT - scaledHeight;
|
||||
} catch {
|
||||
// no-op: 封面形象加载失败不影响主流程
|
||||
await loadAvatarImage(canvas, config.avatarImage);
|
||||
} catch (err) {
|
||||
console.warn('[useCoverFabric] 封面形象加载失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const template = TEMPLATES[config.template];
|
||||
|
||||
// 预计算主副标题行数和高度,用于动态定位
|
||||
// 预计算主副标题行数
|
||||
const mainTitleLines = config.mainTitle.trim()
|
||||
? wrapTextByWidth(config.mainTitle.trim(), CANVAS_WIDTH - 120, template.mainTitle.fontSize).slice(0, 2)
|
||||
: [];
|
||||
@@ -313,23 +311,33 @@ export function useCoverFabric() {
|
||||
|
||||
const mainTitleLineHeight = template.mainTitle.fontSize * 1.2;
|
||||
const subtitleLineHeight = template.subtitle.fontSize * 1.5;
|
||||
const mainTitleHeight = mainTitleLines.length * mainTitleLineHeight;
|
||||
const subtitleHeight = subtitleLines.length * subtitleLineHeight;
|
||||
|
||||
// 间距配置
|
||||
const gapAvatarToMain = 50; // 人物与主标题间距
|
||||
const gapMainToSub = 24; // 主标题与副标题间距
|
||||
// 文字位置固定,不再根据封面形象高度和文字行数动态计算
|
||||
const mainTitleTop = template.mainTitle.top;
|
||||
const subtitleTop = template.subtitle.top;
|
||||
|
||||
// 从人物头顶往上计算文字位置
|
||||
// 主标题底部 = 人物顶部 - 间距
|
||||
const mainTitleBottom = avatarTop - gapAvatarToMain;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// 副标题底部 = 主标题顶部 - 间距
|
||||
const subtitleBottom = mainTitleTop - gapMainToSub;
|
||||
const subtitleTop = subtitleBottom - subtitleHeight;
|
||||
|
||||
// 3. 副标题(放在人物上方,主标题上方)
|
||||
// 4. 副标题(放在人物上方,主标题下方)
|
||||
if (subtitleLines.length > 0) {
|
||||
subtitleLines.forEach((line, i) => {
|
||||
const text = new FabricText(line, {
|
||||
@@ -353,30 +361,9 @@ 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]
|
||||
[loadBackground, loadAvatarImage]
|
||||
);
|
||||
|
||||
// 导出 PNG
|
||||
|
||||
@@ -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 {
|
||||
@@ -2105,13 +2141,21 @@
|
||||
|
||||
.about-version-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
padding: var(--spacing-xs) 0;
|
||||
gap: var(--spacing-sm);
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.about-version-row > .about-check-btn {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: unset;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.about-version-text {
|
||||
@@ -2123,10 +2167,12 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.about-version-actions {
|
||||
.about-version-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.about-checking-status {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -317,7 +317,7 @@ export default function VoiceMaterialLibrary() {
|
||||
</div>
|
||||
<div className="voice-upload-text">
|
||||
<span className="voice-upload-title">上传音频样本</span>
|
||||
<span className="voice-upload-hint">MP3 / M4A / WAV,建议时长 30s ~ 3min</span>
|
||||
<span className="voice-upload-hint">MP3 / M4A / WAV,建议时长 10 秒 ~ 5 分钟</span>
|
||||
</div>
|
||||
<div className="voice-upload-arrow">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -208,6 +208,7 @@ const SOURCE_TYPE_LABELS: Record<string, string> = {
|
||||
compose: '压制成片',
|
||||
subtitle_burn: '字幕烧录',
|
||||
cover_design: '封面设计',
|
||||
cover_avatar: '封面人物形象',
|
||||
caption: '字幕生成',
|
||||
};
|
||||
|
||||
@@ -217,6 +218,7 @@ const SOURCE_TYPE_OPTIONS = [
|
||||
{ value: 'polish', label: '文案润色' },
|
||||
{ value: 'title', label: '标题生成' },
|
||||
{ value: 'tts', label: '配音合成' },
|
||||
{ value: 'cover_avatar', label: '封面人物形象' },
|
||||
{ value: 'voice_clone', label: '声音复刻' },
|
||||
{ value: 'video', label: '视频生成' },
|
||||
{ value: 'compose', label: '压制成片' },
|
||||
|
||||
@@ -132,11 +132,14 @@ export default function Settings() {
|
||||
}, 2000);
|
||||
}, []);
|
||||
|
||||
// 获取缓存大小
|
||||
// 获取媒体缓存大小(视频缓存 + BGM 缓存)
|
||||
const fetchCacheSize = useCallback(async () => {
|
||||
try {
|
||||
const size = await invoke<number>('get_video_cache_size_cmd');
|
||||
setCacheSize(size);
|
||||
const [videoSize, bgmSize] = await Promise.all([
|
||||
invoke<number>('get_video_cache_size_cmd'),
|
||||
invoke<number>('get_bgm_cache_size_cmd'),
|
||||
]);
|
||||
setCacheSize(videoSize + bgmSize);
|
||||
} catch {
|
||||
setCacheSize(0);
|
||||
}
|
||||
@@ -151,7 +154,7 @@ export default function Settings() {
|
||||
};
|
||||
}, [fetchCacheSize]);
|
||||
|
||||
// 清理缓存
|
||||
// 清理媒体缓存(视频缓存 + BGM 缓存)
|
||||
const handleClearCache = async () => {
|
||||
if (cacheSize === 0) {
|
||||
toast.info('暂无缓存需要清理');
|
||||
@@ -159,9 +162,13 @@ export default function Settings() {
|
||||
}
|
||||
setClearingCache(true);
|
||||
try {
|
||||
const freed = await invoke<number>('clear_video_cache_cmd');
|
||||
const [videoFreed, bgmFreed] = await Promise.all([
|
||||
invoke<number>('clear_video_cache_cmd'),
|
||||
invoke<number>('clear_bgm_cache_cmd'),
|
||||
]);
|
||||
const totalFreed = videoFreed + bgmFreed;
|
||||
setCacheSize(0);
|
||||
toast.success(`已清理 ${formatBytes(freed)} 缓存`);
|
||||
toast.success(`已清理 ${formatBytes(totalFreed)} 缓存`);
|
||||
} catch {
|
||||
toast.error('清理缓存失败');
|
||||
} finally {
|
||||
@@ -208,27 +215,27 @@ export default function Settings() {
|
||||
>
|
||||
当前版本 v{CURRENT_VERSION}
|
||||
</span>
|
||||
<div className="about-version-actions">
|
||||
{checking ? (
|
||||
<span className="about-checking-status">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
检查中...
|
||||
</span>
|
||||
) : checkResult === 'latest' ? (
|
||||
<span className="about-check-result about-check-latest">当前已是最新版本</span>
|
||||
) : checkResult === 'available' ? (
|
||||
<span className="about-check-result about-check-available">发现新版本 {updateInfo?.version}</span>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-sm about-check-btn"
|
||||
onClick={handleCheck}
|
||||
disabled={checking || downloading}
|
||||
>
|
||||
{checking ? '检查中' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm about-check-btn"
|
||||
onClick={handleCheck}
|
||||
disabled={checking || downloading}
|
||||
>
|
||||
{checking ? '检查中' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="about-version-status">
|
||||
{checking ? (
|
||||
<span className="about-checking-status">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--primary)" strokeWidth="2" style={{ animation: 'spin 1s linear infinite' }}>
|
||||
<path d="M21 12a9 9 0 11-6.219-8.56" />
|
||||
</svg>
|
||||
检查中...
|
||||
</span>
|
||||
) : checkResult === 'latest' ? (
|
||||
<span className="about-check-result about-check-latest">当前已是最新版本</span>
|
||||
) : checkResult === 'available' ? (
|
||||
<span className="about-check-result about-check-available">发现新版本 {updateInfo?.version}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 更新详情(发现新版本时展开) */}
|
||||
|
||||
@@ -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);
|
||||
@@ -633,7 +635,7 @@ export default function SubtitleBurning() {
|
||||
onClick={() => setPreviewMode('result')}
|
||||
disabled={!burnedVideoUrl}
|
||||
>
|
||||
{previewMode === 'result' ? '视频预览中...' : '视频预览'}
|
||||
{previewMode === 'result' ? '视频预览中' : '视频预览'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,9 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import { exists } from '@tauri-apps/plugin-fs';
|
||||
import { compositeApi } from '../../api/modules/videoComposite';
|
||||
import { downloadFile } from '../../api/modules/videoCompose';
|
||||
import { bgmMusicApi, type BgmMusicItem } from '../../api/modules/bgmMusic';
|
||||
import { pointsApi } from '../../api/modules/points';
|
||||
import { useProjectStore, saveMetaToLocalFile } from '../../store';
|
||||
@@ -110,7 +112,11 @@ export default function VideoCompose() {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.onended = null;
|
||||
}
|
||||
const audioSrc = item.url || (item.filePath ? `/Users/0fun/work/meijiaka-zy/mixkit_bgm/${item.filePath}` : '');
|
||||
const audioSrc = item.url;
|
||||
if (!audioSrc) {
|
||||
toast.warning('该音乐暂无可用的试听链接');
|
||||
return;
|
||||
}
|
||||
const audio = new Audio(audioSrc);
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewId(null);
|
||||
@@ -264,11 +270,25 @@ export default function VideoCompose() {
|
||||
// 4. 如果选择了 BGM,混合背景音乐
|
||||
if (bgmMusicPath) {
|
||||
useProgressStore.getState().update('正在混合背景音乐...');
|
||||
const bgmFullPath = bgmMusicPath;
|
||||
let finalBgmPath = bgmMusicPath;
|
||||
|
||||
// BGM 为 URL 时,先下载到本地缓存
|
||||
if (bgmMusicPath.startsWith('http')) {
|
||||
const cacheDir = await invoke<string>('get_bgm_cache_dir');
|
||||
const ext = bgmMusicPath.split('.').pop() || 'mp3';
|
||||
const cachedPath = `${cacheDir}/bgm_${bgmMusicId}.${ext}`;
|
||||
const fileExists = await exists(cachedPath);
|
||||
if (!fileExists) {
|
||||
useProgressStore.getState().update('正在下载背景音乐...');
|
||||
await downloadFile(bgmMusicPath, cachedPath);
|
||||
}
|
||||
finalBgmPath = cachedPath;
|
||||
}
|
||||
|
||||
const mixRes = await invoke<{ code: number; data?: string; message: string }>('mix_bgm_to_video', {
|
||||
args: {
|
||||
videoPath: result,
|
||||
bgmPath: bgmFullPath,
|
||||
bgmPath: finalBgmPath,
|
||||
outputPath: result,
|
||||
videoVolume: 1.0,
|
||||
bgmVolume: (bgmVolume ?? 0.25),
|
||||
@@ -317,6 +337,7 @@ export default function VideoCompose() {
|
||||
coverPath,
|
||||
});
|
||||
|
||||
useProjectStore.getState().clearStepDirty(6);
|
||||
setDone(true);
|
||||
useProgressStore.getState().success('压制成片完成', composePoints);
|
||||
} catch (error: unknown) {
|
||||
@@ -397,9 +418,21 @@ export default function VideoCompose() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setBgmModalOpen(true); }}>
|
||||
更换
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-ghost btn-sm" onClick={e => { e.stopPropagation(); setBgmModalOpen(true); }}>
|
||||
更换
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setBgmMusic(undefined, undefined, undefined);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -730,7 +763,7 @@ export default function VideoCompose() {
|
||||
<div
|
||||
className="modal-bgm-info"
|
||||
onClick={() => {
|
||||
setBgmMusic(item.id, item.title, item.url || item.filePath);
|
||||
setBgmMusic(item.id, item.title, item.url);
|
||||
setBgmModalOpen(false);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -29,7 +29,7 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
|
||||
<div style={{ display: 'flex', gap: 'var(--spacing-md)', width: '100%' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1, marginTop: 0, flexShrink: 0 }}
|
||||
style={{ flexGrow: 1, flexShrink: 0, flexBasis: '0%', marginTop: 0 }}
|
||||
onClick={onGenerate}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
@@ -37,10 +37,10 @@ const GenerationControls: React.FC<GenerationControlsProps> = ({
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, marginTop: 0, flexShrink: 0 }}
|
||||
style={{ flexGrow: 1, flexShrink: 0, flexBasis: '0%', marginTop: 0 }}
|
||||
onClick={onPreview}
|
||||
>
|
||||
{isPreviewing ? '视频预览中...' : '视频预览'}
|
||||
{isPreviewing ? '视频预览中' : '视频预览'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ interface ShotTimelineProps {
|
||||
userUploadedMaterials: Record<string, { path: string; duration: number }>;
|
||||
selectedAvatarMaterial: AvatarMaterial | null;
|
||||
isComposedPreview: boolean;
|
||||
matchingShotIds?: Set<string>;
|
||||
onSceneClick: (id: number, shot: ScriptShot) => void;
|
||||
onSwitchMaterial: (shotId: string) => void;
|
||||
onUploadMaterial: (shotId: string) => void;
|
||||
@@ -33,6 +34,7 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
|
||||
userUploadedMaterials,
|
||||
selectedAvatarMaterial,
|
||||
isComposedPreview,
|
||||
matchingShotIds,
|
||||
onSceneClick,
|
||||
onSwitchMaterial,
|
||||
onUploadMaterial,
|
||||
@@ -123,6 +125,7 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
style={{ fontSize: '11px', padding: '2px 6px', color: 'var(--primary)' }}
|
||||
disabled={matchingShotIds?.has(String(shot.id))}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const sid = String(shot.id);
|
||||
@@ -133,7 +136,11 @@ const ShotTimeline: React.FC<ShotTimelineProps> = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{materialMatchMap[String(shot.id)] ? '换一个' : '匹配素材'}
|
||||
{matchingShotIds?.has(String(shot.id))
|
||||
? '匹配中...'
|
||||
: materialMatchMap[String(shot.id)]
|
||||
? '换一个'
|
||||
: '匹配素材'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
|
||||
@@ -438,6 +438,7 @@ export function useVideoGeneration({
|
||||
});
|
||||
|
||||
useProjectStore.getState().clearStepDirty(3);
|
||||
useProjectStore.getState().markStepsDirty(3);
|
||||
onSuccess();
|
||||
|
||||
progress.success('视频生成完成', actualVideoPoints);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { exists } from '@tauri-apps/plugin-fs';
|
||||
import { getPreviewVideoUrl } from '../../utils/videoPreview';
|
||||
@@ -24,7 +24,7 @@ export default function VideoGeneration() {
|
||||
const composedVideoPath = useProjectStore((state) => state.composedVideoPath);
|
||||
const stepDirtyFlags = useProjectStore((state) => state.stepDirtyFlags);
|
||||
const isStepDirty = (stepDirtyFlags?.[3] ?? false) && !!composedVideoPath;
|
||||
const { videoUrl: composedVideoBlobUrl } = useLocalVideo(composedVideoPath);
|
||||
const { videoUrl: composedVideoBlobUrl, isLoading: isComposedVideoLoading } = useLocalVideo(composedVideoPath);
|
||||
const projectId = getCurrentProjectId();
|
||||
|
||||
// 本地人物出镜素材
|
||||
@@ -63,6 +63,17 @@ export default function VideoGeneration() {
|
||||
const dubbingAudioDuration = useProjectStore(state => state.dubbingAudioDuration);
|
||||
|
||||
const [activeScene, setActiveScene] = useState<number>(1);
|
||||
const activeSceneRef = useRef(activeScene);
|
||||
useEffect(() => {
|
||||
activeSceneRef.current = activeScene;
|
||||
}, [activeScene]);
|
||||
|
||||
// 匹配请求进行中 shotId 集合,防止重复点击
|
||||
const [matchingShotIds, setMatchingShotIds] = useState<Set<string>>(new Set());
|
||||
// 匹配请求序号,用于丢弃过期的异步返回结果
|
||||
const matchRequestIdRef = useRef(0);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const composedVideoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// 控制右侧预览区显示完整视频还是镜头素材
|
||||
const [isComposedPreview, setIsComposedPreview] = useState(false);
|
||||
@@ -70,7 +81,6 @@ export default function VideoGeneration() {
|
||||
// 预览视频
|
||||
const [previewVideoUrl, setPreviewVideoUrl] = useState<string | null>(null);
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const previewBlobRef = useRef<string | null>(null);
|
||||
|
||||
// 页面刷新后恢复组件本地 state(store 字段由 VideoCreation/index.tsx 统一恢复)
|
||||
useEffect(() => {
|
||||
@@ -183,6 +193,35 @@ 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]);
|
||||
|
||||
/**
|
||||
* 预览视频(素材或人物形象)
|
||||
*/
|
||||
const handlePreview = useCallback(async (urlOrPath: string) => {
|
||||
setPreviewVideoUrl(null);
|
||||
setIsPreviewLoading(true);
|
||||
|
||||
// 统一走转码/缓存流程:本地视频直接转码,网络视频先下载到本地再转码
|
||||
try {
|
||||
const url = await getPreviewVideoUrl(urlOrPath);
|
||||
setPreviewVideoUrl(url);
|
||||
setIsPreviewLoading(false);
|
||||
} catch (e) {
|
||||
console.error('[VideoGeneration] 预览视频失败:', e);
|
||||
toast.error('预览失败');
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 点击卡片后自动预览对应素材/形象
|
||||
useEffect(() => {
|
||||
const activeShot = shots.find((s) => Number(s.id) === activeScene);
|
||||
@@ -190,32 +229,21 @@ 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);
|
||||
handlePreview(matched.url);
|
||||
} else {
|
||||
setPreviewVideoUrl(null);
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
} else if (selectedAvatarMaterial) {
|
||||
(async () => {
|
||||
try {
|
||||
const url = await getPreviewVideoUrl(selectedAvatarMaterial.path);
|
||||
setPreviewVideoUrl(url);
|
||||
} catch (e) {
|
||||
console.error('[VideoGeneration] 预览形象视频失败:', e);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
setPreviewVideoUrl(null);
|
||||
handlePreview(selectedAvatarMaterial.path);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeScene]);
|
||||
}, [activeScene, handlePreview]);
|
||||
|
||||
// 视频生成
|
||||
const { isComposing, handleGenerate } = useVideoGeneration({
|
||||
@@ -230,35 +258,6 @@ export default function VideoGeneration() {
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 预览视频(素材或人物形象)
|
||||
*/
|
||||
const handlePreview = async (urlOrPath: string) => {
|
||||
if (previewBlobRef.current) {
|
||||
previewBlobRef.current = null;
|
||||
setPreviewVideoUrl(null);
|
||||
}
|
||||
setIsPreviewLoading(true);
|
||||
|
||||
// 线上视频:直接设 src,不走 blob
|
||||
if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
|
||||
setPreviewVideoUrl(urlOrPath);
|
||||
setIsPreviewLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 本地视频
|
||||
try {
|
||||
const url = await getPreviewVideoUrl(urlOrPath);
|
||||
setPreviewVideoUrl(url);
|
||||
} catch (e) {
|
||||
console.error('[VideoGeneration] 预览本地视频失败:', e);
|
||||
toast.error('预览失败');
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择本地人物视频
|
||||
*/
|
||||
@@ -305,17 +304,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);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理素材切换
|
||||
@@ -332,23 +323,44 @@ export default function VideoGeneration() {
|
||||
};
|
||||
|
||||
// 处理素材匹配(单个镜头触发匹配)
|
||||
// 匹配成功后自动切换到对应镜头并预览,但只处理最新请求的结果,
|
||||
// 避免用户快速连点多个镜头时,后返回的请求把画面切回之前的镜头。
|
||||
const handleMatchMaterial = async (shotId: string) => {
|
||||
const result = await matchSingleMaterial(shotId);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
if (Number(shotId) === activeScene) {
|
||||
// 当前镜头已激活,直接播放
|
||||
setPreviewVideoUrl(result.url);
|
||||
} else {
|
||||
// 切换到该镜头,useEffect 会自动加载预览
|
||||
setActiveScene(Number(shotId));
|
||||
setIsComposedPreview(false);
|
||||
if (matchingShotIds.has(shotId)) return;
|
||||
setMatchingShotIds(prev => new Set(prev).add(shotId));
|
||||
|
||||
const requestId = ++matchRequestIdRef.current;
|
||||
|
||||
try {
|
||||
const result = await matchSingleMaterial(shotId);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
// 如果已经有更新的请求发出,丢弃当前过期结果
|
||||
if (requestId !== matchRequestIdRef.current) {
|
||||
return;
|
||||
}
|
||||
if (Number(shotId) === activeSceneRef.current) {
|
||||
// 当前镜头已激活,直接预览(setActiveScene 值不变时不会触发 useEffect)
|
||||
handlePreview(result.url);
|
||||
} else {
|
||||
// 切换到对应镜头,useEffect 会自动加载预览
|
||||
setActiveScene(Number(shotId));
|
||||
setIsComposedPreview(false);
|
||||
}
|
||||
} finally {
|
||||
setMatchingShotIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(shotId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const activeShot = shots.find((s) => Number(s.id) === activeScene);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{isStepDirty && (
|
||||
@@ -374,6 +386,7 @@ export default function VideoGeneration() {
|
||||
userUploadedMaterials={userUploadedMaterials}
|
||||
selectedAvatarMaterial={selectedAvatarMaterial}
|
||||
isComposedPreview={isComposedPreview}
|
||||
matchingShotIds={matchingShotIds}
|
||||
onSceneClick={handleSceneClick}
|
||||
onSwitchMaterial={handleSwitchMaterial}
|
||||
onUploadMaterial={handleUploadMaterial}
|
||||
@@ -409,50 +422,65 @@ export default function VideoGeneration() {
|
||||
{isComposedPreview && composedVideoBlobUrl ? (
|
||||
<video
|
||||
key={composedVideoBlobUrl}
|
||||
ref={composedVideoRef}
|
||||
src={composedVideoBlobUrl}
|
||||
className="preview-video"
|
||||
controls
|
||||
autoPlay
|
||||
onCanPlay={() => {
|
||||
composedVideoRef.current?.play().catch(() => {});
|
||||
}}
|
||||
/>
|
||||
) : isComposedPreview && isComposedVideoLoading ? (
|
||||
<div className="video-placeholder">
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid rgba(255,255,255,0.2)',
|
||||
borderTop: '3px solid var(--primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'avatar-spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: '#fff', fontSize: '14px', marginTop: '12px' }}>视频加载中...</p>
|
||||
</div>
|
||||
) : previewVideoUrl ? (
|
||||
<>
|
||||
<video
|
||||
key={previewVideoUrl}
|
||||
ref={videoRef}
|
||||
src={previewVideoUrl}
|
||||
className="preview-video"
|
||||
controls
|
||||
autoPlay
|
||||
onLoadStart={() => setIsPreviewLoading(true)}
|
||||
muted
|
||||
playsInline
|
||||
onCanPlay={() => {
|
||||
videoRef.current?.play().catch(() => {});
|
||||
}}
|
||||
onLoadedData={() => setIsPreviewLoading(false)}
|
||||
onError={(e) => {
|
||||
console.error('[VideoGeneration] 视频加载失败:', e);
|
||||
toast.error('视频加载失败');
|
||||
setIsPreviewLoading(false);
|
||||
}}
|
||||
/>
|
||||
{isPreviewLoading && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
background: 'rgba(26, 26, 46, 0.8)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid rgba(255,255,255,0.2)',
|
||||
borderTop: '3px solid var(--primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'avatar-spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: '#fff', fontSize: '14px' }}>加载中...</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : isPreviewLoading ? (
|
||||
<div className="video-placeholder">
|
||||
<div
|
||||
style={{
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
border: '3px solid rgba(255,255,255,0.2)',
|
||||
borderTop: '3px solid var(--primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'avatar-spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
<p style={{ color: '#fff', fontSize: '14px', marginTop: '12px' }}>加载中...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="video-placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1">
|
||||
|
||||
@@ -78,7 +78,9 @@ export const useProgressStore = create<ProgressState & ProgressActions>((set) =>
|
||||
},
|
||||
|
||||
setProgress: (progress) => {
|
||||
set({ progress: Math.min(100, Math.max(0, progress)) });
|
||||
set((state) => ({
|
||||
progress: Math.min(100, Math.max(state.progress ?? 0, progress)),
|
||||
}));
|
||||
},
|
||||
|
||||
success: (message = '生成完成', pointsConsumed) => {
|
||||
|
||||
@@ -24,6 +24,5 @@ export async function getPreviewVideoUrl(path: string): Promise<string> {
|
||||
throw new Error(resp.message || '预览视频处理失败');
|
||||
}
|
||||
|
||||
// Rust 返回本地文件系统路径,转为 asset:// 协议 URL
|
||||
return convertFileSrc(resp.data);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user