feat(points): 积分系统收尾 + 充值弹窗改造 + 命名统一

后端:
- 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试
- recharge() 加 order_id 幂等保护,防重复充值
- time_expire 使用北京时间(UTC+8),修复时区 bug
- 充值档位后端配置化(points-config.yaml + /recharge-options API)
- 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等)

前端:
- 充值弹窗:自动轮询 + 【我已支付】手动兜底
- 二维码倒计时显示,过期后遮罩 + 刷新按钮
- 充值档位从后端动态加载
- 去掉 select/qrcode 弹窗标题,金额红色突出显示
- 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等)
- Modal 关闭按钮独立于 title 显示
This commit is contained in:
小鱼开发
2026-05-09 21:29:35 +08:00
parent 0722225c62
commit 04e467e433
77 changed files with 1475 additions and 629 deletions
+3 -3
View File
@@ -10,7 +10,7 @@
- **产品标识**: `cn.meijiaka.ai-video` / `cn.meijiaka.ai-jian`
- **版本**: `0.1.0`
- **核心功能**: AI 脚本生成、AI 配音(TTS)、声音复刻、对口型Vidu)、视频字幕生成、视频合成FFmpeg)、项目本地持久化
- **核心功能**: AI 脚本生成、AI 配音合成TTS)、声音复刻、视频生成Vidu)、视频字幕生成、压制成片FFmpeg)、项目本地持久化
### 技术栈总览
@@ -96,7 +96,7 @@ tauri-app/
│ │ ├── main.rs # 程序入口
│ │ ├── lib.rs # Tauri Builder、Command 定义、公共类型
│ │ ├── ffmpeg_cmd.rs # FFmpeg 命令封装
│ │ ├── video_processing.rs # 视频合成业务逻辑
│ │ ├── video_processing.rs # 压制成片业务逻辑
│ │ ├── api_proxy.rs # Python API 代理
│ │ ├── auth.rs # 认证命令
│ │ ├── avatar_cache.rs # 头像缓存
@@ -123,7 +123,7 @@ tauri-app/
1. **纯数据 API**(脚本、TTS、字幕、视频生成、任务查询等)→ 前端通过 HTTP **直连 Python 后端**
- 脚本/TTS/字幕/视频生成统一走异步任务调度(`POST /tasks/{task_type}` + 轮询 `/tasks/{task_id}`),由 Scheduler 独立进程消费。
2. **需要本地系统能力**FFmpeg 视频合成、文件系统读写、项目本地持久化、认证状态存储)→ 走 **Tauri IPC → Rust 层** 处理。
2. **需要本地系统能力**FFmpeg 压制成片、文件系统读写、项目本地持久化、认证状态存储)→ 走 **Tauri IPC → Rust 层** 处理。
> 新增纯数据 API 时,**无需修改 Rust 代码**,直接在 `tauri-app/src/api/modules/` 下使用 `client.post/get` 调用即可。只有涉及本地系统能力的 API 才需要在 Rust 层新增 `#[tauri::command]`。
+2 -2
View File
@@ -67,7 +67,7 @@
```sql
INSERT INTO app_releases (version, release_date, notes, mandatory) VALUES
('0.1.0', '2026-04-01 10:00:00', '初始版本发布', FALSE),
('0.1.1', '2026-04-14 10:00:00', '新功能:视频字幕压制\n修复:导出问题', FALSE),
('0.1.1', '2026-04-14 10:00:00', '新功能:视频字幕烧录\n修复:导出问题', FALSE),
('0.2.0', '2026-04-20 10:00:00', '新增:批量导出功能\n优化:性能提升 30%', FALSE);
```
@@ -1632,7 +1632,7 @@ if __name__ == "__main__":
cd python-api
python scripts/upload_release.py \
--version 0.1.1 \
--notes "新功能:视频字幕压制\n修复:导出问题" \
--notes "新功能:视频字幕烧录\n修复:导出问题" \
--path ../tauri-app/src-tauri/target/release/bundle
```
+260
View File
@@ -0,0 +1,260 @@
# 项目名词统一梳理报告
> 本文档梳理了美家卡智影项目中所有核心功能在用户界面和开发代码中的命名现状,识别出不一致问题,并提出统一标准方案。
---
## 一、核心功能总览(10个业务域)
| # | source_type | 用户界面名称 | 开发层关键命名 | 计费模式 |
|---|-------------|-------------|---------------|---------|
| 1 | `script` | 脚本生成 | `ScriptCreation.tsx`, `scriptApi` | 固定 5 |
| 2 | `polish` | 文案润色 | `scriptApi.polish`, `polish_content` | 固定 1 |
| 3 | `title` | 标题生成 | `scriptApi.generateTitle`, `generate_title` | 固定 1 |
| 4 | `tts` | 音频合成 / 配音 | `VoiceDubbing.tsx`, `synthesizeTTS`, `voice.ts` | 按秒计费 |
| 5 | `voice_clone` | 声音复刻 / 声音克隆 | `VoiceMaterialLibrary.tsx`, `voice.ts` 声音克隆 API | 固定 200 |
| 6 | `video` | 视频生成 / 对口型 | `VideoGeneration.tsx`, `vidu.ts`, `lipSync` | 按秒计费 |
| 7 | `caption` | 字幕生成 | `createTask('subtitle', ...)` | 免费 |
| 8 | `subtitle_burn` | 字幕烧录 | `SubtitleBurning.tsx`, `subtitle_burn` | 固定 2 |
| 9 | `cover_design` | 封面设计 / 封面制作 / 封面生成 | `CoverDesign.tsx`, `cover_design` | 固定 2 |
| 10 | `compose` | 压制成片 / 视频合成 | `VideoComposite.tsx`, `videoComposite.ts`, `videoCompose.ts`, `video_processing.rs`, `video_compose.rs` | 固定 5 |
---
## 二、问题分类
### A类:后端分类映射错误(数据层污染)
文件:`python-api/app/services/point_service.py` 中的 `_CATEGORY_MAP`
| source_type | 当前映射 | 正确映射 | 影响 |
|-------------|---------|---------|------|
| `polish` | "脚本生成" ❌ | "文案润色" | 用户流水显示错误分类 |
| `title` | "脚本生成" ❌ | "标题生成" | 用户流水显示错误分类 |
| `voice_clone` | "音频合成" ❌ | "声音复刻" | 用户流水显示错误分类 |
> **严重程度:高**。这会导致积分明细中用户无法区分自己到底消费了什么服务。
---
### B类:同一功能多词混用(用户层混乱)
#### B1. TTS / 音频合成 / 配音
| 位置 | 用词 |
|------|------|
| 步骤标签(Step 2 | **音频合成** |
| 页面文件名 | `VoiceDubbing.tsx` |
| 页面注释 | "语音**配音**页面" |
| 进度弹窗 | "生成**配音**"、"**配音**已就绪" |
| 按钮文案 | "生成**配音**" |
| 字段标签 | "**配音**文案" |
| 错误提示 | "请返回第二步重新生成**配音**" |
| TermsModal | "AI **配音**TTS" |
| API 模块注释 | "TTS 合成" |
| 积分明细(UsageDetail)筛选 | "**音频合成**" |
**问题**:步骤标签用"音频合成",但页面内全部用"配音"。用户从步骤导航点进来,看到的内容全是"配音",会产生"这是同一个功能吗?"的困惑。
#### B2. 封面设计 / 封面制作 / 封面生成
| 位置 | 用词 |
|------|------|
| 步骤标签(Step 5 | **封面制作** |
| 页面文件名 | `CoverDesign.tsx` |
| 页面注释 | "**封面制作**页面" |
| 进度弹窗 | "**封面生成**"、"**封面生成**完成" |
| 积分 description | "**封面设计**" |
| 按钮文案 | "立即生成封面图" |
| 预览区标题 | "**封面**预览" |
**问题**"制作"、"设计"、"生成"三个词混用。
#### B3. 声音复刻 / 声音克隆
| 位置 | 用词 |
|------|------|
| 侧边栏 Sidebar | **声音克隆** |
| 积分明细 UsageDetail | **声音复刻** |
| API 模块注释(`voice.ts` | "**声音克隆** API" |
| 页面标题(`VoiceMaterialLibrary.tsx` | "**声音克隆**" |
**问题**:同一功能两个不同的中文名称。
#### B4. 视频生成 / 对口型
| 位置 | 用词 |
|------|------|
| 步骤标签(Step 3 | **视频生成** |
| 积分明细 | **视频生成** |
| 技术实现注释/日志 | "**对口型**任务"、"**对口型**视频"、"**对口型**处理中..." |
| 错误提示 | "**对口型**任务失败"、"**对口型**任务超时" |
| 字段名 | `lipSyncTaskId`, `lipSyncVideoPath` |
**问题**:技术实现术语"对口型"泄露到用户可见文案中。
---
### C类:开发命名与业务命名不匹配
| 业务名称 | 开发命名现状 | 问题 |
|---------|-------------|------|
| 压制成片 | `compose` (source_type) / `composite` (API 模块名) / `videoComposite.ts` / `videoCompose.ts` / `video_processing.rs` / `video_compose.rs` / `VideoComposite.tsx` | **同一业务 5 种不同命名**,开发人员无法一眼看出这些代码对应同一个功能 |
| 音频合成 | `tts` (source_type) / `VoiceDubbing.tsx` (页面) / `synthesizeTTS` (函数) / `voice.ts` (API 模块) | 页面文件名 `VoiceDubbing` 与步骤名"音频合成"语义不匹配 |
| 文案润色 | `polish` (source_type/API) / `polish_content` (service) | 基本对应,但"polish"在代码中同时指"画面描述润色"和"配音文案润色" |
---
### D类:术语在注释/文案中的不统一
- `ScriptCreation.tsx` 中:`voiceover` 字段的注释混用"配音文案"、"画外音"
- `VideoGeneration.tsx` 中:用户错误提示混用"返回第二步重新生成配音"和"回到第2步重新生成音频"
- `voice.ts` 中:API 模块标题为"TTS 合成、批量合成、声音克隆",但对应的功能页面叫"音频合成"
---
## 三、统一标准方案
### 3.1 用户层统一名称(用户可见的所有文案)
| source_type | 统一名称 | 子类型/说明 |
|-------------|---------|------------|
| `script` | **脚本生成** | — |
| `polish` | **文案润色** | 含画面描述润色、配音文案润色 |
| `title` | **标题生成** | 封面主/副标题 |
| `tts` | **配音合成** | 步骤标签、页面标题、按钮、进度统一用此 |
| `voice_clone` | **声音复刻** | 统一用"复刻",不用"克隆" |
| `video` | **视频生成** | 技术实现是对口型,但用户界面禁止出现"对口型" |
| `caption` | **字幕生成** | 从视频提取字幕文本 |
| `subtitle_burn` | **字幕烧录** | 将字幕文件烧录到视频画面中 |
| `cover_design` | **封面设计** | 统一用"设计",不用"制作/生成" |
| `compose` | **压制成片** | FFmpeg 拼接输出最终成品视频 |
**说明**
- **配音合成**:选择这个词是因为它比"音频合成"更贴近用户理解("我给视频配个音"),又比单独的"配音"更像一个功能名称。步骤标签从"音频合成"改为"配音合成"。
- **声音复刻**"复刻"比"克隆"更符合国内 AI 产品用语习惯(如剪映用"声音克隆",但通义/讯飞多用"声音复刻")。考虑到积分明细已用"复刻",统一到此。
- **封面设计**:文件名已经是 `CoverDesign`,积分 description 也是"封面设计",步骤标签从"封面制作"改为此,形成统一。
- **视频生成**:技术实现是 Vidu 对口型,但所有用户文案(含错误提示、进度提示)统一用"视频生成"。
### 3.2 开发层统一命名
#### source_type(数据库/API 层,已较规范,保持不变)
```
script / polish / title / tts / voice_clone / video / caption / subtitle_burn / cover_design / compose
```
#### 前端文件/模块命名
| 业务 | 当前文件名 | 建议文件名 | 理由 |
|------|----------|----------|------|
| 脚本生成 | `ScriptCreation.tsx` | ✅ 保持不变 | 语义清晰 |
| 文案润色 | 无独立页面(在 ScriptCreation 内) | — | — |
| 标题生成 | 无独立页面(在 CoverDesign/SubtitleBurning 内调用) | — | — |
| 配音合成 | `VoiceDubbing.tsx` | `VoiceSynthesis.tsx` | `VoiceDubbing` 侧重"配音"动作,不够功能化;`Synthesis``synthesizeTTS` 对应 |
| 声音复刻 | `VoiceMaterialLibrary.tsx` | ✅ 保持不变 | 页面本身是素材库,其中包含声音复刻功能,可以接受 |
| 视频生成 | `VideoGeneration.tsx` | ✅ 保持不变 | 语义清晰 |
| 字幕生成 | 无独立页面(在 VoiceDubbing 内调用) | — | — |
| 字幕烧录 | `SubtitleBurning.tsx` | ✅ 保持不变 | 语义清晰 |
| 封面设计 | `CoverDesign.tsx` | ✅ 保持不变 | 语义清晰 |
| 压制成片 | `VideoComposite.tsx` | `VideoCompose.tsx` | 与 source_type `compose` 一致。注意当前已有 `videoCompose.ts`(上传模块),需先厘清两者边界 |
> ⚠️ **关于 `videoCompose.ts` vs `videoComposite.ts`**
> - `videoCompose.ts`:提供 `uploadVideoFile`(上传本地视频到后端→七牛云),文件注释写"压制成片 IPC 模块",实际做的是上传,命名混乱。
> - `videoComposite.ts`:提供 `compositeApi.synthesis`(调用 Rust 压制成片),命名与业务对应。
> **建议**`videoCompose.ts` 改名为 `videoUpload.ts`(或合并到七牛上传模块),`VideoComposite.tsx` 改名为 `VideoCompose.tsx`。
#### 后端文件/模块命名
| 业务 | 当前命名 | 建议 | 理由 |
|------|---------|------|------|
| 压制成片 | `video_compose.rs` + `video_processing.rs` | ✅ 保持两个文件,但统一对外 command 名 | `video_processing.rs` 是业务逻辑层,`video_compose.rs` 是 command 层,分层合理。只需统一 Rust command 名和响应消息 |
---
## 四、具体修改清单
### 必改(数据层错误)
1. **`python-api/app/services/point_service.py`**
- `_CATEGORY_MAP["polish"]` → "文案润色"
- `_CATEGORY_MAP["title"]` → "标题生成"
- `_CATEGORY_MAP["voice_clone"]` → "声音复刻"
2. **`python-api/app/api/v1/script.py`**(如果 description 硬编码了错误分类)
- `description="【文案润色】"` ✅ 已正确
- `title` 端点消费记录需要确认 description 格式
### 用户文案统一
3. **Step 标签(`tauri-app/src/pages/VideoCreation/index.tsx`**
- Step 2: "音频合成" → "配音合成"
- Step 5: "封面制作" → "封面设计"
4. **配音合成页面(`tauri-app/src/pages/VideoCreation/VoiceDubbing.tsx`**
- 页面注释:"语音配音页面" → "配音合成页面"
- 进度:`show('生成配音')``show('配音合成')`
- 进度:`update('正在生成配音...')``update('正在合成配音...')`
- 成功:`success('配音已就绪')``success('配音合成完成')`
- 按钮:`生成配音``合成配音`
- 右侧标题:`配音文案``配音文本`
- 字段标签:`配音``配音文本`
- 错误提示中所有"配音"保持不动("重新生成配音"是动作描述,不需要改)
5. **封面设计页面(`tauri-app/src/pages/VideoCreation/CoverDesign.tsx`**
- 进度:`show('封面生成')``show('封面设计')`
- 成功:`success('封面生成完成')``success('封面设计完成')`
- 错误:`封面生成失败``封面设计失败`
- 按钮:`立即生成封面图``立即设计封面`
- 积分 description`封面设计` ✅ 已正确
6. **声音复刻(`tauri-app/src/components/Layout/Sidebar.tsx` + `tauri-app/src/pages/ContentManagement/VoiceMaterialLibrary.tsx` + `tauri-app/src/api/modules/voice.ts`**
- Sidebar: "声音克隆" → "声音复刻"
- `voice.ts` 注释:"声音克隆" → "声音复刻"
- `VoiceMaterialLibrary.tsx` 标题和文案
7. **视频生成页面(`tauri-app/src/pages/VideoCreation/VideoGeneration.tsx`**
- 所有用户可见的"对口型"改为"视频生成"
- 进度:`show('视频生成')` ✅ 已正确
- 进度:`update('正在提交对口型任务...')``update('正在提交视频生成任务...')`
- 进度:`update('正在等待对口型处理...')``update('正在等待视频处理...')`
- 进度:`update('对口型处理中...')``update('视频处理中...')`
- 进度:`update('正在下载对口型视频...')``update('正在下载生成视频...')`
- 错误:`对口型任务失败``视频生成失败`
- 错误:`对口型任务超时``视频生成超时`
- 日志/注释中的"对口型"可以保留(开发层)
8. **压制成片页面(`tauri-app/src/pages/VideoCreation/VideoComposite.tsx`**
- 镜头列表中的`配音`标签 → `配音文本`
9. **TermsModal`tauri-app/src/components/Modal/TermsModal.tsx`**
- "AI 配音(TTS" → "AI 配音合成(TTS"
- "声音复刻" ✅ 已正确
10. **积分明细(`tauri-app/src/pages/Profile/UsageDetail.tsx`**
- `voice_clone` 筛选标签:"声音复刻" ✅ 已正确
- `tts` 筛选标签:"音频合成" → "配音合成"
- `cover_design` 筛选标签:"封面设计" ✅ 已正确
11. **我的作品(`tauri-app/src/pages/ContentManagement/MyWorks.tsx`**
- 空状态文案 ✅ 已改为"压制成片"
### 开发层整理(建议项,不影响用户)
12. **`tauri-app/src/api/modules/videoCompose.ts`**
- 文件注释写"压制成片 IPC 模块",实际做的是上传视频。建议改名或修正注释。
13. **`tauri-app/src/pages/VideoCreation/VideoComposite.tsx`**
- 建议未来改名为 `VideoCompose.tsx`,与 source_type `compose` 一致。
---
## 五、确认清单
请确认以下决策:
1. **Step 2 标签**"音频合成" → "配音合成"(还是保持"音频合成"?)
2. **Step 5 标签**"封面制作" → "封面设计"(是否接受?)
3. **声音功能**:统一为"声音复刻"(放弃"声音克隆")?
4. **视频生成**:所有用户文案中的"对口型"全部替换为"视频生成"
5. **文件改名**`VoiceDubbing.tsx``VoiceSynthesis.tsx``VideoComposite.tsx``VideoCompose.tsx` 是否执行?
+5 -5
View File
@@ -34,7 +34,7 @@
- 用户可无限免费使用 AI 服务,积分系统形同虚设
**2. 前端创作前无余额预检**
- 脚本生成、视频合成等操作点击即触发,不检查余额
- 脚本生成、压制成片等操作点击即触发,不检查余额
- 后端返回 402 后前端没有统一拦截和充值引导
### P1 — 影响体验和数据准确性
@@ -109,11 +109,11 @@ async def generate_tts(request, db, current_user):
| 脚本生成 | `script` | `5` | `5` | 固定值,预估 = 实际 |
| 润色 | `polish` | `1` | `1` | 固定值,预估 = 实际 |
| 标题生成 | `title` | `1` | `1` | 固定值,预估 = 实际 |
| 声音克隆 | `voice_clone` | `200` | `200` | 固定值,预估 = 实际 |
| 声音复刻 | `voice_clone` | `200` | `200` | 固定值,预估 = 实际 |
| TTS 配音 | `tts` | `ceil(seconds / 5)` | `ceil(字数 × 0.3 / 5)` | 按字数保守预估 |
| 数字人视频 | `video` | `seconds * 5` | `输入素材秒数 * 5``300` | 无素材时用默认值 60 秒 |
| 视频生成 | `video` | `seconds * 5` | `输入素材秒数 * 5``300` | 无素材时用默认值 60 秒 |
| 字幕生成 | `caption` | `ceil(seconds / 5)` | `输入视频秒数` | 输入视频时长已知 |
| 视频合成 | `compose` | `seconds * 5` | `分镜总秒数 * 5` | 分镜 duration 前端已知 |
| 压制成片 | `compose` | `seconds * 5` | `分镜总秒数 * 5` | 分镜 duration 前端已知 |
**关键规则**
- 余额 >= 预估上限:放行执行业务,出结果后按实际扣费(可能欠费)
@@ -226,7 +226,7 @@ if (error.code === 402) {
## 五、需要确认的问题
1. **脚本生成固定 5 积分?** 不按场景数?
2. **声音克隆 200 积分?**
2. **声音复刻 200 积分?**
3. **视频/配音秒数从哪取?** Rust 层合成完成后 FFmpeg 可以取时长,是否已有现成方法?
4. **是否需要免费额度/体验积分?** 新用户注册是否赠送一定积分?
5. **用户 model 是否有 role 字段?** 用于管理员充值权限检查。
+7 -7
View File
@@ -13,7 +13,7 @@
| 新增平台接入成本 < 30 分钟 | 提供 Adapter 模板,复制粘贴后填充 4 个方法即可 |
| 第三方故障不拖垮用户 | 单点故障时,用户 100ms 内收到明确错误,而非超时 30 秒 |
| 多用户同时使用无冲突 | 5 个用户同时生成 TTS/脚本时,不触发第三方 429 限流 |
| 任务状态可追踪 | 用户关闭应用后重开,能恢复进行中的对口型/字幕任务 |
| 任务状态可追踪 | 用户关闭应用后重开,能恢复进行中的视频生成/字幕任务 |
| 未来换平台无感知 | 换 TTS 供应商时,前端接口、存储层、用户历史记录全部无感知 |
---
@@ -29,7 +29,7 @@
│ - 回调入口:/webhooks/{platform} │
├──────────────────────────────────────────────┤
│ Application Service │
│ - ScriptService:编排脚本→TTS→对口型
│ - ScriptService:编排脚本→TTS→视频生成
│ - VideoService:编排字幕→合成 │
│ - 只操作领域对象,不感知平台差异 │
├──────────────────────────────────────────────┤
@@ -371,10 +371,10 @@ SSE 流式输出
**关键约束**:流式中途失败**不降级**。已输出内容保持不变,前端收到 error 事件后自行处理。
### 4.3 对口型任务提交(异步任务)
### 4.3 视频生成任务提交(异步任务)
```
用户点击"生成对口型"
用户点击"生成视频"
POST /vidu/lip-sync
@@ -423,7 +423,7 @@ Redis 未命中或 state=processing → 穿透供应商查询 → 更新 Redis
- 第 3 次:t=3sblocking=0
- 第 4 次起:t=7s, 12s, 17s...blocking=1
### 4.5 回调处理(Vid 对口型完成)
### 4.5 回调处理(Vidu 视频生成完成)
```
Vidu 服务器 POST /webhooks/vidu
@@ -453,13 +453,13 @@ Task Gateway.handle_webhook(...)
│ 第一层:任务层(Slot Scheduler
│ 控制"同时有多少个异步任务在执行" │
│ - 火山字幕:max 5 │
│ - 对口型:按 Vidu 配额配置 │
│ - 视频生成:按 Vidu 配额配置 │
│ - 脚本生成:max 10 │
├─────────────────────────────────────────┤
│ 第二层:请求层(Gateway Token Bucket
│ 控制"每秒向某平台发多少请求" │
│ - Vidu TTS20/s │
│ - Vidu 对口型提交:5/s │
│ - Vidu 视频生成提交:5/s │
│ - 火山方舟:50/s │
│ - 火山字幕提交:2/s │
├─────────────────────────────────────────┤
@@ -101,7 +101,7 @@ class SyncCapable(Protocol):
@runtime_checkable
class TaskCapable(Protocol):
"""异步任务能力(对口型、字幕、视频生成等)"""
"""异步任务能力(视频生成、字幕、TTS 等)"""
async def submit(self, task_type: str, payload: dict, callback_url: str | None) -> AdapterResponse: ...
async def query(self, platform_job_id: str) -> TaskStatus: ...
@@ -424,7 +424,7 @@ async def get_runtime_config():
| 5.2 | Pydantic `JobResponse` Schema | `app/schemas/job.py` | 覆盖所有字段 |
| 5.3 | `JobRegistry` 改为先写数据库、再写 Redis | `app/scheduler/registry.py` | 数据库有数据 |
| 5.4 | `JobStatus` 扩展为 6 种状态 | `app/schemas/enums.py` | 覆盖所有场景 |
| 5.5 | `ViduHandler` 接入 Async Engine | `app/scheduler/handlers/vidu_handler.py` | 对口型任务走 Engine |
| 5.5 | `ViduHandler` 接入 Async Engine | `app/scheduler/handlers/vidu_handler.py` | 视频生成任务走 Engine |
| 5.6 | `SubtitleHandler` 改为通过 Gateway 调用 | `app/scheduler/handlers/subtitle_handler.py` | 字幕任务走 Gateway |
| 5.7 | 统一回调入口 `/webhooks/{platform}` | `app/api/v1/webhooks.py` | Vidu 回调正常 |
| 5.8 | 删除 Router 中私设 Redis key 的代码 | `app/api/v1/vidu.py` | 无 `vidu:lipsync:` 字样 |
@@ -433,7 +433,7 @@ async def get_runtime_config():
| 5.11 | 删除 `/script/generate/stream` SSE 端点 | `app/api/v1/script.py` | 端点不存在 |
**验收标准**
- [ ] Vidu 对口型任务提交后,Redis 中只有 `job:{uuid}` 格式的 key
- [ ] Vidu 视频生成任务提交后,Redis 中只有 `job:{uuid}` 格式的 key
- [ ] 应用重启后,从数据库恢复 running 任务继续执行
- [ ] 前端轮询 `/jobs/{id}` 获取所有异步任务状态
@@ -502,7 +502,7 @@ async def get_runtime_config():
| 风险 | 影响 | 概率 | 应对 |
|-----|------|------|------|
| `aiohttp` 迁移到 `httpx` 导致 Vidu 某些边缘场景行为不一致 | 功能回归 | 中 | 迁移后全量测试 Vidu TTS/对口型/克隆 |
| `aiohttp` 迁移到 `httpx` 导致 Vidu 某些边缘场景行为不一致 | 功能回归 | 中 | 迁移后全量测试 Vidu TTS/视频生成/声音复刻 |
| `PlatformError` 未覆盖所有异常路径,仍有裸 Exception 漏出 | 前端收到 500 无法处理 | 低 | `make lint-semantic` 强制检查 + Code Review |
| 配置热重载导致运行时行为突变 | 线上限流突然变更 | 低 | Admin API 加操作日志,变更前确认 |
| Phase 5 数据库改造影响现有 Async Engine | 字幕/脚本任务异常 | 中 | 数据库方案评审后再实施,分步迁移 |
+4 -4
View File
@@ -188,7 +188,7 @@ curl -X POST https://api.vidu.cn/ent/v2/audio-clone \
---
## 六、对口型Lip Sync
## 六、视频生成Lip Sync
### 端点
@@ -208,7 +208,7 @@ POST /ent/v2/lip-sync
| speed | Float | 否 | 语速,默认 1.0,范围 [0.5, 2]。仅文字生成时生效 |
| voice_id | String | 否 | 音色 ID。仅文字生成时生效 |
| volume | Int | 否 | 音量,默认 0(正常音量),范围 [0, 10]。仅文字生成时生效 |
| ref_photo_url | String | 否 | 人脸参考图 URLjpg/jpeg/png/bmp/webp192~4096px,≤10MB)。视频中有多张人脸时,用于指定对口型目标人物 |
| ref_photo_url | String | 否 | 人脸参考图 URLjpg/jpeg/png/bmp/webp192~4096px,≤10MB)。视频中有多张人脸时,用于指定视频生成目标人物 |
| callback_url | String | 否 | 回调地址,任务状态变化时 POST 回调 |
### 视频素材规范
@@ -385,10 +385,10 @@ x-request-nonce:123e4567-e89b-12d3-a456-426614174000
## 八、接入建议
1. **Vidu 优势**:情绪控制、多音字标注、16 个音色(含精品版)、同步复刻、对口型
1. **Vidu 优势**:情绪控制、多音字标注、16 个音色(含精品版)、同步复刻、视频生成
2. **Vidu 劣势**:没有独立的"查询音色列表"API,音色通过飞书表格维护
3. **接口类型差异**
- TTS / 声音复刻:**同步接口**,直接返回结果
- 对口型**异步接口**,需轮询 `GET /tasks/{id}/creations` 或使用 callback
- 视频生成**异步接口**,需轮询 `GET /tasks/{id}/creations` 或使用 callback
4. **速度/音量/音调类型**Vidu 的速度是 **Float**,音量和音调是 **Int**(和 MiniMax 不同,MiniMax 三者都要求 Int
5. **前端适配**:语速 slider 范围改为 0.5~2.0;音量改为 0~10;音调改为 -12~12
@@ -0,0 +1,27 @@
"""
积分流水表添加 category 字段
用于业务分类展示和筛选。
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "8aa48b89a07d"
down_revision: Union[str, None] = "95eb1a1c0af9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"mjk_point_transactions",
sa.Column("category", sa.String(32), nullable=True),
)
def downgrade() -> None:
op.drop_column("mjk_point_transactions", "category")
+1 -1
View File
@@ -76,7 +76,7 @@ class SyncCapable(Protocol):
@runtime_checkable
class TaskCapable(Protocol):
"""异步任务能力(对口型、字幕、视频生成等)"""
"""异步任务能力(视频生成、字幕、TTS 等)"""
async def submit(
self,
+3 -3
View File
@@ -250,7 +250,7 @@ class ScriptPromptBuilder:
"id": 1,
"type": "segment",
"scene": "画面描述",
"voiceover": "配音文",
"voiceover": "配音文",
"duration": "5s"
}
]
@@ -277,7 +277,7 @@ class PolishPromptBuilder:
POLISH_TYPES = {
"scene": "画面描述",
"voiceover": "配音文",
"voiceover": "配音文",
"text": "文案内容",
}
@@ -310,7 +310,7 @@ class PolishPromptBuilder:
请直接返回优化后的画面描述,不要添加解释。"""
def _build_voiceover_prompt(self) -> str:
"""构建配音文润色 Prompt"""
"""构建配音文润色 Prompt"""
return """你是一位专业的短视频文案编辑。你的任务是优化口播文案,使其更加流畅、有吸引力。
优化要求:
@@ -1,4 +1,4 @@
你是一位短视频口播文案专家。请润色以下配音文,使其更适合短视频口播:
你是一位短视频口播文案专家。请润色以下配音文,使其更适合短视频口播:
【原文】
{content}
+3 -3
View File
@@ -236,7 +236,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近半包装修、报价询价主题,优先选工地恶搞、量房勘测、现场交底等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选墙体拆除、水电施工、吊顶造型、瓷砖铺贴、墙面基层等半包施工相关空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -247,8 +247,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -241,7 +241,7 @@
【分镜固定结构规则】
开篇的分镜为: 一段人物出镜
其他都是空镜补充
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 170-190 字,按每秒 4 个字计算,对应时长 42.5-47.5s,内容精炼、节奏紧凑,适配短平快知识口播。
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
@@ -253,8 +253,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数)
【示例】
[
{
+3 -3
View File
@@ -247,7 +247,7 @@
【分镜固定结构规则】
开篇的分镜为: 一段人物出镜
其他都是空镜补充
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
字数与时长控制:纯文字 + 数字扣除标点,严格控制在 220-240 字,按每秒 4 个字核算,对应时长 55-60s,内容精炼紧凑、节奏适中,适配短平快知识口播。
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
@@ -259,8 +259,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数)
【示例】
[
{
+3 -3
View File
@@ -231,7 +231,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近装修合同主题,优先选工地恶搞、装修合同核对、合同签署等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选合同签署、装修合同核对、施工方案现场讲解等贴合合同签约主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,装修报价别只看总价,漏一项,后期就得多花好几万。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,装修报价别只看总价,漏一项,后期就得多花好几万。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -244,8 +244,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -229,7 +229,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近合同主题,优先选装修合同核对、工地恶搞相关)+ 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选合同签署、装修合同核对等贴合合同主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,如:“第一,装修报价别只看总价,漏一项,后期就得多花好几万。”每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,如:“第一,装修报价别只看总价,漏一项,后期就得多花好几万。”每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的"分镜时长"为{严格按**每秒4个纯文字**计算时长。文字统计硬性定义:**纯文字包含汉字、阿拉伯数字,只扣除标点符号**,所有字数、时长全部按这个口径计算,即"分镜文案"的纯文字字数/4},严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -242,8 +242,8 @@ type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
【示例】
[
{
+3 -3
View File
@@ -231,7 +231,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近装修合同签约、条款避坑主题,优先选工地恶搞、装修合同核对、毛坯全景等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选装修合同核对、现场交底、全屋验收等贴合合同签约避坑主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -242,8 +242,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -229,7 +229,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近装修监工主题,优先选工地恶搞、墙体拆除、墙面开裂等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选墙体拆除、吊顶造型、水电验收、瓷砖铺贴等贴合施工节点主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,瓷砖排版别让瓦工来做,商家设计师免费排版更精准。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,瓷砖排版别让瓦工来做,商家设计师免费排版更精准。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -242,8 +242,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -232,7 +232,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近装修监工、节点把控主题,优先选工地恶搞、墙面空鼓、毛坯全景等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选吊顶造型、防水施工、面漆涂刷、全屋定制相关空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -243,8 +243,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -232,7 +232,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近装修施工、业主干预工序主题,优先选贴砖恶搞、墙面空鼓、水电施工等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选水电施工、防水施工、木作基层、瓷砖铺贴等贴合家装全流程主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,瓦工结束后不能立马验收,至少要等五六天。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,瓦工结束后不能立马验收,至少要等五六天。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数,如 “装修师傅最讨厌业主 6 件事,千万别乱干预施工” 总共 20 个文字 1 个数字,则是 "5.25s"
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -243,8 +243,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -229,7 +229,7 @@
【分镜固定结构规则】
开篇的分镜为: 一段人物出镜
其他都是空镜补充
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -240,8 +240,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数)
【示例】
[
{
+3 -3
View File
@@ -222,7 +222,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近全屋定制主题,优先选工地恶搞、装修合同核对、柜体木作等相关)+ 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选柜体木作、板材裁切、装修合同核对等贴合全屋定制主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,如:“第一,瓷砖排版别让瓦工来做,商家设计师免费排版更精准。”每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,如:“第一,瓷砖排版别让瓦工来做,商家设计师免费排版更精准。”每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的"分镜时长"为{严格按**每秒4个纯文字**计算时长。文字统计硬性定义:**纯文字包含汉字、阿拉伯数字,只扣除标点符号**,所有字数、时长全部按这个口径计算,即"分镜文案"的纯文字字数/4},严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -235,8 +235,8 @@ type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
【示例】
[
{
+4 -4
View File
@@ -249,11 +249,11 @@ type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近主题)+ 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
"分镜文案"等于"配音文",每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,则用多个分镜
"分镜文案"等于"配音文",每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,则用多个分镜
每个分镜的"分镜时长"为{"分镜文案"的字数(含数字,不含标点符号)/4},严格控制在3-8秒,可以是两位小数,如 3.25 秒
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
“empty_shot”(空镜补充)对应上述素材库标题,文案内容需匹配,如无法匹配则选择近似的空镜
配音文必须要有标点符号断句,避免大长句,如:装修报价别只看总价,漏一项,后期就得多花好几万。
配音文必须要有标点符号断句,避免大长句,如:装修报价别只看总价,漏一项,后期就得多花好几万。
禁止总字数偏离400–480(含数字,不含标点符号)、总时长偏离100–120秒。
禁止篡改原文防水尺寸、时间、工艺核心数据。
@@ -263,8 +263,8 @@ type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
【示例】
[
{
+3 -3
View File
@@ -224,7 +224,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近水电主题,优先选水管错位、工地恶搞、水电完工环视等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选水路施工、电路施工、水电验收等贴合水电主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数。
type 为 segment = 人物出镜;type=empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -237,8 +237,8 @@ type 为 segment = 人物出镜;type=empty_shot = 从下方内置素材库选
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
【示例】
[
{
+3 -3
View File
@@ -224,7 +224,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,贴合装修省钱避坑主题)+ 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,匹配不上选近似主材、家装类空镜
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
分镜文案=配音文,必须带标点断句,不做大长句;每个分镜文案纯文字(含数字、扣标点)严格12-32字。
分镜文案=配音文,必须带标点断句,不做大长句;每个分镜文案纯文字(含数字、扣标点)严格12-32字。
分镜时长计算规则:纯文字含汉字+阿拉伯数字,只扣除标点,字数÷4,保留两位小数,单镜时长严控3-8秒。
type规则:segment=人物出镜,empty_shot=选上方素材库标题。
硬性约束:全程总纯文字200-240字、总时长50-60s;每次随机打乱8个点位顺序,不篡改原文参数和选购逻辑。
@@ -234,8 +234,8 @@ type规则:segment=人物出镜,empty_shot=选上方素材库标题。
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
【示例】
[
{
+3 -3
View File
@@ -230,7 +230,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近瓷砖铺贴、停工避坑主题,优先选贴砖恶搞、墙面空鼓、瓷砖铺贴等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选瓷砖铺贴、美缝施工、成品保护、水电验收等贴合瓷砖铺贴停工主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,瓦工结束后不能立马验收,至少要等五六天。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,如:“第一,瓦工结束后不能立马验收,至少要等五六天。” 每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数,如 “瓷砖铺完别着急复工,这 5 件事做早了全是坑” 总共 20 个文字 1 个数字,则是 "5.25s"
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -241,8 +241,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “瓷砖铺完别着急复工,这 5 件事做早了全是坑” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “瓷砖铺完别着急复工,这 5 件事做早了全是坑” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -240,7 +240,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近瓦工铺贴、监工话术主题,优先选贴砖恶搞、瓷砖铺贴、墙面空鼓等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选瓷砖铺贴、瓷砖开孔、墙面基层、水电验收等贴合瓦工监工主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -251,8 +251,8 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在 12-32 个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为 "配音文" 的字数(含数字,不含标点符号)/4,严格控制在 3-8 秒,可以是两位小数,如 “他不是在赶工期,只是在图省事,这 4 点一定要做好” 总共 20 个文字 1 个数字,则是 "5.25s"
【示例】
[
{
+3 -3
View File
@@ -229,7 +229,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近瓦工主题,优先选贴砖恶搞、墙面空鼓、瓷砖开裂等相关)+ 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选瓷砖铺贴、瓷砖开孔、陶粒回填等贴合瓦工主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,如:“第一,瓷砖排版别让瓦工来做,商家设计师免费排版更精准。”每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,如:“第一,瓷砖排版别让瓦工来做,商家设计师免费排版更精准。”每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的"分镜时长"为{严格按**每秒4个纯文字**计算时长。文字统计硬性定义:**纯文字包含汉字、阿拉伯数字,只扣除标点符号**,所有字数、时长全部按这个口径计算,即"分镜文案"的纯文字字数/4},严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹配标题。
“segment”(主播口播出镜)对应 “人物出镜”,人物出镜画面的内容,可以不用完整的句子,句子可以延伸到下一个画面
@@ -242,8 +242,8 @@ type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
【示例】
[
{
+3 -3
View File
@@ -240,7 +240,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近装修通用避坑主题,优先选工地恶搞、墙面开裂、墙面空鼓等相关)+ 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选瓷砖铺贴、吊顶造型、柜体木作、墙面基层等贴合家装细节主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有2段人物出镜
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案"等于"配音文”,“配音文”必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在12-32个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的"分镜时长"为{严格按**每秒4个纯文字**计算时长。文字统计硬性定义:**纯文字包含汉字、阿拉伯数字,只扣除标点符号**,所有字数、时长全部按这个口径计算,即"分镜文案"的纯文字字数/4},严格控制在3-8秒,可以是两位小数。
type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹配标题。
“segment”对应 “人物出镜”,人物出镜画面内容可语句顺延到下一画面。
@@ -254,8 +254,8 @@ type为segment=人物出镜;type为empty_shot=从下方内置素材库选匹
id: 按顺序递增(1、2、3…)
type: “segment”(主播口播出镜)或 “empty_shot”(空镜补充)
scene: “人物出镜” 或上述素材库标题(严格与文案内容匹配,如文案内容前后有区别,以文案开头内容为主)
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
voiceover: “配音文”(必填,口语化,每个分镜严格控制在12-32个字,含数字,不含标点符号,必须要有标点符号断句,避免大长句,贴合决策期业主痛点)
duration: “分镜时长”(如 “5s”,时长为"配音文"的字数(含数字,不含标点符号)/4,严格控制在3-8秒,可以是两位小数,如“他不是在赶工期,只是在图省事,这4点一定要做好”总共20个文字1个数字,则是"5.25s"
【示例】
[
{
+2 -2
View File
@@ -227,7 +227,7 @@
开篇的分镜为:一段网红开篇(可选用恶搞开篇或施工翻车镜,最好能贴近油工主题,优先选墙面开裂、墙面空鼓、搞笑涂料施工等相关)+ 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
分点阐述全部用空镜,空镜(素材库标题)与文案内容需匹配,如无法匹配则选择近似的空镜(优先选墙面基层、面漆涂刷、吊顶造型等贴合油工主题的空镜)
结尾的分镜为:一段空镜补充 + 一段人物出镜 + 一段空镜补充,不得有 2 段人物出镜
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
“分镜文案 "等于" 配音文”,“配音文” 必须要有标点符号断句,避免大长句,每段分镜的分镜文案字数严格控制在 12-32 个字,含数字,不含标点符号。文案一个分镜说不完,超出必须拆分句子多分镜。句子过长强制拆分成多个分镜,保证语句通顺、带完整标点。
每个分镜的 "分镜时长" 为 {严格按每秒 4 个纯文字计算时长。文字统计硬性定义:纯文字包含汉字、阿拉伯数字,只扣除标点符号,所有字数、时长全部按这个口径计算,即 "分镜文案" 的纯文字字数 / 4},严格控制在 3-8 秒,可以是两位小数。
type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库选匹配标题。
“segment” 对应 “人物出镜”,人物出镜画面内容可语句顺延到下一画面。
@@ -240,7 +240,7 @@ type 为 segment = 人物出镜;type 为 empty_shot = 从下方内置素材库
id: 按顺序递增(1、2、3…)
type: “segment” 或 “empty_shot”
scene: “人物出镜” 或上述素材库标题
voiceover: “配音文
voiceover: “配音文
duration: “分镜时长”
【示例】
[
+3 -3
View File
@@ -5,7 +5,7 @@ Vidu API Provider
封装 Vidu 语音/视频相关 HTTP API
- 同步 TTS/ent/v2/audio-tts
- 声音复刻(/ent/v2/audio-clone
- 对口型/ent/v2/lip-sync
- 视频生成/ent/v2/lip-sync
- 查询任务(/ent/v2/tasks/{id}/creations
统一使用 httpx.AsyncClient,由 lifespan 统一管理生命周期。
@@ -193,7 +193,7 @@ class ViduProvider:
error_type=PlatformErrorType.TIMEOUT,
) from e
# ==================== 对口型 ====================
# ==================== 视频生成 ====================
async def lip_sync(
self,
@@ -207,7 +207,7 @@ class ViduProvider:
callback_url: str | None = None,
payload: str | None = None,
) -> dict[str, Any]:
"""对口型(异步接口)
"""视频生成(异步接口)
POST /ent/v2/lip-sync
"""
+92 -52
View File
@@ -52,20 +52,66 @@ async def get_balance(
@router.get("/transactions", response_model=ApiResponse[PointTransactionListResponse])
async def list_transactions(
pagination: PaginationParams = Depends(),
tx_type: str | None = None,
category: str | None = None,
start_time: str | None = None,
end_time: str | None = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取当前用户积分流水记录"""
"""
获取当前用户积分流水记录(支持筛选)
- tx_type: consume / recharge / expire
- category: 脚本生成 / 配音合成 / 视频生成 / 压制成片 / 字幕烧录 / 封面设计 / 充值
- start_time / end_time: ISO 8601 格式,时间范围最多 30 天
"""
from datetime import UTC, datetime, timedelta
# 解析时间范围
parsed_start: datetime | None = None
parsed_end: datetime | None = None
if start_time:
try:
parsed_start = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="start_time 格式错误,应为 ISO 8601")
if end_time:
try:
parsed_end = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
except ValueError:
raise HTTPException(status_code=400, detail="end_time 格式错误,应为 ISO 8601")
# 限制时间范围最多 30 天
if parsed_start and parsed_end:
if (parsed_end - parsed_start) > timedelta(days=30):
raise HTTPException(status_code=400, detail="时间范围最多 30 天")
elif parsed_start and not parsed_end:
parsed_end = parsed_start + timedelta(days=30)
elif parsed_end and not parsed_start:
parsed_start = parsed_end - timedelta(days=30)
items = await point_transaction.get_by_user_id(
db,
user_id=current_user.id,
skip=pagination.offset,
limit=pagination.page_size,
tx_type=tx_type,
category=category,
start_time=parsed_start,
end_time=parsed_end,
)
# 由于 point_transaction CRUD 没有 count 方法,这里用 items 长度近似
# 实际需要时可以在 CRUD 加 count 方法
total = len(items)
total = await point_transaction.count_by_user_id(
db,
user_id=current_user.id,
tx_type=tx_type,
category=category,
start_time=parsed_start,
end_time=parsed_end,
)
return success_response(
data=PointTransactionListResponse(
@@ -116,6 +162,15 @@ async def create_recharge_order(
out_trade_no = f"MJZ{order.id:012d}"
order.out_trade_no = out_trade_no
# 二维码有效期 5 分钟(与前端轮询对齐)
from datetime import datetime, timedelta, timezone
# 前端倒计时用 UTC ISO 格式,不受服务器时区影响
expire_at = datetime.now(timezone.utc) + timedelta(minutes=5)
# 微信 time_expire 要求北京时间(UTC+8),格式 yyyyMMddHHmmss
beijing_tz = timezone(timedelta(hours=8))
time_expire = expire_at.astimezone(beijing_tz).strftime("%Y%m%d%H%M%S")
# 调用微信支付统一下单
try:
from app.services.wxpay_service import WechatPayError, get_wxpay_service
@@ -131,6 +186,7 @@ async def create_recharge_order(
out_trade_no=out_trade_no,
amount=request.amount_rmb,
attach=str(current_user.id),
time_expire=time_expire,
)
# 记录微信返回
@@ -152,6 +208,7 @@ async def create_recharge_order(
points=request.points,
amount_rmb=request.amount_rmb,
code_url=code_url,
expire_at=expire_at.isoformat(),
),
message="充值订单已创建,请扫描微信二维码完成支付",
)
@@ -359,6 +416,21 @@ async def query_recharge_status(
)
# ── 充值档位查询 ──────────────────────────────────────
@router.get("/recharge-options", response_model=ApiResponse[list[dict]])
async def get_recharge_options(
current_user: User = Depends(get_current_user),
):
"""
获取充值档位配置(由后端控制,支持积分赠送)。
前端充值弹窗调用此接口展示可选档位,无需硬编码。
"""
options = point_service.get_recharge_options()
return success_response(data=options, message="获取充值档位成功")
# ── 积分预估查询 ──────────────────────────────────────
@router.get("/cost")
@@ -424,28 +496,27 @@ async def consume_points(
直接消费积分(不经过冻结)
用于 Rust/前端层业务在执行本地操作前扣费:
- compose视频合成
- subtitle_burn(字幕压制
- compose压制成片
- subtitle_burn(字幕烧录
- cover_design(封面设计)
余额不足时返回 402,前端应拦截并引导充值。
"""
# 余额预检:不允许欠费
balance_info = await point_service.get_user_balance(db, current_user.id)
if balance_info["balance"] < request.points:
raise HTTPException(
status_code=402,
detail=f"积分不足,当前余额 {balance_info['balance']},需要 {request.points} 积分",
try:
tx = await point_service.consume(
db,
user_id=current_user.id,
points=request.points,
source_type=request.source_type,
source_id=request.source_id,
description=f"{request.description or request.source_type}",
allow_negative=False,
)
tx = await point_service.consume(
db,
user_id=current_user.id,
points=request.points,
source_type=request.source_type,
source_id=request.source_id,
description=f"{request.description or request.source_type}",
)
except ValueError as e:
# 余额不足(在同一事务内判断,避免竞态)
if "积分不足" in str(e):
raise HTTPException(status_code=402, detail=str(e))
raise HTTPException(status_code=400, detail=str(e))
await db.commit()
return success_response(
@@ -487,34 +558,3 @@ async def list_recharge_orders(
)
# ── 内部管理接口(后续可加管理员权限检查)─────────────
@router.post("/admin/recharge", response_model=ApiResponse[dict])
async def admin_recharge(
user_id: str,
points: int,
source: str = "compensation",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
管理员直接充值(用于补偿、活动赠送等)
需要管理员权限,当前先做接口占位。
"""
# TODO: 加管理员权限检查
try:
tx = await point_service.recharge(
db,
user_id=user_id,
points=points,
source=source,
description=f"管理员操作: {source}",
)
await db.commit()
return success_response(
data={"transaction_id": tx.id, "points": points},
message=f"成功充值 {points} 积分",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+2 -2
View File
@@ -39,13 +39,13 @@ api_router.include_router(script.router, prefix="/script", tags=["Script"])
# 字幕生成模块(火山引擎-豆包语音)
api_router.include_router(caption.router, tags=["Caption"])
# 语音合成模块(TTS + 声音克隆
# 语音合成模块(TTS + 声音复刻
api_router.include_router(voice.router, tags=["Voice"])
# 文件上传模块
api_router.include_router(upload.router, tags=["Upload"])
# Vidu 对口型模块
# Vidu 视频生成模块
api_router.include_router(vidu.router, tags=["Vidu"])
# 空镜素材模块
+7 -27
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import asyncio
import logging
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
@@ -59,7 +59,7 @@ async def polish_content(
AI 润色文案/画面描述
- `polishType=scene`: 润色画面描述(根据 shot_type 自动区分分镜/空镜)
- `polishType=voiceover`: 润色配音文
- `polishType=voiceover`: 润色配音文
参数:
- `shot_type`: "segment"(分镜)或 "empty_shot"(空镜),画面润色时必填
@@ -92,18 +92,10 @@ async def polish_content(
)
except ValueError as e:
logger.warning(f"[Polish] 润色失败: {e}")
return success_response(
code=500,
message="润色失败,请检查输入内容后重试",
data=None,
)
raise HTTPException(status_code=500, detail="润色失败,请检查输入内容后重试")
except Exception as e:
logger.error(f"[Polish] 润色异常: {e}")
return success_response(
code=500,
message=f"{type_name}润色失败,请稍后重试",
data=None,
)
raise HTTPException(status_code=500, detail=f"{type_name}润色失败,请稍后重试")
@router.get("/model-health", response_model=ApiResponse[ModelHealthResponse])
@@ -157,11 +149,7 @@ async def generate_title(
user_template = load_prompt("user/title")
if not system_prompt or not user_template:
return success_response(
code=500,
message="标题生成提示词文件缺失",
data=None,
)
raise HTTPException(status_code=500, detail="标题生成提示词文件缺失")
# 根据使用场景确定描述
if request.usage == "cover":
@@ -221,15 +209,7 @@ async def generate_title(
)
except TimeoutError:
logger.warning("[generate_title] 标题生成超时")
return success_response(
code=500,
message="标题生成超时,请重试",
data=None,
)
raise HTTPException(status_code=500, detail="标题生成超时,请重试")
except Exception as e:
logger.error(f"[generate_title] 标题生成失败: {e}")
return success_response(
code=500,
message=f"标题生成失败: {str(e)}",
data=None,
)
raise HTTPException(status_code=500, detail=f"标题生成失败: {str(e)}")
+3 -3
View File
@@ -85,7 +85,7 @@ class TTSParams(BaseModel):
class VideoParams(BaseModel):
"""视频生成(对口型)参数"""
"""视频生成参数"""
video_url: str = Field(..., min_length=1, description="原视频 URL(数字人模板)")
audio_url: str | None = Field(default=None, description="音频 URL(与 text 二选一)")
@@ -94,7 +94,7 @@ class VideoParams(BaseModel):
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="语速")
volume: int = Field(default=0, ge=0, le=10, description="音量")
ref_photo_url: str | None = Field(default=None, description="人脸参考图 URL")
duration: float | None = Field(default=None, description="输入音频时长(秒),用于后端扣费")
duration: float = Field(..., gt=0, description="输入音频时长(秒),用于后端扣费")
@field_validator("video_url")
@classmethod
@@ -231,7 +231,7 @@ async def create_task(
logger.info(f"[API] Task created: {task_id}, type={task_type}, user={user_id}")
return TaskCreateResponse(
task_id=task_id,
status="pending",
status="running",
message=f"{task_type} 任务已创建",
)
+2 -4
View File
@@ -2,7 +2,7 @@
Vidu 回调路由
============
仅保留 Vidu 对口型任务完成回调接口
仅保留 Vidu 视频生成任务完成回调接口
视频生成任务统一走 /tasks/video 创建/tasks/{task_id} 轮询
Vidu 任务完成后主动 POST 通知此接口
@@ -31,7 +31,7 @@ def get_platform_gateway(request: Request) -> PlatformGateway:
@router.post("/callback")
async def vidu_callback(request: Request):
"""
Vidu 对口型任务完成回调
Vidu 视频生成任务完成回调
Vidu 任务完成后主动 POST 通知此接口
无需登录校验Vidu 外部调用统一走 PlatformGateway 处理
@@ -106,8 +106,6 @@ async def vidu_callback(request: Request):
message="视频生成失败",
error=task_status.error_message or "视频生成失败",
)
# 移除 running set(如果还在)
await registry.remove_running(internal_task_id)
logger.info(
f"[Vidu] 回调已更新 TaskRegistry: task={internal_task_id}, "
f"state={task_status.state}, video_url={video_url}"
+21 -8
View File
@@ -2,10 +2,11 @@
语音合成与克隆 API 路由
=======================
提供 TTS 语音合成批量合成声音克隆等功能
提供 TTS 语音合成批量合成声音复刻等功能
基于 Vidu API
"""
import asyncio
import logging
import re
import uuid
@@ -67,7 +68,7 @@ class TTSBatchRequest(BaseModel):
class VoiceCloneSubmitRequest(BaseModel):
"""声音克隆提交请求"""
"""声音复刻提交请求"""
source_audio_url: str | None = Field(None, description="源音频 URL5-30秒,mp3/wav,需公开可访问)")
source_video_url: str | None = Field(None, description="源视频 URL(可选)")
@@ -119,6 +120,7 @@ class VoiceInfo(BaseModel):
async def upload_voice_file(
file: UploadFile = File(...),
file_type: str = Form(default="audio", description="文件类型: audio | video"),
current_user: User = Depends(get_current_user),
):
"""
上传音频/视频文件到七牛云
@@ -186,7 +188,9 @@ async def upload_voice_file(
@router.get("/voices", response_model=ApiResponse[list[VoiceInfo]])
async def list_voices():
async def list_voices(
current_user: User = Depends(get_current_user),
):
"""
获取可用音色列表
@@ -351,11 +355,15 @@ async def synthesize_to_file(
output_path: str,
service: ViduService = Depends(get_vidu_service),
request: Request = None,
current_user: User = Depends(get_current_user),
):
"""
TTS 合成并保存到指定路径
将文本转换为语音并保存到指定文件路径
注意不对 output_path 做目录白名单限制
本接口仅用于 Tauri 桌面端本地文件写入调用方就是用户自己的设备
不存在第三方攻击场景故不做路径限制
"""
try:
audio_url = await service.synthesize(
@@ -430,7 +438,7 @@ async def submit_clone_task(
current_user: User = Depends(get_current_user),
):
"""
提交声音克隆任务Vidu
提交声音复刻任务Vidu
Vidu 声音复刻是同步接口直接返回结果
"""
@@ -450,7 +458,7 @@ async def submit_clone_task(
points=points,
source_type="voice_clone",
source_id=result.get("voice_id", "unknown"),
description="【声音克隆",
description="【声音复刻",
)
await db.commit()
except Exception as e:
@@ -478,9 +486,13 @@ async def submit_clone_task(
@router.get("/clone/query/{task_id}", response_model=ApiResponse[VoiceCloneTaskResponse])
async def query_clone_task(task_id: str, blocking: bool = False):
async def query_clone_task(
task_id: str,
blocking: bool = False,
current_user: User = Depends(get_current_user),
):
"""
查询声音克隆任务状态Vidu
查询声音复刻任务状态Vidu
Vidu 声音复刻是同步接口此端点仅做兼容直接返回成功状态
"""
@@ -498,6 +510,7 @@ async def clone_and_wait(
request: VoiceCloneSubmitRequest,
service: ViduService = Depends(get_vidu_service),
poll_interval: float = 5.0,
current_user: User = Depends(get_current_user),
):
"""
一站式克隆提交并等待完成
@@ -526,5 +539,5 @@ async def clone_and_wait(
raise
except Exception as e:
logger.error(f"[Voice] 克隆失败: {e}")
raise HTTPException(status_code=500, detail="声音克隆失败,请稍后重试")
raise HTTPException(status_code=500, detail="声音复刻失败,请稍后重试")
+47 -8
View File
@@ -5,6 +5,8 @@
只增不改用于审计和对账
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -25,17 +27,54 @@ class PointTransactionCRUD(CRUDBase[PointTransaction]):
user_id: str,
skip: int = 0,
limit: int = 50,
tx_type: str | None = None,
category: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> list[PointTransaction]:
"""根据用户 ID 获取流水记录(分页,按时间倒序)"""
result = await db.execute(
select(PointTransaction)
.where(PointTransaction.user_id == user_id)
.order_by(PointTransaction.created_at.desc())
.offset(skip)
.limit(limit)
)
"""根据用户 ID 获取流水记录(支持筛选和分页,按时间倒序)"""
stmt = select(PointTransaction).where(PointTransaction.user_id == user_id)
if tx_type:
stmt = stmt.where(PointTransaction.type == tx_type)
if category:
stmt = stmt.where(PointTransaction.category == category)
if start_time:
stmt = stmt.where(PointTransaction.created_at >= start_time)
if end_time:
stmt = stmt.where(PointTransaction.created_at <= end_time)
stmt = stmt.order_by(PointTransaction.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return list(result.scalars().all())
async def count_by_user_id(
self,
db: AsyncSession,
*,
user_id: str,
tx_type: str | None = None,
category: str | None = None,
start_time: datetime | None = None,
end_time: datetime | None = None,
) -> int:
"""根据筛选条件统计流水记录总数"""
from sqlalchemy import func
stmt = select(func.count(PointTransaction.id)).where(PointTransaction.user_id == user_id)
if tx_type:
stmt = stmt.where(PointTransaction.type == tx_type)
if category:
stmt = stmt.where(PointTransaction.category == category)
if start_time:
stmt = stmt.where(PointTransaction.created_at >= start_time)
if end_time:
stmt = stmt.where(PointTransaction.created_at <= end_time)
result = await db.execute(stmt)
return result.scalar() or 0
async def get_by_source(
self,
db: AsyncSession,
@@ -78,6 +78,12 @@ class PointTransaction(BaseModelBigInt):
comment="时长(秒),按秒计费业务记录",
)
category: Mapped[str | None] = mapped_column(
String(32),
nullable=True,
comment="业务分类:脚本生成 / 配音合成 / 视频生成 / 压制成片 / 字幕烧录 / 封面设计 / 充值",
)
description: Mapped[str | None] = mapped_column(
Text,
nullable=True,
@@ -52,6 +52,26 @@ class VideoHandler(AsyncHandler):
)
return self.service
async def _deduct_video_points(self, task: Any, params: dict[str, Any]) -> None:
"""视频生成后置扣费(callback 成功和主动查询成功共用)"""
try:
duration = float(params.get("duration", 0) or 0)
if duration > 0:
async with AsyncSessionLocal() as db:
points = ps._calculate_cost("video", {"seconds": duration})
await ps.consume(
db,
user_id=task.user_id,
points=points,
source_type="video",
source_id=task.task_id,
description="【视频生成】",
duration=duration,
)
await db.commit()
except Exception as e:
logger.error(f"[Video {task.task_id}] 扣费失败: {e}")
async def tick(
self, tasks: list[Any], registry: TaskRegistry, slots: SlotManager
) -> list[StateChange]:
@@ -66,24 +86,7 @@ class VideoHandler(AsyncHandler):
result_data = task.result or {}
if result_data.get("video_url") or result_data.get("state") == "success":
# callback 已到达,结果已写入 TaskRegistry
# 后置扣费
try:
duration = float(params.get("duration", 0) or 0)
if duration > 0:
async with AsyncSessionLocal() as db:
points = ps._calculate_cost("video", {"seconds": duration})
await ps.consume(
db,
user_id=task.user_id,
points=points,
source_type="video",
source_id=task.task_id,
description="【数字人视频】",
duration=duration,
)
await db.commit()
except Exception as e:
logger.error(f"[Video {task.task_id}] 扣费失败: {e}")
await self._deduct_video_points(task, params)
changes.append(
StateChange(
@@ -112,8 +115,8 @@ class VideoHandler(AsyncHandler):
)
)
elif task.status == "failed":
# callback 已标记失败
pass # 状态已在 callback 中更新,无需额外变更
# callback 已标记失败,移除 running
await registry.remove_running(task.task_id)
else:
# callback 尚未到达,fallback:主动查询 Vidu API
try:
@@ -124,24 +127,7 @@ class VideoHandler(AsyncHandler):
video_url = creations[0].get("url") if creations else None
if vidu_state == "success" and video_url:
# 后置扣费
try:
duration = float(params.get("duration", 0) or 0)
if duration > 0:
async with AsyncSessionLocal() as db:
points = ps._calculate_cost("video", {"seconds": duration})
await ps.consume(
db,
user_id=task.user_id,
points=points,
source_type="video",
source_id=task.task_id,
description="【数字人视频】",
duration=duration,
)
await db.commit()
except Exception as e:
logger.error(f"[Video {task.task_id}] 扣费失败: {e}")
await self._deduct_video_points(task, params)
changes.append(
StateChange(
@@ -247,7 +233,7 @@ class VideoHandler(AsyncHandler):
raise ValueError("callback_url 未配置且无法自动推断应用地址")
logger.info(
f"[Video {task.task_id}] 准备提交 Vidu 对口型: "
f"[Video {task.task_id}] 准备提交 Vidu 视频生成: "
f"callback_url={callback_url}, video_url={params.get('video_url', '')[:60]}..."
)
vidu_task_id = await service.lip_sync_create(
+3
View File
@@ -33,6 +33,7 @@ class PointTransactionItem(BaseModel):
source_type: str | None = Field(None, description="消费来源类型")
source_id: str | None = Field(None, description="消费来源业务 ID")
duration: float | None = Field(None, description="时长(秒),按秒计费业务记录")
category: str | None = Field(None, description="业务分类:脚本生成 / 配音合成 / 视频生成 / 压制成片 / 字幕烧录 / 封面设计 / 充值")
description: str | None = None
created_at: datetime
@@ -63,6 +64,8 @@ class RechargeResponse(BaseModel):
amount_rmb: int
# Native 支付二维码链接,前端用此生成二维码供用户微信扫描
code_url: str | None = None
# 二维码过期时间(ISO 格式),前端据此显示倒计时
expire_at: str | None = None
# ── 积分预估 ──────────────────────────────────────────
+1 -1
View File
@@ -28,7 +28,7 @@ class Segment(BaseModel):
default="segment", description="分镜类型: segment(分镜) 或 empty_shot(空镜)"
)
scene: str = Field(default="", description="场景描述/画面描述")
voiceover: str = Field(default="", description="配音文(空镜可为空)")
voiceover: str = Field(default="", description="配音文(空镜可为空)")
duration: int | None = Field(default=None, description="时长(秒)")
voice_id: str | None = Field(default=None, description="音色ID(空镜时使用)")
status: SegmentStatus = Field(default=SegmentStatus.PENDING)
+1 -1
View File
@@ -206,7 +206,7 @@ def validate_and_normalize_shots(raw_data: Any) -> list[dict[str, Any]]:
)
normalized["scene"] = sanitize_string(scene, max_length=2000)
# 提取配音文(支持多种字段名)
# 提取配音文(支持多种字段名)
voiceover = (
item.get("voiceover")
or item.get("text")
+86 -27
View File
@@ -57,10 +57,17 @@ def _load_points_config() -> dict:
merged[key] = {"mode": "duration", **rule}
for key in cfg.get("free_services", []):
merged[key] = {"mode": "free", "points": 0}
# 保留充值档位原始配置
merged["_recharge_options"] = cfg.get("recharge_options", [])
return merged
POINTS_CONFIG: dict[str, dict] = _load_points_config()
def get_recharge_options() -> list[dict]:
"""获取充值档位配置(由后端控制,支持积分赠送)"""
return POINTS_CONFIG.get("_recharge_options", [])
EXPIRATION_DAYS = 180
@@ -211,6 +218,20 @@ async def recharge(
now = _now()
# 幂等保护:同一笔订单(order_id)只能充值一次
if order_id:
existing_result = await db.execute(
select(PointTransaction)
.where(
PointTransaction.source_id == str(order_id),
PointTransaction.type == "recharge",
)
)
existing_tx = existing_result.scalar_one_or_none()
if existing_tx:
logger.warning(f"[Points] 订单 {order_id} 已充值过,跳过重复充值")
return existing_tx
# 1. 获取或创建用户积分账户
result = await db.execute(
select(UserPoint).where(UserPoint.user_id == user_id)
@@ -233,17 +254,23 @@ async def recharge(
up.balance += points
up.total_recharged += points
# 3. 写入批次
expired_at = batch_expired_at or (now + timedelta(days=EXPIRATION_DAYS))
batch = PointBatch(
user_id=user_id,
amount=points,
remaining=points,
expired_at=expired_at,
source=source,
)
db.add(batch)
await db.flush()
# 3. 写入批次(欠费充值时,先用新积分偿还欠费,剩余部分才写入批次)
debt = max(0, -balance_before) # 欠费金额
batch_remaining = max(0, points - debt) # 实际可用的批次积分
batch_id = None
if batch_remaining > 0:
expired_at = batch_expired_at or (now + timedelta(days=EXPIRATION_DAYS))
batch = PointBatch(
user_id=user_id,
amount=batch_remaining,
remaining=batch_remaining,
expired_at=expired_at,
source=source,
)
db.add(batch)
await db.flush()
batch_id = batch.id
# 4. 写流水
tx = PointTransaction(
@@ -254,7 +281,8 @@ async def recharge(
balance_after=up.balance,
source_type=source,
source_id=str(order_id) if order_id else None,
batch_id=batch.id,
batch_id=batch_id,
category=_CATEGORY_MAP.get(source, "充值"),
description=description or f"{source} 充值 {points} 积分",
)
db.add(tx)
@@ -264,6 +292,24 @@ async def recharge(
# ── 消费(后置扣费)───────────────────────────────────
# source_type → category 映射(用于流水分类展示)
_CATEGORY_MAP: dict[str, str] = {
"script": "脚本生成",
"polish": "文案润色",
"title": "标题生成",
"tts": "配音合成",
"voice_clone": "声音复刻",
"video": "视频生成",
"compose": "压制成片",
"subtitle_burn": "字幕烧录",
"cover_design": "封面设计",
"wxpay": "充值",
"compensation": "充值",
"invite": "充值",
"gift": "充值",
}
async def consume(
db: AsyncSession,
*,
@@ -273,22 +319,42 @@ async def consume(
source_id: str,
description: str = "",
duration: float | None = None,
category: str | None = None,
allow_negative: bool = True,
) -> PointTransaction:
"""
直接扣费后置计费
业务执行成功后调用按实际消耗直接扣除余额
余额不足时允许欠费余额变负但欠费后用户不可继续使用
默认允许欠费余额变负用于 Scheduler 后置扣费
前端/Rust 层调用时可设置 allow_negative=False余额不足时抛出异常
:param points: 实际消耗积分正整数
:param source_type: 消费来源类型
:param source_id: 关联的任务 ID 或订单 ID
:param allow_negative: 是否允许扣费后余额为负
:return: 消费流水记录
"""
if points <= 0:
raise ValueError("消费积分必须大于 0")
# 1. 获取用户积分账户(加锁)
# 加锁顺序约定:先 PointBatch 后 UserPoint,避免与 expire_batches 死锁
# expire_batches 的锁顺序也是 PointBatch → UserPoint
# 1. FIFO 扣减批次 remaining(先加锁,与 expire_batches 顺序一致)
result = await db.execute(
select(PointBatch)
.where(
PointBatch.user_id == user_id,
PointBatch.remaining > 0,
PointBatch.expired_at > _now(),
)
.order_by(PointBatch.expired_at.asc())
.with_for_update()
)
batches: list[PointBatch] = list(result.scalars().all())
# 2. 获取用户积分账户(加锁)
result = await db.execute(
select(UserPoint)
.where(UserPoint.user_id == user_id)
@@ -308,18 +374,9 @@ async def consume(
db.add(up)
await db.flush()
# 2. FIFO 扣减批次 remaining
result = await db.execute(
select(PointBatch)
.where(
PointBatch.user_id == user_id,
PointBatch.remaining > 0,
PointBatch.expired_at > _now(),
)
.order_by(PointBatch.expired_at.asc())
.with_for_update()
)
batches: list[PointBatch] = list(result.scalars().all())
# 3. 余额检查(在同一事务内,避免竞态)
if not allow_negative and up.balance < points:
raise ValueError(f"积分不足,当前余额 {up.balance},需要 {points} 积分")
remaining_to_deduct = points
for batch in batches:
@@ -329,7 +386,7 @@ async def consume(
batch.remaining -= deduct
remaining_to_deduct -= deduct
# 3. 更新用户账户(允许欠费:balance 可能变负)
# 4. 更新用户账户(允许欠费:balance 可能变负)
balance_before = up.balance
up.balance -= points
up.total_consumed += points
@@ -345,6 +402,7 @@ async def consume(
source_id=source_id,
batch_id=batches[0].id if batches else None,
duration=duration,
category=category or _CATEGORY_MAP.get(source_type, source_type),
description=description or f"消费 {source_type} {points} 积分",
)
db.add(tx)
@@ -406,6 +464,7 @@ async def expire_batches(db: AsyncSession) -> int:
source_type=batch.source,
source_id=None,
batch_id=batch.id,
category="过期回收",
description=f"积分批次过期回收 {recoverable} 积分",
)
db.add(tx)
+1 -1
View File
@@ -5,7 +5,7 @@
提供音频视频文件的上传管理和访问功能
使用场景
1. 声音克隆 - 上传音频样本文件
1. 声音复刻 - 上传音频样本文件
2. 音频生成 - 存储 TTS 生成的音频
3. 视频素材 - 上传视频文件用于后续处理
"""
+2 -2
View File
@@ -105,7 +105,7 @@ class ScriptService:
Args:
content: 待润色内容
polish_type: 润色类型可选 "scene"画面描述 "voiceover"配音文
polish_type: 润色类型可选 "scene"画面描述 "voiceover"配音文
shot_type: 镜头类型可选 "segment"分镜 "empty_shot"空镜仅用于画面润色
Returns:
@@ -126,7 +126,7 @@ class ScriptService:
if not prompt_template:
prompt_template = self._load_prompt("polish/scene")
else:
# 配音文润色
# 配音文润色
prompt_template = self._load_prompt("polish/voiceover")
if not prompt_template:
+4 -4
View File
@@ -216,7 +216,7 @@ class ViduService:
"""
return {"voice_id": voice_id, "status": "succeeded"}
# ==================== 对口型 ====================
# ==================== 视频生成 ====================
async def lip_sync_create(
self,
@@ -230,7 +230,7 @@ class ViduService:
callback_url: str | None = None,
task_id: str | None = None,
) -> str:
"""创建对口型任务(异步接口),返回 task_id。
"""创建视频生成任务(异步接口),返回 task_id。
Args:
task_id: Async Engine 的内部任务 IDcallback 场景必须传入
@@ -254,7 +254,7 @@ class ViduService:
if not result:
raise PlatformError(
"对口型任务创建失败,请稍后重试",
"视频生成任务创建失败,请稍后重试",
platform="vidu",
retryable=False,
error_type=PlatformErrorType.BAD_REQUEST,
@@ -264,7 +264,7 @@ class ViduService:
return result
async def lip_sync_query(self, task_id: str) -> dict[str, Any]:
"""查询对口型任务状态及生成物(task_id 为内部 ID)。"""
"""查询视频生成任务状态及生成物(task_id 为内部 ID)。"""
status = await self.gateway.query_task_by_internal_id(task_id)
result_data = status.result or {}
+4
View File
@@ -207,6 +207,7 @@ class WechatPayService:
out_trade_no: str,
amount: int,
attach: str | None = None,
time_expire: str | None = None,
) -> dict:
"""
Native 支付 - 统一下单
@@ -215,6 +216,7 @@ class WechatPayService:
:param out_trade_no: 商户订单号
:param amount: 订单金额单位
:param attach: 附加数据回调原样返回
:param time_expire: 订单失效时间格式yyyyMMddHHmmss 20240101120000
:return: code_url 的字典
"""
params = {
@@ -228,6 +230,8 @@ class WechatPayService:
}
if attach:
params["attach"] = attach
if time_expire:
params["time_expire"] = time_expire
return await self._request(client, WXPAY_UNIFIEDORDER_ENDPOINT, params)
+17 -4
View File
@@ -23,13 +23,13 @@ fixed_costs:
# 声音克隆:录制用户声音样本,训练专属 TTS 音色(一次性高成本操作)
voice_clone: 200
# 字幕压制:将生成的字幕文件烧录到视频中(FFmpeg 合成)
# 字幕烧录:将生成的字幕文件烧录到视频中(FFmpeg 合成)
subtitle_burn: 2
# 封面制作:根据视频内容自动生成封面图
cover_design: 2
# 视频合成:将多个素材片段合并为最终视频(FFmpeg 拼接)
# 压制成片:将多个素材片段合并为最终视频(FFmpeg 拼接)
compose: 5
@@ -56,7 +56,7 @@ duration_based_costs:
# TODO: 收集实测数据后校准此值。
seconds_per_char: 0.3
# ── 数字人视频(对口型)──
# ── 视频生成(对口型)──
# 计费公式:max(min_points, ceil(实际视频秒数) × multiplier)
# 说明:秒数先向上取整为整数,再乘以 multiplier。不足 1 秒按 1 秒计算。
# 示例:4.5秒视频 → ceil(4.5) × 5 = 25 积分;0.8秒 → ceil(0.8) × 5 = 5 积分
@@ -67,11 +67,24 @@ duration_based_costs:
# 预估参数(执行业务前检查余额时使用)
estimation:
# 是否直接使用输入素材秒数作为预估上限。
# 数字人视频时长 = 输入音频/素材时长,因此用 input_seconds 预估最准确。
# 视频生成时长 = 输入音频/素材时长,因此用 input_seconds 预估最准确。
# 调用方需传入 input_seconds 参数。
use_input_seconds: true
# ── 充值档位配置 ──────────────────────────────────────
# 前端充值弹窗展示的选项,由后端统一控制。
# 支持积分赠送:points 为实际到账积分数,amount_rmb 为支付金额(分)。
# label 为空时不显示标签角标。
recharge_options:
- { price: 1, points: 100, label: "测试" }
- { price: 500, points: 100, label: "入门" }
- { price: 1000, points: 220, label: "热销" }
- { price: 3000, points: 650, label: "推荐" }
- { price: 5000, points: 1200, label: "超值" }
- { price: 10000, points: 2500, label: "尊享" }
# ── 免费业务(不扣积分)───────────────────────────────
# 以下业务仅提取/处理数据,不产生 AI 生成内容,故不扣积分。
free_services:
+3 -3
View File
@@ -11,7 +11,7 @@
- **产品标识**: `cn.meijiaka.ai-video`
- **版本**: `0.1.0`
- **窗口尺寸**: 1200×800,不可缩放(`resizable: false`
- **核心功能**: AI 脚本生成、AI 配音、数字人视频生成、视频合成FFmpeg)、项目本地持久化
- **核心功能**: AI 脚本生成、AI 配音合成、视频生成、压制成片FFmpeg)、项目本地持久化
### 技术栈
@@ -32,7 +32,7 @@
采用**混合通信架构**
1. **纯数据 API**(脚本、配音、视频生成)→ 前端通过 HTTP **直连 Python 后端**`http://127.0.0.1:8080/api/v1`)。
2. **需要本地系统能力**FFmpeg 视频合成、文件系统读写、认证)→ 走 **Tauri IPC → Rust 层** 处理。
2. **需要本地系统能力**FFmpeg 压制成片、文件系统读写、认证)→ 走 **Tauri IPC → Rust 层** 处理。
> 新增纯数据 API 时,**无需修改 Rust 代码**,直接在 `src/api/modules/` 下使用 `client.post/get` 调用即可。
@@ -59,7 +59,7 @@
│ │ ├── script.ts # 脚本生成(含 SSE 流式接口)
│ │ ├── voice.ts
│ │ ├── video.ts
│ │ ├── videoComposite.ts # 视频合成(走 IPC
│ │ ├── videoComposite.ts # 压制成片(走 IPC
│ │ ├── cover.ts
│ │ └── system.ts # 项目持久化
│ ├── components/ # 可复用组件(PascalCase 文件夹)
+1 -1
View File
@@ -229,4 +229,4 @@ function useSyncProject(projectId: string) {
**建议**
- 脚本编辑:完全离线
- 视频生成:需要联网(AI 服务)
- 视频合成:可离线(本地 FFmpeg
- 压制成片:可离线(本地 FFmpeg
@@ -153,7 +153,7 @@ pub async fn compose_video(
ApiResponse {
code: 200,
message: "视频合成成功".to_string(),
message: "压制成片成功".to_string(),
data: Some(ComposeVideoResult {
output_path: output_path_str,
duration: total_duration,
+7 -7
View File
@@ -2,7 +2,7 @@
//!
//! 模块拆分:
//! - ffmpeg_cmd: FFmpeg 命令封装
//! - video_processing: 视频合成业务逻辑
//! - video_processing: 压制成片业务逻辑
//! - utils: 通用工具函数
//! - commands: Tauri IPC 命令入口(按领域拆分)
//! - storage: 本地存储引擎
@@ -75,7 +75,7 @@ pub fn run() {
commands::file::read_user_file,
commands::file::validate_media_path,
// 字幕压制
// 字幕烧录
burn_subtitle,
// 视频首帧提取
extract_video_first_frame,
@@ -87,7 +87,7 @@ pub fn run() {
commands::asset::save_project_asset,
// 获取视频保存路径
commands::asset::get_video_save_path,
// 视频合成
// 压制成片
video_composite_synthesis,
// 获取成品保存路径
commands::product::get_product_save_path,
@@ -108,7 +108,7 @@ pub fn run() {
commands::voice::load_voice_materials,
commands::voice::save_voice_material,
commands::voice::delete_voice_material_cmd,
// 视频合成Phase 2
// 压制成片Phase 2
commands::video_compose::compose_video,
commands::video_compose::extract_video_segment,
commands::video_compose::concat_video_clips,
@@ -125,7 +125,7 @@ pub fn run() {
}
// ============================================================
// 字幕压制命令
// 字幕烧录命令
// ============================================================
#[derive(Deserialize)]
@@ -331,7 +331,7 @@ async fn standardize_audio(
}
// ============================================================
// 视频合成命令
// 压制成片命令
// ============================================================
#[derive(Deserialize)]
@@ -364,7 +364,7 @@ async fn video_composite_synthesis(
{
return ApiResponse {
code: 200,
message: "视频合成成功".to_string(),
message: "压制成片成功".to_string(),
data: Some(path.to_string()),
};
}
+2 -2
View File
@@ -104,7 +104,7 @@ pub async fn handle_video_synthesis(
match move_result {
Ok(_) => ApiResponse {
code: 200,
message: "视频合成成功".to_string(),
message: "压制成片成功".to_string(),
data: Some(serde_json::json!({
"outputPath": output_path,
})),
@@ -142,7 +142,7 @@ pub async fn handle_video_synthesis(
match move_result {
Ok(_) => ApiResponse {
code: 200,
message: "视频合成成功".to_string(),
message: "压制成片成功".to_string(),
data: Some(serde_json::json!({
"outputPath": output_path,
})),
+4 -4
View File
@@ -71,10 +71,10 @@ interface ProjectSegment {
actualDuration?: number; // 实际时长(秒,基于字幕打轴)
clipAudioPath?: string; // 截取后的音频片段本地路径
clipAudioUrl?: string; // 截取后的音频片段七牛云 URL
lipSyncTaskId?: string; // Vidu 对口型任务 ID
lipSyncState?: string; // Vidu 对口型任务状态
lipSyncVideoPath?: string; // 对口型视频本地路径
lipSyncVideoUrl?: string; // 对口型视频 URL
lipSyncTaskId?: string; // Vidu 视频生成任务 ID
lipSyncState?: string; // Vidu 视频生成任务状态
lipSyncVideoPath?: string; // 视频生成本地路径
lipSyncVideoUrl?: string; // 视频生成 URL
lipSyncStartTime?: number; // 从人物素材截取的起始时间(秒)
clipVideoPath?: string; // 标准化后的片段视频本地路径(供 concat 使用)
voiceVolume?: number;
+44 -6
View File
@@ -16,11 +16,18 @@ export interface PointBalance {
totalExpired: number;
}
export interface RechargeOption {
price: number; // 人民币(分)
points: number; // 到账积分数
label: string; // 标签,空字符串不显示
}
export interface RechargeOrder {
orderId: number;
points: number;
amountRmb: number;
codeUrl: string | null;
expireAt: string | null; // ISO 格式,二维码过期时间
}
interface RechargeOrderStatus {
@@ -63,6 +70,18 @@ export const pointsApi = {
return client.get<PointBalance>('/points/balance');
},
/**
* Native
* POST /points/recharge
*/
/**
*
* GET /points/recharge-options
*/
getRechargeOptions: async (): Promise<RechargeOption[]> => {
return client.get<RechargeOption[]>('/points/recharge-options');
},
/**
* Native
* POST /points/recharge
@@ -88,15 +107,34 @@ export const pointsApi = {
},
/**
*
*
* GET /points/transactions
*/
getTransactions: async (
page = 1,
pageSize = 20,
): Promise<PointTransactionList> => {
getTransactions: async (params: {
page?: number;
pageSize?: number;
txType?: 'consume' | 'recharge' | 'expire';
sourceType?: string;
startTime?: string;
endTime?: string;
} = {}): Promise<PointTransactionList> => {
const {
page = 1,
pageSize = 20,
txType,
sourceType,
startTime,
endTime,
} = params;
const query = new URLSearchParams();
query.set('page', String(page));
query.set('page_size', String(pageSize));
if (txType) query.set('tx_type', txType);
if (sourceType) query.set('source_type', sourceType);
if (startTime) query.set('start_time', startTime);
if (endTime) query.set('end_time', endTime);
return client.get<PointTransactionList>(
`/points/transactions?page=${page}&page_size=${pageSize}`,
`/points/transactions?${query.toString()}`,
);
},
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* IPC
* IPC
* =================
*
* Phase 2: 本地 FFmpeg +
*
*/
import { invoke } from '@tauri-apps/api/core';
+3 -3
View File
@@ -9,11 +9,11 @@ interface VideoCompositeRequest {
}
/**
* API Tauri IPC
* API Tauri IPC
*/
export const compositeApi = {
/**
*
*
*/
synthesis: async (request: VideoCompositeRequest): Promise<string> => {
const response = await invoke<ApiResponse<string>>('video_composite_synthesis', { request });
@@ -21,7 +21,7 @@ export const compositeApi = {
throw new Error('IPC request returned invalid response');
}
if ('code' in response && response.code !== 200) {
throw new Error(response.message || '视频合成失败');
throw new Error(response.message || '压制成片失败');
}
return response.data as string;
},
+4 -4
View File
@@ -1,8 +1,8 @@
/**
* Vidu API
* Vidu API
* ====================
*
* /tasks/video /tasks/{task_id}
* /tasks/video /tasks/{task_id}
*/
import { client } from '../client';
@@ -31,7 +31,7 @@ interface LipSyncStatusResponse {
}
/**
* Vidu /tasks/video
* Vidu /tasks/video
*/
export async function submitLipSync(request: LipSyncRequest): Promise<LipSyncResponse> {
return client.post<LipSyncResponse>('/tasks/video', {
@@ -41,7 +41,7 @@ export async function submitLipSync(request: LipSyncRequest): Promise<LipSyncRes
}
/**
* Redis
* Redis
*/
export async function queryLipSyncStatus(taskId: string): Promise<LipSyncStatusResponse> {
const res = await client.get<{
+4 -4
View File
@@ -2,7 +2,7 @@
* Voice API
* ===================
*
* TTS
* TTS
*/
import { invoke } from '@tauri-apps/api/core';
@@ -34,7 +34,7 @@ interface TTSResult {
voiceId: string;
}
// ====================== 声音克隆类型 ======================
// ====================== 声音复刻类型 ======================
interface VoiceCloneSubmitRequest {
sourceAudioUrl: string;
@@ -104,9 +104,9 @@ export async function uploadAudio(file: File): Promise<string> {
return result.url;
}
// ====================== 声音克隆 API ======================
// ====================== 声音复刻 API ======================
/** 提交声音克隆任务 */
/** 提交声音复刻任务 */
export async function submitCloneTask(request: VoiceCloneSubmitRequest): Promise<VoiceCloneTaskResponse> {
return client.post<VoiceCloneTaskResponse>('/voice/clone/submit', request);
}
+1 -1
View File
@@ -21,7 +21,7 @@ const navItems: NavItem[] = [
label: '内容管理',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
children: [
{ id: 'voice-material', label: '声音克隆' },
{ id: 'voice-material', label: '声音复刻' },
{ id: 'my-works', label: '我的作品' },
],
},
+31 -25
View File
@@ -46,38 +46,44 @@ export default function Modal({
}}
>
<div className="modal-container animate-slideUp" style={{ width: '90%', maxWidth: width }}>
{title && (
<div
className="modal-header"
style={centerTitle ? { justifyContent: 'center' } : undefined}
>
<div
className="modal-header"
style={
centerTitle
? { justifyContent: 'center' }
: title
? undefined
: { justifyContent: 'flex-end', borderBottom: 'none', paddingBottom: 0 }
}
>
{title && (
<h3
className="modal-title"
style={centerTitle ? { textAlign: 'center', flex: 1 } : undefined}
>
{title}
</h3>
<button
className="modal-close"
onClick={onClose}
style={centerTitle ? { position: 'absolute', right: '16px' } : undefined}
)}
<button
className="modal-close"
onClick={onClose}
style={centerTitle ? { position: 'absolute', right: '16px' } : undefined}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)}
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
@@ -14,7 +14,7 @@ ${COMPANY}(以下简称"本公司")运营 ${APP_NAME} 平台(以下简称"
AI AI TTS
AI AI TTS
@@ -16,6 +16,22 @@
margin-bottom: var(--spacing-md);
}
.recharge-options-error {
padding: var(--spacing-lg);
text-align: center;
color: #ff4d4f;
font-size: 14px;
background: #fff2f0;
border-radius: var(--radius-md);
}
.recharge-options-loading {
padding: var(--spacing-lg);
text-align: center;
color: var(--text-secondary);
font-size: 14px;
}
.recharge-amount-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@@ -74,6 +90,7 @@
}
.recharge-qrcode-wrapper {
position: relative;
padding: var(--spacing-lg);
background: white;
border-radius: var(--radius-md);
@@ -86,37 +103,63 @@
height: 200px;
}
.recharge-qrcode-wrapper.expired img {
filter: grayscale(100%) opacity(0.3);
}
.recharge-qrcode-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
background: rgba(255, 255, 255, 0.85);
border-radius: var(--radius-md);
}
.recharge-qrcode-overlay-text {
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
}
.recharge-qrcode-tip {
margin-top: var(--spacing-md);
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
/* 轮询状态 */
.recharge-polling-status {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
gap: 6px;
}
/* 倒计时 */
.recharge-qrcode-countdown {
margin-top: var(--spacing-sm);
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.recharge-qrcode-countdown span {
color: #ff6b6b;
font-weight: 600;
font-family: monospace;
}
/* 二维码下方操作按钮 */
.recharge-qrcode-actions {
display: flex;
flex-direction: row;
gap: var(--spacing-md);
margin-top: var(--spacing-md);
font-size: 14px;
color: var(--primary-color);
width: 100%;
}
.recharge-polling-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--primary-color);
border-top-color: transparent;
border-radius: 50%;
animation: recharge-spin 0.8s linear infinite;
}
@keyframes recharge-spin {
to {
transform: rotate(360deg);
}
.recharge-qrcode-actions .btn {
flex: 1;
}
/* 成功状态 */
@@ -201,19 +244,20 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-md);
background: var(--bg-secondary);
padding: var(--spacing-md) var(--spacing-lg);
background: #fff8f5;
border: 1px solid #ffe4d9;
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
}
.recharge-order-info .label {
font-size: 13px;
font-size: 14px;
color: var(--text-secondary);
}
.recharge-order-info .value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
color: #ff4d4f;
}
@@ -3,7 +3,7 @@
* ==============
*
* Native
* code_url
* code_url /
*/
import { useEffect, useRef, useState, useCallback } from 'react';
@@ -27,18 +27,7 @@ interface AmountOption {
label?: string;
}
// 预设充值档位:1元 = 20积分
const PRESET_OPTIONS: AmountOption[] = [
{ price: 1, points: 100, label: '测试' },
{ price: 500, points: 100, label: '入门' },
{ price: 1000, points: 200, label: '热销' },
{ price: 3000, points: 600 },
{ price: 5000, points: 1000, label: '超值' },
{ price: 10000, points: 2000, label: '尊享' },
];
const POLLING_INTERVAL = 3000; // 3 秒
const POLLING_TIMEOUT = 300000; // 5 分钟
const POLLING_INTERVAL = 5000; // 5 秒(温和轮询,减少请求)
export default function RechargeModal({
open,
@@ -46,81 +35,173 @@ export default function RechargeModal({
onRechargeSuccess,
}: RechargeModalProps) {
const [step, setStep] = useState<RechargeStep>('select');
const [options, setOptions] = useState<AmountOption[]>([]);
const [optionsError, setOptionsError] = useState('');
const [selectedOption, setSelectedOption] = useState<AmountOption | null>(null);
const [qrcodeDataUrl, setQrcodeDataUrl] = useState<string>('');
const orderIdRef = useRef<number>(0);
const expireAtRef = useRef<number>(0); // 二维码过期时间戳(毫秒)
const [countdown, setCountdown] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const [isExpired, setIsExpired] = useState(false);
const [errorMsg, setErrorMsg] = useState('');
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTimeRef = useRef<number>(0);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const onRechargeSuccessRef = useRef(onRechargeSuccess);
onRechargeSuccessRef.current = onRechargeSuccess;
// 清理轮询
const clearPolling = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
// 清理所有定时器
const clearTimers = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (countdownTimerRef.current) {
clearInterval(countdownTimerRef.current);
countdownTimerRef.current = null;
}
}, []);
// 弹窗关闭时重置状态
useEffect(() => {
if (!open) {
clearPolling();
clearTimers();
// 延迟重置,避免关闭动画期间状态突变
const timer = setTimeout(() => {
setStep('select');
setSelectedOption(null);
setQrcodeDataUrl('');
orderIdRef.current = 0;
expireAtRef.current = 0;
setOptions([]);
setOptionsError('');
setIsLoadingOptions(false);
setIsExpired(false);
setCountdown('');
setErrorMsg('');
}, 300);
return () => clearTimeout(timer);
}
}, [open, clearPolling]);
}, [open, clearTimers]);
// 组件卸载时清理
useEffect(() => {
return () => clearPolling();
}, [clearPolling]);
return () => clearTimers();
}, [clearTimers]);
// 开始轮询支付状态
// 弹窗打开时加载充值档位配置
useEffect(() => {
if (open && step === 'select' && options.length === 0 && !isLoadingOptions) {
setIsLoadingOptions(true);
pointsApi.getRechargeOptions()
.then(data => {
setOptions(data.map(o => ({ price: o.price, points: o.points, label: o.label || undefined })));
setIsLoadingOptions(false);
})
.catch(e => {
console.error('[Recharge] 加载充值档位失败:', e);
setOptionsError('加载充值选项失败,请稍后重试');
setIsLoadingOptions(false);
});
}
}, [open, step, options.length, isLoadingOptions]);
// 查询支付状态(轮询或手动触发共用)
const queryPaymentStatus = useCallback(
async (orderId: number, isManual: boolean = false): Promise<boolean> => {
try {
const status = await pointsApi.queryRechargeStatus(orderId);
if (status.status === 'paid') {
clearTimers();
setStep('success');
toast.success(`充值成功!${status.points} 积分已到账`);
onRechargeSuccessRef.current?.();
return true;
} else if (status.status === 'closed' || status.status === 'failed') {
clearTimers();
setStep('error');
setErrorMsg('订单已关闭或支付失败,请重新下单');
return true;
}
// pending 状态:手动查询时给用户提示,轮询时静默继续
if (isManual) {
toast.info('订单仍在处理中,请确认微信支付是否已完成');
}
return false;
} catch (e) {
console.error('[Recharge] 查询支付状态失败:', e);
if (isManual) {
toast.error(`查询失败: ${e instanceof Error ? e.message : '请稍后重试'}`);
}
return false;
}
},
[clearTimers],
);
// 开始轮询 + 倒计时
const startPolling = useCallback(
(targetOrderId: number) => {
startTimeRef.current = Date.now();
timerRef.current = setInterval(async () => {
// 超时检查
if (Date.now() - startTimeRef.current > POLLING_TIMEOUT) {
clearPolling();
setStep('error');
setErrorMsg('支付超时,请检查是否已完成支付,或重新下单');
// 轮询:每 5 秒查一次,直到过期
pollTimerRef.current = setInterval(async () => {
// 已过期,停止轮询,显示遮罩
if (Date.now() >= expireAtRef.current) {
clearTimers();
setCountdown('00:00');
setIsExpired(true);
return;
}
try {
const status = await pointsApi.queryRechargeStatus(targetOrderId);
if (status.status === 'paid') {
clearPolling();
setStep('success');
toast.success(`充值成功!${status.points} 积分已到账`);
onRechargeSuccess?.();
} else if (status.status === 'closed' || status.status === 'failed') {
clearPolling();
setStep('error');
setErrorMsg('订单已关闭或支付失败,请重新下单');
}
// pending 状态继续轮询
} catch (e) {
// 轮询异常不中断,继续尝试
console.error('[Recharge] 轮询状态失败:', e);
}
await queryPaymentStatus(targetOrderId);
}, POLLING_INTERVAL);
// 倒计时显示
countdownTimerRef.current = setInterval(() => {
const remaining = expireAtRef.current - Date.now();
if (remaining <= 0) {
clearTimers();
setCountdown('00:00');
setIsExpired(true);
return;
}
const minutes = Math.floor(remaining / 60000);
const seconds = Math.floor((remaining % 60000) / 1000);
setCountdown(`${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`);
}, 1000);
},
[clearPolling, onRechargeSuccess],
[clearTimers, queryPaymentStatus],
);
// 用户点击【我已支付】手动查询
const handleQueryPayment = async () => {
const orderId = orderIdRef.current;
if (!orderId) return;
// 二维码已过期,直接提示
if (Date.now() >= expireAtRef.current) {
clearTimers();
setIsExpired(true);
return;
}
setIsLoading(true);
await queryPaymentStatus(orderId, true);
setIsLoading(false);
};
// 刷新二维码(过期后重新下单)
const handleRefreshQrcode = async () => {
if (!selectedOption) return;
setIsExpired(false);
setQrcodeDataUrl('');
setCountdown('');
orderIdRef.current = 0;
expireAtRef.current = 0;
await handleConfirm();
};
// 确认充值
const handleConfirm = async () => {
if (!selectedOption) {
@@ -140,6 +221,7 @@ export default function RechargeModal({
}
orderIdRef.current = order.orderId;
expireAtRef.current = order.expireAt ? new Date(order.expireAt).getTime() : Date.now() + 5 * 60 * 1000;
// 生成二维码图片
const dataUrl = await QRCode.toDataURL(order.codeUrl, {
@@ -153,7 +235,7 @@ export default function RechargeModal({
setQrcodeDataUrl(dataUrl);
setStep('qrcode');
// 开始轮询
// 开始温和轮询(同时保留【我已支付】手动兜底)
startPolling(orderIdRef.current);
} catch (e) {
console.error('[Recharge] 创建订单失败:', e);
@@ -174,27 +256,31 @@ export default function RechargeModal({
<>
<div className="recharge-amount-section">
<div className="recharge-amount-label"></div>
<div className="recharge-amount-grid">
{PRESET_OPTIONS.map(option => (
<div
key={option.price}
className={`recharge-amount-card ${selectedOption?.price === option.price ? 'selected' : ''}`}
onClick={() => setSelectedOption(option)}
>
{option.label && <span className="tag">{option.label}</span>}
<div className="price">{formatPrice(option.price)}</div>
<div className="points">{option.points} </div>
</div>
))}
</div>
{optionsError ? (
<div className="recharge-options-error">{optionsError}</div>
) : isLoadingOptions || options.length === 0 ? (
<div className="recharge-options-loading">...</div>
) : (
<div className="recharge-amount-grid">
{options.map(option => (
<div
key={option.price}
className={`recharge-amount-card ${selectedOption?.price === option.price ? 'selected' : ''}`}
onClick={() => setSelectedOption(option)}
>
{option.label && <span className="tag">{option.label}</span>}
<div className="price">{formatPrice(option.price)}</div>
<div className="points">{option.points} </div>
</div>
))}
</div>
)}
</div>
{selectedOption && (
<div className="recharge-order-info">
<span className="label"></span>
<span className="value" style={{ color: 'var(--primary-color)' }}>
{formatPrice(selectedOption.price)}
</span>
<span className="value">{formatPrice(selectedOption.price)}</span>
</div>
)}
@@ -205,7 +291,7 @@ export default function RechargeModal({
<button
className="btn btn-primary"
onClick={handleConfirm}
disabled={!selectedOption || isLoading}
disabled={!selectedOption || isLoading || options.length === 0}
>
{isLoading ? '创建订单中...' : '确认充值'}
</button>
@@ -222,8 +308,16 @@ export default function RechargeModal({
</div>
<div className="recharge-qrcode-section">
<div className="recharge-qrcode-wrapper">
<div className={`recharge-qrcode-wrapper ${isExpired ? 'expired' : ''}`}>
<img src={qrcodeDataUrl} alt="微信支付二维码" />
{isExpired && (
<div className="recharge-qrcode-overlay">
<div className="recharge-qrcode-overlay-text"></div>
<button className="btn btn-primary" onClick={handleRefreshQrcode}>
</button>
</div>
)}
</div>
<div className="recharge-qrcode-tip">
<svg
@@ -239,17 +333,31 @@ export default function RechargeModal({
</svg>
使
</div>
<div className="recharge-polling-status">
<div className="recharge-polling-spinner" />
<span>...</span>
{countdown && (
<div className="recharge-qrcode-countdown">
<span>{countdown}</span>
</div>
)}
<div className="recharge-qrcode-actions">
<button
className="btn btn-primary"
onClick={handleQueryPayment}
disabled={isLoading}
>
{isLoading ? '查询中...' : '我已支付'}
</button>
<button
className="btn btn-ghost"
onClick={() => {
setStep('select');
setSelectedOption(null);
setErrorMsg('');
}}
>
</button>
</div>
</div>
<div className="recharge-modal-footer">
<button className="btn btn-ghost" onClick={onClose}>
</button>
</div>
</>
);
@@ -334,7 +442,7 @@ export default function RechargeModal({
<Modal
open={open}
onClose={onClose}
title={stepTitles[step]}
title={step === 'qrcode' || step === 'select' ? '' : stepTitles[step]}
width="420px"
centerTitle={step === 'success' || step === 'error'}
>
@@ -379,7 +379,7 @@ export default function MyWorks() {
<div className="empty-state-page">
<div className="empty-state-icon"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg></div>
<p className="empty-state-title"></p>
<p className="empty-state-desc"></p>
<p className="empty-state-desc"></p>
</div>
)}
{totalPages > 1 && (
@@ -206,7 +206,7 @@ export default function VoiceMaterialLibrary() {
const progress = useProgressStore.getState();
setUploadModalOpen(false);
progress.show('声音克隆');
progress.show('声音复刻');
try {
progress.update('文件校验中...');
await addVoiceMaterial(selectedFile, uploadName.trim());
@@ -278,7 +278,7 @@ export default function VoiceMaterialLibrary() {
{/* 页面标题和上传区域 */}
<div className="voice-clone-wrapper">
<div className="voice-clone-title-group">
<h2></h2>
<h2></h2>
<p className="voice-clone-desc">AI </p>
</div>
+221 -43
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { pointsApi, type PointTransaction } from '../../api/modules/points';
import '../ContentManagement/ContentManagement.css';
type TabType = 'consume' | 'recharge';
type TransactionType = PointTransaction['type'];
const TYPE_LABELS: Record<TransactionType, string> = {
@@ -18,26 +19,107 @@ const TYPE_COLORS: Record<TransactionType, string> = {
refund: '#4a90d9',
};
// source_type → 中文分类映射
const SOURCE_TYPE_LABELS: Record<string, string> = {
script: '脚本生成',
polish: '文案润色',
title: '标题生成',
tts: '配音合成',
voice_clone: '声音复刻',
video: '视频生成',
compose: '压制成片',
subtitle_burn: '字幕烧录',
cover_design: '封面设计',
caption: '字幕生成',
};
const SOURCE_TYPE_OPTIONS = [
{ value: '', label: '全部类型' },
{ value: 'script', label: '脚本生成' },
{ value: 'polish', label: '文案润色' },
{ value: 'title', label: '标题生成' },
{ value: 'tts', label: '配音合成' },
{ value: 'voice_clone', label: '声音复刻' },
{ value: 'video', label: '视频生成' },
{ value: 'compose', label: '压制成片' },
{ value: 'subtitle_burn', label: '字幕烧录' },
{ value: 'cover_design', label: '封面设计' },
{ value: 'caption', label: '字幕生成' },
];
export default function UsageDetail() {
const [activeTab, setActiveTab] = useState<TabType>('consume');
const [transactions, setTransactions] = useState<PointTransaction[]>([]);
const [loading, setLoading] = useState(true);
const [sourceType, setSourceType] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [dateError, setDateError] = useState('');
// 初始化默认时间范围(最近30天)
useEffect(() => {
const load = async () => {
try {
const data = await pointsApi.getTransactions(1, 50);
setTransactions(data.items);
} catch (e) {
console.error('[UsageDetail] 获取流水失败:', e);
} finally {
setLoading(false);
}
};
load();
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 30);
setEndDate(end.toISOString().split('T')[0]);
setStartDate(start.toISOString().split('T')[0]);
}, []);
const handleExport = () => {
// 导出数据功能占位
// 加载数据
const load = useCallback(async () => {
setLoading(true);
setDateError('');
try {
if (startDate && endDate) {
const s = new Date(startDate);
const e = new Date(endDate);
const diff = (e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24);
if (diff < 0) {
setDateError('结束日期不能早于开始日期');
setLoading(false);
return;
}
if (diff > 30) {
setDateError('时间范围最多 30 天');
setLoading(false);
return;
}
}
const params: Parameters<typeof pointsApi.getTransactions>[0] = {
page: 1,
pageSize: 100,
txType: activeTab === 'consume' ? 'consume' : 'recharge',
};
if (activeTab === 'consume' && sourceType) {
params.sourceType = sourceType;
}
if (startDate) {
params.startTime = `${startDate}T00:00:00+08:00`;
}
if (endDate) {
params.endTime = `${endDate}T23:59:59+08:00`;
}
const data = await pointsApi.getTransactions(params);
setTransactions(data.items);
} catch (e) {
console.error('[UsageDetail] 获取流水失败:', e);
} finally {
setLoading(false);
}
}, [activeTab, sourceType, startDate, endDate]);
useEffect(() => {
load();
}, [load]);
// 切换 Tab 时重置类型筛选
const handleTabChange = (tab: TabType) => {
setActiveTab(tab);
if (tab === 'recharge') {
setSourceType('');
}
};
// 格式化时间
@@ -49,7 +131,6 @@ export default function UsageDetail() {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
@@ -64,31 +145,107 @@ export default function UsageDetail() {
<div className="settings-section">
<h2></h2>
{/* 筛选区 */}
<div className="usage-filter-bar">
<div className="filter-item">
<span className="filter-label"></span>
<div className="filter-input-group">
<span className="filter-input-box"></span>
</div>
</div>
<button className="btn btn-secondary btn-sm" onClick={handleExport}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{/* Tab 切换 */}
<div
style={{
display: 'flex',
gap: 'var(--spacing-sm)',
marginBottom: 'var(--spacing-md)',
borderBottom: '1px solid var(--border-color)',
}}
>
{(['consume', 'recharge'] as TabType[]).map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
style={{
padding: 'var(--spacing-sm) var(--spacing-md)',
border: 'none',
borderBottom: activeTab === tab ? '2px solid var(--primary)' : '2px solid transparent',
background: 'transparent',
color: activeTab === tab ? 'var(--primary)' : 'var(--text-secondary)',
fontWeight: activeTab === tab ? 600 : 400,
cursor: 'pointer',
fontSize: 'var(--font-md)',
transition: 'all 0.2s',
}}
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
{tab === 'consume' ? '消耗' : '充值'}
</button>
))}
</div>
{/* 筛选区 */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 'var(--spacing-md)',
alignItems: 'center',
marginBottom: 'var(--spacing-md)',
}}
>
{/* 类型筛选(仅消耗 Tab */}
{activeTab === 'consume' && (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)' }}></span>
<select
value={sourceType}
onChange={(e) => setSourceType(e.target.value)}
style={{
padding: '6px 12px',
borderRadius: '6px',
border: '1px solid var(--border-color)',
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
fontSize: 'var(--font-sm)',
cursor: 'pointer',
}}
>
{SOURCE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
{/* 时间范围 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: 'var(--font-sm)', color: 'var(--text-secondary)' }}></span>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
style={{
padding: '6px 10px',
borderRadius: '6px',
border: '1px solid var(--border-color)',
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
fontSize: 'var(--font-sm)',
}}
/>
<span style={{ color: 'var(--text-secondary)', fontSize: 'var(--font-sm)' }}></span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
style={{
padding: '6px 10px',
borderRadius: '6px',
border: '1px solid var(--border-color)',
background: 'var(--bg-secondary)',
color: 'var(--text-primary)',
fontSize: 'var(--font-sm)',
}}
/>
</div>
{dateError && (
<span style={{ color: '#ff6b6b', fontSize: 'var(--font-sm)' }}>{dateError}</span>
)}
</div>
{/* 积分明细表 */}
@@ -100,6 +257,7 @@ export default function UsageDetail() {
<th></th>
<th></th>
<th></th>
{activeTab === 'consume' && <th></th>}
<th></th>
<th></th>
<th></th>
@@ -108,23 +266,23 @@ export default function UsageDetail() {
<tbody>
{loading ? (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
<td colSpan={activeTab === 'consume' ? 8 : 7} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
...
</td>
</tr>
) : transactions.length === 0 ? (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
<td colSpan={activeTab === 'consume' ? 8 : 7} style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
</td>
</tr>
) : (
transactions.map(tx => (
transactions.map((tx) => (
<tr key={tx.id}>
<td>
<span
className="tag"
style={{
display: 'inline-block',
background: `${TYPE_COLORS[tx.type]}15`,
color: TYPE_COLORS[tx.type],
border: `1px solid ${TYPE_COLORS[tx.type]}30`,
@@ -149,6 +307,26 @@ export default function UsageDetail() {
</td>
<td>{tx.balanceBefore}</td>
<td>{tx.balanceAfter}</td>
{activeTab === 'consume' && (
<td>
{tx.sourceType ? (
<span
style={{
display: 'inline-block',
background: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
fontSize: '12px',
padding: '2px 8px',
borderRadius: '4px',
}}
>
{SOURCE_TYPE_LABELS[tx.sourceType] || tx.sourceType}
</span>
) : (
'-'
)}
</td>
)}
<td>{tx.duration != null ? `${tx.duration.toFixed(1)}s` : '-'}</td>
<td>{tx.description || '-'}</td>
<td>{formatTime(tx.createdAt)}</td>
@@ -1,5 +1,5 @@
/**
* (Step 5) - Fabric.js
* (Step 5) - Fabric.js
* ================================
*
* 使 Fabric.js Canvas
@@ -238,28 +238,19 @@ export default function CoverDesign() {
if (!projectId) {return;}
if (!config.mainTitle.trim()) {return;}
useProgressStore.getState().show('封面生成');
useProgressStore.getState().show('封面设计');
try {
useProgressStore.getState().update('正在导出封面图片...');
useProgressStore.getState().update('正在设计封面...');
// 1. 导出 PNG (base64) — 纯前端操作,先确认成功再扣费
const dataUrl = exportPng();
if (!dataUrl) {
throw new Error('封面导出失败');
throw new Error('封面设计失败');
}
const base64 = dataUrl.split(',')[1];
// 2. 扣费:封面设计(cover_design,固定 2 积分)— 在 Rust IPC 之前扣费
await pointsApi.consumePoints({
points: 2,
sourceType: 'cover_design',
sourceId: `cover_design_${projectId}_${Date.now()}`,
description: '封面设计',
});
await usePointStore.getState().fetchBalance();
// 3. 保存到本地
// 2. 保存到本地(先保存,成功后再扣费,避免保存失败导致积分损失)
const result = await invoke<{
code: number;
data?: string;
@@ -274,7 +265,16 @@ export default function CoverDesign() {
throw new Error(result.message);
}
// 3. 更新状态
// 3. 扣费:封面设计(cover_design,固定 2 积分)
await pointsApi.consumePoints({
points: 2,
sourceType: 'cover_design',
sourceId: `cover_design_${projectId}_${Date.now()}`,
description: '封面设计',
});
await usePointStore.getState().fetchBalance();
// 4. 更新状态
setCoverPath(result.data);
setCoverConfig({
template: config.template,
@@ -283,10 +283,10 @@ export default function CoverDesign() {
subtitle: config.subtitle.trim(),
});
useProgressStore.getState().success('封面生成完成');
useProgressStore.getState().success('封面设计完成');
} catch (error: unknown) {
const message = (error instanceof Error ? error.message : String(error)) || '封面生成失败';
console.error('封面生成失败:', error);
const message = (error instanceof Error ? error.message : String(error)) || '封面设计失败';
console.error('封面设计失败:', error);
if (message.includes('402') || message.includes('积分不足')) {
setShowRechargeModal(true);
} else {
@@ -405,7 +405,7 @@ export default function CoverDesign() {
onClick={handleGenerate}
style={{ marginTop: 'auto', flexShrink: 0 }}
>
</button>
</div>
@@ -273,7 +273,7 @@ export default function ScriptCreation() {
) => {
setPolishingState({ id, type });
try {
// 后端润色接口:scene=画面描述, voiceover=配音文
// 后端润色接口:scene=画面描述, voiceover=配音文
const polishType = type === 'scene' || type === 'prompt' ? 'scene' : 'voiceover';
// 获取当前镜头的类型(分镜/空镜)
const shot = shots.find(s => s.id === id);
@@ -282,6 +282,7 @@ export default function ScriptCreation() {
handleFieldChange(id, type, result);
} catch (error) {
console.error('润色失败:', error);
toast.error('润色失败,请重试');
} finally {
setPolishingState(null);
}
@@ -453,10 +454,10 @@ export default function ScriptCreation() {
</p>
</div>
{/* 配音文/画外音(两种类型都有) */}
{/* 配音文/画外音(两种类型都有) */}
<div className="segment-field">
<div className="segment-field-header">
<span className="segment-field-label"></span>
<span className="segment-field-label"></span>
<div className="segment-field-actions">
<button
className="btn btn-ghost btn-xs"
@@ -498,7 +499,7 @@ export default function ScriptCreation() {
onChange={e => handleFieldChange(seg.id, 'voiceover', e.target.value)}
rows={3}
autoFocus
placeholder="输入配音文..."
placeholder="输入配音文..."
/>
) : (
<p
@@ -1,12 +1,12 @@
/**
* (Step 4)
* (Step 4)
* =====================
*
*
* + 使 step-layout
*/
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { homeDir } from '@tauri-apps/api/path';
@@ -231,24 +231,31 @@ export default function SubtitleBurning() {
})) ?? [];
const subtitleEnabled = !!loadedVideoUrl && processedUtterances.length > 0;
const subtitleStyle = useMemo(() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {};
}, [captionPreset]);
const mainTitleStyle = useMemo(() => {
const preset = MAIN_TITLE_PRESETS.find(p => p.id === mainTitlePreset);
return preset ? buildMainTitleStyle(preset) : undefined;
}, [mainTitlePreset]);
const subTitleStyle = useMemo(() => {
const preset = SUB_TITLE_PRESETS.find(p => p.id === subTitlePreset);
return preset ? buildSubTitleStyle(preset) : undefined;
}, [subTitlePreset]);
useCanvasSubtitleRenderer({
videoRef,
canvasRef,
utterances: processedUtterances,
mainTitle: mainTitle || undefined,
subTitle: subTitle || undefined,
subtitleStyle: (() => {
const preset = SUBTITLE_PRESETS.find(p => p.id === captionPreset);
return preset ? buildSubtitleStyle(preset) : {};
})(),
mainTitleStyle: (() => {
const preset = MAIN_TITLE_PRESETS.find(p => p.id === mainTitlePreset);
return preset ? buildMainTitleStyle(preset) : undefined;
})(),
subTitleStyle: (() => {
const preset = SUB_TITLE_PRESETS.find(p => p.id === subTitlePreset);
return preset ? buildSubTitleStyle(preset) : undefined;
})(),
subtitleStyle,
mainTitleStyle,
subTitleStyle,
enabled: previewMode === 'style' && subtitleEnabled,
});
@@ -256,6 +263,7 @@ export default function SubtitleBurning() {
const totalDurationSeconds = (alignment?.duration || 0);
const phaseRef = useRef({ step: 1, total: 1 });
useEffect(() => {
let canceled = false;
let unlistenProgress: (() => void) | undefined;
let unlistenPhase: (() => void) | undefined;
if (isBurning) {
@@ -263,8 +271,13 @@ export default function SubtitleBurning() {
listen('ffmpeg-phase-start', event => {
const info = event.payload as { step: number; total: number };
phaseRef.current = { step: info.step, total: info.total };
}).then(u => { unlistenPhase = u; })
.catch(err => console.error('Failed to listen to ffmpeg phase:', err));
}).then(u => {
if (canceled) {
u();
} else {
unlistenPhase = u;
}
}).catch(err => console.error('Failed to listen to ffmpeg phase:', err));
// 监听进度
listen('ffmpeg-progress', event => {
@@ -282,11 +295,16 @@ export default function SubtitleBurning() {
}
})
.then(u => {
unlistenProgress = u;
if (canceled) {
u();
} else {
unlistenProgress = u;
}
})
.catch(err => console.error('Failed to listen to ffmpeg progress:', err));
}
return () => {
canceled = true;
if (unlistenProgress) {unlistenProgress();}
if (unlistenPhase) {unlistenPhase();}
};
@@ -308,7 +326,7 @@ export default function SubtitleBurning() {
}
setIsBurning(true);
useProgressStore.getState().show('字幕压制');
useProgressStore.getState().show('字幕烧录');
useProgressStore.getState().setProgress(0);
// 先保存配置(防止压制过程中刷新丢失)
@@ -378,12 +396,12 @@ export default function SubtitleBurning() {
// 5. 解析视频路径
const resolvedVideoPath = await resolveHostPath(actualVideoPath);
// 扣费:字幕压制subtitle_burn,固定 2 积分)
// 扣费:字幕烧录subtitle_burn,固定 2 积分)
await pointsApi.consumePoints({
points: 2,
sourceType: 'subtitle_burn',
sourceId: `subtitle_burn_${projectId}_${Date.now()}`,
description: '字幕压制',
description: '字幕烧录',
});
await usePointStore.getState().fetchBalance();
@@ -403,7 +421,7 @@ export default function SubtitleBurning() {
// 7. 保存压制结果
useProjectStore.setState({ burnedVideoPath: outputPath });
await saveMetaToLocalFile({ burnedVideoPath: outputPath });
useProgressStore.getState().success('字幕压制完成');
useProgressStore.getState().success('字幕烧录完成');
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[SubtitleBurning] 压制失败:', error);
@@ -12,7 +12,7 @@ import { useLocalVideo } from '../../hooks/useLocalVideo';
import './VideoCreation.css';
import './VideoGeneration.css';
export default function VideoComposite() {
export default function VideoCompose() {
const segments = useProjectStore(state => state.segments);
const coverPath = useProjectStore(state => state.coverPath);
const burnedVideoPath = useProjectStore(state => state.burnedVideoPath);
@@ -29,6 +29,11 @@ export default function VideoComposite() {
const progress = useProgressStore(state => state.progress);
const [resultPath, setResultPath] = useState(finalVideoPath || '');
// 同步 Store 中的 finalVideoPath 变化(如切换项目时)
useEffect(() => {
setDone(!!finalVideoPath);
setResultPath(finalVideoPath || '');
}, [finalVideoPath]);
const shots = segments || [];
const hasResult = done && !!resultPath;
@@ -48,6 +53,7 @@ export default function VideoComposite() {
.filter((p): p is string => !!p) || [];
useEffect(() => {
let canceled = false;
let unlisten: (() => void) | undefined;
if (compositing) {
listen('ffmpeg-progress', event => {
@@ -61,11 +67,16 @@ export default function VideoComposite() {
}
})
.then(u => {
unlisten = u;
if (canceled) {
u();
} else {
unlisten = u;
}
})
.catch(err => console.error('Failed to listen to ffmpeg progress:', err));
}
return () => {
canceled = true;
if (unlisten) {
unlisten();
}
@@ -90,7 +101,7 @@ export default function VideoComposite() {
setCompositing(true);
setDone(false);
setResultPath('');
useProgressStore.getState().show('视频合成');
useProgressStore.getState().show('压制成片');
useProgressStore.getState().setProgress(0);
try {
@@ -133,10 +144,10 @@ export default function VideoComposite() {
});
setDone(true);
useProgressStore.getState().success('视频合成完成');
useProgressStore.getState().success('压制成片完成');
} catch (error: unknown) {
console.error('视频合成失败:', error);
useProgressStore.getState().error('视频合成失败,请重试');
console.error('压制成片失败:', error);
useProgressStore.getState().error('压制成片失败,请重试');
} finally {
setCompositing(false);
}
@@ -237,8 +248,8 @@ export default function VideoComposite() {
{shot.scene || '未设置画面描述'}
</p>
<p className="scene-card-voiceover">
<span className="scene-field-label-pill"></span>
{shot.voiceover || '未设置配音文'}
<span className="scene-field-label-pill"></span>
{shot.voiceover || '未设置配音文'}
</p>
</div>
</div>
@@ -265,10 +276,10 @@ export default function VideoComposite() {
}}
>
{!burnedVideoPath && (
<p> 4</p>
<p> 4</p>
)}
{!coverPath && (
<p> 5</p>
<p> 5</p>
)}
</div>
)}
@@ -317,13 +328,14 @@ export default function VideoComposite() {
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={() => {
// 提取文件所在目录并打开(兼容 Windows \ 和 macOS/Linux /
const lastSep = Math.max(resultPath.lastIndexOf('/'), resultPath.lastIndexOf('\\'));
if (lastSep > 0) {
openPath(resultPath.slice(0, lastSep));
} else {
openPath(resultPath);
onClick={async () => {
try {
// 提取文件所在目录并打开(兼容 Windows \ 和 macOS/Linux /
const lastSep = Math.max(resultPath.lastIndexOf('/'), resultPath.lastIndexOf('\\'));
const target = lastSep > 0 ? resultPath.slice(0, lastSep) : resultPath;
await openPath(target);
} catch {
toast.error('打开文件夹失败');
}
}}
>
@@ -171,10 +171,10 @@ export default function VideoGeneration() {
// Vidu 对口型流程说明:
// 1. handleGenerate Step 1: 提交对口型任务(带 callback_url),保存 lipSyncStartTime
// Vidu 视频生成流程说明:
// 1. handleGenerate Step 1: 提交视频生成任务(带 callback_url),保存 lipSyncStartTime
// 2. handleGenerate Step 2: 轮询后端 /vidu/tasks/{task_id}/statusRedis 缓存,由 Vidu 回调更新)
// 3. handleGenerate Step 3: 下载对口型视频到本地,保存 lipSyncVideoPath
// 3. handleGenerate Step 3: 下载生成视频到本地,保存 lipSyncVideoPath
// 4. handleGenerate Step 4: 拼接时 segment 使用 lipSyncVideoPathstartTime=0
// 自动匹配空镜素材(调用后端接口)
@@ -301,7 +301,29 @@ export default function VideoGeneration() {
const video = document.createElement('video');
video.preload = 'metadata';
const timeoutId = setTimeout(() => {
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '读取视频超时,请重试',
});
}, 15000);
video.onerror = () => {
clearTimeout(timeoutId);
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '无法读取视频文件,请检查文件是否损坏',
});
};
video.onloadedmetadata = () => {
clearTimeout(timeoutId);
const duration = video.duration;
const width = video.videoWidth;
const height = video.videoHeight;
@@ -329,26 +351,6 @@ export default function VideoGeneration() {
resolve({ valid: true, duration, width, height });
};
video.onerror = () => {
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '无法读取视频文件,请检查文件是否损坏',
});
};
setTimeout(() => {
resolve({
valid: false,
duration: 0,
width: 0,
height: 0,
error: '读取视频超时,请重试',
});
}, 15000);
video.src = url;
});
} catch (e) {
@@ -562,12 +564,12 @@ export default function VideoGeneration() {
// 人物视频总时长,用于计算随机截取起始点
const avatarDuration = selectedAvatarMaterial.duration;
// ========== Step 1: 对每个 segment 提交对口型任务 ==========
// ========== Step 1: 对每个 segment 提交视频生成任务 ==========
const segmentShots = shots.filter((s) => s.type === 'segment');
for (let i = 0; i < segmentShots.length; i++) {
const shot = segmentShots[i];
progress.update(`正在提交对口型任务 (${i + 1}/${segmentShots.length})...`);
progress.update(`正在提交视频生成任务 (${i + 1}/${segmentShots.length})...`);
const duration = shot.actualDuration!;
@@ -585,7 +587,7 @@ export default function VideoGeneration() {
const maxStart = Math.max(0, avatarDuration - duration);
const startTime = maxStart > 0 ? Math.random() * maxStart : 0;
// 保存 startTime(后续拼接用对口型结果视频,startTime=0,但需记录原始截取点)
// 保存 startTime(后续拼接用视频生成结果startTime=0,但需记录原始截取点)
updateSegment(shot.id, { lipSyncStartTime: startTime });
const clipRes = await invoke<{ code: number; message: string }>('extract_video_segment', {
@@ -604,9 +606,9 @@ export default function VideoGeneration() {
const clipUrl = await uploadVideoFile(clipPath);
console.info(`[VideoGeneration] Segment ${shot.id} 视频上传完成:`, clipUrl);
// 1d. 提交对口型任务(仅当该分镜有 clipAudioUrl 时)
// 1d. 提交视频生成任务(仅当该分镜有 clipAudioUrl 时)
if (!shot.clipAudioUrl) {
console.warn(`[VideoGeneration] Segment ${shot.id} 无 clipAudioUrl,跳过对口型`);
console.warn(`[VideoGeneration] Segment ${shot.id} 无 clipAudioUrl,跳过视频生成`);
continue;
}
@@ -615,7 +617,7 @@ export default function VideoGeneration() {
audioUrl: shot.clipAudioUrl,
duration: shot.actualDuration,
});
console.info(`[VideoGeneration] Segment ${shot.id} 对口型任务提交成功:`, lipSyncRes.taskId);
console.info(`[VideoGeneration] Segment ${shot.id} 视频生成任务提交成功:`, lipSyncRes.taskId);
// 1e. 保存 taskId 到 segment
updateSegment(shot.id, { lipSyncTaskId: lipSyncRes.taskId, lipSyncState: 'processing' });
@@ -625,7 +627,7 @@ export default function VideoGeneration() {
const currentSegments = useProjectStore.getState().segments;
await localProjectApi.saveSegments(projectId, currentSegments);
// ========== Step 2: 轮询对口型任务状态 ==========
// ========== Step 2: 轮询视频生成任务状态 ==========
// 必须从 store 读取最新 segmentsupdateSegment 已更新 store,但 shots 闭包未变)
const currentSegmentsForPoll = useProjectStore.getState().segments;
const segmentShotsWithTask = currentSegmentsForPoll.filter(
@@ -633,7 +635,7 @@ export default function VideoGeneration() {
);
if (segmentShotsWithTask.length > 0) {
progress.update('正在等待对口型处理...');
progress.update('正在等待视频处理...');
const maxPolls = 120; // 最多轮询 10 分钟(5s × 120)
const pollInterval = 5000;
@@ -654,7 +656,7 @@ export default function VideoGeneration() {
const failed = states.filter(({ status }) => status.state === 'failed');
if (failed.length > 0) {
const failedIds = failed.map(({ shot }) => shot.id).join(', ');
throw new Error(`对口型任务失败 (分镜 ${failedIds})`);
throw new Error(`视频生成失败 (分镜 ${failedIds})`);
}
break;
}
@@ -662,17 +664,17 @@ export default function VideoGeneration() {
const pendingCount = states.filter(
({ status }) => status.state !== 'success' && status.state !== 'failed'
).length;
progress.update(`对口型处理中... 剩余 ${pendingCount}/${segmentShotsWithTask.length}`);
progress.update(`视频处理中... 剩余 ${pendingCount}/${segmentShotsWithTask.length}`);
if (poll < maxPolls - 1) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
} else {
throw new Error('对口型任务超时,请稍后重试');
throw new Error('视频生成超时,请稍后重试');
}
}
// 下载对口型视频到本地
progress.update('正在下载对口型视频...');
// 下载生成视频到本地
progress.update('正在下载生成视频...');
for (const shot of segmentShotsWithTask) {
const status = await queryLipSyncStatus(shot.lipSyncTaskId!);
if (status.state === 'success' && status.videoUrl) {
@@ -689,7 +691,7 @@ export default function VideoGeneration() {
lipSyncVideoUrl: status.videoUrl,
lipSyncVideoPath: pathRes.data,
});
console.info(`[VideoGeneration] Segment ${shot.id} 对口型视频下载完成:`, pathRes.data);
console.info(`[VideoGeneration] Segment ${shot.id} 生成视频下载完成:`, pathRes.data);
}
}
@@ -744,8 +746,8 @@ export default function VideoGeneration() {
progress.update(`正在处理分镜片段 (${i + 1}/${currentSegmentsForCompose.length})...`);
const segDuration = shot.actualDuration!;
// 优先使用对口型后的视频(已经是精确片段,startTime=0
// 未对口型时 fallback 到原始素材,使用保存的 lipSyncStartTime 或重新计算
// 优先使用视频生成后的结果(已经是精确片段,startTime=0
// 未视频生成时 fallback 到原始素材,使用保存的 lipSyncStartTime 或重新计算
const hasLipSync = !!shot.lipSyncVideoPath;
const source = shot.lipSyncVideoPath || selectedAvatarMaterial.path;
let startTime = 0;
@@ -784,12 +786,12 @@ export default function VideoGeneration() {
// ========== Step 4: 拼接所有片段(音视频一起,不做替换)==========
progress.update('正在校验积分...');
// 扣费:视频合成compose,固定 5 积分)
// 扣费:压制成片compose,固定 5 积分)
await pointsApi.consumePoints({
points: 5,
sourceType: 'compose',
sourceId: `compose_${projectId}_${Date.now()}`,
description: '视频合成',
description: '压制成片',
});
await usePointStore.getState().fetchBalance();
@@ -812,9 +814,9 @@ export default function VideoGeneration() {
projectId,
filePath: shot.lipSyncVideoPath,
});
console.info(`[VideoGeneration] 已删除对口型视频: ${shot.lipSyncVideoPath}`);
console.info(`[VideoGeneration] 已删除生成视频: ${shot.lipSyncVideoPath}`);
} catch (e) {
console.warn(`[VideoGeneration] 删除对口型视频失败: ${shot.lipSyncVideoPath}`, e);
console.warn(`[VideoGeneration] 删除生成视频失败: ${shot.lipSyncVideoPath}`, e);
}
}
if (shot.clipVideoPath) {
@@ -850,7 +852,7 @@ export default function VideoGeneration() {
// 视频生成完成后,切换到完整视频预览,取消卡片高亮
setIsComposedPreview(true);
// 对口型流程已完成:提交任务 → 后端回调更新 Redis → 前端轮询 → 下载视频 → 拼接
// 视频生成流程已完成:提交任务 → 后端回调更新 Redis → 前端轮询 → 下载视频 → 拼接
progress.success('视频生成完成');
} catch (error) {
@@ -1029,7 +1031,7 @@ export default function VideoGeneration() {
</p>
<p className="scene-card-voiceover">
<span className="scene-field-label-pill"></span>
{shot.voiceover || '未设置配音文'}
{shot.voiceover || '未设置配音文'}
</p>
{/* 素材匹配状态 - 仅空镜 */}
{shot.type === 'empty_shot' && (
@@ -1,8 +1,8 @@
/**
* (Step 3)
* (Step 3)
* ======================
*
* + + |
* + + |
*/
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
@@ -18,9 +18,9 @@ import { createTask, getTaskStatus } from '../../api/modules/task';
import { matchSegmentsToUtterances } from '../../utils/audioAlign';
import { uploadAudioFile } from '../../api/modules/videoCompose';
import { localProjectApi } from '../../api/modules/localStorage';
import './VoiceDubbing.css';
import './VoiceSynthesis.css';
export default function VoiceDubbing() {
export default function VoiceSynthesis() {
const projectId = getCurrentProjectId();
const segments = useProjectStore(state => state.segments);
const updateSegment = useProjectStore(state => state.updateSegment);
@@ -60,6 +60,16 @@ export default function VoiceDubbing() {
if (projectId) {loadProjectAudios(projectId);}
}, [loadPresetVoices, loadProjectAudios, loadVoiceMaterials, projectId]);
// 组件卸载时清理音频播放
useEffect(() => {
return () => {
audioInstanceRef.current?.pause();
generatedAudioRef.current?.pause();
audioInstanceRef.current = null;
generatedAudioRef.current = null;
};
}, []);
const mergedText = useMemo(
() => segments.map(s => s.voiceover?.trim() || '【空镜】').join('\n'),
[segments]
@@ -149,7 +159,7 @@ export default function VoiceDubbing() {
return;
}
if (!alignResult.utterances?.length) {
console.warn('[VoiceDubbing] 打轴返回空结果');
console.warn('[VoiceSynthesis] 打轴返回空结果');
progress.error('字幕处理异常');
return;
}
@@ -160,7 +170,7 @@ export default function VoiceDubbing() {
.map(s => ({ id: s.id, voiceover: s.voiceover || '' }));
const matched = matchSegmentsToUtterances(matchSegments, alignResult.utterances);
if (!matched.length) {
console.warn('[VoiceDubbing] 文本匹配无结果');
console.warn('[VoiceSynthesis] 文本匹配无结果');
progress.error('音频对齐失败');
return;
}
@@ -200,7 +210,7 @@ export default function VoiceDubbing() {
clipAudioUrl: clipUrl,
});
} catch (e) {
console.error(`[VoiceDubbing] Segment ${m.segmentId} 截取/上传失败:`, e);
console.error(`[VoiceSynthesis] Segment ${m.segmentId} 截取/上传失败:`, e);
}
}
@@ -208,7 +218,7 @@ export default function VoiceDubbing() {
const currentSegments = useProjectStore.getState().segments;
const segmentsSaved = await localProjectApi.saveSegments(projectId, currentSegments);
if (!segmentsSaved) {
console.error('[VoiceDubbing] segments.json 保存失败');
console.error('[VoiceSynthesis] segments.json 保存失败');
toast.error('分镜数据保存失败,请重试');
return;
}
@@ -228,7 +238,7 @@ export default function VoiceDubbing() {
progress.success('音频处理完成');
} catch (err) {
console.error('[VoiceDubbing] 打轴截取流程失败:', err);
console.error('[VoiceSynthesis] 打轴截取流程失败:', err);
progress.error(err instanceof Error ? err.message : '音频处理失败');
}
}, [projectId, segments, updateSegment]);
@@ -263,10 +273,10 @@ export default function VoiceDubbing() {
const progress = useProgressStore.getState();
setIsGenerating(true);
progress.show('生成配音');
progress.show('配音合成');
try {
progress.update('正在成配音...');
progress.update('正在成配音...');
const result = await synthesizeTTS({ text: truncatedText, voiceId: currentVoiceId, speed: currentSpeed, volume: currentVolume, pitch: currentPitch });
if (!result.audioUrl) {throw new Error('未返回音频 URL');}
@@ -294,7 +304,7 @@ export default function VoiceDubbing() {
const audioId = `voice_${Date.now()}`;
const meta = await saveAudio({
projectId, audioId, audioData: base64,
name: `配音-${segments.length}`, voiceId: currentVoiceId || 'tianxin_xiaoling', duration: 0,
name: `配音合成-${segments.length}`, voiceId: currentVoiceId || 'tianxin_xiaoling', duration: 0,
skipList: true,
});
@@ -308,7 +318,7 @@ export default function VoiceDubbing() {
dubbingAudioPath: meta.filePath,
});
// 配音音频是项目级别的,不写入各分镜
// 配音合成音频是项目级别的,不写入各分镜
for (const seg of segments) {
const segId = seg.id;
if (segId) {
@@ -321,7 +331,7 @@ export default function VoiceDubbing() {
// 生成完成后自动执行打轴+截取
await handleAlignAndClip(qiniuUrl, meta.filePath);
progress.success('配音已就绪');
progress.success('配音合成完成');
} catch (err) {
progress.error(err instanceof Error ? err.message : '生成失败');
} finally {
@@ -490,7 +500,7 @@ export default function VoiceDubbing() {
<div className="voice-generate-wrap">
{!hasGeneratedAudio ? (
<button className="btn btn-primary generate-btn" onClick={handleGenerate} disabled={isGenerating || !mergedText.trim()}>
{isGenerating ? '合成中...' : '成配音'}
{isGenerating ? '合成中...' : '成配音'}
</button>
) : (
<div className="voice-generate-btns">
@@ -508,10 +518,10 @@ export default function VoiceDubbing() {
</div>
</div>
{/* 右侧:配音文 */}
{/* 右侧:配音文 */}
<div className="script-content">
<div className="script-content-header">
<span className="script-content-meta">{totalChars} · {segments.length} </span>
</div>
<textarea readOnly value={mergedText} rows={20} className="script-textarea" />
+14 -14
View File
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import ScriptCreation from './ScriptCreation';
import VoiceDubbing from './VoiceDubbing';
import VoiceSynthesis from './VoiceSynthesis';
import VideoGeneration from './VideoGeneration';
import SubtitleBurning from './SubtitleBurning';
import CoverDesign from './CoverDesign';
import VideoComposite from './VideoComposite';
import VideoCompose from './VideoCompose';
import { useProjectStore, createNewProject, saveMetaToLocalFile } from '../../store';
import { toast } from '../../store/uiStore';
@@ -13,18 +13,18 @@ import './VideoCreation.css';
const steps = [
{ id: 1, label: '脚本生成' },
{ id: 2, label: '音合成' },
{ id: 2, label: '音合成' },
{ id: 3, label: '视频生成' },
{ id: 4, label: '字幕压制' },
{ id: 5, label: '封面制作' },
{ id: 6, label: '视频合成' },
{ id: 4, label: '字幕烧录' },
{ id: 5, label: '封面设计' },
{ id: 6, label: '压制成片' },
];
/**
*
*
*
* - 6 -> -> -> -> ->
* - 6 -> -> -> -> ->
* - 使 projectStore
*/
function VideoCreationContent() {
@@ -56,7 +56,7 @@ function VideoCreationContent() {
// Step 1 参数
if (meta.topic !== undefined) {updates.topic = meta.topic;}
// Step 2 配音
// Step 2 配音合成
if (meta.selectedVoiceId !== undefined) {updates.selectedVoiceId = meta.selectedVoiceId;}
if (meta.dubbingAudioUrl !== undefined) {updates.dubbingAudioUrl = meta.dubbingAudioUrl;}
if (meta.dubbingAudioPath !== undefined) {updates.dubbingAudioPath = meta.dubbingAudioPath;}
@@ -114,15 +114,15 @@ function VideoCreationContent() {
// 步骤完成检查
const isStep1Complete = segments.length > 0;
// Step 2 音合成:必须有配音音频
// Step 2 音合成:必须有配音音频
const isStep2Complete = isStep1Complete && !!dubbingAudioUrl;
// Step 3 视频生成:拼接视频已生成
const isStep3Complete = isStep2Complete && !!composedVideoPath;
// Step 4 字幕压制:必须有压制后的视频
// Step 4 字幕烧录:必须有烧录后的视频
const isStep4Complete = isStep3Complete && !!burnedVideoPath;
// Step 5 封面制作:必须有封面图
// Step 5 封面设计:必须有封面图
const isStep5Complete = isStep4Complete && !!coverPath;
// Step 6 视频合成:必须有成品视频
// Step 6 压制成片:必须有成品视频
const isStep6Complete = isStep5Complete && !!finalVideoPath;
// 判断用户能否进入某步骤
@@ -230,7 +230,7 @@ function VideoCreationContent() {
case 1:
return <ScriptCreation />;
case 2:
return <VoiceDubbing />;
return <VoiceSynthesis />;
case 3:
return <VideoGeneration />;
case 4:
@@ -238,7 +238,7 @@ function VideoCreationContent() {
case 5:
return <CoverDesign />;
case 6:
return <VideoComposite />;
return <VideoCompose />;
default:
return <ScriptCreation />;
}
@@ -14,6 +14,7 @@ vi.mock('../../api/client', () => ({
clearAuthCache: vi.fn(),
PYTHON_API_BASE_URL: 'http://localhost:8000',
setOnTokenRefreshed: vi.fn(),
setOnAuthFailed: vi.fn(),
}));
import { useAuthStore } from '../authStore';