Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33c4caf9ba | |||
| cf55d475b9 | |||
| 631591ff88 | |||
| 7d4e1b1b2f | |||
| 9c7bf7f67f | |||
| 734508bea5 | |||
| 765725b878 | |||
| 66024c12b6 | |||
| 2cb5f10d8e | |||
| 88e9311fb0 | |||
| ec8d1786ac | |||
| 563131917e | |||
| b9ee5288dd | |||
| d5bec6ebe5 | |||
| 8de71c86bf | |||
| 20d7e02a75 | |||
| 877d454e80 | |||
| 975a094a0c | |||
| a3cc325bd8 | |||
| 09ac626505 | |||
| d7315544d3 | |||
| e2e7c88f22 | |||
| 99d83cc21e | |||
| b2f97c70ae | |||
| 248fd5408c | |||
| 052a660ddb | |||
| 3a6846bc66 | |||
| bd9623fd20 | |||
| 9414dc1318 | |||
| 042f4162b0 | |||
| 76f41529fe | |||
| d537d95376 | |||
| 1742db77a4 | |||
| 60e8cd5247 | |||
| 5abadd053a | |||
| cb9d085396 | |||
| 7e3671c06b | |||
| 6d246ca11e | |||
| aff8f6dae8 | |||
| 2f9c858711 | |||
| 33cc67305f | |||
| 8b6800b63f | |||
| 88c71268c9 | |||
| 316f01b3f0 | |||
| ec12db15c7 | |||
| 749a1aae7e | |||
| 36d590e55a | |||
| aaca53a76a | |||
| 4f323b394b | |||
| 44243a6bb7 | |||
| f9e7aa9887 | |||
| 5034e25749 | |||
| df8325542d | |||
| 6c70056a12 | |||
| e7de349db6 | |||
| 4931554cb8 | |||
| 9e57d35b2e | |||
| ce3cd080f4 | |||
| 344a87c800 | |||
| 91ac40dbef | |||
| a66ecc26d8 | |||
| ffe32330a1 | |||
| 0d8acb38b7 | |||
| 0ba007f326 | |||
| 0d3c3e81ac | |||
| 57c181ce97 | |||
| c411b732bd | |||
| 8507729ca9 | |||
| 39a9d0b7f2 | |||
| bc7d00b470 | |||
| 43d4cd041c | |||
| 5f427386e3 | |||
| e65edd6f53 | |||
| c3858b0cf4 | |||
| 9ba14da6c3 | |||
| 2e4f61e11d | |||
| 643239279e | |||
| bb5bed0f4c | |||
| e27a48f0e0 | |||
| 4e35a2c855 | |||
| ffebaa7249 | |||
| 004de97394 | |||
| 9643b62a70 | |||
| 2fefc9659f | |||
| 7b573f9fd0 | |||
| 42924a747f | |||
| e2e40a6325 | |||
| b790356269 | |||
| 554cdb4ac5 | |||
| 6ab182737f | |||
| 4800ada7ec | |||
| f396e3ecb4 | |||
| dc38e494e7 |
@@ -21,9 +21,8 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
|
||||
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
||||
group: ci-pipeline-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
check-frontend-only:
|
||||
name: Check if frontend-only change
|
||||
@@ -1087,26 +1086,7 @@ jobs:
|
||||
- name: Run Playwright E2E on staging
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-e2e-$$"
|
||||
# 强制清理可能残留的同名容器(上一次异常退出时未清理)
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
bash scripts/ci/run_staging_tests.sh e2e
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1153,22 +1133,7 @@ jobs:
|
||||
- name: Run API integration tests on staging
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||
CONTAINER_NAME="staging-api-tests-$$"
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
exit $EXIT_CODE
|
||||
bash scripts/ci/run_staging_tests.sh api
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1495,9 +1460,8 @@ jobs:
|
||||
shell: sh
|
||||
run: bash scripts/ci/step_timer_start.sh
|
||||
- name: Run production browser E2E
|
||||
shell: sh
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm --ipc=host \
|
||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
||||
@@ -1506,7 +1470,7 @@ jobs:
|
||||
-v "$PWD:/workspace" \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying..."; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||
|
||||
- name: Job duration summary
|
||||
if: always()
|
||||
@@ -1862,4 +1826,4 @@ jobs:
|
||||
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
|
||||
START_TIME=""
|
||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||
@@ -95,12 +95,9 @@ jobs:
|
||||
set -eu
|
||||
cd apps/web
|
||||
|
||||
# Config npm mirror for speed
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --no-audit --no-fund && break
|
||||
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break
|
||||
echo "npm install failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
@@ -109,12 +106,12 @@ jobs:
|
||||
|
||||
# TypeScript check
|
||||
echo "=== TypeScript check ==="
|
||||
npx --no-install tsc --noEmit
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
|
||||
# Vite build
|
||||
echo "=== Vite build ==="
|
||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
||||
npx --no-install vite build
|
||||
./node_modules/.bin/vite build
|
||||
|
||||
echo "=== Build completed ==="
|
||||
ls -la dist/
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
---
|
||||
AIGC:
|
||||
Label: "1"
|
||||
ContentProducer: 001191110102MACQD9K64018705
|
||||
ProduceID: 15868733686388_0/project_7655981463858544923-files/docs/1197_preview_generation_proposal.md
|
||||
ReservedCode1: ""
|
||||
ContentPropagator: 001191110102MACQD9K64028705
|
||||
PropagateID: 15868733686388#1785468313901
|
||||
ReservedCode2: ""
|
||||
---
|
||||
# #1197 预览生成接口方案评估
|
||||
|
||||
## 背景
|
||||
|
||||
智能剪辑「一键生成」流程中,第3步预览生成当前被跳过,直接进入下一步。需要实现真正的预览生成功能,让用户在正式生成前能看到效果预览。
|
||||
|
||||
## 现状分析
|
||||
|
||||
### 现有生成链路
|
||||
|
||||
```
|
||||
API 触发生成 → GenerationTask入库 → Celery异步任务 → UnifiedRenderService渲染 → OSS上传 → 更新状态
|
||||
```
|
||||
|
||||
**关键节点:**
|
||||
1. **API层**:`POST /generation-tasks` 或 `POST /templates/{id}/generate` 触发生成
|
||||
2. **任务调度**:Celery task `worker.generate_video`
|
||||
3. **渲染引擎**:`UnifiedRenderService`(统一渲染引擎,已接入9个效果层)
|
||||
4. **输出配置**:默认 720p (1280x720),支持 `resolution` 字段自定义
|
||||
5. **产物存储**:`GeneratedVideo` 表记录,OSS 存储视频文件
|
||||
|
||||
### 已有可复用能力
|
||||
|
||||
| 能力 | 位置 | 是否可复用 |
|
||||
|------|------|-----------|
|
||||
| 任务创建与状态管理 | `GenerationTask` + `CreateGenerationTaskUseCase` | ✅ 是 |
|
||||
| 素材下载与预处理 | `_download_video_assets` / `_download_voice_asset` | ✅ 是 |
|
||||
| 统一渲染引擎 | `UnifiedRenderService` | ✅ 是 |
|
||||
| 分辨率配置 | `resolution` 字段已支持 | ✅ 是 |
|
||||
| 混音与后处理 | `_render_video` 内流程 | ✅ 是 |
|
||||
| OSS 上传与查重 | `_upload_and_dedup` | ✅ 是 |
|
||||
| 进度追踪 | `append_log` / `progress` 字段 | ✅ 是 |
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案A:复用现有生成链路 + is_preview 标记(推荐)
|
||||
|
||||
**思路**:在现有 GenerationTask 上加 `is_preview` 标记,预览生成走完整链路但参数降级。
|
||||
|
||||
**改动点:**
|
||||
1. **数据模型**:`GenerationTask` 加 `is_preview: bool` 字段(默认 false);`GeneratedVideo` 加 `is_preview: bool`
|
||||
2. **API 层**:生成接口加 `is_preview` 参数,预览任务不计入配额
|
||||
3. **渲染参数**:预览模式下自动调整
|
||||
- 分辨率:480p (854x480)
|
||||
- 时长:限制前 15 秒(或模板第一个片段)
|
||||
- 码率:降低至 1.5Mbps(正式 4Mbps)
|
||||
- 效果层:跳过高级转场/粒子特效等耗时效果
|
||||
4. **任务调度**:预览任务走低优先级队列(或复用现有队列,标记优先级)
|
||||
5. **前端对接**:预览生成结果带 `is_preview=true` 标记,前端展示"预览"标签
|
||||
|
||||
**优点:**
|
||||
- 代码复用率 90%+,改动最小
|
||||
- 与正式生成逻辑一致,预览效果真实可信
|
||||
- 进度查询、结果展示等功能直接复用
|
||||
- 后续可平滑升级:预览满意后一键转正式生成
|
||||
|
||||
**缺点:**
|
||||
- 需要区分预览和正式任务,避免数据混淆
|
||||
- 预览任务和正式任务竞争同一队列资源(可后续优化为独立队列)
|
||||
|
||||
**开发量估算**:2-3 天
|
||||
- 数据模型 + 迁移:0.5 天
|
||||
- API 层改造:0.5 天
|
||||
- 渲染参数降级:1 天
|
||||
- 测试 + 联调:1 天
|
||||
|
||||
---
|
||||
|
||||
### 方案B:新建独立预览接口 + 轻量渲染逻辑
|
||||
|
||||
**思路**:新建独立的预览生成接口,使用简化的渲染逻辑(如只拼接素材+基础配音,跳过大部分效果)。
|
||||
|
||||
**改动点:**
|
||||
1. 新增 `PreviewTask` 数据模型
|
||||
2. 新增 `POST /api/v1/preview/generate` 接口
|
||||
3. 新增独立的 Celery task `worker.generate_preview`
|
||||
4. 简化渲染流程:只做素材裁剪+拼接+配音,跳过转场/滤镜/字幕特效等
|
||||
|
||||
**优点:**
|
||||
- 完全隔离,不影响正式生成链路
|
||||
- 可以做极致优化,预览生成速度快
|
||||
- 数据模型清晰,不会混淆
|
||||
|
||||
**缺点:**
|
||||
- 代码重复率高,两套生成逻辑维护成本翻倍
|
||||
- 预览效果与正式生成可能不一致(效果层差异)
|
||||
- 前端需要对接两套接口
|
||||
- 无法从预览升级为正式生成(需重新走完整流程)
|
||||
|
||||
**开发量估算**:4-5 天
|
||||
- 数据模型 + 接口:1 天
|
||||
- 简化渲染逻辑:2 天
|
||||
- 测试 + 联调:1-2 天
|
||||
|
||||
---
|
||||
|
||||
### 方案C:图片预览(首帧/关键帧截图)
|
||||
|
||||
**思路**:不生成视频,只生成几张关键帧的预览图片。
|
||||
|
||||
**优点:**
|
||||
- 生成速度极快(秒级)
|
||||
- 资源消耗小
|
||||
|
||||
**缺点:**
|
||||
- 预览效果差,用户无法感知动态效果
|
||||
- 无法验证配音、转场、节奏等时间维度的效果
|
||||
- 用户体验不佳,不如"真预览"有说服力
|
||||
|
||||
**开发量估算**:1-2 天
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案:方案A(复用现有生成链路)
|
||||
|
||||
### 核心理由
|
||||
|
||||
1. **效果保真**:预览和正式生成用同一套渲染引擎,效果一致,用户信任度高
|
||||
2. **开发效率**:90% 代码复用,2-3 天可上线
|
||||
3. **可扩展性强**:后续可加「预览转正式」「低分辨率快速预览」等增强功能
|
||||
4. **维护成本低**:一套生成逻辑,bug 修复和新功能同时生效
|
||||
|
||||
### 详细设计
|
||||
|
||||
#### 1. 数据模型变更
|
||||
|
||||
```python
|
||||
# GenerationTask 新增字段
|
||||
is_preview: bool = False
|
||||
"""是否为预览生成"""
|
||||
|
||||
preview_of: str = ""
|
||||
"""预览对应的正式任务 ID(或反向关联)"""
|
||||
|
||||
# GeneratedVideo 新增字段
|
||||
is_preview: bool = False
|
||||
"""是否为预览视频"""
|
||||
```
|
||||
|
||||
**迁移**:alembic 新增 migration,两个表各加 1-2 个字段。
|
||||
|
||||
#### 2. API 层
|
||||
|
||||
```
|
||||
POST /api/v1/generation-tasks
|
||||
Body 增加 is_preview: bool = false
|
||||
|
||||
POST /api/v1/templates/{id}/generate
|
||||
Query 增加 is_preview: bool = false
|
||||
```
|
||||
|
||||
**配额处理**:预览生成不计入用户配额,不占用生成次数限制。
|
||||
|
||||
#### 3. 渲染参数降级
|
||||
|
||||
| 参数 | 正式生成 | 预览生成 |
|
||||
|------|---------|---------|
|
||||
| 分辨率 | 720p (1280x720) | 480p (854x480) |
|
||||
| 码率 | 4 Mbps | 1.5 Mbps |
|
||||
| 时长 | 完整时长 | 前 15 秒(或第一段) |
|
||||
| 帧率 | 30 fps | 24 fps |
|
||||
| 转场效果 | 完整转场 | 仅淡入淡出(或简单切) |
|
||||
| 特效滤镜 | 全部启用 | 跳过粒子/光效等高级效果 |
|
||||
| 字幕 | 完整渲染 | 正常渲染(字幕是核心信息) |
|
||||
| 配音 | 完整混音 | 正常混音(配音是核心信息) |
|
||||
|
||||
**实现方式**:在 `_render_video` 或 UnifiedRenderService 入口处,根据 `is_preview` 标记调整渲染配置。
|
||||
|
||||
#### 4. 任务调度
|
||||
|
||||
- 初期复用现有队列,预览任务正常排队
|
||||
- 后续如需优化,可拆分独立预览队列(低优先级)
|
||||
- 预览任务可设置较短超时时间
|
||||
|
||||
#### 5. 前端对接
|
||||
|
||||
- 调用生成接口时传 `is_preview=true`
|
||||
- 结果列表中预览视频带「预览」标签
|
||||
- 预览满意后可一键「升级为正式生成」(重新触发全分辨率生成,可复用素材下载缓存)
|
||||
|
||||
### 实施步骤
|
||||
|
||||
**Phase 1(MVP,2天):**
|
||||
1. 数据模型 + 迁移
|
||||
2. API 层支持 is_preview 参数
|
||||
3. 渲染分辨率降级(480p)
|
||||
4. 不计入配额
|
||||
5. 基础测试
|
||||
|
||||
**Phase 2(优化,1-2天):**
|
||||
1. 时长限制(前15秒)
|
||||
2. 效果层降级(跳高级效果)
|
||||
3. 预览任务低优先级队列
|
||||
4. 预览转正式生成功能
|
||||
|
||||
## 与前端对齐点
|
||||
|
||||
1. 预览生成的触发时机(第3步自动生成?用户点击才生成?)
|
||||
2. 预览时长是固定15秒还是完整但低清?
|
||||
3. 是否需要「预览转正式生成」功能
|
||||
4. 预览视频的展示形态(和正式视频一样还是有特殊UI)
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **数据混淆**:确保统计、计费、列表展示时正确区分预览和正式任务
|
||||
2. **存储成本**:预览视频也占 OSS 空间,可设置自动清理(7天后自动删除)
|
||||
3. **用户预期**:要明确告诉用户这是预览,效果和正式生成一致但清晰度低
|
||||
4. **并发压力**:如果用户频繁生成预览,可能增加系统负载,需要限流
|
||||
|
||||
---
|
||||
|
||||
> 本内容由 Coze AI 生成,请遵循相关法律法规及《人工智能生成合成内容标识办法》使用与传播。
|
||||
Executable
+382
@@ -0,0 +1,382 @@
|
||||
# #1197 预览生成接口技术方案(v2)
|
||||
|
||||
> 更新说明:v2 新增「多版本预览生成」能力,支持一个模板生成多个不重复的预览视频,左侧列表展示,用户可挑选满意的版本转正式生成。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
**现状**:智能剪辑「一键生成」第3步预览生成被跳过,用户直接进入正式生成,缺少效果预览环节。
|
||||
|
||||
**目标**:
|
||||
1. ✅ 实现真正的预览生成(低分辨率快速出片)
|
||||
2. ✅ **支持生成 1~N 个不重复的预览版本**(默认 3 个),左侧列表展示
|
||||
3. ✅ 预览满意后可一键转正式生成(复用素材下载缓存)
|
||||
4. ✅ 不计入用户配额,不占用正式生成次数
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有生成链路分析
|
||||
|
||||
### 2.1 链路总览
|
||||
|
||||
```
|
||||
API 触发生成 → GenerationTask入库 → Celery异步任务
|
||||
→ 下载素材 → 构建plan/clips → UnifiedRenderService渲染
|
||||
→ 混音后处理 → OSS上传 + 查重 → 更新状态
|
||||
```
|
||||
|
||||
### 2.2 决定视频差异的变量
|
||||
|
||||
要做"多个不重复版本",先分析哪些环节可以引入变化:
|
||||
|
||||
| 变量 | 当前行为 | 能否引入变化 | 影响程度 |
|
||||
|------|---------|------------|---------|
|
||||
| 素材选择 | 按 asset_ids 顺序全用 | ✅ 可随机选择子集/不同组合 | 大 |
|
||||
| 素材排序 | 按 asset_ids 顺序 | ✅ 可 shuffle 重排 | 大 |
|
||||
| 配音选择 | 固定 voice_library_id | ✅ 可选不同音色 | 中 |
|
||||
| 标题选择 | 固定 title_ids 或随机选 | ✅ 可选不同标题 | 中 |
|
||||
| BGM | 固定 bgm_config | ✅ 可选不同BGM | 小 |
|
||||
| 转场效果 | 模板固定 | ✅ 可随机化转场类型 | 小 |
|
||||
| 播放速度 | 模板固定 | ✅ 可微调速度 | 小 |
|
||||
| 分辨率/码率 | 固定 | ✅ 预览可降级 | 不影响内容 |
|
||||
|
||||
### 2.3 可复用能力
|
||||
|
||||
- 任务创建与状态管理:`GenerationTask` + `CreateGenerationTaskUseCase`
|
||||
- 素材下载与预处理:`_download_all_assets`
|
||||
- 统一渲染引擎:`UnifiedRenderService`
|
||||
- 分辨率配置:`resolution` 字段已支持
|
||||
- 批量任务:`batch_id` 字段已存在(可用于预览组)
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体方案:复用现有链路 + 多变体引擎
|
||||
|
||||
**核心思路**:沿用 v1 的"复用现有生成链路 + is_preview 标记"方案,在此基础上增加「多版本生成」能力。
|
||||
|
||||
**架构**:
|
||||
```
|
||||
预览生成请求(count=N)
|
||||
↓
|
||||
创建预览批次(preview_batch)
|
||||
↓
|
||||
变体引擎生成 N 个变体参数(variation seed + 参数组合)
|
||||
↓
|
||||
为每个变体创建 1 个 GenerationTask(is_preview=true)
|
||||
↓
|
||||
N 个 Celery 任务并行执行(走现有生成链路,参数降级)
|
||||
↓
|
||||
N 个结果汇聚,前端左侧列表展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细设计
|
||||
|
||||
### 4.1 数据模型变更
|
||||
|
||||
#### 4.1.1 GenerationTask 新增字段
|
||||
|
||||
```python
|
||||
# 现有字段保留,新增:
|
||||
is_preview: bool = False
|
||||
"""是否为预览生成"""
|
||||
|
||||
preview_batch_id: str = ""
|
||||
"""预览批次 ID(同批次的 N 个预览共享一个 batch)"""
|
||||
|
||||
variant_seed: int = 0
|
||||
"""变体种子,用于控制随机化行为(素材选择、排序、转场等)"""
|
||||
|
||||
variant_params: dict = field(default_factory=dict)
|
||||
"""变体参数快照(记录本次使用了哪些素材、标题、配音等,可追溯)
|
||||
{
|
||||
"asset_ids": [...], # 实际选用的素材子集
|
||||
"title_id": "", # 选用的标题
|
||||
"voice_id": "", # 选用的配音
|
||||
"transition_style": "", # 转场风格
|
||||
"bgm_track": "", # BGM 音轨
|
||||
}
|
||||
"""
|
||||
```
|
||||
|
||||
#### 4.1.2 GeneratedVideo 新增字段
|
||||
|
||||
```python
|
||||
is_preview: bool = False
|
||||
"""是否为预览视频"""
|
||||
|
||||
preview_batch_id: str = ""
|
||||
"""所属预览批次"""
|
||||
|
||||
variant_index: int = 0
|
||||
"""在批次中的序号(0, 1, 2...)"""
|
||||
```
|
||||
|
||||
#### 4.1.3 迁移方案
|
||||
|
||||
alembic 新增 migration,两个表各加 4 个字段,默认值为空/false,无数据回填成本。
|
||||
|
||||
---
|
||||
|
||||
### 4.2 变体引擎(Variant Engine)
|
||||
|
||||
**核心组件**:根据 count 和 seed,生成 N 组互不相同的生成参数。
|
||||
|
||||
#### 4.2.1 变纬度设计
|
||||
|
||||
| 维度 | 策略 | 说明 |
|
||||
|------|------|------|
|
||||
| **素材子集选择** | 从素材池中随机选 M 个(M=min(素材数, 模板clip数*2)) | 版本差异最大的来源 |
|
||||
| **素材排序** | 随机打乱顺序 | 影响叙事节奏 |
|
||||
| **标题选择** | 从 title_ids 中随机选 1 个 | 影响文案内容 |
|
||||
| **配音选择** | 从 voice_ids 中随机选 1 个(如有多个) | 影响听觉体验 |
|
||||
| **转场风格** | 从预设转场池中随机选 1 种 | 影响视觉过渡 |
|
||||
| **BGM 选择** | 从 bgm 列表中随机选 1 首(如有配置) | 影响氛围 |
|
||||
|
||||
#### 4.2.2 去重机制
|
||||
|
||||
- 同一批次内,变体参数必须两两不同(至少素材组合或排序不同)
|
||||
- 使用 `variant_seed` 保证可复现(相同 seed → 相同变体)
|
||||
- 如果素材数量不足导致无法生成 N 个不同版本,按实际能生成的数量返回
|
||||
|
||||
#### 4.2.3 接口设计
|
||||
|
||||
```python
|
||||
def generate_variants(
|
||||
count: int,
|
||||
seed: int,
|
||||
asset_pool: list[str], # 可用素材 ID 列表
|
||||
title_pool: list[str] = [], # 可用标题 ID 列表
|
||||
voice_pool: list[str] = [], # 可用配音 ID 列表
|
||||
template_id: str = "",
|
||||
) -> list[dict]:
|
||||
"""
|
||||
生成 count 组变体参数。
|
||||
|
||||
每组参数包含:asset_ids(选用的素材+排序)、title_id、voice_id、
|
||||
transition_style 等,确保两两不同。
|
||||
"""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4.3 API 层设计
|
||||
|
||||
#### 4.3.1 预览生成接口
|
||||
|
||||
```
|
||||
POST /api/v1/templates/{template_id}/generate-preview
|
||||
```
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{
|
||||
"asset_library_id": "lib_xxx",
|
||||
"asset_ids": ["asset_1", "asset_2", ...],
|
||||
"title_ids": ["title_1", "title_2"],
|
||||
"voice_ids": ["voice_1", "voice_2"],
|
||||
"bgm_config": {},
|
||||
"count": 3,
|
||||
"seed": 0
|
||||
}
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
||||
|------|------|------|------|------|
|
||||
| template_id | path | ✅ | - | 模板 ID |
|
||||
| asset_library_id | body | ✅ | - | 素材库 ID |
|
||||
| asset_ids | body | ✅ | - | 素材池(从中选子集/排序) |
|
||||
| title_ids | body | - | [] | 标题池(可选,不传则不用标题) |
|
||||
| voice_ids | body | - | [] | 配音池(可选) |
|
||||
| bgm_config | body | - | {} | BGM 配置 |
|
||||
| count | body | - | 3 | 生成几个预览版本(1~10) |
|
||||
| seed | body | - | 0 | 随机种子,0 表示随机 |
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"preview_batch_id": "pb_xxx",
|
||||
"count": 3,
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "gen_xxx_0",
|
||||
"variant_index": 0,
|
||||
"status": "processing"
|
||||
},
|
||||
{
|
||||
"task_id": "gen_xxx_1",
|
||||
"variant_index": 1,
|
||||
"status": "processing"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.2 预览批次查询接口
|
||||
|
||||
```
|
||||
GET /api/v1/preview-batches/{batch_id}
|
||||
```
|
||||
|
||||
返回批次内所有预览任务的状态、结果(已完成的带 video_url)。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"preview_batch_id": "pb_xxx",
|
||||
"count": 3,
|
||||
"completed_count": 2,
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "gen_xxx_0",
|
||||
"variant_index": 0,
|
||||
"status": "completed",
|
||||
"video_url": "https://oss.xxx/preview/xxx.mp4",
|
||||
"duration": 15.5,
|
||||
"thumbnail_url": "https://oss.xxx/preview/xxx.jpg"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3.3 预览转正式生成
|
||||
|
||||
```
|
||||
POST /api/v1/preview-batches/{batch_id}/tasks/{task_id}/promote
|
||||
```
|
||||
|
||||
将某个预览版本升级为正式生成(复用素材缓存,重新全分辨率渲染)。
|
||||
|
||||
---
|
||||
|
||||
### 4.4 渲染参数降级
|
||||
|
||||
预览模式下自动调整以下参数:
|
||||
|
||||
| 参数 | 正式生成 | 预览生成 |
|
||||
|------|---------|---------|
|
||||
| 分辨率 | 720p (1280x720) | 480p (854x480) |
|
||||
| 码率 | 4 Mbps | 1.5 Mbps |
|
||||
| 帧率 | 30 fps | 24 fps |
|
||||
| 时长 | 完整时长 | 前 15 秒(或第一段完整clip) |
|
||||
| 转场效果 | 完整转场 | 仅淡入淡出 |
|
||||
| 高级特效 | 全部启用 | 跳过粒子/光效等 |
|
||||
| 字幕 | 完整渲染 | 正常渲染 |
|
||||
| 配音 | 完整混音 | 正常混音 |
|
||||
| 输出质量 | high | medium |
|
||||
|
||||
**实现位置**:`_render_video` 函数入口处,根据 `is_preview` 标记调整渲染配置。
|
||||
|
||||
---
|
||||
|
||||
### 4.5 任务调度
|
||||
|
||||
- **并行执行**:N 个预览任务并行提交到 Celery,不排队等待
|
||||
- **低优先级**:预览任务走独立队列(`preview_queue`),不抢占正式生成资源
|
||||
- **超时控制**:预览任务超时时间 5 分钟(正式 30 分钟)
|
||||
- **自动清理**:预览视频 7 天后自动从 OSS 删除,任务记录标记为 archived
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端对接要点
|
||||
|
||||
### 5.1 交互流程
|
||||
|
||||
```
|
||||
第2步选素材 → 第3步点击"生成预览"
|
||||
→ 显示 loading + 进度
|
||||
→ 预览陆续完成,左侧列表逐张出现
|
||||
→ 用户点击左侧不同版本,右侧预览区切换
|
||||
→ 用户选中满意版本 → 点击"正式生成"
|
||||
```
|
||||
|
||||
### 5.2 需要对齐的接口
|
||||
|
||||
1. **预览创建**:`POST /templates/{id}/generate-preview`
|
||||
2. **批次状态轮询**:`GET /preview-batches/{id}`(建议 2s 轮询,或走 SSE)
|
||||
3. **预览转正式**:`POST /preview-batches/{id}/tasks/{task_id}/promote`
|
||||
|
||||
### 5.3 数据格式对齐
|
||||
|
||||
预览视频条目结构:
|
||||
```json
|
||||
{
|
||||
"id": "gen_xxx",
|
||||
"variant_index": 0,
|
||||
"status": "completed",
|
||||
"video_url": "https://...",
|
||||
"duration": 15.5,
|
||||
"file_size": 2850000,
|
||||
"thumbnail_url": "https://...",
|
||||
"is_preview": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 配额与计费
|
||||
|
||||
- 预览生成**不计入**用户配额
|
||||
- 同一模板 + 同一素材池,每天最多生成 3 次多版本预览(防滥用)
|
||||
- 单个预览批次最多 10 个版本
|
||||
|
||||
---
|
||||
|
||||
## 7. 实施步骤
|
||||
|
||||
### Phase 1:单版本预览(MVP,2 天)
|
||||
1. 数据模型 + 迁移(is_preview 字段)
|
||||
2. API 层支持 is_preview 参数
|
||||
3. 渲染分辨率降级(480p)
|
||||
4. 不计入配额
|
||||
5. 基础测试
|
||||
|
||||
### Phase 2:多版本预览(3 天)
|
||||
1. 变体引擎实现(素材随机选择 + 排序 + 去重)
|
||||
2. preview_batch 批次管理
|
||||
3. 批量创建 N 个预览任务
|
||||
4. 批次查询接口
|
||||
5. 前端联调
|
||||
|
||||
### Phase 3:预览转正式 + 优化(2 天)
|
||||
1. 预览转正式生成接口(promote)
|
||||
2. 素材下载缓存复用
|
||||
3. 独立预览队列(低优先级)
|
||||
4. 自动清理机制
|
||||
5. 完整测试 + 压测
|
||||
|
||||
---
|
||||
|
||||
## 8. 风险与注意事项
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
|------|------|------|
|
||||
| 并发预览任务过多打满 worker | 正式生成被阻塞 | 独立预览队列 + 限流 |
|
||||
| 变体生成的视频差异不够大 | 用户觉得"都一样" | 优先素材子集+排序差异,保证视觉差异 |
|
||||
| 预览视频占用 OSS 存储 | 存储成本上升 | 7 天自动清理 + 低码率 |
|
||||
| N 个版本同时下载重复素材 | 带宽浪费 | 批次内共享一次下载(Phase 3 优化) |
|
||||
| 用户预期管理 | 以为预览就是最终效果 | 明确标注"预览版",说明分辨率差异 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 开发量估算
|
||||
|
||||
| 阶段 | 后端 | 前端 | 合计 |
|
||||
|------|------|------|------|
|
||||
| Phase 1 单版本预览 | 2 天 | 1 天 | 3 天 |
|
||||
| Phase 2 多版本预览 | 3 天 | 2 天 | 5 天 |
|
||||
| Phase 3 转正式+优化 | 2 天 | 1 天 | 3 天 |
|
||||
| **总计** | **7 天** | **4 天** | **~7 天(并行)** |
|
||||
|
||||
---
|
||||
|
||||
## 10. 与 v1 方案的差异总结
|
||||
|
||||
1. **新增多版本能力**:从"生成1个预览"升级为"生成N个不重复预览"
|
||||
2. **新增变体引擎**:负责素材选择/排序/配音/标题的随机化
|
||||
3. **新增批次概念**:preview_batch 管理一组预览任务
|
||||
4. **新增 promote 接口**:预览转正式生成
|
||||
5. **独立队列**:预览不抢占正式生成资源
|
||||
6. **开发量**:从 2-3 天增加到约 7 天(后端)
|
||||
@@ -0,0 +1,82 @@
|
||||
"""确认生成 API 改造:为 generation_tasks 表添加 source_task_id、output_width、output_height、cover_url、custom_title 字段
|
||||
|
||||
Revision ID: 054_confirm_gen_fields
|
||||
Revises: 053_generation_task_is_preview
|
||||
Create Date: 2026-08-16
|
||||
|
||||
Changes:
|
||||
1. generation_tasks 表新增 source_task_id(来源预览任务 ID,带索引)
|
||||
2. generation_tasks 表新增 output_width / output_height(动态输出分辨率)
|
||||
3. generation_tasks 表新增 cover_url / custom_title(自定义封面和标题)
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "054_confirm_gen_fields"
|
||||
down_revision = "053_generation_task_is_preview"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
is_pg = conn.dialect.name == "postgresql"
|
||||
|
||||
if is_pg:
|
||||
# 幂等检查:source_task_id 列是否已存在
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_name = 'generation_tasks' AND column_name = 'source_task_id'"
|
||||
)
|
||||
)
|
||||
if result.scalar() is not None:
|
||||
return
|
||||
|
||||
# source_task_id
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# output_width
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_width", sa.Integer, nullable=False, server_default=sa.text("1280")),
|
||||
)
|
||||
|
||||
# output_height
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("output_height", sa.Integer, nullable=False, server_default=sa.text("720")),
|
||||
)
|
||||
|
||||
# cover_url
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# custom_title
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
# 索引
|
||||
op.create_index(
|
||||
"ix_generation_tasks_source_task_id",
|
||||
"generation_tasks",
|
||||
["source_task_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_source_task_id", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "custom_title")
|
||||
op.drop_column("generation_tasks", "cover_url")
|
||||
op.drop_column("generation_tasks", "output_height")
|
||||
op.drop_column("generation_tasks", "output_width")
|
||||
op.drop_column("generation_tasks", "source_task_id")
|
||||
@@ -19,6 +19,9 @@ from app.schemas.asset import (
|
||||
BatchTagRequest,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
SmartMatchItem,
|
||||
SmartMatchRequest,
|
||||
SmartMatchResponse,
|
||||
UpdateAssetRequest,
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
@@ -30,6 +33,7 @@ from packages.application import (
|
||||
CreateAssetUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -519,6 +523,51 @@ def batch_mark_assets(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/smart-match", response_model=SmartMatchResponse)
|
||||
def smart_match_assets(
|
||||
request: SmartMatchRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> SmartMatchResponse:
|
||||
"""智能选素材:根据素材库内容,按质量分+时长均衡+新鲜度+未使用偏好综合评分,返回 Top N 素材。"""
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 获取素材库中所有 ready 素材(DB 层按 kind 过滤,避免加载不必要的数据到内存)
|
||||
# kind → file_type 映射:schema 已校验只允许 video/image/audio,与 file_type 一致
|
||||
if request.kind:
|
||||
filtered_assets = asset_repository.find_by_library_and_file_type(
|
||||
request.library_id, request.kind, status=["ready"], limit=10000
|
||||
)
|
||||
else:
|
||||
filtered_assets = asset_repository.find_by_library(
|
||||
request.library_id, status=["ready"], limit=10000
|
||||
)
|
||||
total_candidates = len(filtered_assets)
|
||||
|
||||
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(
|
||||
filtered_assets,
|
||||
limit=request.limit,
|
||||
kind=None,
|
||||
)
|
||||
|
||||
items = [
|
||||
SmartMatchItem(
|
||||
asset=_to_asset_response(r.asset),
|
||||
score=r.score,
|
||||
breakdown=r.breakdown,
|
||||
)
|
||||
for r in results
|
||||
]
|
||||
|
||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
def get_asset(
|
||||
asset_id: str,
|
||||
@@ -622,20 +671,28 @@ def create_asset(
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> AssetResponse:
|
||||
project = project_repository.find_by_id(request.project_id)
|
||||
# 先获取素材库,用于推导 project_id(前端可能不传)
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
|
||||
# project_id 自动推导:优先用请求值,否则从 library 关联的项目获取
|
||||
project_id = request.project_id or library.project_id
|
||||
|
||||
project = project_repository.find_by_id(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
||||
if not project.can_access(authenticated_user.user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||
|
||||
library = asset_library_repository.get(request.library_id)
|
||||
if library is None or library.project_id != request.project_id:
|
||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||
# 确保 library 和 project 归属一致
|
||||
if library.project_id != project_id:
|
||||
raise HTTPException(status_code=400, detail="AssetLibrary does not belong to the specified project")
|
||||
|
||||
use_case = CreateAssetUseCase(asset_repository)
|
||||
item = use_case.execute(
|
||||
CreateAssetCommand(
|
||||
project_id=request.project_id,
|
||||
project_id=project_id,
|
||||
library_id=request.library_id,
|
||||
name=request.name,
|
||||
storage_key=request.storage_key,
|
||||
|
||||
@@ -28,6 +28,9 @@ from app.schemas.generation_task import (
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_template_repository import (
|
||||
SQLAlchemyEditTemplateRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
@@ -99,6 +102,69 @@ def _infer_video_ratio_from_template(
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_strategy_id_from_template(
|
||||
template_id: str, db: Session, user_id: str = ""
|
||||
) -> str:
|
||||
"""从模板读取 editing_mode / mode 作为 strategy_id。
|
||||
|
||||
优先查新模板系统(EditTemplate.editing_mode),fallback 旧模板(Template.mode)。
|
||||
Worker 端使用 strategy_id 作为渲染 mode,为空则默认 one_take。
|
||||
"""
|
||||
if not template_id:
|
||||
return ""
|
||||
|
||||
# 优先查新模板系统
|
||||
try:
|
||||
new_repo = SQLAlchemyEditTemplateRepository(db)
|
||||
new_template = new_repo.get(template_id)
|
||||
if new_template and getattr(new_template, "editing_mode", ""):
|
||||
mode = new_template.editing_mode.strip()
|
||||
if mode:
|
||||
logger.info(
|
||||
"[预览生成] 从新模板 editing_mode=%s (template_id=%s)",
|
||||
mode,
|
||||
template_id,
|
||||
)
|
||||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
||||
if mode in ("pip", "voice_pip"):
|
||||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
||||
mode = "one_take"
|
||||
return mode
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"[预览生成] 新模板查询失败,尝试旧模板: template_id=%s",
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# fallback 旧模板系统
|
||||
try:
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id)
|
||||
if old_template:
|
||||
mode = getattr(old_template, "mode", "") or ""
|
||||
mode = mode.strip()
|
||||
if mode:
|
||||
logger.info(
|
||||
"[预览生成] 从旧模板 mode=%s (template_id=%s)",
|
||||
mode,
|
||||
template_id,
|
||||
)
|
||||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
||||
if mode in ("pip", "voice_pip"):
|
||||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
||||
mode = "one_take"
|
||||
return mode
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[预览生成] 旧模板查询也失败,strategy_id 留空: template_id=%s",
|
||||
template_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _mark_task_failed(repo, task, reason: str) -> None:
|
||||
"""入队失败时将任务标记为 failed,避免产生僵尸 pending 数据。"""
|
||||
try:
|
||||
@@ -204,10 +270,11 @@ def create_preview_generation_task(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
logger.info(
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d",
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
||||
user_id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
request.preview_count,
|
||||
)
|
||||
|
||||
# 预检查队列限流
|
||||
@@ -234,6 +301,9 @@ def create_preview_generation_task(
|
||||
if not video_ratio and request.template_id:
|
||||
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
|
||||
|
||||
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
|
||||
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
try:
|
||||
@@ -241,7 +311,7 @@ def create_preview_generation_task(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id="",
|
||||
strategy_id=strategy_id,
|
||||
voice_library_id="",
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
|
||||
@@ -26,6 +26,7 @@ from app.schemas.generated_video import (
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
@@ -38,6 +39,7 @@ from packages.application import (
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -61,6 +63,12 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -124,19 +132,11 @@ def _select_assets_from_library(
|
||||
return [a.id for a in selected]
|
||||
|
||||
if mode == "smart":
|
||||
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
||||
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
||||
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
||||
scored_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
-(a.quality_score if a.quality_score is not None else 0.0),
|
||||
-(getattr(a, "duration", 0.0) or 0.0),
|
||||
),
|
||||
)
|
||||
if count > 0:
|
||||
scored_assets = scored_assets[:count]
|
||||
return [a.id for a in scored_assets]
|
||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
limit = count if count > 0 else None
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
|
||||
return [r.asset.id for r in results]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
return [a.id for a in ready_video_assets]
|
||||
@@ -271,13 +271,19 @@ def create_generation_task(
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
|
||||
effective_strategy_id = request.strategy_id
|
||||
if effective_strategy_id in ("pip", "voice_pip"):
|
||||
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
|
||||
effective_strategy_id = "one_take"
|
||||
|
||||
try:
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
strategy_id=effective_strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
@@ -292,6 +298,12 @@ def create_generation_task(
|
||||
bgm_config=request.bgm_config,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
is_preview=request.is_preview,
|
||||
source_task_id=request.source_task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -332,6 +344,83 @@ def create_generation_task(
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
|
||||
def confirm_generation(
|
||||
task_id: str,
|
||||
request: ConfirmGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
"""确认生成 — 基于预览任务创建正式生成任务。
|
||||
|
||||
查找预览任务,复制其配置,创建新的正式生成任务(is_preview=False),
|
||||
使用高分辨率,复用 worker.generate_video 渲染路径。
|
||||
"""
|
||||
# 1. 查找源预览任务
|
||||
source_task = generation_task_repository.get(task_id)
|
||||
if source_task is None:
|
||||
raise HTTPException(status_code=404, detail=f"Preview task {task_id} not found")
|
||||
|
||||
# 2. 权限检查
|
||||
if source_task.created_by_user_id and source_task.created_by_user_id != authenticated_user.user.id:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
if source_task.project_id:
|
||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
||||
|
||||
# 3. 创建正式生成任务,复制预览任务的配置
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
new_task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=source_task.project_id,
|
||||
asset_library_id=source_task.asset_library_id,
|
||||
strategy_id=source_task.strategy_id,
|
||||
voice_library_id=source_task.voice_library_id,
|
||||
template_id=source_task.template_id,
|
||||
asset_ids=source_task.asset_ids,
|
||||
title_ids=source_task.title_ids,
|
||||
voice_ids=source_task.voice_ids,
|
||||
created_by_user_id=authenticated_user.user.id,
|
||||
source_edit_plan_id=source_task.source_edit_plan_id or "",
|
||||
asset_select_mode=source_task.asset_select_mode,
|
||||
video_title=getattr(source_task, "video_title", ""),
|
||||
resolution=getattr(source_task, "resolution", ""),
|
||||
is_preview=False,
|
||||
source_task_id=task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=request.cover_url,
|
||||
custom_title=request.custom_title,
|
||||
)
|
||||
)
|
||||
|
||||
# 4. 调度 worker.generate_video(同一条渲染路径)
|
||||
try:
|
||||
if not safe_enqueue_generation_task(
|
||||
new_task,
|
||||
generation_task_repository,
|
||||
user_id=authenticated_user.user.id,
|
||||
log_prefix="[确认生成]",
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(new_task)],
|
||||
total=1,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -429,6 +518,12 @@ def retry_generation_task(
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
custom_title=getattr(task, "custom_title", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
|
||||
@@ -98,6 +98,15 @@ def _auto_fallback_assign_assets(
|
||||
clips_without_asset = [c for c in all_clips if not c.asset_id]
|
||||
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d "
|
||||
"clips_without_asset=%d config_asset_ids=%r",
|
||||
plan_id,
|
||||
len(all_clips),
|
||||
len(clips_without_asset),
|
||||
config_asset_ids[:5] if config_asset_ids else [],
|
||||
)
|
||||
|
||||
if clips_without_asset and config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||
@@ -105,11 +114,42 @@ def _auto_fallback_assign_assets(
|
||||
len(clips_without_asset),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
assigned = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||
clips_without_asset = []
|
||||
try:
|
||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned += 1
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
|
||||
plan_id,
|
||||
assigned,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新检查剩余无素材片段
|
||||
all_clips_after = svc.list_clips(plan_id)
|
||||
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
|
||||
if clips_without_asset:
|
||||
logger.warning(
|
||||
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
|
||||
plan_id,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
elif not clips_without_asset:
|
||||
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
|
||||
elif not config_asset_ids:
|
||||
logger.info(
|
||||
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
|
||||
plan_id,
|
||||
)
|
||||
|
||||
return clips_without_asset
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ def generate_editor_draft(
|
||||
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
||||
)
|
||||
|
||||
# 检查是否可生成
|
||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
||||
try:
|
||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateAssetRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
project_id: str | None = Field(default=None, description="可选,不传时从 library.project_id 自动推导")
|
||||
library_id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -101,3 +101,30 @@ class ListAssetsResponse(BaseModel):
|
||||
total: int = Field(default=0, ge=0)
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=100, ge=1)
|
||||
|
||||
|
||||
class SmartMatchRequest(BaseModel):
|
||||
"""智能选素材请求。"""
|
||||
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
limit: int | None = Field(default=None, ge=1, le=200, description="最大返回数量,不传则返回全部匹配素材")
|
||||
kind: str | None = Field(
|
||||
default=None,
|
||||
pattern="^(video|image|audio)$",
|
||||
description="按文件类型过滤,不传则返回所有类型",
|
||||
)
|
||||
|
||||
|
||||
class SmartMatchItem(BaseModel):
|
||||
"""智能选素材结果条目。"""
|
||||
|
||||
asset: AssetResponse
|
||||
score: float = Field(..., ge=0, le=100, description="综合得分 0-100")
|
||||
breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细")
|
||||
|
||||
|
||||
class SmartMatchResponse(BaseModel):
|
||||
"""智能选素材响应。"""
|
||||
|
||||
items: list[SmartMatchItem]
|
||||
total_candidates: int = Field(default=0, ge=0, description="参与评分的候选素材总数")
|
||||
|
||||
@@ -4,6 +4,15 @@ from datetime import datetime
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
class ConfirmGenerationRequest(BaseModel):
|
||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
||||
|
||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
||||
output_height: int = Field(default=1920, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
|
||||
class CreateGenerationTaskRequest(BaseModel):
|
||||
"""创建生成任务请求。
|
||||
|
||||
@@ -57,6 +66,13 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
# ── 预览 / 确认生成 ──
|
||||
is_preview: bool = Field(default=False, description="是否为预览任务")
|
||||
source_task_id: str = Field(default="", description="来源预览任务 ID(确认生成时传入)")
|
||||
output_width: int = Field(default=1280, description="输出视频宽度")
|
||||
output_height: int = Field(default=720, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="封面图片 URL")
|
||||
custom_title: str = Field(default="", description="自定义视频标题")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -87,6 +103,12 @@ class GenerationTaskResponse(BaseModel):
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = Field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
@@ -146,6 +168,12 @@ class CreatePreviewGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
preview_count: int = Field(
|
||||
default=1,
|
||||
ge=1,
|
||||
le=10,
|
||||
description="预览视频生成数量,范围 1-10,默认 1",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
|
||||
@@ -571,6 +571,10 @@ class EditPlanService:
|
||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||
"""检查是否可以触发渲染
|
||||
|
||||
包含最后一道防线的自动修复:
|
||||
- 如果 clips 存在但都没有 asset_id,且 config.asset_ids 非空,
|
||||
直接在内部执行素材分配,不再依赖前置 fallback 链路。
|
||||
|
||||
Returns:
|
||||
tuple: (can_generate, reason)
|
||||
"""
|
||||
@@ -585,10 +589,69 @@ class EditPlanService:
|
||||
if not clips:
|
||||
return False, "请先添加片段后再生成视频"
|
||||
|
||||
# 检查是否至少有一个片段分配了素材
|
||||
has_asset = any(c.asset_id for c in clips)
|
||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
||||
logger.info(
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
|
||||
plan_id,
|
||||
plan.status,
|
||||
len(clips),
|
||||
clips_with_asset_count,
|
||||
config_asset_ids_count,
|
||||
)
|
||||
if not has_asset:
|
||||
# ── 最后防线:自动从 config.asset_ids 分配素材 ──
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
if config_asset_ids:
|
||||
logger.warning(
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
|
||||
plan_id,
|
||||
len(clips),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
clips_without_asset = [c for c in clips if not c.asset_id]
|
||||
assigned_count = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
self.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned_count += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"can_generate 最后防线: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"can_generate 最后防线: plan=%s 已为 %d/%d 个片段分配素材",
|
||||
plan_id,
|
||||
assigned_count,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新加载 clips 验证分配结果
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
if not any(c.asset_id for c in clips):
|
||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
||||
else:
|
||||
logger.warning(
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
|
||||
plan_id,
|
||||
len(clips),
|
||||
)
|
||||
return False, "没有可渲染的就绪片段,请确保已选择素材"
|
||||
|
||||
return True, ""
|
||||
|
||||
def mark_clips_ready(self, plan_id: str) -> int:
|
||||
"""将所有 pending 状态的片段标记为 ready
|
||||
"""将已分配素材的 pending 片段标记为 ready
|
||||
|
||||
只标记同时满足以下条件的片段:
|
||||
- status == PENDING
|
||||
- asset_id 非空(已分配素材)
|
||||
|
||||
Returns:
|
||||
int: 标记的片段数量
|
||||
@@ -599,10 +662,16 @@ class EditPlanService:
|
||||
)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
clip.mark_ready()
|
||||
self._clip_repo.update(clip)
|
||||
count += 1
|
||||
logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
|
||||
if clip.asset_id:
|
||||
clip.mark_ready()
|
||||
self._clip_repo.update(clip)
|
||||
count += 1
|
||||
logger.info(
|
||||
"标记片段就绪: plan_id=%s marked=%d total_pending=%d",
|
||||
plan_id,
|
||||
count,
|
||||
len(clips),
|
||||
)
|
||||
return count
|
||||
|
||||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||||
|
||||
@@ -49,9 +49,10 @@ class PlanGeneratorService:
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
def __init__(self, db: Session, asset_repo=None) -> None:
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
self._asset_repo = asset_repo
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -64,6 +65,7 @@ class PlanGeneratorService:
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
name: str = "",
|
||||
random_preview: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""基于模板+素材生成剪辑计划
|
||||
|
||||
@@ -74,6 +76,7 @@ class PlanGeneratorService:
|
||||
project_id: 所属项目 ID
|
||||
created_by_user_id: 创建者用户 ID
|
||||
name: 计划名称(为空则自动取模板名)
|
||||
random_preview: 是否启用随机预览模式(随机选素材+随机截取片段)
|
||||
|
||||
Returns:
|
||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||
@@ -115,7 +118,17 @@ class PlanGeneratorService:
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
self._distribute_assets(clips, asset_ids, editing_mode)
|
||||
# 如果是随机预览模式,获取素材时长信息
|
||||
asset_durations = None
|
||||
if random_preview and self._asset_repo:
|
||||
asset_durations = self._fetch_asset_durations(asset_ids)
|
||||
self._distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
random_selection=random_preview,
|
||||
asset_durations=asset_durations,
|
||||
)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: List[EditPlanClip] = []
|
||||
@@ -199,9 +212,34 @@ class PlanGeneratorService:
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
*,
|
||||
random_selection: bool = False,
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||
|
||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||
"""
|
||||
distribute_assets(clips, asset_ids, editing_mode)
|
||||
distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
random_selection=random_selection,
|
||||
asset_durations=asset_durations,
|
||||
)
|
||||
|
||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
||||
"""从数据库获取素材时长信息.
|
||||
|
||||
Args:
|
||||
asset_ids: 素材 ID 列表
|
||||
|
||||
Returns:
|
||||
dict: 素材 ID -> 时长(秒)映射
|
||||
"""
|
||||
durations: dict[str, float] = {}
|
||||
for asset_id in asset_ids:
|
||||
asset = self._asset_repo.get(asset_id)
|
||||
if asset and hasattr(asset, "duration"):
|
||||
durations[asset_id] = float(asset.duration or 0.0)
|
||||
return durations
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""SmartAssetSelector — 智能素材选择服务.
|
||||
|
||||
根据多维度评分从素材库中自动选择最优视频素材,
|
||||
用于一键生成等需要自动选取素材的场景。
|
||||
|
||||
评分维度(加权求和,总分 0-1):
|
||||
- 质量分(quality_score):权重 0.5 — 来自人工或AI的质量评分
|
||||
- 分辨率适配:权重 0.2 — 分辨率越接近 1080p 得分越高
|
||||
- 时长合理性:权重 0.2 — 3-30 秒区间最佳,过短/过长扣分
|
||||
- 码率质量:权重 0.1 — 用文件大小/时长估算,码率适中得分高
|
||||
|
||||
特性:
|
||||
- 最低质量分门槛:自动过滤低质量素材
|
||||
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
||||
- 兼容全部模式:素材库模式和项目模式都可用
|
||||
|
||||
纯逻辑部分已抽离到 packages.domain.asset_scoring。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||
from packages.domain.asset_scoring import (
|
||||
AssetScoreDetail,
|
||||
SmartSelectResult,
|
||||
diverse_selection,
|
||||
filter_candidates,
|
||||
score_asset_detail,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmartAssetSelector:
|
||||
"""智能素材选择器.
|
||||
|
||||
从一组素材中按综合评分选择最优的 N 个,
|
||||
同时保证时长分布的多样性。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min_quality_score: float = 30.0,
|
||||
target_width: int = 1920,
|
||||
target_height: int = 1080,
|
||||
):
|
||||
self.min_quality_score = min_quality_score
|
||||
self.target_width = target_width
|
||||
self.target_height = target_height
|
||||
|
||||
# ── 公开方法 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def select(
|
||||
self,
|
||||
assets: list,
|
||||
count: int = 0,
|
||||
*,
|
||||
ensure_diversity: bool = True,
|
||||
) -> SmartSelectResult:
|
||||
"""从素材列表中智能选择最优素材.
|
||||
|
||||
Args:
|
||||
assets: Asset 实体列表(需要有 id/quality_score/width/height/duration/file_size 属性)
|
||||
count: 选取数量,0 表示全部符合条件的
|
||||
ensure_diversity: 是否保证时长多样性(默认开启)
|
||||
|
||||
Returns:
|
||||
SmartSelectResult 选择结果
|
||||
"""
|
||||
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
|
||||
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
|
||||
|
||||
if not candidates:
|
||||
return SmartSelectResult(
|
||||
selected_ids=[],
|
||||
total_candidates=0,
|
||||
filtered_out=filtered_out,
|
||||
avg_score=0.0,
|
||||
details=[],
|
||||
)
|
||||
|
||||
# 2. 对每个候选素材评分
|
||||
scored: list[AssetScoreDetail] = []
|
||||
for asset in candidates:
|
||||
detail = score_asset_detail(
|
||||
asset_id=asset.id,
|
||||
quality=getattr(asset, "quality_score", None),
|
||||
width=getattr(asset, "width", None),
|
||||
height=getattr(asset, "height", None),
|
||||
duration=getattr(asset, "duration", None),
|
||||
file_size=getattr(asset, "file_size", 0) or 0,
|
||||
target_width=self.target_width,
|
||||
target_height=self.target_height,
|
||||
)
|
||||
scored.append(detail)
|
||||
|
||||
# 3. 按总分降序排列
|
||||
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||
|
||||
# 4. 多样性选择(如果需要且数量有限制)
|
||||
if ensure_diversity and count > 0 and len(scored) > count:
|
||||
selected = diverse_selection(scored, count)
|
||||
else:
|
||||
# 无数量限制或不要求多样性,直接按排名取
|
||||
selected = scored if count <= 0 else scored[:count]
|
||||
|
||||
avg_score = sum(d.total_score for d in selected) / len(selected) if selected else 0.0
|
||||
|
||||
result = SmartSelectResult(
|
||||
selected_ids=[d.asset_id for d in selected],
|
||||
total_candidates=len(candidates),
|
||||
filtered_out=filtered_out,
|
||||
avg_score=avg_score,
|
||||
details=selected,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"智能素材选择完成: 候选=%d, 过滤=%d, 选中=%d, 平均分=%.3f",
|
||||
result.total_candidates,
|
||||
result.filtered_out,
|
||||
len(result.selected_ids),
|
||||
result.avg_score,
|
||||
)
|
||||
return result
|
||||
|
||||
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
|
||||
|
||||
def _score_asset(self, asset) -> AssetScoreDetail:
|
||||
"""对单个素材进行多维度评分(向后兼容)."""
|
||||
return score_asset_detail(
|
||||
asset_id=asset.id,
|
||||
quality=getattr(asset, "quality_score", None),
|
||||
width=getattr(asset, "width", None),
|
||||
height=getattr(asset, "height", None),
|
||||
duration=getattr(asset, "duration", None),
|
||||
file_size=getattr(asset, "file_size", 0) or 0,
|
||||
target_width=self.target_width,
|
||||
target_height=self.target_height,
|
||||
)
|
||||
|
||||
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
||||
"""分辨率评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_resolution
|
||||
|
||||
return score_resolution(width, height, self.target_width, self.target_height)
|
||||
|
||||
def _score_duration(self, duration: float | None) -> float:
|
||||
"""时长评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_duration
|
||||
|
||||
return score_duration(duration)
|
||||
|
||||
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
||||
"""码率评分(向后兼容)."""
|
||||
from packages.domain.asset_scoring import score_bitrate
|
||||
|
||||
return score_bitrate(file_size, duration)
|
||||
|
||||
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
||||
"""多样性选择(向后兼容)."""
|
||||
return diverse_selection(scored, count)
|
||||
@@ -196,24 +196,33 @@ test.describe("Core generation flow", () => {
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: preview (纯展示页,AI 智能匹配预览)
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
|
||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: title
|
||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
||||
// 点击"生成预览"按钮触发预览生成
|
||||
await page.locator(".xx-preview-generate-btn").click()
|
||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 120_000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
||||
// 如果 AI 自动选择标题模式开启,先切换到手动模式以显示输入框
|
||||
const aiSwitch = page.locator(".xx-title-ai-toggle .xx-switch.active")
|
||||
if (await aiSwitch.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await aiSwitch.click()
|
||||
// 等待输入框出现(条件渲染,需要等待 DOM 更新)
|
||||
await expect(page.getByPlaceholder("输入或从标题库选择…")).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
||||
await firstVoiceCard.click()
|
||||
await page.getByPlaceholder("输入或从标题库选择…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
|
||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 7: confirm and generate
|
||||
@@ -297,4 +306,3 @@ test.describe("Core generation flow", () => {
|
||||
expect(Array.isArray(tasksData.items)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Generated
+14
-26
@@ -1847,7 +1847,7 @@
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -1937,7 +1937,7 @@
|
||||
},
|
||||
"node_modules/@types/aria-query": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -3112,7 +3112,7 @@
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -4028,18 +4028,6 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4468,7 +4456,7 @@
|
||||
},
|
||||
"node_modules/lz-string": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -5010,7 +4998,7 @@
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -5026,7 +5014,7 @@
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -5038,14 +5026,6 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format/node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -5743,6 +5723,14 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -49,6 +49,17 @@ export const getAssetsByKind = async (
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能匹配素材(后端 AI 选素材)
|
||||
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材
|
||||
*/
|
||||
export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => {
|
||||
const response = await apiClient.post("/assets/smart-match", {
|
||||
library_id: libraryId,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||
export const createAsset = async (data: {
|
||||
library_id: string
|
||||
|
||||
@@ -29,10 +29,11 @@ export {
|
||||
deleteAssetLibrary,
|
||||
} from "./libraries"
|
||||
|
||||
// 素材 CRUD
|
||||
// 素材 CRUD + 智能匹配
|
||||
export {
|
||||
getAssets,
|
||||
getAssetsByKind,
|
||||
smartMatchAssets,
|
||||
createAsset,
|
||||
updateAsset,
|
||||
updateAssetReviewStatus,
|
||||
|
||||
@@ -18,6 +18,7 @@ const mapAssetToMediaAsset = (asset: AssetItem): MediaAsset => {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
type: inferMediaType(asset.mime_type || ""),
|
||||
source_url: asset.file_url,
|
||||
thumbnail_url: asset.thumbnail_url,
|
||||
duration: asset.duration ?? metaDuration,
|
||||
size: asset.file_size ?? undefined,
|
||||
|
||||
@@ -424,6 +424,8 @@ export interface MediaAsset {
|
||||
id: string
|
||||
name: string
|
||||
type: "video" | "image" | "audio"
|
||||
/** 源文件 URL(视频/原图),用于悬浮预览播放 */
|
||||
source_url?: string
|
||||
/** 缩略图 URL */
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),仅 video/audio */
|
||||
|
||||
@@ -456,7 +456,8 @@
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.as-preview-overlay-thumb img {
|
||||
.as-preview-overlay-thumb img,
|
||||
.as-preview-overlay-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
|
||||
@@ -12,7 +12,16 @@ const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
|
||||
return (
|
||||
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
{asset.type === "video" && asset.source_url ? (
|
||||
<video
|
||||
src={asset.source_url}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
poster={asset.thumbnail_url}
|
||||
/>
|
||||
) : asset.thumbnail_url ? (
|
||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||
) : (
|
||||
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
@@ -60,7 +60,6 @@ export const useTtsPanel = ({ open, config, onChange, onClose }: UseTtsPanelOpti
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open])
|
||||
|
||||
/* ── 组件卸载时清理音频资源 ── */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 智能剪辑页面 — V21 原型 1:1 还原
|
||||
* 7 步向导:选择模板 → 选择素材 → 生成预览 → 选择标题 → 选择配音 → 选择封面 → 确认生成
|
||||
* 智能剪辑页面 — V22 多预览 + 配音前置
|
||||
* 7 步向导:选择模板 → 选择素材 → 选择配音 → 生成预览 → 选择标题 → 选择封面 → 确认生成
|
||||
* 左右布局:左侧 generate-form + 右侧 generate-preview
|
||||
* 主组件仅保留整体布局与事件编排
|
||||
* 状态管理 → hooks/useGenerateFormState
|
||||
@@ -9,7 +9,7 @@
|
||||
* 底部按钮 → components/GenerateStepActions
|
||||
* 生成核心逻辑 → hooks/useGenerateVideo
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { Modal, message } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
@@ -24,7 +24,7 @@ import GenerateStepActions from "./components/GenerateStepActions"
|
||||
import { useGenerateFormState } from "./hooks/useGenerateFormState"
|
||||
import { useStepNavigation } from "./hooks/useStepNavigation"
|
||||
import { useGenerateVideo } from "./hooks/useGenerateVideo"
|
||||
import { useStep3Preview } from "./hooks/useStep3Preview"
|
||||
import { useStep4Preview } from "./hooks/useStep4Preview"
|
||||
import "./generate.css"
|
||||
|
||||
const GeneratePage: React.FC = () => {
|
||||
@@ -80,8 +80,24 @@ const GeneratePage: React.FC = () => {
|
||||
message.success("音色克隆成功!")
|
||||
}
|
||||
|
||||
/* ── Step3 预览生成 ── */
|
||||
const step3Preview = useStep3Preview({
|
||||
/* ── 预览数量(多预览) ── */
|
||||
const [previewCount, setPreviewCount] = useState(1)
|
||||
|
||||
/* ── 根据 voiceMode 构建 voiceIds 传给预览接口 ── */
|
||||
/* selectedVoice / selectedClonedVoice 均为 string 类型(voice ID),
|
||||
见 useGenerateFormState 返回值类型定义 */
|
||||
const previewVoiceIds = useMemo((): string[] => {
|
||||
if (voiceMode === "clone") {
|
||||
const id: string = selectedClonedVoice
|
||||
return id ? [id] : []
|
||||
}
|
||||
// preset / custom 模式
|
||||
const id: string = selectedVoice
|
||||
return id ? [id] : []
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice])
|
||||
|
||||
/* ── Step4 预览生成(多预览 + voice_ids) ── */
|
||||
const step4Preview = useStep4Preview({
|
||||
templates: userTemplates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
@@ -89,6 +105,8 @@ const GeneratePage: React.FC = () => {
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: previewVoiceIds,
|
||||
previewCount,
|
||||
})
|
||||
|
||||
/* ── 步骤导航 ── */
|
||||
@@ -100,7 +118,7 @@ const GeneratePage: React.FC = () => {
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
titleSettings,
|
||||
previewReady: step3Preview.canProceed,
|
||||
previewReady: step4Preview.canProceed,
|
||||
})
|
||||
|
||||
/* ── 视频生成核心逻辑 ── */
|
||||
@@ -187,14 +205,20 @@ const GeneratePage: React.FC = () => {
|
||||
onDismissError={handleDismissError}
|
||||
presetVoices={presetVoices}
|
||||
videoRatio={videoRatio}
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
previewTemplateName={step3Preview.templateName}
|
||||
previewMaterialCount={step3Preview.materialCount}
|
||||
previewProgress={step3Preview.progress}
|
||||
onGeneratePreview={step3Preview.generatePreview}
|
||||
onRegeneratePreview={step3Preview.regeneratePreview}
|
||||
/* Step4 多预览 */
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={setPreviewCount}
|
||||
previewItems={step4Preview.items}
|
||||
previewSelectedIndex={step4Preview.selectedIndex}
|
||||
onSelectPreview={step4Preview.setSelectedIndex}
|
||||
previewOverallStatus={step4Preview.previewStatus}
|
||||
previewOverallError={step4Preview.previewError}
|
||||
previewOverallProgress={step4Preview.progress}
|
||||
previewAnyGenerating={step4Preview.anyGenerating}
|
||||
previewTemplateName={step4Preview.templateName}
|
||||
previewMaterialCount={step4Preview.materialCount}
|
||||
onGeneratePreview={step4Preview.generatePreview}
|
||||
onRegeneratePreview={step4Preview.regeneratePreview}
|
||||
/>
|
||||
|
||||
<GenerateStepActions
|
||||
@@ -210,35 +234,37 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
{/* ════ 右侧:预览 + 生成结果 ════ */}
|
||||
<div className="xx-generate-right-col">
|
||||
{/* 预览视频面板(Step3+ 常驻) */}
|
||||
{currentStep >= 3 && (
|
||||
{/* 预览视频面板(Step4+ 常驻,展示选中的预览) */}
|
||||
{currentStep >= 4 && (
|
||||
<PreviewVideoPanel
|
||||
previewStatus={step3Preview.previewStatus}
|
||||
previewResult={step3Preview.previewResult}
|
||||
previewError={step3Preview.previewError}
|
||||
progress={step3Preview.progress}
|
||||
previewStatus={step4Preview.previewStatus}
|
||||
previewResult={step4Preview.previewResult}
|
||||
previewError={step4Preview.previewError}
|
||||
progress={step4Preview.progress}
|
||||
videoRatio={videoRatio}
|
||||
onRegenerate={step3Preview.regeneratePreview}
|
||||
onRegenerate={step4Preview.regeneratePreview}
|
||||
titleText={titleSettings.title}
|
||||
titleSettings={titleSettings}
|
||||
titleSettings={currentStep >= 5 ? titleSettings : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 正式生成结果 */}
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
{/* 正式生成结果(Step5+ 才显示) */}
|
||||
{currentStep >= 5 && (
|
||||
<GenerateResultPanel
|
||||
generated={generated}
|
||||
generating={generating}
|
||||
progress={progress}
|
||||
generateError={generateError}
|
||||
generatedVideos={generatedVideos}
|
||||
onVideoPreview={(video) => {
|
||||
setPreviewVideo(video)
|
||||
setPreviewModalOpen(true)
|
||||
}}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onGoToLibrary={() => navigate("/app/products")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤内容渲染
|
||||
* 根据当前步骤渲染对应的 Step 组件
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import React from "react"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
@@ -8,12 +9,12 @@ import type { PresetVoiceItem } from "@/api/voices"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { TitleSettings } from "../types"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import Step1TemplateSelect from "../components/Step1TemplateSelect"
|
||||
import Step2MaterialSelect from "../components/Step2MaterialSelect"
|
||||
import Step3GeneratePreview from "../components/Step3GeneratePreview"
|
||||
import Step4TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step5VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step3VoiceSelect from "../components/Step5VoiceSelect"
|
||||
import Step4GeneratePreview from "../components/Step4GeneratePreview"
|
||||
import Step5TitleSettings from "../components/Step4TitleSettings"
|
||||
import Step6CoverSettings from "../components/Step6CoverSettings"
|
||||
import Step7ConfirmGenerate from "../components/Step7ConfirmGenerate"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
@@ -63,13 +64,18 @@ export interface GenerateStepContentProps {
|
||||
/* 其他 */
|
||||
presetVoices: PresetVoiceItem[]
|
||||
videoRatio: string
|
||||
/* Step3 预览 */
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
/* Step4 预览(多预览) */
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
previewItems: PreviewItem[]
|
||||
previewSelectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
previewOverallStatus: PreviewStatus
|
||||
previewOverallError: string
|
||||
previewOverallProgress: number
|
||||
previewAnyGenerating: boolean
|
||||
previewTemplateName: string
|
||||
previewMaterialCount: string
|
||||
previewProgress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
@@ -94,14 +100,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
voiceMode,
|
||||
onVoiceModeChange,
|
||||
selectedClonedVoice,
|
||||
onSelectedClonedVoiceChange,
|
||||
clonedVoices,
|
||||
addClone,
|
||||
hasProcessing,
|
||||
cloneModalOpen,
|
||||
onCloneModalOpenChange,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
generating,
|
||||
@@ -113,12 +113,17 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
onDismissError,
|
||||
presetVoices,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
previewItems,
|
||||
previewSelectedIndex,
|
||||
onSelectPreview,
|
||||
previewOverallStatus,
|
||||
previewOverallError,
|
||||
previewOverallProgress,
|
||||
previewAnyGenerating,
|
||||
previewTemplateName,
|
||||
previewMaterialCount,
|
||||
previewProgress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
} = props
|
||||
@@ -145,41 +150,36 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
)
|
||||
case 3:
|
||||
return (
|
||||
<Step3GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
previewStatus={previewStatus}
|
||||
previewResult={previewResult}
|
||||
previewError={previewError}
|
||||
progress={previewProgress}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
<Step3VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
/>
|
||||
)
|
||||
case 4:
|
||||
return (
|
||||
<Step4TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
<Step4GeneratePreview
|
||||
templateName={previewTemplateName}
|
||||
materialCount={previewMaterialCount}
|
||||
duration={duration}
|
||||
videoRatio={videoRatio}
|
||||
previewCount={previewCount}
|
||||
onPreviewCountChange={onPreviewCountChange}
|
||||
items={previewItems}
|
||||
selectedIndex={previewSelectedIndex}
|
||||
onSelectPreview={onSelectPreview}
|
||||
overallStatus={previewOverallStatus}
|
||||
overallError={previewOverallError}
|
||||
overallProgress={previewOverallProgress}
|
||||
anyGenerating={previewAnyGenerating}
|
||||
onGeneratePreview={onGeneratePreview}
|
||||
onRegeneratePreview={onRegeneratePreview}
|
||||
/>
|
||||
)
|
||||
case 5:
|
||||
return (
|
||||
<Step5VoiceSelect
|
||||
selectedVoice={selectedVoice}
|
||||
onSelectedVoiceChange={onSelectedVoiceChange}
|
||||
voiceMode={voiceMode}
|
||||
onVoiceModeChange={onVoiceModeChange}
|
||||
selectedClonedVoice={selectedClonedVoice}
|
||||
onSelectedClonedVoiceChange={onSelectedClonedVoiceChange}
|
||||
clonedVoices={clonedVoices}
|
||||
addClone={addClone}
|
||||
hasProcessing={hasProcessing}
|
||||
cloneModalOpen={cloneModalOpen}
|
||||
onCloneModalOpenChange={onCloneModalOpenChange}
|
||||
titleText={titleSettings.title}
|
||||
<Step5TitleSettings
|
||||
titleSettings={titleSettings}
|
||||
onTitleSettingsChange={onTitleSettingsChange}
|
||||
/>
|
||||
)
|
||||
case 6:
|
||||
@@ -188,6 +188,8 @@ export const GenerateStepContent: React.FC<GenerateStepContentProps> = (props) =
|
||||
coverSettings={coverSettings}
|
||||
onCoverSettingsChange={onCoverSettingsChange}
|
||||
duration={duration}
|
||||
assetIds={materialMode === "auto" ? smartSelectedIds : selectedMaterials}
|
||||
selectedTemplate={selectedTemplate}
|
||||
/>
|
||||
)
|
||||
case 7:
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* 右侧预览视频面板
|
||||
* Step3 生成预览后常驻显示预览视频
|
||||
* Step4+ 显示标题实时预览
|
||||
* Step4 生成预览后常驻显示预览视频
|
||||
* Step5+ 用 Canvas 绘制标题预览(替代 CSS overlay,与 ASS 渲染行为一致)
|
||||
*
|
||||
* 设计说明:标题预览仅在有视频时显示(叠加在视频画面上方)。
|
||||
* 无视频状态(idle/loading/error)下不再单独显示标题预览,这是有意为之的设计简化。
|
||||
*
|
||||
* Canvas 居中修复说明:
|
||||
* Canvas 的 CSS 位置和尺寸直接匹配视频实际渲染区域(通过 getBoundingClientRect),
|
||||
* 绘制坐标系基于 Canvas 自身尺寸,x = w/2 即可实现水平居中,
|
||||
* 避免容器与视频尺寸不一致时浏览器拉伸 Canvas 导致居中偏移。
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useRef, useEffect, useCallback } from "react"
|
||||
import { PlayCircleOutlined, LoadingOutlined } from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
import type { TitleSettings } from "../types"
|
||||
|
||||
interface PreviewVideoPanelProps {
|
||||
@@ -15,46 +23,137 @@ interface PreviewVideoPanelProps {
|
||||
progress: number
|
||||
videoRatio: string
|
||||
onRegenerate: () => void
|
||||
/** 标题文字(Step4 起传入) */
|
||||
/** 标题文字(Step5 起传入) */
|
||||
titleText?: string
|
||||
/** 标题样式设置(Step4 起传入) */
|
||||
/** 标题样式设置(Step5 起传入) */
|
||||
titleSettings?: TitleSettings
|
||||
}
|
||||
|
||||
/** 根据 position 返回 absolute 定位样式(叠加在视频上) */
|
||||
const positionOverlayStyle = (position: string): React.CSSProperties => {
|
||||
/* ── Canvas 绘制工具函数 ── */
|
||||
|
||||
/**
|
||||
* 将文本按 maxWidth 逐字换行,返回行数组。
|
||||
* 与 ASS 字幕引擎的逐字换行行为一致。
|
||||
*/
|
||||
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
||||
const lines: string[] = []
|
||||
let currentLine = ""
|
||||
for (const char of text) {
|
||||
const testLine = currentLine + char
|
||||
if (ctx.measureText(testLine).width > maxWidth && currentLine) {
|
||||
lines.push(currentLine)
|
||||
currentLine = char
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
if (currentLine) lines.push(currentLine)
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 canvas 上绘制标题文字(含描边/阴影/多行居中)
|
||||
*
|
||||
* Canvas 已通过 CSS 定位到视频实际渲染位置,
|
||||
* 坐标系基于 Canvas 自身尺寸,居中直接使用 w/2。
|
||||
*
|
||||
* @param ctx canvas 上下文
|
||||
* @param w canvas CSS 宽度(= 视频渲染宽度)
|
||||
* @param h canvas CSS 高度(= 视频渲染高度)
|
||||
* @param text 标题文字
|
||||
* @param settings 标题样式
|
||||
* @param paddingX 左右边距(px),与 ASS 的 MarginL/MarginR 对应
|
||||
* @param position "top" | "center" | "bottom"
|
||||
* @param topOffset 顶部/底部偏移量
|
||||
*/
|
||||
function drawTitleOnCanvas(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
text: string,
|
||||
settings: TitleSettings,
|
||||
paddingX: number,
|
||||
position: string,
|
||||
topOffset: number,
|
||||
) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
// 设置 canvas 物理像素尺寸(高清屏适配)
|
||||
ctx.canvas.width = Math.round(w * dpr)
|
||||
ctx.canvas.height = Math.round(h * dpr)
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// 清除
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
// 可用宽度 = 总宽 - 左右边距
|
||||
const availableWidth = w - paddingX * 2
|
||||
if (availableWidth <= 0) return
|
||||
|
||||
// 字体设置
|
||||
const fontSize = Math.round(Math.min(settings.size, 36))
|
||||
const fontWeight = settings.bold ? "bold" : "normal"
|
||||
const fontStyle = settings.italic ? "italic" : "normal"
|
||||
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px "${settings.font}"`
|
||||
|
||||
// 文字属性
|
||||
ctx.textAlign = "center"
|
||||
ctx.textBaseline = "middle"
|
||||
ctx.fillStyle = settings.color
|
||||
|
||||
const lineHeight = fontSize * 1.4
|
||||
|
||||
// 描边 & 阴影
|
||||
if (settings.stroke) {
|
||||
ctx.strokeStyle = "rgba(0,0,0,0.6)"
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = "round"
|
||||
}
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "rgba(0,0,0,0.7)"
|
||||
ctx.shadowBlur = 4
|
||||
ctx.shadowOffsetX = 2
|
||||
ctx.shadowOffsetY = 2
|
||||
}
|
||||
|
||||
// 换行
|
||||
const displayText = text && text.trim() ? text : "请选择或输入标题"
|
||||
const lines = wrapText(ctx, displayText, availableWidth)
|
||||
|
||||
// 起始 Y:根据 position 计算
|
||||
const totalTextHeight = lines.length * lineHeight
|
||||
let startY: number
|
||||
switch (position) {
|
||||
case "top":
|
||||
return { top: 16, left: 0, right: 0 }
|
||||
startY = topOffset
|
||||
break
|
||||
case "center":
|
||||
return { top: "50%", left: 0, right: 0, transform: "translateY(-50%)" }
|
||||
startY = (h - totalTextHeight) / 2 + lineHeight / 2
|
||||
break
|
||||
case "bottom":
|
||||
default:
|
||||
return { bottom: 56, left: 0, right: 0 } // 56px = 给视频控制条留空间
|
||||
startY = h - topOffset - totalTextHeight + lineHeight / 2
|
||||
break
|
||||
}
|
||||
|
||||
// 居中 x = w/2(Canvas 已定位到视频位置,无需额外偏移)
|
||||
const x = w / 2
|
||||
lines.forEach((line, i) => {
|
||||
const y = startY + i * lineHeight
|
||||
if (settings.stroke) ctx.strokeText(line, x, y)
|
||||
ctx.fillText(line, x, y)
|
||||
})
|
||||
|
||||
// 重置 shadow(避免影响后续绘制)
|
||||
if (settings.shadow) {
|
||||
ctx.shadowColor = "transparent"
|
||||
ctx.shadowBlur = 0
|
||||
ctx.shadowOffsetX = 0
|
||||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据 TitleSettings 生成文字样式 */
|
||||
const buildTitleStyle = (settings: TitleSettings): React.CSSProperties => {
|
||||
const style: React.CSSProperties = {
|
||||
fontFamily: settings.font,
|
||||
fontSize: Math.min(settings.size, 36), // 预览区域限制最大字号
|
||||
color: settings.color,
|
||||
fontWeight: settings.bold ? 700 : 400,
|
||||
fontStyle: settings.italic ? "italic" : "normal",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.4,
|
||||
wordBreak: "break-word",
|
||||
padding: "0 12px",
|
||||
}
|
||||
if (settings.stroke) {
|
||||
style.WebkitTextStroke = "1px rgba(0,0,0,0.6)"
|
||||
}
|
||||
if (settings.shadow) {
|
||||
style.textShadow = "2px 2px 4px rgba(0,0,0,0.7)"
|
||||
}
|
||||
return style
|
||||
}
|
||||
/* ── 组件 ── */
|
||||
|
||||
export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
previewStatus,
|
||||
@@ -70,6 +169,126 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
const isLoading = previewStatus === "pending" || previewStatus === "generating"
|
||||
const isError = previewStatus === "error"
|
||||
const showTitlePreview = !!titleSettings
|
||||
const videoAspectStyle = { aspectRatio: (videoRatio || "16:9").replace(":", "/") }
|
||||
|
||||
// video 模式 refs
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
// 字体加载状态(ref 供 draw 回调同步读取,无需 state 避免触发不必要的重渲染)
|
||||
const fontLoadedRef = useRef(false)
|
||||
|
||||
/** 在 video canvas 上绘制标题 */
|
||||
const drawVideoTitle = useCallback(() => {
|
||||
// 通过 ref 读取字体状态,避免 fontLoaded 进入依赖数组
|
||||
if (!fontLoadedRef.current) return
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!canvas || !container || !titleSettings) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
if (containerRect.width <= 0 || containerRect.height <= 0) return
|
||||
|
||||
// 使用 video 元素的 getBoundingClientRect 获取实际渲染尺寸和位置
|
||||
const videoEl = videoRef.current
|
||||
let drawW = containerRect.width
|
||||
let drawH = containerRect.height
|
||||
let offsetX = 0
|
||||
let offsetY = 0
|
||||
|
||||
if (videoEl && videoEl.clientWidth > 0 && videoEl.clientHeight > 0) {
|
||||
const videoRect = videoEl.getBoundingClientRect()
|
||||
drawW = videoRect.width
|
||||
drawH = videoRect.height
|
||||
offsetX = videoRect.left - containerRect.left
|
||||
offsetY = videoRect.top - containerRect.top
|
||||
}
|
||||
|
||||
// 更新 Canvas CSS 位置和尺寸,使其与视频实际渲染区域完全对齐
|
||||
canvas.style.left = `${offsetX}px`
|
||||
canvas.style.top = `${offsetY}px`
|
||||
canvas.style.width = `${drawW}px`
|
||||
canvas.style.height = `${drawH}px`
|
||||
|
||||
// 绘制时,坐标系基于 Canvas 自身尺寸,无需额外偏移
|
||||
drawTitleOnCanvas(
|
||||
ctx,
|
||||
drawW,
|
||||
drawH,
|
||||
titleText || "",
|
||||
titleSettings,
|
||||
40,
|
||||
titleSettings.position,
|
||||
60,
|
||||
)
|
||||
}, [titleText, titleSettings])
|
||||
|
||||
// video 模式:ResizeObserver 监听容器尺寸变化 → 重绘
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !hasPreview) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
drawVideoTitle()
|
||||
})
|
||||
observer.observe(container)
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [showTitlePreview, hasPreview, drawVideoTitle])
|
||||
|
||||
// 字体加载检测:字体变更时重新检测,确保 measureText 使用正确字体
|
||||
useEffect(() => {
|
||||
if (!showTitlePreview || !titleSettings) {
|
||||
fontLoadedRef.current = false
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
fontLoadedRef.current = false
|
||||
|
||||
const fontWeight = titleSettings.bold ? "bold" : ""
|
||||
const fontStyle = titleSettings.italic ? "italic" : ""
|
||||
const fontSpec =
|
||||
`${fontStyle} ${fontWeight} ${titleSettings.size}px "${titleSettings.font}"`.trim()
|
||||
|
||||
const onFontReady = () => {
|
||||
if (cancelled) return
|
||||
fontLoadedRef.current = true
|
||||
// ref 已同步更新,显式触发重绘(draw 内部通过 ref 检查字体状态)
|
||||
requestAnimationFrame(() => {
|
||||
if (!cancelled) {
|
||||
drawVideoTitle()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (document.fonts.check(fontSpec)) {
|
||||
onFontReady()
|
||||
return
|
||||
}
|
||||
|
||||
document.fonts
|
||||
.load(fontSpec)
|
||||
.then(() => onFontReady())
|
||||
.catch(() => {
|
||||
document.fonts.ready.then(() => onFontReady())
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [showTitlePreview, titleSettings, drawVideoTitle])
|
||||
|
||||
// video 加载完成后重绘
|
||||
const handleVideoLoaded = useCallback(() => {
|
||||
if (showTitlePreview) {
|
||||
requestAnimationFrame(drawVideoTitle)
|
||||
}
|
||||
}, [showTitlePreview, drawVideoTitle])
|
||||
|
||||
return (
|
||||
<div className="xx-generate-preview">
|
||||
@@ -92,7 +311,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成中 */}
|
||||
{isLoading && (
|
||||
<div className="xx-preview-loading-panel">
|
||||
<div className="xx-preview-video">
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<div className="xx-preview-loading-center">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#fff" }} spin />
|
||||
<p style={{ marginTop: 12, color: "rgba(255,255,255,0.8)", fontSize: 14 }}>
|
||||
@@ -109,7 +328,7 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
{/* 生成失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error-panel">
|
||||
<div className="xx-preview-video xx-preview-video--error">
|
||||
<div className="xx-preview-video xx-preview-video--error" style={videoAspectStyle}>
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 14 }}>预览生成失败</p>
|
||||
</div>
|
||||
<p className="xx-preview-error-msg">
|
||||
@@ -121,50 +340,35 @@ export const PreviewVideoPanel: React.FC<PreviewVideoPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览成功 + 标题叠加 */}
|
||||
{/* 预览成功 + Canvas 标题叠加 */}
|
||||
{hasPreview && (
|
||||
<div style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video">
|
||||
<video src={previewResult.videoUrl} controls preload="metadata" />
|
||||
<div ref={containerRef} style={{ position: "relative" }}>
|
||||
<div className="xx-preview-video" style={videoAspectStyle}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
onLoadedMetadata={handleVideoLoaded}
|
||||
/>
|
||||
</div>
|
||||
{/* 标题叠加在视频画面上 */}
|
||||
{showTitlePreview && (
|
||||
<div
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
...positionOverlayStyle(titleSettings!.position),
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
zIndex: 1,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<span style={buildTitleStyle(titleSettings!)}>
|
||||
{titleText && titleText.trim() ? titleText : "请选择或输入标题"}
|
||||
</span>
|
||||
</div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无视频时的标题预览(idle/loading/error 状态下仍然显示标题预览) */}
|
||||
{!hasPreview && showTitlePreview && (
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(0,0,0,0.3)",
|
||||
borderRadius: 8,
|
||||
minHeight: 64,
|
||||
marginTop: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<span style={buildTitleStyle(titleSettings!)}>
|
||||
{titleText && titleText.trim() ? titleText : "请选择或输入标题"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览信息 */}
|
||||
{hasPreview && previewResult && (
|
||||
<div className="xx-preview-info">
|
||||
|
||||
@@ -65,8 +65,6 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
{m.materialMode === "auto" && (
|
||||
<div className="xx-smart-match-section">
|
||||
<SmartMatchInput
|
||||
inputValue={m.smartMatchInput}
|
||||
onInputChange={m.setSmartMatchInput}
|
||||
matching={m.smartMatching}
|
||||
onMatch={m.handleSmartMatch}
|
||||
hasMatched={m.hasMatched}
|
||||
@@ -76,7 +74,7 @@ const Step2MaterialSelect: React.FC<Step2MaterialSelectProps> = (props) => {
|
||||
/>
|
||||
|
||||
<SmartMatchResults
|
||||
results={m.smartMatchedResults}
|
||||
matchedAssets={m.smartMatchedResults}
|
||||
selectedIds={m.smartSelectedIds}
|
||||
matching={m.smartMatching}
|
||||
hasMatched={m.hasMatched}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Step 3 生成预览组件
|
||||
* 调用后端预览生成接口,展示真实视频预览
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import type { PreviewResult, PreviewStatus } from "../hooks/useStep3Preview"
|
||||
|
||||
interface Step3GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewStatus: PreviewStatus
|
||||
previewResult: PreviewResult | null
|
||||
previewError: string
|
||||
progress: number
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
const Step3GeneratePreview: React.FC<Step3GeneratePreviewProps> = ({
|
||||
templateName,
|
||||
materialCount,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 预览生成按钮 */}
|
||||
{previewStatus === "idle" && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板和素材,智能生成完整视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 排队中 */}
|
||||
{previewStatus === "pending" && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成中 */}
|
||||
{previewStatus === "generating" && (
|
||||
<div className="xx-preview-loading">
|
||||
<LoadingOutlined style={{ fontSize: 32, color: "#3b82f6" }} spin />
|
||||
<p className="xx-preview-loading-text">正在生成预览视频... {progress}%</p>
|
||||
<p className="xx-preview-loading-desc">AI 正在剪辑素材并合成预览视频</p>
|
||||
<div className="xx-preview-progress-bar">
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成失败 */}
|
||||
{previewStatus === "error" && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof previewError === "string" && previewError ? previewError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 生成成功 - 视频预览 */}
|
||||
{previewStatus === "ready" && previewResult && (
|
||||
<>
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>预览生成成功,确认效果后进入下一步</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 视频播放器 */}
|
||||
<div className="xx-preview-video-wrapper">
|
||||
<video
|
||||
className="xx-preview-video"
|
||||
src={previewResult.videoUrl}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 剪辑方案信息 */}
|
||||
<div className="xx-preview-plan-card">
|
||||
<div className="xx-preview-plan-title">剪辑方案信息</div>
|
||||
<div className="xx-preview-plan-info">
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">模板</span>
|
||||
<span className="xx-preview-plan-value">{templateName}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材数量</span>
|
||||
<span className="xx-preview-plan-value">{materialCount}</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">片段数</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.clipCount} 段</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">转场次数</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.transitionCount} 次</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">素材使用率</span>
|
||||
<span className="xx-preview-plan-value">{previewResult.materialUsage}%</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">实际时长</span>
|
||||
<span className="xx-preview-plan-value">
|
||||
{(typeof previewResult.duration === "number"
|
||||
? previewResult.duration
|
||||
: 0
|
||||
).toFixed(1)}{" "}
|
||||
秒
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-preview-plan-row">
|
||||
<span className="xx-preview-plan-label">视频比例</span>
|
||||
<span className="xx-preview-plan-value">{videoRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-preview-plan-hint">💡 这是 480p 预览版,正式生成将输出高清视频</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step3GeneratePreview
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Step 4 生成预览组件(支持多预览)
|
||||
* 调用后端预览生成接口,展示多个真实视频预览(网格布局)
|
||||
*/
|
||||
import React from "react"
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
PlayCircleOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { InputNumber } from "antd"
|
||||
import type { PreviewItem, PreviewStatus } from "../hooks/useStep4Preview"
|
||||
|
||||
interface Step4GeneratePreviewProps {
|
||||
templateName: string
|
||||
materialCount: string
|
||||
duration: number
|
||||
videoRatio: string
|
||||
previewCount: number
|
||||
onPreviewCountChange: (count: number) => void
|
||||
items: PreviewItem[]
|
||||
selectedIndex: number
|
||||
onSelectPreview: (index: number) => void
|
||||
overallStatus: PreviewStatus
|
||||
overallError: string
|
||||
overallProgress: number
|
||||
anyGenerating: boolean
|
||||
onGeneratePreview: () => void
|
||||
onRegeneratePreview: () => void
|
||||
}
|
||||
|
||||
/** 预览数量选项 */
|
||||
const PREVIEW_COUNT_OPTIONS = [
|
||||
{ value: 1, label: "1个" },
|
||||
{ value: 2, label: "2个" },
|
||||
{ value: 3, label: "3个" },
|
||||
]
|
||||
|
||||
const Step4GeneratePreview: React.FC<Step4GeneratePreviewProps> = ({
|
||||
templateName: _templateName,
|
||||
materialCount: _materialCount,
|
||||
videoRatio,
|
||||
previewCount,
|
||||
onPreviewCountChange,
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelectPreview,
|
||||
overallStatus,
|
||||
overallError,
|
||||
overallProgress,
|
||||
anyGenerating,
|
||||
onGeneratePreview,
|
||||
onRegeneratePreview,
|
||||
}) => {
|
||||
const aspectRatio = (videoRatio || "16:9").replace(":", "/") // "9:16" → "9/16", "16:9" → "16/9"
|
||||
const isIdle = overallStatus === "idle"
|
||||
const isError = overallStatus === "error" && !items.some((it) => it.status === "ready")
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎬 生成预览</h3>
|
||||
|
||||
{/* 预览数量选择器(仅在 idle 状态显示) */}
|
||||
{isIdle && (
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<span style={{ fontSize: 14, color: "#666" }}>预览数量:</span>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{PREVIEW_COUNT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onPreviewCountChange(opt.value)}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
borderRadius: 6,
|
||||
border: previewCount === opt.value ? "1px solid #1677ff" : "1px solid #d9d9d9",
|
||||
background: previewCount === opt.value ? "#e6f4ff" : "#fff",
|
||||
color: previewCount === opt.value ? "#1677ff" : "#666",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: previewCount === opt.value ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={10}
|
||||
value={previewCount}
|
||||
onChange={(val) => val && onPreviewCountChange(val)}
|
||||
style={{ width: 70 }}
|
||||
placeholder="自定义"
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "#999", marginLeft: 4 }}>(1~10)</span>
|
||||
</div>
|
||||
{previewCount > 1 && (
|
||||
<span style={{ fontSize: 12, color: "#999" }}>生成多个预览可对比不同剪辑效果</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览生成按钮(idle 状态) */}
|
||||
{isIdle && (
|
||||
<div className="xx-preview-generate-section">
|
||||
<div className="xx-preview-generate-hint">
|
||||
<PlayCircleOutlined style={{ fontSize: 32, color: "#3b82f6", marginBottom: 12 }} />
|
||||
<p className="xx-preview-generate-title">一键生成剪辑预览</p>
|
||||
<p className="xx-preview-generate-desc">
|
||||
AI 将根据您选择的模板、素材和配音,智能生成
|
||||
{previewCount > 1 ? `${previewCount}个不同版本的` : ""}视频预览(480p 低清版)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="xx-btn xx-btn-primary xx-preview-generate-btn"
|
||||
onClick={onGeneratePreview}
|
||||
>
|
||||
✨ 生成预览{previewCount > 1 ? `(${previewCount}个)` : ""}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体排队中(所有都在 pending) */}
|
||||
{anyGenerating && items.every((it) => it.status === "pending") && (
|
||||
<div className="xx-preview-loading">
|
||||
<ClockCircleOutlined style={{ fontSize: 32, color: "#faad14" }} spin />
|
||||
<p className="xx-preview-loading-text">预览排队中...</p>
|
||||
<p className="xx-preview-loading-desc">正在等待渲染资源,请稍候</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 多预览网格(生成中/完成/部分完成) */}
|
||||
{(anyGenerating || overallStatus === "ready") && items.length > 0 && (
|
||||
<div
|
||||
className="xx-preview-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${Math.min(items.length, 3)}, 1fr)`,
|
||||
gap: 12,
|
||||
maxWidth: `${Math.min(items.length, 3) * 280 + (Math.min(items.length, 3) - 1) * 12}px`,
|
||||
margin: "0 auto 16px",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = item.index === selectedIndex
|
||||
return (
|
||||
<div
|
||||
key={item.index}
|
||||
onClick={() => {
|
||||
if (item.status === "ready") onSelectPreview(item.index)
|
||||
}}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
overflow: "hidden",
|
||||
cursor: item.status === "ready" ? "pointer" : "default",
|
||||
opacity: item.status === "error" ? 0.6 : 1,
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{/* 缩略图/状态区域 */}
|
||||
<div
|
||||
style={{
|
||||
aspectRatio,
|
||||
background: "#000",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{item.status === "ready" && item.result && (
|
||||
<video
|
||||
src={item.result.videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{(item.status === "pending" || item.status === "generating") && (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<LoadingOutlined style={{ fontSize: 24, color: "#fff" }} spin />
|
||||
<p style={{ color: "rgba(255,255,255,0.8)", fontSize: 12, marginTop: 8 }}>
|
||||
{item.status === "pending" ? "排队中..." : `生成中 ${item.progress}%`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<div style={{ textAlign: "center", padding: 8 }}>
|
||||
<ExclamationCircleFilled style={{ fontSize: 20, color: "#ef4444" }} />
|
||||
<p
|
||||
style={{
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
生成失败
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 选中角标 */}
|
||||
{isSelected && item.status === "ready" && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
fontSize: 10,
|
||||
padding: "2px 6px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
预览 #{item.index + 1}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部信息 */}
|
||||
{item.status === "ready" && item.result && (
|
||||
<div
|
||||
style={{
|
||||
padding: "6px 8px",
|
||||
background: "#fafafa",
|
||||
fontSize: 11,
|
||||
color: "#666",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<span>{item.result.duration.toFixed(1)}秒</span>
|
||||
<span>{item.result.clipCount}段</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 整体进度条(多预览生成中) */}
|
||||
{anyGenerating && (
|
||||
<div className="xx-preview-progress-bar" style={{ marginBottom: 12 }}>
|
||||
<div className="xx-preview-progress-fill" style={{ width: `${overallProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部完成提示 */}
|
||||
{overallStatus === "ready" && (
|
||||
<div className="xx-preview-tip">
|
||||
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
|
||||
<span>
|
||||
{items.filter((it) => it.status === "ready").length} 个预览生成成功
|
||||
{items.length > 1 ? ",点击选择要查看的版本" : ",确认效果后进入下一步"}
|
||||
</span>
|
||||
<button
|
||||
className="xx-preview-regenerate-btn"
|
||||
onClick={onRegeneratePreview}
|
||||
title="重新生成"
|
||||
>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全部失败 */}
|
||||
{isError && (
|
||||
<div className="xx-preview-error">
|
||||
<ExclamationCircleFilled style={{ fontSize: 28, color: "#ef4444" }} />
|
||||
<p className="xx-preview-error-text">预览生成失败</p>
|
||||
<p className="xx-preview-error-desc">
|
||||
{typeof overallError === "string" && overallError ? overallError : "请稍后重试"}
|
||||
</p>
|
||||
<button className="xx-btn xx-btn-primary" onClick={onRegeneratePreview}>
|
||||
<ReloadOutlined /> 重新生成
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Step4GeneratePreview
|
||||
@@ -2,7 +2,7 @@
|
||||
* Step 4 标题设置组件
|
||||
*/
|
||||
import React from "react"
|
||||
import { Select } from "antd"
|
||||
import { AutoComplete } from "antd"
|
||||
import { POSITION_OPTIONS, FONT_OPTIONS } from "../constants"
|
||||
import type { TitleSettings } from "../types"
|
||||
import { useStep4Title } from "../hooks/useStep4Title"
|
||||
@@ -21,40 +21,73 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
{/* AI 自动选择模式 */}
|
||||
{t.titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch active" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>或手动选择</span>
|
||||
</div>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div
|
||||
className={`xx-switch ${t.titleSettings.aiAutoSelect ? "active" : ""}`}
|
||||
onClick={t.toggleAiAutoSelect}
|
||||
>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
{/* 显示当前 AI 选中的标题(只读)+ 换一个按钮 */}
|
||||
<div className="xx-form-field">
|
||||
<label>当前 AI 选定标题</label>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary, rgba(0,0,0,0.04))",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--text-primary, #333)",
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1 }}>{t.titleSettings.title || "AI 将自动为你选择标题"}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary"
|
||||
style={{ flexShrink: 0, fontSize: 13, padding: "4px 12px" }}
|
||||
onClick={t.autoGenerateTitle}
|
||||
>
|
||||
🔄 换一个
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 手动选择模式 */}
|
||||
{!t.titleSettings.aiAutoSelect && (
|
||||
<>
|
||||
<AiTitleGenerator
|
||||
inputValue={t.aiTitleInput}
|
||||
onInputChange={t.setAiTitleInput}
|
||||
generating={t.aiTitleGenerating}
|
||||
onGenerate={t.handleGenerateAiTitles}
|
||||
results={t.aiTitleResults}
|
||||
hasGenerated={t.hasGeneratedTitles}
|
||||
onSelect={t.handleSelectAiTitle}
|
||||
selectedTitle={t.titleSettings.title}
|
||||
onRefresh={t.handleRefreshAiTitles}
|
||||
/>
|
||||
|
||||
<div className="xx-title-ai-toggle">
|
||||
<span className="xx-toggle-label">AI 自动选择标题</span>
|
||||
<div className="xx-switch" onClick={t.toggleAiAutoSelect}>
|
||||
<div className="xx-switch-knob" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-form-field">
|
||||
<label>从标题库选择</label>
|
||||
<Select
|
||||
placeholder="请选择标题…"
|
||||
<label>标题</label>
|
||||
<AutoComplete
|
||||
placeholder="输入或从标题库选择…"
|
||||
allowClear
|
||||
showSearch
|
||||
maxLength={50}
|
||||
style={{ width: "100%" }}
|
||||
value={t.titleSettings.title || undefined}
|
||||
onChange={(val) => t.updateTitle(val || "")}
|
||||
@@ -62,9 +95,10 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
label: ut.content,
|
||||
value: ut.content,
|
||||
}))}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
filterOption={(inputValue, option) => {
|
||||
const title = (option?.label || option?.value || "") as string
|
||||
return title.toLowerCase().includes((inputValue || "").toLowerCase())
|
||||
}}
|
||||
notFoundContent={
|
||||
t.userTitles.length === 0 ? (
|
||||
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
|
||||
@@ -74,33 +108,25 @@ const Step4TitleSettings: React.FC<Step4TitleSettingsProps> = (props) => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-form-field" style={{ marginTop: 14 }}>
|
||||
<label>或手动输入</label>
|
||||
<input
|
||||
placeholder="输入自定义标题…"
|
||||
value={t.titleSettings.title}
|
||||
onChange={(e) => t.updateTitle(e.target.value)}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={t.updatePosition}
|
||||
onUpdateFont={t.updateFont}
|
||||
onUpdateSize={t.updateSize}
|
||||
onToggleBold={t.toggleBold}
|
||||
onToggleItalic={t.toggleItalic}
|
||||
onToggleStroke={t.toggleStroke}
|
||||
onToggleShadow={t.toggleShadow}
|
||||
onApplyPreset={t.applyPreset}
|
||||
activePreset={t.activePreset}
|
||||
titlePresets={t.titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 样式面板始终可见,两种模式下都可调整标题展示样式 */}
|
||||
<TitleStylePanel
|
||||
settings={t.titleSettings}
|
||||
onUpdatePosition={t.updatePosition}
|
||||
onUpdateFont={t.updateFont}
|
||||
onUpdateSize={t.updateSize}
|
||||
onToggleBold={t.toggleBold}
|
||||
onToggleItalic={t.toggleItalic}
|
||||
onToggleStroke={t.toggleStroke}
|
||||
onToggleShadow={t.toggleShadow}
|
||||
onApplyPreset={t.applyPreset}
|
||||
activePreset={t.activePreset}
|
||||
titlePresets={t.titlePresets}
|
||||
POSITION_OPTIONS={POSITION_OPTIONS}
|
||||
FONT_OPTIONS={FONT_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,137 +1,252 @@
|
||||
/**
|
||||
* Step 5 配音选择组件
|
||||
* 展示用户已上传的配音素材,支持选中、预览播放
|
||||
*/
|
||||
import React from "react"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import { useStep5Voice } from "../hooks/useStep5Voice"
|
||||
import VoiceRecommendSection from "./voice/VoiceRecommendSection"
|
||||
import VoiceChoiceCard from "./voice/VoiceChoiceCard"
|
||||
import PresetVoiceDetail from "./voice/PresetVoiceDetail"
|
||||
import CustomVoicePanel from "./voice/CustomVoicePanel"
|
||||
import SaveVoiceModal from "./voice/SaveVoiceModal"
|
||||
import CloneVoiceSection from "./voice/CloneVoiceSection"
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { AudioOutlined, SoundOutlined } from "@ant-design/icons"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface Step5VoiceSelectProps {
|
||||
selectedVoice: string
|
||||
onSelectedVoiceChange: (voiceId: string) => void
|
||||
voiceMode: "preset" | "custom" | "clone"
|
||||
onVoiceModeChange: (mode: "preset" | "custom" | "clone") => void
|
||||
selectedClonedVoice: string
|
||||
onSelectedClonedVoiceChange: (voiceId: string) => void
|
||||
clonedVoices: VoiceClone[]
|
||||
addClone: (voice: VoiceClone) => void
|
||||
hasProcessing: boolean
|
||||
cloneModalOpen: boolean
|
||||
onCloneModalOpenChange: (open: boolean) => void
|
||||
titleText: string
|
||||
onSelectedVoiceChange: (id: string) => void
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = (props) => {
|
||||
const v = useStep5Voice(props)
|
||||
/** 格式化时长 mm:ss */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00"
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatFileSize = (bytes?: number): string => {
|
||||
if (!bytes || bytes <= 0) return "未知"
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
||||
}
|
||||
|
||||
const Step5VoiceSelect: React.FC<Step5VoiceSelectProps> = ({
|
||||
selectedVoice,
|
||||
onSelectedVoiceChange,
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// 获取用户上传的配音素材
|
||||
const { data: materials = [], isLoading } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
/** 切换播放/暂停 */
|
||||
const togglePlay = useCallback(
|
||||
(material: AssetItem) => {
|
||||
// 如果当前正在播放同一个素材,则暂停
|
||||
if (playingId === material.id && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingId(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 停止之前的播放
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
|
||||
// 创建新的 audio 元素播放
|
||||
const audio = new Audio(material.file_url)
|
||||
audioRef.current = audio
|
||||
setPlayingId(material.id)
|
||||
|
||||
audio.onended = () => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
}
|
||||
|
||||
audio.play().catch(() => {
|
||||
setPlayingId(null)
|
||||
audioRef.current = null
|
||||
})
|
||||
},
|
||||
[playingId],
|
||||
)
|
||||
|
||||
/** 选中素材 */
|
||||
const handleSelect = useCallback(
|
||||
(id: string) => {
|
||||
onSelectedVoiceChange(id)
|
||||
},
|
||||
[onSelectedVoiceChange],
|
||||
)
|
||||
|
||||
/** 跳转到配音库上传 */
|
||||
const handleGoToUpload = useCallback(() => {
|
||||
navigate("/app/voices")
|
||||
}, [navigate])
|
||||
|
||||
// 加载中状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
<div style={{ textAlign: "center", padding: "40px 0", color: "#999" }}>
|
||||
加载配音素材中...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 空状态
|
||||
if (materials.length === 0) {
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "60px 0",
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined style={{ fontSize: 48, color: "#d9d9d9", marginBottom: 16 }} />
|
||||
<p style={{ fontSize: 16, marginBottom: 16 }}>暂无配音素材</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoToUpload}
|
||||
style={{
|
||||
padding: "8px 24px",
|
||||
background: "#1677ff",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
去配音库上传
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎙️ 选择配音</h3>
|
||||
<p style={{ color: "#666", marginBottom: 16, fontSize: 14 }}>
|
||||
从配音库中选择已上传的素材,点击卡片可预览播放
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
{materials.map((item) => {
|
||||
const isSelected = selectedVoice === item.id
|
||||
const isPlaying = playingId === item.id
|
||||
|
||||
<VoiceRecommendSection
|
||||
presetVoices={v.presetVoices}
|
||||
voiceRecommendLoading={v.voiceRecommendLoading}
|
||||
voiceRecommendations={v.voiceRecommendations}
|
||||
hasVoiceRecommend={v.hasVoiceRecommend}
|
||||
onRecommend={v.handleVoiceRecommend}
|
||||
onSelectVoice={v.handleSelectRecommendedVoice}
|
||||
selectedVoiceId={v.selectedVoice}
|
||||
voiceMode={v.voiceMode}
|
||||
VOICE_GENDER_ICON={v.VOICE_GENDER_ICON}
|
||||
/>
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
style={{
|
||||
padding: 16,
|
||||
borderRadius: 8,
|
||||
border: isSelected ? "2px solid #1677ff" : "1px solid #e8e8e8",
|
||||
background: isSelected ? "#e6f4ff" : "#fff",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.2s",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{/* 顶部:图标 + 播放按钮 */}
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
background: isSelected
|
||||
? "linear-gradient(135deg, #1677ff, #4096ff)"
|
||||
: "linear-gradient(135deg, #f0f0f0, #e8e8e8)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<AudioOutlined style={{ fontSize: 20, color: isSelected ? "#fff" : "#666" }} />
|
||||
</div>
|
||||
{item.file_url && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
togglePlay(item)
|
||||
}}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: "50%",
|
||||
border: "none",
|
||||
background: isPlaying ? "#ff4d4f" : "#1677ff",
|
||||
color: "#fff",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 14,
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "播放"}
|
||||
>
|
||||
<SoundOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="xx-divider">
|
||||
<span>全部音色</span>
|
||||
{/* 名称 */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "#333",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={item.name}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
|
||||
{/* 时长 + 大小 */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
fontSize: 12,
|
||||
color: "#999",
|
||||
}}
|
||||
>
|
||||
<span>{formatDuration(item.duration)}</span>
|
||||
<span>{formatFileSize(item.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="xx-voice-choice-list" style={{ marginBottom: 16 }}>
|
||||
{v.presetVoices.slice(0, 3).map((pv) => (
|
||||
<VoiceChoiceCard
|
||||
key={pv.voice_id}
|
||||
selected={v.voiceMode === "preset" && v.selectedVoice === pv.voice_id}
|
||||
onClick={() => v.handleSelectPresetVoice(pv.voice_id)}
|
||||
avatar={v.VOICE_GENDER_ICON[pv.gender] ?? "✨"}
|
||||
title={pv.name}
|
||||
description={pv.description}
|
||||
/>
|
||||
))}
|
||||
{v.presetVoicesLoading && (
|
||||
<VoiceChoiceCard
|
||||
selected={false}
|
||||
onClick={() => {}}
|
||||
avatar="⏳"
|
||||
title="加载中…"
|
||||
loading
|
||||
/>
|
||||
)}
|
||||
<VoiceChoiceCard
|
||||
selected={v.voiceMode === "clone"}
|
||||
onClick={v.handleSelectCloneVoice}
|
||||
avatar="🎤"
|
||||
title="克隆我的声音"
|
||||
description="上传语音样本克隆"
|
||||
avatarStyle={{ background: "linear-gradient(135deg, #10b981, #059669)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{v.voiceMode === "preset" && (
|
||||
<PresetVoiceDetail
|
||||
presetVoices={v.presetVoices}
|
||||
selectedVoice={v.selectedVoice}
|
||||
onSelect={v.handleSelectPresetVoice}
|
||||
playingVoice={v.playingVoice}
|
||||
onTogglePlay={v.toggleVoicePlay}
|
||||
presetVoicesLoading={v.presetVoicesLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "custom" && (
|
||||
<>
|
||||
<CustomVoicePanel
|
||||
customVoiceText={v.customVoiceText}
|
||||
onTextChange={v.setCustomVoiceText}
|
||||
synthesizePending={v.synthesizeMutation.isPending}
|
||||
onSynthesize={v.handleSynthesizeVoice}
|
||||
ttsError={v.ttsError}
|
||||
customAudioUrl={v.customAudioUrl}
|
||||
completedTtsJobId={v.completedTtsJobId}
|
||||
onOpenSaveModal={v.handleOpenSaveModal}
|
||||
/>
|
||||
<SaveVoiceModal
|
||||
open={v.saveModalOpen}
|
||||
onClose={() => v.setSaveModalOpen(false)}
|
||||
saveName={v.saveName}
|
||||
onNameChange={v.setSaveName}
|
||||
saveTagIds={v.saveTagIds}
|
||||
onTagIdsChange={v.setSaveTagIds}
|
||||
saveNewTag={v.saveNewTag}
|
||||
onNewTagChange={v.setSaveNewTag}
|
||||
onAddTag={v.handleAddTagInModal}
|
||||
allTags={v.allTags}
|
||||
savePending={v.saveToLibraryMutation.isPending}
|
||||
onConfirm={v.handleConfirmSave}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{v.voiceMode === "clone" && (
|
||||
<CloneVoiceSection
|
||||
clonedVoices={v.clonedVoices}
|
||||
hasProcessing={v.hasProcessing}
|
||||
selectedClonedVoice={v.selectedClonedVoice}
|
||||
onSelect={v.handleSelectClonedVoice}
|
||||
onOpenCloneModal={v.handleOpenCloneModal}
|
||||
playingVoiceId={v.playingCloneVoice}
|
||||
onPlaySample={v.handlePlayCloneSample}
|
||||
CLONE_STATUS_CONFIG={v.CLONE_STATUS_CONFIG}
|
||||
formatDuration={v.formatDuration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react"
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { useStep6Cover } from "../hooks/useStep6Cover"
|
||||
import { CoverModeSelector } from "./cover-settings/CoverModeSelector"
|
||||
@@ -9,6 +9,10 @@ interface Step6CoverSettingsProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
@@ -19,10 +23,47 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
} = useStep6Cover(props)
|
||||
} = useStep6Cover({
|
||||
coverSettings: props.coverSettings,
|
||||
onCoverSettingsChange: props.onCoverSettingsChange,
|
||||
duration: props.duration,
|
||||
assetIds: props.assetIds,
|
||||
selectedTemplate: props.selectedTemplate,
|
||||
})
|
||||
|
||||
// 进入 auto 模式时自动触发智能封面生成
|
||||
const autoTriggeredRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// 切换模式、禁用封面或素材变更时重置触发标记
|
||||
if (coverSettings.mode !== "auto" || !coverSettings.enabled) {
|
||||
autoTriggeredRef.current = false
|
||||
return
|
||||
}
|
||||
// 有素材且未生成过封面时自动触发
|
||||
if (
|
||||
coverSettings.mode === "auto" &&
|
||||
!coverSettings.thumbnail_url &&
|
||||
!autoTriggeredRef.current &&
|
||||
props.assetIds &&
|
||||
props.assetIds.length > 0
|
||||
) {
|
||||
autoTriggeredRef.current = true
|
||||
generateAutoCover()
|
||||
}
|
||||
}, [
|
||||
coverSettings.enabled,
|
||||
coverSettings.mode,
|
||||
coverSettings.thumbnail_url,
|
||||
generateAutoCover,
|
||||
props.assetIds,
|
||||
])
|
||||
|
||||
// 预览图:优先 thumbnail_url,其次 upload_url
|
||||
const previewUrl = coverSettings.thumbnail_url || coverSettings.upload_url
|
||||
|
||||
return (
|
||||
<div className="xx-form-section">
|
||||
@@ -52,9 +93,6 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
|
||||
{coverSettings.mode === "auto" && (
|
||||
<div className="xx-cover-auto">
|
||||
<div className="xx-cover-auto-desc">
|
||||
AI 将分析视频内容,自动选择最具吸引力的画面作为封面。
|
||||
</div>
|
||||
<div className="xx-cover-auto-badge">
|
||||
<span style={{ fontSize: 24 }}>🤖</span>
|
||||
<span>AI 智能选帧</span>
|
||||
@@ -77,21 +115,21 @@ const Step6CoverSettings: React.FC<Step6CoverSettingsProps> = (props) => {
|
||||
|
||||
<div className="xx-section-title">封面预览</div>
|
||||
<div className="xx-cover-preview-box">
|
||||
{coverSettings.upload_url ? (
|
||||
<img src={coverSettings.upload_url} alt="封面预览" className="xx-cover-preview-img" />
|
||||
{previewUrl ? (
|
||||
<img src={previewUrl} alt="封面预览" className="xx-cover-preview-img" />
|
||||
) : (
|
||||
<div className="xx-cover-preview-placeholder">
|
||||
<span style={{ fontSize: 28 }}>🖼️</span>
|
||||
<span>
|
||||
{coverSettings.mode === "auto"
|
||||
? "AI 智能选择"
|
||||
? "AI 正在选择..."
|
||||
: coverSettings.mode === "frame"
|
||||
? `帧 ${formatTime(coverSettings.frame_time)}`
|
||||
: "未上传封面"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-cover-preview-ratio">16:9</div>
|
||||
<div className="xx-cover-preview-ratio">9:16</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 手动选择素材列表
|
||||
*/
|
||||
import React from "react"
|
||||
import React, { useRef, useCallback } from "react"
|
||||
import { Typography } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
@@ -20,6 +20,33 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
selectedMaterials,
|
||||
onToggle,
|
||||
}) => {
|
||||
// 追踪当前正在播放的视频元素,确保同时只有一个视频播放
|
||||
const activeVideoRef = useRef<HTMLVideoElement | null>(null)
|
||||
|
||||
const handleVideoMouseEnter = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
// 暂停之前正在播放的视频(检查是否仍在 DOM 中)
|
||||
if (
|
||||
activeVideoRef.current &&
|
||||
activeVideoRef.current !== video &&
|
||||
document.body.contains(activeVideoRef.current)
|
||||
) {
|
||||
activeVideoRef.current.pause()
|
||||
activeVideoRef.current.currentTime = 0
|
||||
}
|
||||
activeVideoRef.current = video
|
||||
video.play().catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleVideoMouseLeave = useCallback((e: React.MouseEvent<HTMLVideoElement>) => {
|
||||
const video = e.currentTarget
|
||||
video.pause()
|
||||
video.currentTime = 0
|
||||
if (activeVideoRef.current === video) {
|
||||
activeVideoRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{materialsLoading ? (
|
||||
@@ -32,6 +59,8 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||
{materials.items.map((m) => {
|
||||
const checked = selectedMaterials.includes(m.id)
|
||||
const isVideo = m.mime_type?.startsWith("video/") ?? false
|
||||
const thumbSrc = m.thumbnail_url || undefined
|
||||
return (
|
||||
<label
|
||||
key={m.id}
|
||||
@@ -53,13 +82,103 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(m.id)}
|
||||
style={{ accentColor: "var(--primary-color, #4f46e5)" }}
|
||||
style={{
|
||||
accentColor: "var(--primary-color, #4f46e5)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{/* 缩略图预览 48×48 */}
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
overflow: "hidden",
|
||||
background: "#e2e8f0",
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isVideo && m.file_url ? (
|
||||
<video
|
||||
src={m.file_url}
|
||||
poster={m.thumbnail_url || undefined}
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="none"
|
||||
onMouseEnter={handleVideoMouseEnter}
|
||||
onMouseLeave={handleVideoMouseLeave}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
) : thumbSrc ? (
|
||||
<img
|
||||
src={thumbSrc}
|
||||
alt={m.name}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "cover",
|
||||
}}
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = "none"
|
||||
const fallback = target.nextElementSibling as HTMLElement | null
|
||||
if (fallback) fallback.style.display = "flex"
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{!thumbSrc && !isVideo && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎵
|
||||
</span>
|
||||
)}
|
||||
{!thumbSrc && isVideo && !m.file_url && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
🎬
|
||||
</span>
|
||||
)}
|
||||
{/* img onError 时显示的 fallback(初始隐藏) */}
|
||||
{thumbSrc && !(isVideo && m.file_url) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 20,
|
||||
opacity: 0.5,
|
||||
display: "none",
|
||||
}}
|
||||
>
|
||||
{isVideo ? "🎬" : "🎵"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-primary)",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{m.name}
|
||||
@@ -68,9 +187,10 @@ const ManualMaterialList: React.FC<ManualMaterialListProps> = ({
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-tertiary, #94a3b8)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{m.mime_type.split("/")[1].toUpperCase()}
|
||||
{m.mime_type?.split("/")?.[1]?.toUpperCase() ?? "FILE"}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
/**
|
||||
* 单个智能匹配卡片
|
||||
* 智能匹配卡片(Q5 简化版)
|
||||
* 只展示素材缩略图、名称、时长,无匹配分数
|
||||
*/
|
||||
import React from "react"
|
||||
import { PlayCircleOutlined, CheckCircleFilled } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SmartMatchResultItem {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface SmartMatchCardProps {
|
||||
result: SmartMatchResultItem
|
||||
asset: AssetItem
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
formatDuration: (seconds: number) => string
|
||||
}
|
||||
|
||||
const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
|
||||
result,
|
||||
asset,
|
||||
selected,
|
||||
onClick,
|
||||
formatDuration,
|
||||
}) => {
|
||||
const { asset, matchScore, matchReason } = result
|
||||
return (
|
||||
<div className={`xx-smart-match-card ${selected ? "selected" : ""}`} onClick={onClick}>
|
||||
{/* 缩略图 */}
|
||||
@@ -36,7 +30,6 @@ const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
|
||||
<PlayCircleOutlined style={{ fontSize: 32, opacity: 0.5 }} />
|
||||
</div>
|
||||
)}
|
||||
<div className="xx-smart-match-score">{matchScore}%</div>
|
||||
{selected && (
|
||||
<div className="xx-smart-match-check">
|
||||
<CheckCircleFilled style={{ fontSize: 20, color: "#fff" }} />
|
||||
@@ -46,12 +39,11 @@ const SmartMatchCard: React.FC<SmartMatchCardProps> = ({
|
||||
<div className="xx-smart-match-duration">{formatDuration(asset.duration)}</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 信息区 */}
|
||||
{/* 名称 */}
|
||||
<div className="xx-smart-match-info">
|
||||
<div className="xx-smart-match-name" title={asset.name}>
|
||||
{asset.name}
|
||||
</div>
|
||||
<div className="xx-smart-match-reason">🎯 {matchReason}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* 智能匹配输入区
|
||||
* textarea + 提示 + 按钮组
|
||||
* 智能匹配操作区(Q5 简化版)
|
||||
* 一键触发 AI 选素材,无需输入描述
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import { LoadingOutlined, ThunderboltOutlined } from "@ant-design/icons"
|
||||
|
||||
interface SmartMatchInputProps {
|
||||
inputValue: string
|
||||
onInputChange: (value: string) => void
|
||||
matching: boolean
|
||||
onMatch: () => void
|
||||
hasMatched: boolean
|
||||
@@ -17,8 +15,6 @@ interface SmartMatchInputProps {
|
||||
}
|
||||
|
||||
const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
|
||||
inputValue,
|
||||
onInputChange,
|
||||
matching,
|
||||
onMatch,
|
||||
hasMatched,
|
||||
@@ -28,22 +24,9 @@ const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-smart-match-input-area">
|
||||
<label className="xx-smart-match-label">🤖 描述你想要的视频内容</label>
|
||||
<textarea
|
||||
className="xx-smart-match-input"
|
||||
placeholder="例如:一个科技感十足的产品宣传视频,画面要有现代办公场景、团队协作、数据分析图表…"
|
||||
value={inputValue}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
rows={3}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
onMatch()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="xx-smart-match-input-footer">
|
||||
<div className="xx-smart-match-action-row">
|
||||
<span className="xx-smart-match-tip">
|
||||
{loading ? "扫描视频库中…" : `当前视频库共 ${materialsCount} 个素材可供匹配`}
|
||||
{loading ? "扫描视频库中…" : `当前视频库共 ${materialsCount} 个素材`}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{hasMatched && (
|
||||
@@ -60,15 +43,18 @@ const SmartMatchInput: React.FC<SmartMatchInputProps> = ({
|
||||
type="button"
|
||||
className="xx-btn xx-btn-primary xx-btn-sm"
|
||||
onClick={onMatch}
|
||||
disabled={matching || loading || !inputValue.trim()}
|
||||
disabled={matching || loading}
|
||||
>
|
||||
{matching ? (
|
||||
<>
|
||||
<LoadingOutlined style={{ marginRight: 6 }} />
|
||||
匹配中…
|
||||
AI 选择中…
|
||||
</>
|
||||
) : (
|
||||
"✨ 智能匹配"
|
||||
<>
|
||||
<ThunderboltOutlined style={{ marginRight: 6 }} />
|
||||
{hasMatched ? "重新选择" : "让 AI 帮你选"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
/**
|
||||
* 智能匹配结果区(含加载/空状态/已选汇总)
|
||||
* 智能匹配结果区(Q5 简化版)
|
||||
* 直接展示 AI 选中的素材,无匹配分数和理由
|
||||
*/
|
||||
import React from "react"
|
||||
import { LoadingOutlined } from "@ant-design/icons"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import SmartMatchCard from "./SmartMatchCard"
|
||||
|
||||
interface SmartMatchResultItem {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
|
||||
interface SmartMatchResultsProps {
|
||||
results: SmartMatchResultItem[]
|
||||
matchedAssets: AssetItem[]
|
||||
selectedIds: string[]
|
||||
matching: boolean
|
||||
hasMatched: boolean
|
||||
@@ -25,7 +20,7 @@ interface SmartMatchResultsProps {
|
||||
}
|
||||
|
||||
const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
results,
|
||||
matchedAssets,
|
||||
selectedIds,
|
||||
matching,
|
||||
hasMatched,
|
||||
@@ -42,9 +37,9 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 32, color: "var(--primary-color)", marginBottom: 12 }}
|
||||
/>
|
||||
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在分析素材…</div>
|
||||
<div style={{ color: "var(--text-primary)", fontSize: 14 }}>AI 正在为你选择素材…</div>
|
||||
<div style={{ color: "var(--text-tertiary)", fontSize: 12, marginTop: 4 }}>
|
||||
正在根据描述从视频库中匹配最合适的素材
|
||||
正在分析视频库,匹配最合适的素材
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -56,20 +51,22 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
<div className="xx-smart-match-empty">
|
||||
<div style={{ fontSize: 36, marginBottom: 8 }}>💡</div>
|
||||
<div style={{ color: "var(--text-secondary)", fontSize: 13 }}>
|
||||
输入视频内容描述,点击「智能匹配」让 AI 帮你选素材
|
||||
点击「让 AI 帮你选」,自动从视频库中选择最合适的素材
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 无结果
|
||||
if (results.length === 0) return null
|
||||
if (matchedAssets.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="xx-smart-match-results">
|
||||
<div className="xx-smart-match-results-header">
|
||||
<span className="xx-smart-match-results-title">推荐素材 ({results.length}个)</span>
|
||||
<span className="xx-smart-match-results-title">
|
||||
AI 已选素材 ({matchedAssets.length}个)
|
||||
</span>
|
||||
<div className="xx-smart-match-results-actions">
|
||||
<button type="button" className="xx-link-btn" onClick={onSelectAll}>
|
||||
全选
|
||||
@@ -81,14 +78,14 @@ const SmartMatchResults: React.FC<SmartMatchResultsProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-smart-match-grid">
|
||||
{results.map((result) => {
|
||||
const isSelected = selectedIds.includes(result.asset.id)
|
||||
{matchedAssets.map((asset) => {
|
||||
const isSelected = selectedIds.includes(asset.id)
|
||||
return (
|
||||
<SmartMatchCard
|
||||
key={result.asset.id}
|
||||
result={result}
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
selected={isSelected}
|
||||
onClick={() => onToggle(result.asset.id)}
|
||||
onClick={() => onToggle(asset.id)}
|
||||
formatDuration={formatDuration}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -13,9 +13,15 @@ interface TitlePresetsGridProps {
|
||||
presets: TitlePresetItem[]
|
||||
activePreset: string | null
|
||||
onApply: (presetKey: string) => void
|
||||
fontFamily?: string
|
||||
}
|
||||
|
||||
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({ presets, activePreset, onApply }) => {
|
||||
const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({
|
||||
presets,
|
||||
activePreset,
|
||||
onApply,
|
||||
fontFamily,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xx-title-presets-grid">
|
||||
{presets.map((p) => {
|
||||
@@ -27,7 +33,10 @@ const TitlePresetsGrid: React.FC<TitlePresetsGridProps> = ({ presets, activePres
|
||||
onClick={() => onApply(p.key)}
|
||||
title={p.label}
|
||||
>
|
||||
<span className="xx-title-preset-preview-text" style={p.previewStyle}>
|
||||
<span
|
||||
className="xx-title-preset-preview-text"
|
||||
style={{ ...p.previewStyle, ...(fontFamily ? { fontFamily } : {}) }}
|
||||
>
|
||||
标题
|
||||
</span>
|
||||
<span className="xx-title-preset-card-label">{p.label}</span>
|
||||
|
||||
@@ -107,6 +107,7 @@ const TitleStylePanel: React.FC<TitleStylePanelProps> = ({
|
||||
presets={titlePresets}
|
||||
activePreset={activePreset}
|
||||
onApply={onApplyPreset}
|
||||
fontFamily={settings.font}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ export const VOICE_GENDER_ICON: Record<string, string> = {
|
||||
export const STEPS = [
|
||||
{ key: 1, label: "选择模板" },
|
||||
{ key: 2, label: "选择素材" },
|
||||
{ key: 3, label: "生成预览" },
|
||||
{ key: 4, label: "选择标题" },
|
||||
{ key: 5, label: "选择配音" },
|
||||
{ key: 3, label: "选择配音" },
|
||||
{ key: 4, label: "生成预览" },
|
||||
{ key: 5, label: "选择标题" },
|
||||
{ key: 6, label: "选择封面" },
|
||||
{ key: 7, label: "确认生成" },
|
||||
]
|
||||
|
||||
@@ -891,7 +891,7 @@
|
||||
|
||||
/* ── 视频预览 ── */
|
||||
.xx-preview-video {
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
@@ -1433,47 +1433,14 @@
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.xx-smart-match-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1e293b);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.xx-smart-match-input {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary, #1e293b);
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-primary, #e2e8f0);
|
||||
border-radius: 10px;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.xx-smart-match-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #4f46e5);
|
||||
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
|
||||
}
|
||||
|
||||
.xx-smart-match-input::placeholder {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
}
|
||||
|
||||
.xx-smart-match-input-footer {
|
||||
.xx-smart-match-action-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* textarea removed in Q5 */
|
||||
|
||||
.xx-smart-match-tip {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
@@ -2389,6 +2356,8 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px dashed var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-generate-hint {
|
||||
@@ -2414,6 +2383,7 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
|
||||
/* ── 加载中状态 ── */
|
||||
.xx-preview-loading {
|
||||
text-align: center;
|
||||
@@ -2421,6 +2391,8 @@
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-loading-text {
|
||||
@@ -2436,13 +2408,15 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 错误状态 ── */
|
||||
/* ── 错误状态(手机屏尺寸)── */
|
||||
.xx-preview-error {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.xx-preview-error-text {
|
||||
@@ -2489,6 +2463,9 @@
|
||||
margin-bottom: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
max-width: 320px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.xx-preview-plan-card {
|
||||
@@ -2603,6 +2580,16 @@
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── 整体进度条(手机屏尺寸)── */
|
||||
.xx-preview-progress-bar {
|
||||
max-width: 320px;
|
||||
margin: 12px auto;
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xx-preview-progress-bar-wrap {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
@@ -2680,7 +2667,7 @@
|
||||
.xx-preview-video-wrapper .xx-preview-video {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
aspect-ratio: 9 / 16;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,23 @@ export const buildEditPlanPayload = (props: UseGenerateVideoProps) => {
|
||||
* 返回错误信息,通过则返回 null
|
||||
*/
|
||||
export const validateGenerateInputs = (props: UseGenerateVideoProps): string | null => {
|
||||
const { titleSettings, materialMode, selectedMaterials, voiceMode, selectedClonedVoice } = props
|
||||
const {
|
||||
titleSettings,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
voiceMode,
|
||||
selectedClonedVoice,
|
||||
} = props
|
||||
|
||||
if (!titleSettings.title.trim()) {
|
||||
// AI 自动选择模式下,标题可以为空(后端会自行生成)
|
||||
if (!titleSettings.aiAutoSelect && !titleSettings.title?.trim()) {
|
||||
return "请先选择或输入标题"
|
||||
}
|
||||
if (materialMode === "manual" && selectedMaterials.length === 0) {
|
||||
return "请至少选择一个素材"
|
||||
// 无论手动还是自动模式,都必须有素材
|
||||
const materialIds = materialMode === "auto" ? smartSelectedIds || [] : selectedMaterials || []
|
||||
if (materialIds.length === 0) {
|
||||
return materialMode === "auto" ? "AI 未匹配到素材,请手动选择素材后重试" : "请至少选择一个素材"
|
||||
}
|
||||
if (voiceMode === "clone" && !selectedClonedVoice) {
|
||||
return "请先选择一个克隆音色"
|
||||
|
||||
@@ -1,75 +1,88 @@
|
||||
import { useState, useCallback, useMemo } from "react"
|
||||
import { message } from "antd"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
import { SMART_MATCH_REASONS } from "../../constants"
|
||||
|
||||
interface SmartMatchedResult {
|
||||
asset: AssetItem
|
||||
matchScore: number
|
||||
matchReason: string
|
||||
}
|
||||
import { smartMatchAssets } from "@/api/assets"
|
||||
|
||||
interface UseSmartMatchOptions {
|
||||
libraryId: string
|
||||
materials: { items: AssetItem[]; total: number }
|
||||
smartSelectedIds: string[]
|
||||
onSmartSelectedIdsChange: (ids: string[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能素材匹配 Hook
|
||||
* 封装 AI 匹配、换一批、全选/清空等逻辑
|
||||
* 智能素材匹配 Hook(Q5 简化版)
|
||||
* 用户不手动选素材时,一键调用后端 AI 选素材
|
||||
* 后端统一选素材逻辑后续完善,当前先走前端流程简化
|
||||
*/
|
||||
export function useSmartMatch({
|
||||
libraryId,
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
}: UseSmartMatchOptions) {
|
||||
const [smartMatchInput, setSmartMatchInput] = useState("")
|
||||
const [smartMatching, setSmartMatching] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<SmartMatchedResult[]>([])
|
||||
const [hasMatched, setHasMatched] = useState(false)
|
||||
const [smartMatchedResults, setSmartMatchedResults] = useState<AssetItem[]>([])
|
||||
|
||||
/* ── 智能素材匹配 ── */
|
||||
/* ── 一键智能匹配 ── */
|
||||
const handleSmartMatch = useCallback(async () => {
|
||||
if (!smartMatchInput.trim()) {
|
||||
message.warning("请先输入视频内容描述")
|
||||
if (!libraryId) {
|
||||
message.warning("请先选择视频库")
|
||||
return
|
||||
}
|
||||
|
||||
if (materials.items.length === 0) {
|
||||
message.warning("当前视频库暂无素材")
|
||||
return
|
||||
}
|
||||
|
||||
setSmartMatching(true)
|
||||
setHasMatched(true)
|
||||
|
||||
// 模拟 AI 匹配延迟
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500))
|
||||
try {
|
||||
// 调用后端智能匹配 API
|
||||
const result = await smartMatchAssets(libraryId)
|
||||
const matchedIds = result.items?.map((a: AssetItem) => a.id) ?? []
|
||||
|
||||
// 从素材库中随机选取 5-8 个作为推荐结果
|
||||
const shuffled = [...materials.items].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 4))
|
||||
const picked = shuffled.slice(0, count)
|
||||
if (matchedIds.length > 0) {
|
||||
onSmartSelectedIdsChange(matchedIds)
|
||||
// 保存 API 返回的完整素材列表
|
||||
const resolved = result.items?.length
|
||||
? result.items
|
||||
: materials.items.filter((a) => matchedIds.includes(a.id))
|
||||
setSmartMatchedResults(resolved)
|
||||
setHasMatched(true)
|
||||
message.success(`AI 已为你选择 ${matchedIds.length} 个素材`)
|
||||
} else {
|
||||
// 后端返回空结果,回退到全选
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
setHasMatched(true)
|
||||
message.info("AI 暂未找到匹配素材,已全选当前库素材")
|
||||
}
|
||||
} catch {
|
||||
// 后端 API 尚未就绪时,回退到全选当前库素材
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
setSmartMatchedResults(materials.items)
|
||||
setHasMatched(true)
|
||||
message.info("已为你全选当前库素材(智能匹配功能即将上线)")
|
||||
} finally {
|
||||
setSmartMatching(false)
|
||||
}
|
||||
}, [libraryId, materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(85 + Math.random() * 14), // 85-99 分
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[idx % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",画面质感优秀" : ""),
|
||||
}))
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
// 换一批 = 重新触发智能匹配
|
||||
await handleSmartMatch()
|
||||
}, [handleSmartMatch])
|
||||
|
||||
// 按匹配度从高到低排序
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(materials.items.map((a) => a.id))
|
||||
}, [materials.items, onSmartSelectedIdsChange])
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
// 默认选中匹配度 >= 90 的素材
|
||||
const defaultSelected = results.filter((r) => r.matchScore >= 90).map((r) => r.asset.id)
|
||||
onSmartSelectedIdsChange(
|
||||
defaultSelected.length > 0 ? defaultSelected : results.slice(0, 3).map((r) => r.asset.id),
|
||||
)
|
||||
setSmartMatching(false)
|
||||
}, [smartMatchInput, materials.items, onSmartSelectedIdsChange])
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
const handleToggleSmartSelect = useCallback(
|
||||
(assetId: string) => {
|
||||
@@ -82,57 +95,20 @@ export function useSmartMatch({
|
||||
[smartSelectedIds, onSmartSelectedIdsChange],
|
||||
)
|
||||
|
||||
const handleRefreshMatch = useCallback(async () => {
|
||||
if (materials.items.length <= 5) {
|
||||
message.info("视频库素材较少,无法换一批")
|
||||
return
|
||||
}
|
||||
setSmartMatching(true)
|
||||
await new Promise((resolve) => setTimeout(resolve, 800))
|
||||
|
||||
const remaining = materials.items.filter(
|
||||
(m) => !smartMatchedResults.some((r) => r.asset.id === m.id),
|
||||
)
|
||||
const shuffled = [...remaining].sort(() => Math.random() - 0.5)
|
||||
const count = Math.min(shuffled.length, 5 + Math.floor(Math.random() * 3))
|
||||
const picked = shuffled.slice(0, count)
|
||||
|
||||
const results = picked.map((asset, idx) => ({
|
||||
asset,
|
||||
matchScore: Math.round(80 + Math.random() * 19),
|
||||
matchReason:
|
||||
SMART_MATCH_REASONS[(idx + 2) % SMART_MATCH_REASONS.length] +
|
||||
(Math.random() > 0.5 ? ",节奏明快" : ""),
|
||||
}))
|
||||
results.sort((a, b) => b.matchScore - a.matchScore)
|
||||
|
||||
setSmartMatchedResults(results)
|
||||
onSmartSelectedIdsChange([])
|
||||
setSmartMatching(false)
|
||||
}, [materials.items, smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleSelectAllMatched = useCallback(() => {
|
||||
onSmartSelectedIdsChange(smartMatchedResults.map((r) => r.asset.id))
|
||||
}, [smartMatchedResults, onSmartSelectedIdsChange])
|
||||
|
||||
const handleClearSmartSelect = useCallback(() => {
|
||||
onSmartSelectedIdsChange([])
|
||||
}, [onSmartSelectedIdsChange])
|
||||
|
||||
/* ── 计算已选智能匹配素材的总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(() => {
|
||||
return smartMatchedResults
|
||||
.filter((r) => smartSelectedIds.includes(r.asset.id))
|
||||
.reduce((sum, r) => sum + (r.asset.duration || 0), 0)
|
||||
}, [smartMatchedResults, smartSelectedIds])
|
||||
/* ── 计算已选素材总时长 ── */
|
||||
const smartSelectedTotalDuration = useMemo(
|
||||
() =>
|
||||
materials.items
|
||||
.filter((a) => smartSelectedIds.includes(a.id))
|
||||
.reduce((sum, a) => sum + (a.duration || 0), 0),
|
||||
[materials.items, smartSelectedIds],
|
||||
)
|
||||
|
||||
return {
|
||||
smartMatchInput,
|
||||
setSmartMatchInput,
|
||||
smartMatching,
|
||||
smartMatchedResults,
|
||||
hasMatched,
|
||||
smartSelectedIds,
|
||||
smartMatchedResults,
|
||||
handleSmartMatch,
|
||||
handleToggleSmartSelect,
|
||||
handleRefreshMatch,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Step 2 素材选择 Hook
|
||||
* 组合素材库加载 + 智能匹配两个子 Hook
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { formatDuration } from "../utils/formatDuration"
|
||||
import { useMaterialLibrary } from "./step2-materials/useMaterialLibrary"
|
||||
import { useSmartMatch } from "./step2-materials/useSmartMatch"
|
||||
@@ -28,11 +28,31 @@ export function useStep2Materials({
|
||||
useMaterialLibrary()
|
||||
|
||||
const smartMatch = useSmartMatch({
|
||||
libraryId: selectedLibraryId,
|
||||
materials,
|
||||
smartSelectedIds,
|
||||
onSmartSelectedIdsChange,
|
||||
})
|
||||
|
||||
/* ── 自动触发智能匹配:选择视频库后自动调用 ── */
|
||||
const autoTriggeredRef = useRef<string>("")
|
||||
const { handleSmartMatch } = smartMatch
|
||||
useEffect(() => {
|
||||
// 离开 auto 模式时重置,确保下次进入 auto 模式能重新触发
|
||||
if (materialMode !== "auto") {
|
||||
autoTriggeredRef.current = ""
|
||||
return
|
||||
}
|
||||
if (!selectedLibraryId) return
|
||||
if (materialsLoading) return
|
||||
if (materials.items.length === 0) return
|
||||
// 防止同一视频库重复触发
|
||||
if (autoTriggeredRef.current === selectedLibraryId) return
|
||||
|
||||
autoTriggeredRef.current = selectedLibraryId
|
||||
handleSmartMatch()
|
||||
}, [selectedLibraryId, materialMode, materialsLoading, materials.items, handleSmartMatch])
|
||||
|
||||
/* ── 手动选择素材 ── */
|
||||
const handleToggleMaterial = useCallback(
|
||||
(materialId: string) => {
|
||||
@@ -58,13 +78,11 @@ export function useStep2Materials({
|
||||
// 手动选择
|
||||
selectedMaterials,
|
||||
handleToggleMaterial,
|
||||
// 智能匹配
|
||||
smartMatchInput: smartMatch.smartMatchInput,
|
||||
setSmartMatchInput: smartMatch.setSmartMatchInput,
|
||||
// 智能匹配(简化版)
|
||||
smartMatching: smartMatch.smartMatching,
|
||||
smartMatchedResults: smartMatch.smartMatchedResults,
|
||||
hasMatched: smartMatch.hasMatched,
|
||||
smartSelectedIds: smartMatch.smartSelectedIds,
|
||||
smartMatchedResults: smartMatch.smartMatchedResults,
|
||||
handleSmartMatch: smartMatch.handleSmartMatch,
|
||||
handleToggleSmartSelect: smartMatch.handleToggleSmartSelect,
|
||||
handleRefreshMatch: smartMatch.handleRefreshMatch,
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
/**
|
||||
* Step 3 生成预览 Hook
|
||||
* 调用 /generation/preview 接口创建预览任务,轮询状态直到完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep3PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
titleText?: string
|
||||
voiceId?: string
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
export function useStep3Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
}: UseStep3PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 预览生成状态 ── */
|
||||
const [previewStatus, setPreviewStatus] = useState<PreviewStatus>("idle")
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [previewError, setPreviewError] = useState<string>("")
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
// 任务 ID + 轮询定时器,用于防止竞态条件
|
||||
const currentTaskIdRef = useRef<string | null>(null)
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback(() => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current)
|
||||
pollTimerRef.current = undefined
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 参数变化时重置预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && previewStatus !== "idle") {
|
||||
// 参数变化,作废当前任务
|
||||
currentTaskIdRef.current = null
|
||||
clearPollTimer()
|
||||
setPreviewStatus("idle")
|
||||
setPreviewResult(null)
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
previewStatus,
|
||||
clearPollTimer,
|
||||
])
|
||||
|
||||
// 组件卸载时清理轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 检查是否已被取消(参数变化或重新生成)
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setPreviewError("预览生成超时,请重试")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
setProgress(100)
|
||||
setPreviewResult({
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
})
|
||||
setPreviewStatus("ready")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setPreviewError(safeString(data.error_message, "预览生成失败,请重试"))
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setPreviewError("预览任务已取消")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
setProgress(safeNumber(data.progress))
|
||||
if (status === "pending") {
|
||||
setPreviewStatus("pending")
|
||||
pollTimerRef.current = setTimeout(poll, 5000)
|
||||
} else {
|
||||
setPreviewStatus("generating")
|
||||
pollTimerRef.current = setTimeout(poll, 2000)
|
||||
}
|
||||
} catch (pollErr) {
|
||||
if (currentTaskIdRef.current !== taskId) return
|
||||
// 轮询出错,延迟后重试
|
||||
pollTimerRef.current = setTimeout(poll, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 首次延迟 1 秒开始轮询
|
||||
pollTimerRef.current = setTimeout(poll, 1000)
|
||||
}, [])
|
||||
|
||||
/** 生成预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setPreviewError("请先选择模板")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setPreviewError("请先选择素材")
|
||||
setPreviewStatus("error")
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的轮询
|
||||
clearPollTimer()
|
||||
|
||||
setPreviewStatus("pending")
|
||||
setPreviewError("")
|
||||
setProgress(0)
|
||||
setPreviewResult(null)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
try {
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
})
|
||||
|
||||
// 竞态检查
|
||||
if (startTimeRef.current === 0) return // 已被重置
|
||||
|
||||
currentTaskIdRef.current = response.task_id
|
||||
pollPreviewStatus(response.task_id)
|
||||
} catch (e) {
|
||||
setPreviewError(safeString(e instanceof Error ? e.message : e, "预览生成失败"))
|
||||
setPreviewStatus("error")
|
||||
}
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否可以下一步(预览已生成) */
|
||||
const canProceed = previewStatus === "ready"
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 预览生成状态
|
||||
previewStatus,
|
||||
previewResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep3Preview
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Step 4 生成预览 Hook(支持多预览 + voice_ids)
|
||||
* 调用 /generation/preview 接口创建多个预览任务,轮询状态直到全部完成
|
||||
*/
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react"
|
||||
import { createPreview, getPreviewStatus } from "@/api/generation"
|
||||
import type { PreviewTaskResponse, PreviewStatus as ApiPreviewStatus } from "@/api/generation"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import { safeExtractError } from "./generate-video/errorUtils"
|
||||
|
||||
/** 安全地将值转为字符串,防止对象被直接渲染导致 React Error #31 */
|
||||
const safeString = (val: unknown, fallback: string): string => {
|
||||
if (val == null) return fallback
|
||||
const s = safeExtractError(val)
|
||||
return s || fallback
|
||||
}
|
||||
|
||||
/** 安全地将值转为数字,防止非数字值进入渲染 */
|
||||
const safeNumber = (val: unknown, fallback = 0): number => {
|
||||
if (typeof val === "number" && !Number.isNaN(val)) return val
|
||||
if (typeof val === "string") {
|
||||
const n = Number(val)
|
||||
return Number.isNaN(n) ? fallback : n
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
interface UseStep4PreviewProps {
|
||||
templates: EditingTemplate[]
|
||||
selectedTemplate: string
|
||||
materialMode: "manual" | "auto"
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
duration: number
|
||||
videoRatio: string
|
||||
/** 配音 voice_ids(传给后端,让预览包含配音音频) */
|
||||
voiceIds?: string[]
|
||||
/** 要生成的预览数量 */
|
||||
previewCount?: number
|
||||
}
|
||||
|
||||
export type PreviewStatus = "idle" | "pending" | "generating" | "ready" | "error"
|
||||
|
||||
/** 单个预览生成结果 */
|
||||
export interface PreviewResult {
|
||||
taskId: string
|
||||
videoUrl: string
|
||||
clipCount: number
|
||||
transitionCount: number
|
||||
materialUsage: number
|
||||
duration: number
|
||||
fileSize: number
|
||||
generateDuration: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
/** 单个预览项的完整状态(用于多预览) */
|
||||
export interface PreviewItem {
|
||||
index: number
|
||||
status: PreviewStatus
|
||||
result: PreviewResult | null
|
||||
error: string
|
||||
progress: number
|
||||
}
|
||||
|
||||
// 轮询超时时间(10 分钟)
|
||||
const POLL_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
/** 初始单项状态 */
|
||||
const createInitialItem = (index: number): PreviewItem => ({
|
||||
index,
|
||||
status: "idle",
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})
|
||||
|
||||
export function useStep4Preview({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount = 1,
|
||||
}: UseStep4PreviewProps) {
|
||||
const templateName = useMemo(
|
||||
() => templates.find((t) => t.id === selectedTemplate)?.name ?? "未选择",
|
||||
[templates, selectedTemplate],
|
||||
)
|
||||
|
||||
const materialCount = useMemo(() => {
|
||||
if (materialMode === "auto") {
|
||||
return `${smartSelectedIds.length} 个素材(智能匹配)`
|
||||
}
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
const materialTotal = materialMode === "auto" ? smartSelectedIds.length : selectedMaterials.length
|
||||
|
||||
/* ── 多预览状态 ── */
|
||||
const [items, setItems] = useState<PreviewItem[]>(() =>
|
||||
Array.from({ length: previewCount }, (_, i) => createInitialItem(i)),
|
||||
)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
// 每个任务 ID + 轮询定时器,用于防止竞态条件(按 index 存储)
|
||||
const taskIdsRef = useRef<Map<number, string>>(new Map())
|
||||
const pollTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const startTimeRef = useRef<number>(0)
|
||||
|
||||
const clearPollTimer = useCallback((index?: number) => {
|
||||
if (index !== undefined) {
|
||||
const timer = pollTimersRef.current.get(index)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pollTimersRef.current.delete(index)
|
||||
}
|
||||
} else {
|
||||
pollTimersRef.current.forEach((timer) => clearTimeout(timer))
|
||||
pollTimersRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 同步 previewCount 变化(增减项)
|
||||
useEffect(() => {
|
||||
setItems((prev) => {
|
||||
if (prev.length === previewCount) return prev
|
||||
if (prev.length > previewCount) return prev.slice(0, previewCount)
|
||||
return [
|
||||
...prev,
|
||||
...Array.from({ length: previewCount - prev.length }, (_, i) =>
|
||||
createInitialItem(prev.length + i),
|
||||
),
|
||||
]
|
||||
})
|
||||
// 如果 selectedIndex 超出范围,重置
|
||||
setSelectedIndex((prev) => Math.min(prev, previewCount - 1))
|
||||
}, [previewCount])
|
||||
|
||||
/* ── 参数变化时重置所有预览状态 ── */
|
||||
const prevDepsRef = useRef({
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const currentKey = [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
[...selectedMaterials].sort().join(","),
|
||||
[...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
[...(voiceIds || [])].sort().join(","),
|
||||
].join("|")
|
||||
|
||||
const prevKey = [
|
||||
prevDepsRef.current.selectedTemplate,
|
||||
prevDepsRef.current.materialMode,
|
||||
prevDepsRef.current.selectedMaterials,
|
||||
prevDepsRef.current.smartSelectedIds,
|
||||
prevDepsRef.current.duration,
|
||||
prevDepsRef.current.videoRatio,
|
||||
prevDepsRef.current.voiceIds,
|
||||
].join("|")
|
||||
|
||||
if (prevKey !== currentKey && items.some((it) => it.status !== "idle")) {
|
||||
taskIdsRef.current.clear()
|
||||
clearPollTimer()
|
||||
setItems(Array.from({ length: previewCount }, (_, i) => createInitialItem(i)))
|
||||
setSelectedIndex(0)
|
||||
}
|
||||
|
||||
prevDepsRef.current = {
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials: [...selectedMaterials].sort().join(","),
|
||||
smartSelectedIds: [...smartSelectedIds].sort().join(","),
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds: [...(voiceIds || [])].sort().join(","),
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialMode,
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
])
|
||||
|
||||
// 组件卸载时清理所有轮询
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearPollTimer()
|
||||
}
|
||||
}, [clearPollTimer])
|
||||
|
||||
/** 轮询单个预览任务状态 */
|
||||
const pollPreviewStatus = useCallback((index: number, taskId: string) => {
|
||||
const poll = async () => {
|
||||
// 竞态检查
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
// 超时检查
|
||||
if (Date.now() - startTimeRef.current > POLL_TIMEOUT_MS) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览生成超时,请重试" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data: PreviewTaskResponse = await getPreviewStatus(taskId)
|
||||
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
|
||||
const status = data.status as ApiPreviewStatus
|
||||
|
||||
if (status === "completed") {
|
||||
const result: PreviewResult = {
|
||||
taskId: safeString(data.task_id, ""),
|
||||
videoUrl: safeString(data.video_url, ""),
|
||||
clipCount: safeNumber(data.clip_count),
|
||||
transitionCount: safeNumber(data.transition_count),
|
||||
materialUsage: safeNumber(data.material_usage),
|
||||
duration: safeNumber(data.duration),
|
||||
fileSize: safeNumber(data.file_size),
|
||||
generateDuration: safeNumber(data.generate_duration),
|
||||
progress: 100,
|
||||
}
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "ready", result, progress: 100 } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "failed") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index
|
||||
? {
|
||||
...it,
|
||||
status: "error",
|
||||
error: safeString(data.error_message, "预览生成失败,请重试"),
|
||||
}
|
||||
: it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (status === "cancelled") {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: "error", error: "预览任务已取消" } : it,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// pending / generating 状态继续轮询
|
||||
const prog = safeNumber(data.progress)
|
||||
const nextStatus: PreviewStatus = status === "pending" ? "pending" : "generating"
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.index === index ? { ...it, status: nextStatus, progress: prog } : it,
|
||||
),
|
||||
)
|
||||
const delay = status === "pending" ? 5000 : 2000
|
||||
pollTimersRef.current.set(index, setTimeout(poll, delay))
|
||||
} catch {
|
||||
if (taskIdsRef.current.get(index) !== taskId) return
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 3000))
|
||||
}
|
||||
}
|
||||
|
||||
pollTimersRef.current.set(index, setTimeout(poll, 1000))
|
||||
}, [])
|
||||
|
||||
/** 生成所有预览 */
|
||||
const generatePreview = useCallback(async () => {
|
||||
if (!selectedTemplate) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择模板" })))
|
||||
return
|
||||
}
|
||||
if (materialTotal === 0) {
|
||||
setItems((prev) => prev.map((it) => ({ ...it, status: "error", error: "请先选择素材" })))
|
||||
return
|
||||
}
|
||||
|
||||
// 取消之前的所有轮询
|
||||
clearPollTimer()
|
||||
taskIdsRef.current.clear()
|
||||
|
||||
// 初始化所有项为 pending
|
||||
setItems(
|
||||
Array.from({ length: previewCount }, (_, i) => ({
|
||||
index: i,
|
||||
status: "pending" as PreviewStatus,
|
||||
result: null,
|
||||
error: "",
|
||||
progress: 0,
|
||||
})),
|
||||
)
|
||||
setSelectedIndex(0)
|
||||
startTimeRef.current = Date.now()
|
||||
|
||||
const assetIds = materialMode === "auto" ? smartSelectedIds : selectedMaterials
|
||||
|
||||
// 并发创建所有预览任务(Promise.all 并行请求,减少串行等待)
|
||||
const createTasks = Array.from({ length: previewCount }, async (_, i) => {
|
||||
try {
|
||||
const response = await createPreview({
|
||||
template_id: selectedTemplate,
|
||||
asset_ids: assetIds,
|
||||
duration: duration || undefined,
|
||||
video_ratio: videoRatio,
|
||||
voice_ids: voiceIds && voiceIds.length > 0 ? voiceIds : undefined,
|
||||
})
|
||||
|
||||
if (startTimeRef.current === 0) return
|
||||
|
||||
taskIdsRef.current.set(i, response.task_id)
|
||||
pollPreviewStatus(i, response.task_id)
|
||||
} catch (e) {
|
||||
const errMsg = safeString(e instanceof Error ? e.message : e, "预览生成失败")
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.index === i ? { ...it, status: "error", error: errMsg } : it)),
|
||||
)
|
||||
}
|
||||
})
|
||||
await Promise.all(createTasks)
|
||||
}, [
|
||||
selectedTemplate,
|
||||
materialTotal,
|
||||
materialMode,
|
||||
smartSelectedIds,
|
||||
selectedMaterials,
|
||||
duration,
|
||||
videoRatio,
|
||||
voiceIds,
|
||||
previewCount,
|
||||
clearPollTimer,
|
||||
pollPreviewStatus,
|
||||
])
|
||||
|
||||
/** 重新生成所有预览 */
|
||||
const regeneratePreview = useCallback(() => {
|
||||
generatePreview()
|
||||
}, [generatePreview])
|
||||
|
||||
/** 是否所有预览都已完成 */
|
||||
const allReady = items.length > 0 && items.every((it) => it.status === "ready")
|
||||
/** 是否至少有一个预览已完成 */
|
||||
const anyReady = items.some((it) => it.status === "ready")
|
||||
/** 是否有任一正在生成中 */
|
||||
const anyGenerating = items.some((it) => it.status === "pending" || it.status === "generating")
|
||||
|
||||
/** 当前选中的预览结果 */
|
||||
const selectedResult = items[selectedIndex]?.result ?? null
|
||||
|
||||
/** 综合状态(兼容旧逻辑) */
|
||||
const previewStatus: PreviewStatus = useMemo(() => {
|
||||
if (items.every((it) => it.status === "idle")) return "idle"
|
||||
if (items.some((it) => it.status === "pending" || it.status === "generating"))
|
||||
return "generating"
|
||||
if (allReady) return "ready"
|
||||
if (items.every((it) => it.status === "error")) return "error"
|
||||
// 部分完成部分出错
|
||||
if (anyReady) return "ready"
|
||||
return "error"
|
||||
}, [items, allReady, anyReady])
|
||||
|
||||
/** 综合进度(取平均) */
|
||||
const progress = useMemo(() => {
|
||||
if (items.length === 0) return 0
|
||||
return Math.round(items.reduce((sum, it) => sum + it.progress, 0) / items.length)
|
||||
}, [items])
|
||||
|
||||
/** 综合错误信息 */
|
||||
const previewError = useMemo(() => {
|
||||
const errorItems = items.filter((it) => it.status === "error" && it.error)
|
||||
if (errorItems.length === 0) return ""
|
||||
if (errorItems.length === 1) return errorItems[0].error
|
||||
return `${errorItems.length} 个预览生成失败`
|
||||
}, [items])
|
||||
|
||||
const canProceed = anyReady
|
||||
|
||||
return {
|
||||
templateName,
|
||||
materialCount,
|
||||
duration,
|
||||
videoRatio,
|
||||
// 多预览状态
|
||||
items,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
previewCount,
|
||||
// 综合状态
|
||||
previewStatus,
|
||||
previewResult: selectedResult,
|
||||
previewError,
|
||||
progress,
|
||||
canProceed,
|
||||
allReady,
|
||||
anyReady,
|
||||
anyGenerating,
|
||||
generatePreview,
|
||||
regeneratePreview,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStep4Preview
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTitles } from "@/api/titles"
|
||||
import type { TitleSettings } from "../../types"
|
||||
@@ -22,27 +23,59 @@ export function useStep4Title({ titleSettings, onTitleSettingsChange }: UseStep4
|
||||
})
|
||||
|
||||
// AI 标题生成
|
||||
const aiGenerator = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
|
||||
const {
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
} = useAiTitleGenerator({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
// 样式更新
|
||||
const styleUpdaters = useTitleStyleUpdaters({ titleSettings, onTitleSettingsChange })
|
||||
|
||||
// 追踪 AI 自动选择开关的上一次值 & 是否首次挂载
|
||||
const prevAiAutoSelect = useRef(titleSettings.aiAutoSelect)
|
||||
const isFirstMount = useRef(true)
|
||||
|
||||
// 当 AI 自动选择开关打开时,自动生成/选择一个标题填入
|
||||
// 首次挂载时如果开关已经是 true 且无标题,也需要触发
|
||||
useEffect(() => {
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false
|
||||
if (titleSettings.aiAutoSelect && !titleSettings.title) {
|
||||
autoGenerateTitle()
|
||||
}
|
||||
prevAiAutoSelect.current = titleSettings.aiAutoSelect
|
||||
return
|
||||
}
|
||||
if (titleSettings.aiAutoSelect && !prevAiAutoSelect.current && !titleSettings.title) {
|
||||
autoGenerateTitle()
|
||||
}
|
||||
prevAiAutoSelect.current = titleSettings.aiAutoSelect
|
||||
}, [titleSettings.aiAutoSelect, titleSettings.title, autoGenerateTitle])
|
||||
|
||||
return {
|
||||
// 数据
|
||||
userTitles,
|
||||
titleSettings,
|
||||
// AI 标题状态
|
||||
aiTitleInput: aiGenerator.aiTitleInput,
|
||||
setAiTitleInput: aiGenerator.setAiTitleInput,
|
||||
aiTitleGenerating: aiGenerator.aiTitleGenerating,
|
||||
aiTitleResults: aiGenerator.aiTitleResults,
|
||||
hasGeneratedTitles: aiGenerator.hasGeneratedTitles,
|
||||
aiTitleInput,
|
||||
setAiTitleInput,
|
||||
aiTitleGenerating,
|
||||
aiTitleResults,
|
||||
hasGeneratedTitles,
|
||||
activePreset: styleUpdaters.activePreset,
|
||||
titlePresets: styleUpdaters.titlePresets,
|
||||
// AI 标题操作
|
||||
handleGenerateAiTitles: aiGenerator.handleGenerateAiTitles,
|
||||
handleSelectAiTitle: aiGenerator.handleSelectAiTitle,
|
||||
handleRefreshAiTitles: aiGenerator.handleRefreshAiTitles,
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
// 标题设置操作
|
||||
updateTitle: styleUpdaters.updateTitle,
|
||||
toggleAiAutoSelect: styleUpdaters.toggleAiAutoSelect,
|
||||
|
||||
@@ -72,9 +72,30 @@ export function useAiTitleGenerator({
|
||||
setAiTitleGenerating(false)
|
||||
}, [aiTitleInput])
|
||||
|
||||
/**
|
||||
* 自动生成标题(供 AI 自动选择开关使用)
|
||||
* 如果已有生成结果,直接从中选一个;否则用默认关键词生成
|
||||
*/
|
||||
const autoGenerateTitle = useCallback((): string => {
|
||||
if (aiTitleResults.length > 0) {
|
||||
const picked = aiTitleResults[Math.floor(Math.random() * aiTitleResults.length)]
|
||||
if (!picked) return ""
|
||||
onTitleSettingsChange({ ...titleSettings, title: picked.title })
|
||||
return picked.title
|
||||
}
|
||||
// 没有已有结果,用默认关键词生成
|
||||
const results = generateTitlesFromTopic("短视频")
|
||||
setAiTitleResults(results)
|
||||
setHasGeneratedTitles(true)
|
||||
const picked = results[Math.floor(Math.random() * results.length)]
|
||||
if (!picked) return ""
|
||||
onTitleSettingsChange({ ...titleSettings, title: picked.title })
|
||||
return picked.title
|
||||
}, [aiTitleResults, titleSettings, onTitleSettingsChange])
|
||||
|
||||
const handleSelectAiTitle = useCallback(
|
||||
(title: string) => {
|
||||
onTitleSettingsChange({ ...titleSettings, title, aiAutoSelect: false })
|
||||
onTitleSettingsChange({ ...titleSettings, title })
|
||||
message.success("已选用此标题")
|
||||
},
|
||||
[titleSettings, onTitleSettingsChange],
|
||||
@@ -98,5 +119,6 @@ export function useAiTitleGenerator({
|
||||
handleGenerateAiTitles,
|
||||
handleSelectAiTitle,
|
||||
handleRefreshAiTitles,
|
||||
autoGenerateTitle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,21 +2,30 @@
|
||||
* Step 6 封面设置 Hook
|
||||
* 封装封面设置的交互逻辑
|
||||
*/
|
||||
import { useCallback } from "react"
|
||||
import { useCallback, useRef } from "react"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import { COVER_MODE_LABELS, COVER_MODE_ICONS, DEFAULT_COVER_SETTINGS } from "../constants"
|
||||
import { generateCover } from "@/api/template-editor"
|
||||
|
||||
interface UseStep6CoverProps {
|
||||
coverSettings: CoverConfig
|
||||
onCoverSettingsChange: (settings: CoverConfig) => void
|
||||
duration: number
|
||||
/** 当前素材 ID 列表,用于智能封面生成 */
|
||||
assetIds?: string[]
|
||||
/** 当前选中的模板 ID */
|
||||
selectedTemplate?: string
|
||||
}
|
||||
|
||||
export function useStep6Cover({
|
||||
coverSettings,
|
||||
onCoverSettingsChange,
|
||||
duration,
|
||||
assetIds = [],
|
||||
selectedTemplate = "",
|
||||
}: UseStep6CoverProps) {
|
||||
const generatingRef = useRef(false)
|
||||
|
||||
const formatTime = useCallback((seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
@@ -62,6 +71,30 @@ export function useStep6Cover({
|
||||
[coverSettings, onCoverSettingsChange],
|
||||
)
|
||||
|
||||
/** 调用后端智能封面 API,生成封面并更新预览 */
|
||||
const generateAutoCover = useCallback(async () => {
|
||||
if (!selectedTemplate || assetIds.length === 0 || generatingRef.current) return
|
||||
generatingRef.current = true
|
||||
try {
|
||||
const response = await generateCover(selectedTemplate, {
|
||||
asset_ids: assetIds,
|
||||
cover_type: "ai_frame",
|
||||
})
|
||||
const thumbnailUrl = response.cover?.thumbnail_url || ""
|
||||
if (thumbnailUrl) {
|
||||
onCoverSettingsChange({
|
||||
...coverSettings,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
ai_suggested_time: response.cover?.frame_time ?? null,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Step6] 智能封面生成失败:", err)
|
||||
} finally {
|
||||
generatingRef.current = false
|
||||
}
|
||||
}, [selectedTemplate, assetIds, coverSettings, onCoverSettingsChange])
|
||||
|
||||
const totalDuration = duration || 30
|
||||
|
||||
return {
|
||||
@@ -71,6 +104,7 @@ export function useStep6Cover({
|
||||
setMode,
|
||||
setFrameTime,
|
||||
handleUpload,
|
||||
generateAutoCover,
|
||||
totalDuration,
|
||||
COVER_MODE_LABELS,
|
||||
COVER_MODE_ICONS,
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
* 封装生成确认页的展示逻辑
|
||||
*/
|
||||
import { useMemo } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import type { EditingTemplate } from "@/api/editing-planner"
|
||||
import type { GeneratedVideo } from "@/api/template-editor"
|
||||
import type { CoverConfig } from "../../editing-planner/types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { PresetVoiceItem } from "@/api/voices"
|
||||
import { getAssetsByKind } from "@/api/assets"
|
||||
import { COVER_MODE_LABELS } from "../constants"
|
||||
|
||||
interface UseStep7GenerateProps {
|
||||
@@ -39,11 +41,11 @@ export function useStep7Generate({
|
||||
selectedMaterials,
|
||||
smartSelectedIds,
|
||||
title,
|
||||
voiceMode,
|
||||
voiceMode: _voiceMode,
|
||||
selectedVoice,
|
||||
selectedClonedVoice,
|
||||
presetVoices,
|
||||
clonedVoices,
|
||||
selectedClonedVoice: _selectedClonedVoice,
|
||||
presetVoices: _presetVoices,
|
||||
clonedVoices: _clonedVoices,
|
||||
coverSettings,
|
||||
generateCount,
|
||||
onGenerateCountChange,
|
||||
@@ -65,14 +67,16 @@ export function useStep7Generate({
|
||||
return `${selectedMaterials.length} 个素材`
|
||||
}, [materialMode, selectedMaterials.length, smartSelectedIds.length])
|
||||
|
||||
// 从配音素材库中查找 voiceName
|
||||
const { data: voiceMaterials = [] } = useQuery({
|
||||
queryKey: ["assets", "voice"],
|
||||
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
|
||||
})
|
||||
|
||||
const voiceName = useMemo(() => {
|
||||
if (voiceMode === "clone") {
|
||||
const cv = clonedVoices.find((v) => v.id === selectedClonedVoice)
|
||||
return cv ? cv.name : "未选择"
|
||||
}
|
||||
const pv = presetVoices.find((v) => v.voice_id === selectedVoice)
|
||||
return pv ? pv.name : "未选择"
|
||||
}, [voiceMode, selectedVoice, selectedClonedVoice, presetVoices, clonedVoices])
|
||||
const asset = voiceMaterials.find((v) => v.id === selectedVoice)
|
||||
return asset ? asset.name : "未选择"
|
||||
}, [voiceMaterials, selectedVoice])
|
||||
|
||||
const coverSummary = useMemo(() => {
|
||||
if (!coverSettings.enabled) return "不使用"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* GeneratePage 步骤导航
|
||||
* 管理步骤切换与各步骤的前置校验
|
||||
* 步骤顺序:模板(1) → 素材(2) → 配音(3) → 预览(4) → 标题(5) → 封面(6) → 确认(7)
|
||||
*/
|
||||
import { message } from "antd"
|
||||
import type { TitleSettings } from "../types"
|
||||
@@ -13,7 +14,7 @@ export interface UseStepNavigationOptions {
|
||||
selectedMaterials: string[]
|
||||
smartSelectedIds: string[]
|
||||
titleSettings: TitleSettings
|
||||
/** Step3 是否已生成预览(Step3校验用) */
|
||||
/** Step4 是否已生成预览 */
|
||||
previewReady: boolean
|
||||
}
|
||||
|
||||
@@ -47,11 +48,12 @@ export const useStepNavigation = (options: UseStepNavigationOptions): UseStepNav
|
||||
message.warning("请先进行智能匹配并选择素材")
|
||||
return
|
||||
}
|
||||
if (currentStep === 3 && !previewReady) {
|
||||
// Step3 配音:配音为可选项,不强制校验,用户可跳过
|
||||
if (currentStep === 4 && !previewReady) {
|
||||
message.warning("请先生成剪辑预览")
|
||||
return
|
||||
}
|
||||
if (currentStep === 4 && !titleSettings.title.trim()) {
|
||||
if (currentStep === 5 && !titleSettings.title.trim()) {
|
||||
message.warning("请选择或输入标题")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,11 +117,9 @@ const VoiceLibrary: React.FC = () => {
|
||||
uploadOpen,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
setUploadName,
|
||||
setUploadGender,
|
||||
setUploadDesc,
|
||||
setUploadOpen,
|
||||
handleFileSelect,
|
||||
@@ -252,14 +250,12 @@ const VoiceLibrary: React.FC = () => {
|
||||
uploadOpen={uploadOpen}
|
||||
uploadFile={uploadFile}
|
||||
uploadName={uploadName}
|
||||
uploadGender={uploadGender}
|
||||
uploadDesc={uploadDesc}
|
||||
uploadProgress={uploadProgress}
|
||||
onUploadClose={handleUploadClose}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleFileRemove}
|
||||
onNameChange={setUploadName}
|
||||
onGenderChange={setUploadGender}
|
||||
onDescChange={setUploadDesc}
|
||||
onUpload={handleUpload}
|
||||
ttsOpen={ttsOpen}
|
||||
|
||||
@@ -14,14 +14,12 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
open,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
onClose,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
onUpload,
|
||||
}) => {
|
||||
@@ -30,7 +28,7 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="上传音频"
|
||||
title={<span style={{ fontSize: 16, fontWeight: 600 }}>上传配音素材</span>}
|
||||
open={open}
|
||||
onCancel={() => {
|
||||
if (uploading) return // 上传中不可关闭
|
||||
@@ -45,7 +43,7 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
padding: "8px 0",
|
||||
padding: "12px 0 4px",
|
||||
}}
|
||||
>
|
||||
{/* 拖拽上传区 */}
|
||||
@@ -64,11 +62,9 @@ const UploadVoiceModal: React.FC<UploadVoiceModalProps> = ({
|
||||
{/* 表单字段 */}
|
||||
<FormFields
|
||||
name={uploadName}
|
||||
gender={uploadGender}
|
||||
desc={uploadDesc}
|
||||
disabled={uploading}
|
||||
onNameChange={onNameChange}
|
||||
onGenderChange={onGenderChange}
|
||||
onDescChange={onDescChange}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* VoiceLibrary 弹窗集合
|
||||
*/
|
||||
import React from "react"
|
||||
import type { ClonedVoiceDisplay, PresetVoiceDisplay, VoiceGender } from "../types"
|
||||
import type { ClonedVoiceDisplay, PresetVoiceDisplay } from "../types"
|
||||
import type { VoiceClone } from "@/api/voice-clone"
|
||||
import type { TtsStatus } from "./TtsModal"
|
||||
import CloneModal from "@/components/voice/CloneModal"
|
||||
@@ -27,14 +27,12 @@ export interface VoiceModalsProps {
|
||||
uploadOpen: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onUploadClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
|
||||
@@ -67,14 +65,12 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
uploadOpen,
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
onUploadClose,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
onUpload,
|
||||
ttsOpen,
|
||||
@@ -113,14 +109,12 @@ export const VoiceModals: React.FC<VoiceModalsProps> = ({
|
||||
open={uploadOpen}
|
||||
uploadFile={uploadFile}
|
||||
uploadName={uploadName}
|
||||
uploadGender={uploadGender}
|
||||
uploadDesc={uploadDesc}
|
||||
uploadProgress={uploadProgress}
|
||||
onClose={onUploadClose}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
onNameChange={onNameChange}
|
||||
onGenderChange={onGenderChange}
|
||||
onDescChange={onDescChange}
|
||||
onUpload={onUpload}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react"
|
||||
import { Button } from "antd"
|
||||
|
||||
interface ActionButtonsProps {
|
||||
uploading: boolean
|
||||
@@ -13,12 +14,6 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
onCancel,
|
||||
onUpload,
|
||||
}) => {
|
||||
const disabled = uploading || !canUpload
|
||||
|
||||
const handleUpload = () => {
|
||||
onUpload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -28,39 +23,32 @@ const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
paddingTop: 4,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-color)",
|
||||
background: "transparent",
|
||||
fontSize: 13,
|
||||
cursor: uploading ? "not-allowed" : "pointer",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpload}
|
||||
disabled={disabled}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={onUpload}
|
||||
disabled={!canUpload}
|
||||
loading={uploading}
|
||||
style={{
|
||||
padding: "8px 20px",
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: disabled ? "var(--text-tertiary)" : "var(--primary-color)",
|
||||
color: "#fff",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
{uploading ? "上传中..." : "开始上传"}
|
||||
</button>
|
||||
{uploading ? "上传中" : "开始上传"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,28 +10,55 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ file }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
padding: "10px 14px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 8,
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
gap: 12,
|
||||
border: "1px solid var(--border-color)",
|
||||
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 18, color: "var(--primary-color)" }} />
|
||||
{/* 文件图标圆形底托 */}
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-soft, rgba(22, 119, 255, 0.1))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<SoundOutlined style={{ fontSize: 16, color: "var(--primary-color)" }} />
|
||||
</div>
|
||||
|
||||
{/* 文件信息 */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: 2,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{formatFileSize(file.size)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,8 @@ const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
return (
|
||||
<Upload.Dragger
|
||||
accept={UPLOAD_CONFIG.accept}
|
||||
@@ -28,25 +30,73 @@ const FileUploadZone: React.FC<FileUploadZoneProps> = ({
|
||||
showUploadList={false}
|
||||
disabled={disabled}
|
||||
>
|
||||
<p
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "var(--primary-color)",
|
||||
marginBottom: 8,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "20px 16px",
|
||||
borderRadius: 12,
|
||||
background: isHovered
|
||||
? "var(--primary-soft, rgba(22, 119, 255, 0.08))"
|
||||
: "var(--primary-soft, rgba(22, 119, 255, 0.03))",
|
||||
border: `2px dashed ${isHovered ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
transition: "all 0.25s ease",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p style={{ fontSize: 14, fontWeight: 500, margin: "0 0 4px" }}>点击或拖拽音频文件到此处</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
|
||||
</p>
|
||||
{/* 图标圆形底托 */}
|
||||
<div
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-soft, rgba(22, 119, 255, 0.1))",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 10,
|
||||
transition: "transform 0.2s ease",
|
||||
transform: isHovered ? "scale(1.08)" : "scale(1)",
|
||||
}}
|
||||
>
|
||||
<UploadOutlined
|
||||
style={{
|
||||
fontSize: 20,
|
||||
color: "var(--primary-color)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 主文案 */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: "var(--text-primary)",
|
||||
margin: "0 0 6px",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
点击或拖拽音频文件到此处
|
||||
</p>
|
||||
|
||||
{/* 副文案 */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
margin: 0,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
支持 MP3、WAV、AAC、FLAC 等格式,最大 {UPLOAD_CONFIG.maxSizeMB}MB
|
||||
</p>
|
||||
</div>
|
||||
</Upload.Dragger>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,101 +1,91 @@
|
||||
import React from "react"
|
||||
import type { VoiceGender } from "@/pages/voices/types"
|
||||
import { GENDER_OPTIONS, UPLOAD_CONFIG } from "./types"
|
||||
import { UPLOAD_CONFIG } from "./types"
|
||||
|
||||
interface FormFieldsProps {
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
desc: string
|
||||
disabled: boolean
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
}
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary, #333)",
|
||||
marginBottom: 6,
|
||||
letterSpacing: "0.02em",
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
const baseInputStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
padding: "10px 12px",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
background: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
outline: "none",
|
||||
transition: "border-color 0.2s ease, box-shadow 0.2s ease",
|
||||
lineHeight: 1.5,
|
||||
}
|
||||
|
||||
const focusStyle: React.CSSProperties = {
|
||||
borderColor: "var(--primary-color)",
|
||||
boxShadow: "0 0 0 2px var(--primary-soft, rgba(22, 119, 255, 0.12))",
|
||||
}
|
||||
|
||||
const FormFields: React.FC<FormFieldsProps> = ({
|
||||
name,
|
||||
gender,
|
||||
desc,
|
||||
disabled,
|
||||
onNameChange,
|
||||
onGenderChange,
|
||||
onDescChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<div style={labelStyle}>素材名称</div>
|
||||
<div style={labelStyle}>配音名称</div>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="输入素材名称"
|
||||
placeholder="输入配音名称"
|
||||
maxLength={UPLOAD_CONFIG.maxNameLength}
|
||||
disabled={disabled}
|
||||
style={inputStyle}
|
||||
style={baseInputStyle}
|
||||
onFocus={(e) => {
|
||||
Object.assign(e.target.style, focusStyle)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "var(--border-color)"
|
||||
e.target.style.boxShadow = "none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 性别选择 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色性别</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{GENDER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => onGenderChange(opt.value)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "6px 0",
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${gender === opt.value ? "var(--primary-color)" : "var(--border-color)"}`,
|
||||
background: gender === opt.value ? "var(--primary-soft)" : "transparent",
|
||||
color: gender === opt.value ? "var(--primary-color)" : "var(--text-secondary)",
|
||||
fontSize: 13,
|
||||
fontWeight: gender === opt.value ? 600 : 400,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
transition: "all 0.2s",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<div style={labelStyle}>音色描述(可选)</div>
|
||||
<div style={labelStyle}>配音描述(可选)</div>
|
||||
<textarea
|
||||
value={desc}
|
||||
onChange={(e) => onDescChange(e.target.value)}
|
||||
placeholder="描述这个音色的特点..."
|
||||
placeholder="描述这段配音的内容..."
|
||||
maxLength={UPLOAD_CONFIG.maxDescLength}
|
||||
rows={2}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
...inputStyle,
|
||||
...baseInputStyle,
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
minHeight: 56,
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
Object.assign(e.target.style, focusStyle)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "var(--border-color)"
|
||||
e.target.style.boxShadow = "none"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -6,25 +6,40 @@ interface UploadProgressProps {
|
||||
|
||||
const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "8px 0" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "var(--bg-secondary)",
|
||||
borderRadius: 10,
|
||||
textAlign: "center",
|
||||
border: "1px solid var(--border-color)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: "var(--primary-color)",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{progress}%
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-secondary)" }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginTop: 2,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{progress < 100 ? "上传中..." : "处理中..."}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
background: "var(--bg-tertiary)",
|
||||
background: "var(--bg-tertiary, #f0f0f0)",
|
||||
borderRadius: 2,
|
||||
marginTop: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
@@ -32,7 +47,7 @@ const UploadProgress: React.FC<UploadProgressProps> = ({ progress }) => {
|
||||
style={{
|
||||
height: "100%",
|
||||
width: `${progress}%`,
|
||||
background: "var(--primary-color)",
|
||||
background: progress < 100 ? "var(--primary-color)" : "var(--success-color, #52c41a)",
|
||||
borderRadius: 2,
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
|
||||
@@ -1,27 +1,17 @@
|
||||
import type { VoiceGender } from "@/pages/voices/types"
|
||||
|
||||
export interface UploadVoiceModalProps {
|
||||
open: boolean
|
||||
uploadFile: File | null
|
||||
uploadName: string
|
||||
uploadGender: VoiceGender
|
||||
uploadDesc: string
|
||||
uploadProgress: number | null
|
||||
onClose: () => void
|
||||
onFileSelect: (file: File) => void
|
||||
onFileRemove: () => void
|
||||
onNameChange: (name: string) => void
|
||||
onGenderChange: (gender: VoiceGender) => void
|
||||
onDescChange: (desc: string) => void
|
||||
onUpload: () => void
|
||||
}
|
||||
|
||||
export const GENDER_OPTIONS: { value: VoiceGender; label: string }[] = [
|
||||
{ value: "female", label: "女声" },
|
||||
{ value: "male", label: "男声" },
|
||||
{ value: "child", label: "童声" },
|
||||
]
|
||||
|
||||
export const UPLOAD_CONFIG = {
|
||||
maxSizeMB: 200,
|
||||
maxNameLength: 100,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useCallback } from "react"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { type VoiceGender } from "../types"
|
||||
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
|
||||
import { getAudioDuration } from "../utils/audio"
|
||||
import { buildVoiceMetadata } from "../types"
|
||||
@@ -19,17 +18,11 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [uploadName, setUploadName] = useState("")
|
||||
const [uploadGender, setUploadGender] = useState<VoiceGender>("female")
|
||||
const [uploadDesc, setUploadDesc] = useState("")
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null)
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
file: File
|
||||
name: string
|
||||
gender: VoiceGender
|
||||
description: string
|
||||
}) => {
|
||||
mutationFn: async (data: { file: File; name: string; description: string }) => {
|
||||
setUploadProgress(0)
|
||||
try {
|
||||
/* 获取或创建默认配音库 */
|
||||
@@ -57,7 +50,6 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
storage_key,
|
||||
mime_type: data.file.type || "audio/mpeg",
|
||||
metadata: buildVoiceMetadata({
|
||||
gender: data.gender,
|
||||
description: data.description,
|
||||
duration,
|
||||
}),
|
||||
@@ -71,8 +63,15 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
showToast("上传成功", "success")
|
||||
handleUploadClose()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
showToast(err.message || "上传失败,请重试", "error")
|
||||
onError: (err: Error & { response?: { status?: number } }) => {
|
||||
const status = err?.response?.status
|
||||
let msg = "上传失败,请重试"
|
||||
if (status && status >= 500) {
|
||||
msg = "服务器暂时不可用,请稍后重试"
|
||||
} else if (err?.message) {
|
||||
msg = err.message
|
||||
}
|
||||
showToast(msg, "error")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -102,10 +101,9 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
uploadMutation.mutate({
|
||||
file: uploadFile,
|
||||
name: uploadName.trim(),
|
||||
gender: uploadGender,
|
||||
description: uploadDesc.trim(),
|
||||
})
|
||||
}, [uploadFile, uploadName, uploadGender, uploadDesc, uploadMutation])
|
||||
}, [uploadFile, uploadName, uploadDesc, uploadMutation])
|
||||
|
||||
return {
|
||||
// 弹窗状态
|
||||
@@ -114,13 +112,11 @@ export function useVoiceUpload({ showToast }: UseVoiceUploadProps) {
|
||||
// 表单状态
|
||||
uploadFile,
|
||||
uploadName,
|
||||
uploadGender,
|
||||
uploadDesc,
|
||||
uploadProgress,
|
||||
isUploading: uploadMutation.isPending,
|
||||
// Setters
|
||||
setUploadName,
|
||||
setUploadGender,
|
||||
setUploadDesc,
|
||||
// Handlers
|
||||
handleFileSelect,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Navigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
/** 受保护的路由组件 */
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
|
||||
|
||||
@@ -26,12 +26,10 @@ import { refreshAccessToken } from "@/api/auth"
|
||||
import apiClient from "@/api/client"
|
||||
|
||||
// 从真实实例取出拦截器回调
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{
|
||||
fulfilled: (config: unknown) => unknown
|
||||
rejected: (error: unknown) => unknown
|
||||
}>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{
|
||||
fulfilled: (response: unknown) => unknown
|
||||
rejected: (error: unknown) => Promise<unknown>
|
||||
@@ -258,7 +256,6 @@ describe("apiClient - 401 token refresh", () => {
|
||||
isAuthenticated: false,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
|
||||
const err = makeAxiosError(401, { detail: "Unauthorized" })
|
||||
@@ -276,7 +273,6 @@ describe("apiClient - 401 token refresh", () => {
|
||||
isAuthenticated: true,
|
||||
clearAuth: vi.fn(),
|
||||
setAuth: mockSetAuth,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockResolvedValue({
|
||||
access_token: "new-access",
|
||||
@@ -306,7 +302,6 @@ describe("apiClient - 401 token refresh", () => {
|
||||
isAuthenticated: true,
|
||||
clearAuth: mockClearAuth,
|
||||
setAuth: vi.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any)
|
||||
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ vi.mock("@/api/assets", () => ({
|
||||
createAssetLibrary: vi.fn().mockResolvedValue({}),
|
||||
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
smartMatchAssets: vi.fn().mockResolvedValue({ items: [] }),
|
||||
createAsset: vi.fn().mockResolvedValue({}),
|
||||
updateAsset: vi.fn().mockResolvedValue({}),
|
||||
deleteAsset: vi.fn().mockResolvedValue({}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { render } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { render, cleanup } from "@testing-library/react"
|
||||
import TtsPanel from "@/pages/editing-planner/components/TtsPanel"
|
||||
import { DEFAULT_TTS_CONFIG } from "@/pages/editing-planner/types"
|
||||
|
||||
@@ -16,6 +16,10 @@ const defaultProps = {
|
||||
}
|
||||
|
||||
describe("TtsPanel", () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it("should render without crashing", () => {
|
||||
const { container } = render(<TtsPanel {...defaultProps} />)
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
@@ -17,7 +17,7 @@ import "@/api/generation/types"
|
||||
// 直接引入所有 Step 组件,建立完整依赖链
|
||||
import "@/pages/generate/GeneratePage"
|
||||
import "@/pages/generate/components/Step2MaterialSelect"
|
||||
import "@/pages/generate/components/Step3GeneratePreview"
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/components/Step4TitleSettings"
|
||||
import "@/pages/generate/components/Step5VoiceSelect"
|
||||
import "@/pages/generate/components/PreviewVideoPanel"
|
||||
@@ -47,7 +47,7 @@ describe("GeneratePage module smoke test", () => {
|
||||
})
|
||||
})
|
||||
import "@/pages/generate/hooks/useGenerateVideo"
|
||||
import "@/pages/generate/hooks/useStep3Preview"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/generate-video/useGenerationPolling"
|
||||
import "@/pages/generate/hooks/useGenerateFormState"
|
||||
import "@/pages/generate/hooks/useGenerateFormState/useTemplateSelection"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Step3GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第3步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step3GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep3Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step3GeneratePreview module smoke test", () => {
|
||||
it("should load all step3 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Step4GeneratePreview smoke test
|
||||
* 确保 vitest related 模式能匹配到第4步预览生成相关文件的改动
|
||||
*/
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
import "@/pages/generate/components/Step4GeneratePreview"
|
||||
import "@/pages/generate/hooks/useStep4Preview"
|
||||
import "@/pages/generate/hooks/useStepNavigation"
|
||||
import "@/pages/generate/components/GenerateStepContent"
|
||||
import "@/pages/generate/GeneratePage"
|
||||
|
||||
describe("Step4GeneratePreview module smoke test", () => {
|
||||
it("should load all step4 preview modules", () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -32,9 +32,7 @@ afterEach(() => {
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Vi {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface Assertion<T = any> extends jest.Matchers<void, T> {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
interface AsymmetricMatchersContaining extends jest.Matchers<void, any> {}
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+38
-30
@@ -465,7 +465,7 @@ class RenderAdapter:
|
||||
失败不阻断主流程,返回 None。
|
||||
"""
|
||||
try:
|
||||
from services.asr_service_factory import get_asr_service
|
||||
from apps.worker.services.asr_service_factory import get_asr_service
|
||||
|
||||
return get_asr_service()
|
||||
except Exception as e:
|
||||
@@ -485,6 +485,7 @@ class RenderAdapter:
|
||||
rendered_clip_ids: list[str] | None = None,
|
||||
failed_clip_ids: list[str] | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
is_preview: bool = False,
|
||||
) -> RenderAdapterResult:
|
||||
"""执行统一渲染核心流程(BGM + ASR + 渲染 + 缩略图 + 上传)。
|
||||
|
||||
@@ -504,10 +505,12 @@ class RenderAdapter:
|
||||
self._report_progress(progress_cb, 40.0, "执行视频渲染")
|
||||
|
||||
# 2. 初始化 ASR
|
||||
asr_service = self._get_asr_service()
|
||||
# 预览模式下,如果 plan.config 中存在 voice_id,仍需初始化 ASR 以支持配音
|
||||
plan_config = plan.config or {}
|
||||
has_voice_id = bool(plan_config.get("voice_id"))
|
||||
asr_service = None if (is_preview and not has_voice_id) else self._get_asr_service()
|
||||
|
||||
# 3. 读取输出分辨率
|
||||
plan_config = plan.config or {}
|
||||
export_config = plan_config.get("export", {}) or {}
|
||||
output_width, output_height = _parse_resolution(export_config.get("resolution"))
|
||||
logger.info(
|
||||
@@ -529,25 +532,27 @@ class RenderAdapter:
|
||||
bgm_path=bgm_path,
|
||||
asr_service=asr_service,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
is_preview=is_preview,
|
||||
)
|
||||
result = render_svc.render()
|
||||
|
||||
# 4.5 渲染后校验输出完整性
|
||||
|
||||
validation = validate_video_output(result.output_path)
|
||||
if not validation.valid:
|
||||
logger.error(
|
||||
"[render-adapter] 渲染输出校验失败: plan_id=%s job_id=%s error=%s",
|
||||
plan_id,
|
||||
job_id,
|
||||
validation.error_message,
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=f"渲染输出校验失败: {validation.error_message}",
|
||||
error_detail=validation.error_message,
|
||||
)
|
||||
|
||||
# 4.5 渲染后校验输出完整性(预览模式跳过,节省耗时)
|
||||
if is_preview:
|
||||
logger.info("[render-adapter] 预览模式:跳过输出校验")
|
||||
else:
|
||||
validation = validate_video_output(result.output_path)
|
||||
if not validation.valid:
|
||||
logger.error(
|
||||
"[render-adapter] 渲染输出校验失败: plan_id=%s job_id=%s error=%s",
|
||||
plan_id,
|
||||
job_id,
|
||||
validation.error_message,
|
||||
)
|
||||
return RenderAdapterResult(
|
||||
success=False,
|
||||
error_message=f"渲染输出校验失败: {validation.error_message}",
|
||||
error_detail=validation.error_message,
|
||||
)
|
||||
self._report_progress(progress_cb, 80.0, "上传渲染结果")
|
||||
|
||||
# 5. 上传结果
|
||||
@@ -556,19 +561,20 @@ class RenderAdapter:
|
||||
|
||||
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
|
||||
|
||||
# 6. 生成缩略图
|
||||
# 6. 生成缩略图(预览模式跳过,节省耗时)
|
||||
thumbnail_url = ""
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
if not is_preview:
|
||||
try:
|
||||
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
|
||||
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
)
|
||||
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
|
||||
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
|
||||
except Exception as thumb_err:
|
||||
logger.warning(
|
||||
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
|
||||
plan_id,
|
||||
thumb_err,
|
||||
)
|
||||
|
||||
self._report_progress(progress_cb, 100.0, "渲染完成")
|
||||
|
||||
@@ -614,6 +620,7 @@ class RenderAdapter:
|
||||
work_dir: Path | None = None,
|
||||
progress_cb: ProgressCallback | None = None,
|
||||
voiceover_audio_path: str | None = None,
|
||||
is_preview: bool = False,
|
||||
) -> RenderAdapterResult:
|
||||
"""使用内存中的 plan/clips/asset_path_map 直接渲染。
|
||||
|
||||
@@ -672,6 +679,7 @@ class RenderAdapter:
|
||||
job_id=job_id,
|
||||
progress_cb=progress_cb,
|
||||
voiceover_audio_path=voiceover_audio_path,
|
||||
is_preview=is_preview,
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as exc:
|
||||
|
||||
@@ -82,14 +82,12 @@ def mix_audio(
|
||||
"""音频后处理混音.
|
||||
|
||||
处理逻辑:
|
||||
1. 主音频源按优先级查找:main > broll(background 不参与主音频,通常是图片无音轨)
|
||||
2. 主图层音频按顺序 concat 拼接
|
||||
3. 独立音频轨(audio role)用 amix 混入
|
||||
4. 输出时长截断到 video_duration
|
||||
5. 无音频流的 clip 会被自动跳过,避免 FFmpeg 引用 [i:a] 失败
|
||||
6. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
|
||||
7. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
|
||||
8. 如果配置了降噪,最后应用降噪
|
||||
1. 丢弃主图层(main/broll/overlay/corner_voice)的原始音频,避免录入源视频杂音
|
||||
2. 仅使用独立音频轨(audio role,TTS/配音)作为主音频
|
||||
3. 如果提供了 bgm_path,则额外混入 BGM(支持淡入淡出、循环、人声闪避)
|
||||
4. 如果配置了 audio_tracks,则混入多轨道音频(配音、音效等)
|
||||
5. 输出时长截断到 video_duration
|
||||
6. 如果配置了降噪,最后应用降噪
|
||||
|
||||
Args:
|
||||
ctx: 渲染上下文
|
||||
@@ -125,8 +123,10 @@ def mix_audio(
|
||||
if "audio" in layer_map:
|
||||
audio_clips = layer_map["audio"].clips
|
||||
|
||||
# ── 丢弃源视频的原始音频(避免录入杂音),成片仅保留 TTS 配音 + BGM ──
|
||||
main_clips = []
|
||||
|
||||
# ── 防御:过滤掉无音频流的 clip ──
|
||||
main_clips = [c for c in main_clips if clip_has_audio(ctx, c)]
|
||||
audio_clips = [c for c in audio_clips if clip_has_audio(ctx, c)]
|
||||
|
||||
if not main_clips and not audio_clips:
|
||||
@@ -144,12 +144,21 @@ def mix_audio(
|
||||
# 构建音频处理命令
|
||||
output_path = ctx.work_dir / f"audio_{ctx.plan_id}.aac"
|
||||
|
||||
# 简单场景:只有主图层 + 无独立音频 → 直接从视频提取音频并拼接
|
||||
if main_clips and not audio_clips:
|
||||
concat_main_audio(ctx, main_clips, output_path, video_duration)
|
||||
# 源视频原始音频已被丢弃(main_clips = []),最终音频完全由独立音频轨 + BGM + 多轨配置组成。
|
||||
# 当无 main_clips 时,将独立音频轨作为主音频走 concat 拼接;当二者均有则走 amix 混音。
|
||||
if main_clips:
|
||||
effective_main = main_clips
|
||||
effective_audio = audio_clips
|
||||
else:
|
||||
effective_main = audio_clips
|
||||
effective_audio = []
|
||||
|
||||
# 简单场景:只有主音频 + 无独立音频 → 直接拼接
|
||||
if effective_main and not effective_audio:
|
||||
concat_main_audio(ctx, effective_main, output_path, video_duration)
|
||||
else:
|
||||
# 有独立音频轨 → amix 混音
|
||||
mix_with_independent_audio(ctx, main_clips, audio_clips, output_path, video_duration)
|
||||
mix_with_independent_audio(ctx, effective_main, effective_audio, output_path, video_duration)
|
||||
|
||||
# ── BGM 混音 ──
|
||||
if bgm_path and bgm_config and bgm_config.get("enabled", False):
|
||||
|
||||
@@ -151,6 +151,7 @@ class UnifiedRenderService:
|
||||
asr_service: Any = None, # ASRService 实例,用于自动生成字幕
|
||||
bgm_path: str | None = None, # BGM 本地文件路径
|
||||
voiceover_audio_path: str | None = None, # 配音素材库音频本地路径
|
||||
is_preview: bool = False, # 预览模式:ultrafast 编码 + 跳过非必要步骤
|
||||
):
|
||||
self.plan = plan
|
||||
self.clips = clips
|
||||
@@ -163,6 +164,7 @@ class UnifiedRenderService:
|
||||
self.asr_service = asr_service
|
||||
self.bgm_path = bgm_path
|
||||
self.voiceover_audio_path = voiceover_audio_path
|
||||
self.is_preview = is_preview
|
||||
self._transition_engine = TransitionEngine(default_duration=transition_duration)
|
||||
self._speed_engine = SpeedEngine()
|
||||
self._asr_timeline_cache: Any = None # ASR 字幕结果缓存,避免重复调用
|
||||
@@ -677,20 +679,40 @@ class UnifiedRenderService:
|
||||
len(top_text),
|
||||
)
|
||||
# 方式B:voice_id + 自动字幕 → 字幕对齐配音(预设配音模式)
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False) and self.asr_service is not None:
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
# ASR 可用时走字幕对齐模式,不可用时降级为整段配音
|
||||
elif top_voice_id and subtitle_cfg.get("auto_generated", False):
|
||||
if self.asr_service is not None:
|
||||
# 字幕对齐模式
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": "",
|
||||
"align_mode": "subtitle",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
use_subtitle_align = True
|
||||
logger.info(
|
||||
"[unified-render] 检测到预设配音+自动字幕,使用字幕对齐模式: plan_id=%s voice_id=%s",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
)
|
||||
else:
|
||||
# 降级:整段配音(预览模式 ASR 不可用时)
|
||||
# 拼接字幕文本作为配音内容
|
||||
subtitle_text_content = subtitle_cfg.get("text", "") or ""
|
||||
tts_cfg = {
|
||||
"enabled": True,
|
||||
"voice_id": top_voice_id,
|
||||
"text": subtitle_text_content,
|
||||
"align_mode": "full",
|
||||
"overlap_mode": "replace",
|
||||
}
|
||||
logger.info(
|
||||
"[unified-render] ASR 不可用,降级为整段配音模式: plan_id=%s voice_id=%s text_len=%d",
|
||||
self.plan.id,
|
||||
top_voice_id,
|
||||
len(subtitle_text_content),
|
||||
)
|
||||
|
||||
# 兼容前端顶层字段:voice_id / custom_text / voice_clone_profile_id
|
||||
# 前端一键生成页面传 config.voice_id + config.custom_text,
|
||||
@@ -1228,9 +1250,9 @@ class UnifiedRenderService:
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"23",
|
||||
"28" if self.is_preview else "23",
|
||||
"-preset",
|
||||
"medium",
|
||||
"ultrafast" if self.is_preview else "medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
@@ -1742,9 +1764,9 @@ class UnifiedRenderService:
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"23",
|
||||
"28" if self.is_preview else "23",
|
||||
"-preset",
|
||||
"medium",
|
||||
"ultrafast" if self.is_preview else "medium",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
@@ -1753,10 +1775,11 @@ class UnifiedRenderService:
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"执行渲染: plan_id=%s inputs=%d output=%s",
|
||||
"执行渲染: plan_id=%s inputs=%d output=%s preview=%s",
|
||||
self.plan.id,
|
||||
input_args.count("-i"),
|
||||
output_path,
|
||||
self.is_preview,
|
||||
)
|
||||
try:
|
||||
run_ffmpeg(command)
|
||||
|
||||
@@ -163,6 +163,36 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
|
||||
job_service.fail_job(job_id, error_msg[:500])
|
||||
raise RuntimeError(result.error_message)
|
||||
|
||||
# 生成封面(如果配置启用)
|
||||
cover_url = None
|
||||
try:
|
||||
from video_processing.cover_generator import generate_cover_from_plan
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
||||
SQLAlchemyEditPlanRepository as EditPlanRepository,
|
||||
)
|
||||
|
||||
# 获取 plan 对象
|
||||
plan_repo = EditPlanRepository(db)
|
||||
plan = plan_repo.get(plan_id)
|
||||
|
||||
if plan and result.output_path:
|
||||
# 检查 cover_config
|
||||
cover_config = (plan.config or {}).get("cover_config")
|
||||
if cover_config and cover_config.get("enabled", False):
|
||||
from pathlib import Path
|
||||
|
||||
output_dir = Path(result.output_path).parent
|
||||
cover_path = generate_cover_from_plan(plan, result.output_path, output_dir)
|
||||
if cover_path:
|
||||
# 生成 cover_url(相对路径或上传到存储)
|
||||
cover_url = f"/covers/{plan_id}.jpg"
|
||||
logger.info("封面生成成功: plan_id=%s cover_path=%s", plan_id, cover_path)
|
||||
else:
|
||||
logger.info("封面生成未启用: plan_id=%s", plan_id)
|
||||
except Exception as e:
|
||||
logger.warning("封面生成失败(不影响视频合成): plan_id=%s error=%s", plan_id, e)
|
||||
|
||||
# 更新 Job 状态为完成
|
||||
result_data = {
|
||||
"plan_id": plan_id,
|
||||
@@ -175,6 +205,7 @@ def _compose_with_unified_engine(task, job_service, job, plan_id: str, db) -> di
|
||||
"width": result.width,
|
||||
"height": result.height,
|
||||
"file_size": result.file_size,
|
||||
"cover_url": cover_url,
|
||||
}
|
||||
job_service.complete_job(job_id, result=result_data)
|
||||
|
||||
|
||||
@@ -235,10 +235,8 @@ def _build_plan_and_clips_from_task(
|
||||
"""根据模式和下载的素材路径,构建虚拟 plan + clips + asset_path_map。
|
||||
|
||||
模式 → clip_type 映射:
|
||||
ONE_TAKE: N 个 main clips
|
||||
PIP: 1 main + N-1 overlay
|
||||
ONE_TAKE: N 个 main clips(默认,pip/voice_pip 已统一映射为此模式)
|
||||
VOICE_OVER: N 个 main(config.role=b_roll)
|
||||
VOICE_PIP: 1 background + 1 corner_voice + N-2 b_roll
|
||||
|
||||
Returns:
|
||||
(virtual_plan, virtual_clips, asset_path_map)
|
||||
@@ -255,23 +253,14 @@ def _build_plan_and_clips_from_task(
|
||||
path_to_asset_id[p] = asset_id
|
||||
path_duration[p] = probe_duration(p)
|
||||
|
||||
# 产品已确认全面下线画中画,pip/voice_pip统一走one_take(顺序拼接)
|
||||
if mode in ("pip", "voice_pip"):
|
||||
logger.info("画中画模式已下线,%s 强制映射为 one_take", mode)
|
||||
mode = "one_take"
|
||||
|
||||
clips: list[_VirtualClip] = []
|
||||
|
||||
if mode == "pip":
|
||||
# 1 main + N-1 overlay
|
||||
for i, p in enumerate(downloaded_paths):
|
||||
clip_type = "main" if i == 0 else "overlay"
|
||||
clips.append(
|
||||
_VirtualClip(
|
||||
id=f"vc_{i:03d}",
|
||||
plan_id=task_id,
|
||||
clip_type=clip_type,
|
||||
order=i,
|
||||
asset_id=path_to_asset_id[p],
|
||||
duration=path_duration[p],
|
||||
)
|
||||
)
|
||||
elif mode == "voice_over":
|
||||
if mode == "voice_over":
|
||||
# N 个 main(config.role=b_roll)
|
||||
for i, p in enumerate(downloaded_paths):
|
||||
clips.append(
|
||||
@@ -285,25 +274,6 @@ def _build_plan_and_clips_from_task(
|
||||
config={"role": "b_roll"},
|
||||
)
|
||||
)
|
||||
elif mode == "voice_pip":
|
||||
# 1 background + 1 corner_voice + N-2 b_roll
|
||||
for i, p in enumerate(downloaded_paths):
|
||||
if i == 0:
|
||||
clip_type = "background"
|
||||
elif i == 1:
|
||||
clip_type = "corner_voice"
|
||||
else:
|
||||
clip_type = "b_roll"
|
||||
clips.append(
|
||||
_VirtualClip(
|
||||
id=f"vc_{i:03d}",
|
||||
plan_id=task_id,
|
||||
clip_type=clip_type,
|
||||
order=i,
|
||||
asset_id=path_to_asset_id[p],
|
||||
duration=path_duration[p],
|
||||
)
|
||||
)
|
||||
else:
|
||||
# ONE_TAKE (default): N 个 main clips
|
||||
for i, p in enumerate(downloaded_paths):
|
||||
@@ -773,7 +743,8 @@ def _download_library_assets(
|
||||
len(asset_ids),
|
||||
)
|
||||
|
||||
downloaded: list[Path] = []
|
||||
# 构建待下载列表 (index, asset, storage_key, local_file)
|
||||
download_jobs: list[tuple[int, Any, str, Path]] = []
|
||||
failed_assets: list[str] = []
|
||||
for i, asset in enumerate(assets):
|
||||
storage_key = asset.file_url if asset.file_url else None
|
||||
@@ -802,52 +773,81 @@ def _download_library_assets(
|
||||
|
||||
ext = Path(storage_key).suffix or ".mp4"
|
||||
local_file = temp_path / f"asset_{i:03d}_{asset.id}{ext}"
|
||||
asset_start = time.monotonic()
|
||||
download_ok = download_asset(storage_key, local_file)
|
||||
asset_elapsed = time.monotonic() - asset_start
|
||||
download_jobs.append((i, asset, storage_key, local_file))
|
||||
|
||||
if download_ok:
|
||||
file_size = local_file.stat().st_size if local_file.exists() else 0
|
||||
downloaded.append(local_file)
|
||||
logger.info(
|
||||
"[task_id=%s] Downloaded asset: %s -> %s (size=%d, time=%.1fs)",
|
||||
task_id,
|
||||
asset.name,
|
||||
local_file,
|
||||
file_size,
|
||||
asset_elapsed,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"下载素材",
|
||||
f"下载成功: {asset.name}",
|
||||
asset_id=asset.id,
|
||||
asset_name=asset.name,
|
||||
success=True,
|
||||
file_size=file_size,
|
||||
duration=round(asset_elapsed, 2),
|
||||
# 并行下载素材(线程池,IO 密集型)
|
||||
downloaded: list[Path] = []
|
||||
if download_jobs:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
max_workers = min(len(download_jobs), 6)
|
||||
logger.info(
|
||||
"[task_id=%s] 并行下载素材: count=%d, workers=%d",
|
||||
task_id,
|
||||
len(download_jobs),
|
||||
max_workers,
|
||||
)
|
||||
|
||||
def _download_one(item: tuple) -> tuple[int, Any, Path, bool, float]:
|
||||
idx, asset, skey, lfile = item
|
||||
t0 = time.monotonic()
|
||||
ok = download_asset(skey, lfile)
|
||||
elapsed = time.monotonic() - t0
|
||||
return idx, asset, lfile, ok, elapsed
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {executor.submit(_download_one, job): job for job in download_jobs}
|
||||
# 按原始顺序收集结果,保证 downloaded 列表顺序稳定
|
||||
results_map: dict[int, tuple[Path, bool, float, Any]] = {}
|
||||
for future in as_completed(futures):
|
||||
idx, asset, lfile, ok, elapsed = future.result()
|
||||
results_map[idx] = (lfile, ok, elapsed, asset)
|
||||
|
||||
# 按原始顺序处理结果
|
||||
for idx in sorted(results_map.keys()):
|
||||
lfile, ok, elapsed, asset = results_map[idx]
|
||||
if ok:
|
||||
file_size = lfile.stat().st_size if lfile.exists() else 0
|
||||
downloaded.append(lfile)
|
||||
logger.info(
|
||||
"[task_id=%s] Downloaded asset: %s -> %s (size=%d, time=%.1fs)",
|
||||
task_id,
|
||||
asset.name,
|
||||
lfile,
|
||||
file_size,
|
||||
elapsed,
|
||||
)
|
||||
else:
|
||||
failed_assets.append(f"{asset.name}({asset.id})")
|
||||
logger.warning(
|
||||
"[task_id=%s] Failed to download asset: %s (id=%s)",
|
||||
task_id,
|
||||
asset.name,
|
||||
asset.id,
|
||||
)
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"下载素材",
|
||||
f"下载失败: {asset.name}",
|
||||
level="WARN",
|
||||
asset_id=asset.id,
|
||||
asset_name=asset.name,
|
||||
success=False,
|
||||
file_size=0,
|
||||
duration=round(asset_elapsed, 2),
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"下载素材",
|
||||
f"下载成功: {asset.name}",
|
||||
asset_id=asset.id,
|
||||
asset_name=asset.name,
|
||||
success=True,
|
||||
file_size=file_size,
|
||||
duration=round(elapsed, 2),
|
||||
)
|
||||
else:
|
||||
failed_assets.append(f"{asset.name}({asset.id})")
|
||||
logger.warning(
|
||||
"[task_id=%s] Failed to download asset: %s (id=%s)",
|
||||
task_id,
|
||||
asset.name,
|
||||
asset.id,
|
||||
)
|
||||
if strict:
|
||||
raise RuntimeError(f"素材下载失败: asset_id={asset.id}, name={asset.name}")
|
||||
if gen_task:
|
||||
gen_task.append_log(
|
||||
"下载素材",
|
||||
f"下载失败: {asset.name}",
|
||||
level="WARN",
|
||||
asset_id=asset.id,
|
||||
asset_name=asset.name,
|
||||
success=False,
|
||||
file_size=0,
|
||||
duration=round(elapsed, 2),
|
||||
)
|
||||
if strict:
|
||||
raise RuntimeError(f"素材下载失败: asset_id={asset.id}, name={asset.name}")
|
||||
|
||||
# 指定了 asset_ids 但全部下载失败 → 无论 strict 与否都报错
|
||||
if asset_ids and not downloaded:
|
||||
@@ -1040,6 +1040,12 @@ def _load_task_info(task_id: str) -> dict | None:
|
||||
"resolution": getattr(gen_task, "resolution", "") or "",
|
||||
"bgm_config": dict(getattr(gen_task, "bgm_config", {}) or {}),
|
||||
"is_preview": bool(getattr(gen_task, "is_preview", False)),
|
||||
"source_task_id": getattr(gen_task, "source_task_id", "") or "",
|
||||
"output_width": getattr(gen_task, "output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH,
|
||||
"output_height": getattr(gen_task, "output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT,
|
||||
"cover_url": getattr(gen_task, "cover_url", "") or "",
|
||||
"custom_title": getattr(gen_task, "custom_title", "") or "",
|
||||
"voice_ids": list(getattr(gen_task, "voice_ids", []) or []),
|
||||
}
|
||||
finally:
|
||||
session.close()
|
||||
@@ -1101,6 +1107,7 @@ def _render_video(
|
||||
resolution: str = "",
|
||||
bgm_config: dict | None = None,
|
||||
is_preview: bool = False,
|
||||
voice_ids: list[str] | None = None,
|
||||
) -> tuple[Path, float]:
|
||||
"""渲染视频(含配音混音)。
|
||||
|
||||
@@ -1175,6 +1182,20 @@ def _render_video(
|
||||
plan_cfg["export"] = export_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
|
||||
# 注入用户选择的配音 voice_id(ASR 字幕对齐模式)
|
||||
if voice_ids:
|
||||
plan_cfg = dict(virtual_plan.config or {})
|
||||
plan_cfg["voice_id"] = voice_ids[0]
|
||||
subtitle_cfg = plan_cfg.get("subtitle", {}) or {}
|
||||
subtitle_cfg["auto_generated"] = True
|
||||
plan_cfg["subtitle"] = subtitle_cfg
|
||||
virtual_plan.config = plan_cfg
|
||||
logger.info(
|
||||
"[task_id=%s] [渲染] 预览配音已注入: voice_id=%s",
|
||||
task_id,
|
||||
voice_ids[0],
|
||||
)
|
||||
|
||||
total_duration = sum(c.duration for c in virtual_clips)
|
||||
logger.info(
|
||||
"[task_id=%s] [剪辑计划] 片段数=%d, 总时长=%.1fs",
|
||||
@@ -1201,6 +1222,7 @@ def _render_video(
|
||||
job_id=task_id,
|
||||
work_dir=temp_path,
|
||||
voiceover_audio_path=voice_path,
|
||||
is_preview=is_preview,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -1414,6 +1436,14 @@ def generate_video(self, task_id: str) -> dict:
|
||||
|
||||
# ── 3. 渲染 + 混音 ───────────────────────────────────────────────
|
||||
_update_task_progress(task_id, 40, "开始渲染")
|
||||
# 动态分辨率:优先使用 output_width/output_height,其次 resolution 字符串
|
||||
_ow = task_info.get("output_width", OUTPUT_WIDTH) or OUTPUT_WIDTH
|
||||
_oh = task_info.get("output_height", OUTPUT_HEIGHT) or OUTPUT_HEIGHT
|
||||
if _ow != OUTPUT_WIDTH or _oh != OUTPUT_HEIGHT:
|
||||
_resolved_resolution = f"{_ow}x{_oh}"
|
||||
else:
|
||||
_resolved_resolution = task_info.get("resolution", "")
|
||||
|
||||
output_path, render_duration = _render_video(
|
||||
task_id=task_id,
|
||||
downloaded_videos=downloaded_videos,
|
||||
@@ -1424,9 +1454,10 @@ def generate_video(self, task_id: str) -> dict:
|
||||
user_id=user_id,
|
||||
temp_path=temp_path,
|
||||
output_name=output_name,
|
||||
resolution=task_info.get("resolution", ""),
|
||||
resolution=_resolved_resolution,
|
||||
bgm_config=task_info.get("bgm_config", {}),
|
||||
is_preview=task_info.get("is_preview", False),
|
||||
voice_ids=task_info.get("voice_ids", []),
|
||||
)
|
||||
|
||||
if gen_task:
|
||||
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
# #1197 预览生成接口方案评估
|
||||
|
||||
## 背景
|
||||
|
||||
智能剪辑「一键生成」流程中,第3步预览生成当前被跳过,直接进入下一步。需要实现真正的预览生成功能,让用户在正式生成前能看到效果预览。
|
||||
|
||||
## 现状分析
|
||||
|
||||
### 现有生成链路
|
||||
|
||||
```
|
||||
API 触发生成 → GenerationTask入库 → Celery异步任务 → UnifiedRenderService渲染 → OSS上传 → 更新状态
|
||||
```
|
||||
|
||||
**关键节点:**
|
||||
1. **API层**:`POST /generation-tasks` 或 `POST /templates/{id}/generate` 触发生成
|
||||
2. **任务调度**:Celery task `worker.generate_video`
|
||||
3. **渲染引擎**:`UnifiedRenderService`(统一渲染引擎,已接入9个效果层)
|
||||
4. **输出配置**:默认 720p (1280x720),支持 `resolution` 字段自定义
|
||||
5. **产物存储**:`GeneratedVideo` 表记录,OSS 存储视频文件
|
||||
|
||||
### 已有可复用能力
|
||||
|
||||
| 能力 | 位置 | 是否可复用 |
|
||||
|------|------|-----------|
|
||||
| 任务创建与状态管理 | `GenerationTask` + `CreateGenerationTaskUseCase` | ✅ 是 |
|
||||
| 素材下载与预处理 | `_download_video_assets` / `_download_voice_asset` | ✅ 是 |
|
||||
| 统一渲染引擎 | `UnifiedRenderService` | ✅ 是 |
|
||||
| 分辨率配置 | `resolution` 字段已支持 | ✅ 是 |
|
||||
| 混音与后处理 | `_render_video` 内流程 | ✅ 是 |
|
||||
| OSS 上传与查重 | `_upload_and_dedup` | ✅ 是 |
|
||||
| 进度追踪 | `append_log` / `progress` 字段 | ✅ 是 |
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案A:复用现有生成链路 + is_preview 标记(推荐)
|
||||
|
||||
**思路**:在现有 GenerationTask 上加 `is_preview` 标记,预览生成走完整链路但参数降级。
|
||||
|
||||
**改动点:**
|
||||
1. **数据模型**:`GenerationTask` 加 `is_preview: bool` 字段(默认 false);`GeneratedVideo` 加 `is_preview: bool`
|
||||
2. **API 层**:生成接口加 `is_preview` 参数,预览任务不计入配额
|
||||
3. **渲染参数**:预览模式下自动调整
|
||||
- 分辨率:480p (854x480)
|
||||
- 时长:限制前 15 秒(或模板第一个片段)
|
||||
- 码率:降低至 1.5Mbps(正式 4Mbps)
|
||||
- 效果层:跳过高级转场/粒子特效等耗时效果
|
||||
4. **任务调度**:预览任务走低优先级队列(或复用现有队列,标记优先级)
|
||||
5. **前端对接**:预览生成结果带 `is_preview=true` 标记,前端展示"预览"标签
|
||||
|
||||
**优点:**
|
||||
- 代码复用率 90%+,改动最小
|
||||
- 与正式生成逻辑一致,预览效果真实可信
|
||||
- 进度查询、结果展示等功能直接复用
|
||||
- 后续可平滑升级:预览满意后一键转正式生成
|
||||
|
||||
**缺点:**
|
||||
- 需要区分预览和正式任务,避免数据混淆
|
||||
- 预览任务和正式任务竞争同一队列资源(可后续优化为独立队列)
|
||||
|
||||
**开发量估算**:2-3 天
|
||||
- 数据模型 + 迁移:0.5 天
|
||||
- API 层改造:0.5 天
|
||||
- 渲染参数降级:1 天
|
||||
- 测试 + 联调:1 天
|
||||
|
||||
---
|
||||
|
||||
### 方案B:新建独立预览接口 + 轻量渲染逻辑
|
||||
|
||||
**思路**:新建独立的预览生成接口,使用简化的渲染逻辑(如只拼接素材+基础配音,跳过大部分效果)。
|
||||
|
||||
**改动点:**
|
||||
1. 新增 `PreviewTask` 数据模型
|
||||
2. 新增 `POST /api/v1/preview/generate` 接口
|
||||
3. 新增独立的 Celery task `worker.generate_preview`
|
||||
4. 简化渲染流程:只做素材裁剪+拼接+配音,跳过转场/滤镜/字幕特效等
|
||||
|
||||
**优点:**
|
||||
- 完全隔离,不影响正式生成链路
|
||||
- 可以做极致优化,预览生成速度快
|
||||
- 数据模型清晰,不会混淆
|
||||
|
||||
**缺点:**
|
||||
- 代码重复率高,两套生成逻辑维护成本翻倍
|
||||
- 预览效果与正式生成可能不一致(效果层差异)
|
||||
- 前端需要对接两套接口
|
||||
- 无法从预览升级为正式生成(需重新走完整流程)
|
||||
|
||||
**开发量估算**:4-5 天
|
||||
- 数据模型 + 接口:1 天
|
||||
- 简化渲染逻辑:2 天
|
||||
- 测试 + 联调:1-2 天
|
||||
|
||||
---
|
||||
|
||||
### 方案C:图片预览(首帧/关键帧截图)
|
||||
|
||||
**思路**:不生成视频,只生成几张关键帧的预览图片。
|
||||
|
||||
**优点:**
|
||||
- 生成速度极快(秒级)
|
||||
- 资源消耗小
|
||||
|
||||
**缺点:**
|
||||
- 预览效果差,用户无法感知动态效果
|
||||
- 无法验证配音、转场、节奏等时间维度的效果
|
||||
- 用户体验不佳,不如"真预览"有说服力
|
||||
|
||||
**开发量估算**:1-2 天
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案:方案A(复用现有生成链路)
|
||||
|
||||
### 核心理由
|
||||
|
||||
1. **效果保真**:预览和正式生成用同一套渲染引擎,效果一致,用户信任度高
|
||||
2. **开发效率**:90% 代码复用,2-3 天可上线
|
||||
3. **可扩展性强**:后续可加「预览转正式」「低分辨率快速预览」等增强功能
|
||||
4. **维护成本低**:一套生成逻辑,bug 修复和新功能同时生效
|
||||
|
||||
### 详细设计
|
||||
|
||||
#### 1. 数据模型变更
|
||||
|
||||
```python
|
||||
# GenerationTask 新增字段
|
||||
is_preview: bool = False
|
||||
"""是否为预览生成"""
|
||||
|
||||
preview_of: str = ""
|
||||
"""预览对应的正式任务 ID(或反向关联)"""
|
||||
|
||||
# GeneratedVideo 新增字段
|
||||
is_preview: bool = False
|
||||
"""是否为预览视频"""
|
||||
```
|
||||
|
||||
**迁移**:alembic 新增 migration,两个表各加 1-2 个字段。
|
||||
|
||||
#### 2. API 层
|
||||
|
||||
```
|
||||
POST /api/v1/generation-tasks
|
||||
Body 增加 is_preview: bool = false
|
||||
|
||||
POST /api/v1/templates/{id}/generate
|
||||
Query 增加 is_preview: bool = false
|
||||
```
|
||||
|
||||
**配额处理**:预览生成不计入用户配额,不占用生成次数限制。
|
||||
|
||||
#### 3. 渲染参数降级
|
||||
|
||||
| 参数 | 正式生成 | 预览生成 |
|
||||
|------|---------|---------|
|
||||
| 分辨率 | 720p (1280x720) | 480p (854x480) |
|
||||
| 码率 | 4 Mbps | 1.5 Mbps |
|
||||
| 时长 | 完整时长 | 前 15 秒(或第一段) |
|
||||
| 帧率 | 30 fps | 24 fps |
|
||||
| 转场效果 | 完整转场 | 仅淡入淡出(或简单切) |
|
||||
| 特效滤镜 | 全部启用 | 跳过粒子/光效等高级效果 |
|
||||
| 字幕 | 完整渲染 | 正常渲染(字幕是核心信息) |
|
||||
| 配音 | 完整混音 | 正常混音(配音是核心信息) |
|
||||
|
||||
**实现方式**:在 `_render_video` 或 UnifiedRenderService 入口处,根据 `is_preview` 标记调整渲染配置。
|
||||
|
||||
#### 4. 任务调度
|
||||
|
||||
- 初期复用现有队列,预览任务正常排队
|
||||
- 后续如需优化,可拆分独立预览队列(低优先级)
|
||||
- 预览任务可设置较短超时时间
|
||||
|
||||
#### 5. 前端对接
|
||||
|
||||
- 调用生成接口时传 `is_preview=true`
|
||||
- 结果列表中预览视频带「预览」标签
|
||||
- 预览满意后可一键「升级为正式生成」(重新触发全分辨率生成,可复用素材下载缓存)
|
||||
|
||||
### 实施步骤
|
||||
|
||||
**Phase 1(MVP,2天):**
|
||||
1. 数据模型 + 迁移
|
||||
2. API 层支持 is_preview 参数
|
||||
3. 渲染分辨率降级(480p)
|
||||
4. 不计入配额
|
||||
5. 基础测试
|
||||
|
||||
**Phase 2(优化,1-2天):**
|
||||
1. 时长限制(前15秒)
|
||||
2. 效果层降级(跳高级效果)
|
||||
3. 预览任务低优先级队列
|
||||
4. 预览转正式生成功能
|
||||
|
||||
## 与前端对齐点
|
||||
|
||||
1. 预览生成的触发时机(第3步自动生成?用户点击才生成?)
|
||||
2. 预览时长是固定15秒还是完整但低清?
|
||||
3. 是否需要「预览转正式生成」功能
|
||||
4. 预览视频的展示形态(和正式视频一样还是有特殊UI)
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **数据混淆**:确保统计、计费、列表展示时正确区分预览和正式任务
|
||||
2. **存储成本**:预览视频也占 OSS 空间,可设置自动清理(7天后自动删除)
|
||||
3. **用户预期**:要明确告诉用户这是预览,效果和正式生成一致但清晰度低
|
||||
4. **并发压力**:如果用户频繁生成预览,可能增加系统负载,需要限流
|
||||
@@ -1659,6 +1659,54 @@
|
||||
"primary_key": false,
|
||||
"type": "DATETIME",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "is_preview",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "BOOLEAN",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": true,
|
||||
"name": "source_task_id",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(32)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "output_width",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "output_height",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "INTEGER",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "cover_url",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(1000)",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"index": false,
|
||||
"name": "custom_title",
|
||||
"nullable": false,
|
||||
"primary_key": false,
|
||||
"type": "VARCHAR(500)",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
@@ -1717,6 +1765,13 @@
|
||||
],
|
||||
"name": "ix_generation_tasks_template_id",
|
||||
"unique": false
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"source_task_id"
|
||||
],
|
||||
"name": "ix_generation_tasks_source_task_id",
|
||||
"unique": false
|
||||
}
|
||||
],
|
||||
"primary_key": [
|
||||
@@ -3515,4 +3570,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
# API_PORT - API 端口映射 (staging: 8000, production: 8001)
|
||||
# WEB_PORT - Web 端口映射 (staging: 3001, production: 3002)
|
||||
# GENERATED_FILES_HOST_DIR - 生成文件的主机目录
|
||||
# WORKER_CONCURRENCY - Worker 并发数 (默认: 1)
|
||||
# WORKER_CONCURRENCY - Worker 并发数 (默认: 4)
|
||||
# WORKER_MAX_TASKS_PER_CHILD - Worker 每个子进程最大任务数 (默认: 100)
|
||||
#
|
||||
# 重要:
|
||||
@@ -109,7 +109,7 @@ services:
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-staging}
|
||||
APP_VERSION: ${APP_VERSION:-unknown}
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
|
||||
WORKER_MAX_TASKS_PER_CHILD: ${WORKER_MAX_TASKS_PER_CHILD:-100}
|
||||
GENERATED_FILES_DIR: /app/generated
|
||||
GENERATED_FILES_URL_PREFIX: /generated-files
|
||||
@@ -136,14 +136,15 @@ services:
|
||||
# 资源限制建议(生产环境建议启用)
|
||||
# =========================================
|
||||
# 注意: Worker 需要处理视频,建议分配更多资源
|
||||
# 并发 4 时需要 4C8G 以上,确保视频渲染不 OOM
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2g
|
||||
cpus: '4.0'
|
||||
memory: 8g
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 1G
|
||||
cpus: '1.0'
|
||||
memory: 2G
|
||||
|
||||
# =========================================
|
||||
# Web 服务(Nginx + 前端静态文件)
|
||||
|
||||
@@ -145,7 +145,7 @@ docker run -d \
|
||||
--network xiaoxia-net-production \
|
||||
-e APP_ENV=production \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
|
||||
@@ -81,7 +81,7 @@ export WEB_DOCKERFILE=infra/docker/web-artifact.Dockerfile
|
||||
export WEB_NGINX_CONF=infra/docker/nginx-production.conf
|
||||
|
||||
docker network create xiaoxia-net-production 2>/dev/null || true
|
||||
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-1}"
|
||||
export WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}"
|
||||
export WORKER_MAX_TASKS_PER_CHILD="${WORKER_MAX_TASKS_PER_CHILD:-100}"
|
||||
|
||||
if [ "${ALLOW_PRODUCTION_BUILDS:-false}" = "true" ]; then
|
||||
|
||||
@@ -108,7 +108,7 @@ docker run -d \
|
||||
--network xiaoxia-net-staging \
|
||||
-e APP_ENV=staging \
|
||||
-e APP_VERSION="$IMAGE_TAG" \
|
||||
-e WORKER_CONCURRENCY=1 \
|
||||
-e WORKER_CONCURRENCY="${WORKER_CONCURRENCY:-4}" \
|
||||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||||
-e GENERATED_FILES_DIR=/app/generated \
|
||||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||||
|
||||
@@ -11,8 +11,7 @@ WORKDIR /app/apps/web
|
||||
# 安装依赖:node_modules写入镜像layer,走ACR缓存保证完整性
|
||||
# /root/.npm 保留cache mount加速下载(不影响构建正确性)
|
||||
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
|
||||
npm config set registry https://registry.npmmirror.com \
|
||||
&& npm ci
|
||||
npm ci --registry=https://registry.npmmirror.com
|
||||
|
||||
# 再拷源码
|
||||
COPY apps/web/ ./
|
||||
@@ -21,8 +20,8 @@ COPY apps/web/ ./
|
||||
# node_modules直接使用镜像中已安装的(layer缓存保证完整性)
|
||||
RUN --mount=type=cache,target=/app/apps/web/.tscache,sharing=locked \
|
||||
mkdir -p .tscache \
|
||||
&& npx tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& npx vite build
|
||||
&& ./node_modules/.bin/tsc --incremental --tsBuildInfoFile .tscache/tsconfig.tsbuildinfo \
|
||||
&& ./node_modules/.bin/vite build
|
||||
|
||||
# Production stage with nginx
|
||||
FROM git.xiaoxiajianji.com/xiaoxia/base/nginx:alpine AS runner
|
||||
|
||||
@@ -37,6 +37,11 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
|
||||
resolution=getattr(model, "resolution", "") or "",
|
||||
bgm_config=dict(getattr(model, "bgm_config", {}) or {}),
|
||||
is_preview=bool(getattr(model, "is_preview", False)),
|
||||
source_task_id=getattr(model, "source_task_id", "") or "",
|
||||
output_width=getattr(model, "output_width", 1280) or 1280,
|
||||
output_height=getattr(model, "output_height", 720) or 720,
|
||||
cover_url=getattr(model, "cover_url", "") or "",
|
||||
custom_title=getattr(model, "custom_title", "") or "",
|
||||
logs=model.logs or "[]",
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
@@ -76,6 +81,11 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
resolution=task.resolution or "",
|
||||
bgm_config=task.bgm_config or {},
|
||||
is_preview=task.is_preview or False,
|
||||
source_task_id=task.source_task_id or "",
|
||||
output_width=task.output_width,
|
||||
output_height=task.output_height,
|
||||
cover_url=task.cover_url or "",
|
||||
custom_title=task.custom_title or "",
|
||||
logs=task.logs,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
@@ -242,6 +252,11 @@ class SQLAlchemyGenerationTaskRepository:
|
||||
model.bgm_config = task.bgm_config or {}
|
||||
if hasattr(model, "is_preview"):
|
||||
model.is_preview = task.is_preview or False
|
||||
model.source_task_id = task.source_task_id or ""
|
||||
model.output_width = task.output_width
|
||||
model.output_height = task.output_height
|
||||
model.cover_url = task.cover_url or ""
|
||||
model.custom_title = task.custom_title or ""
|
||||
model.logs = task.logs
|
||||
self.session.commit()
|
||||
return task
|
||||
|
||||
@@ -293,6 +293,11 @@ class GenerationTaskModel(Base):
|
||||
video_title = Column(String(255), nullable=False, default="")
|
||||
resolution = Column(String(20), nullable=False, default="")
|
||||
is_preview = Column(Boolean, nullable=False, default=False, index=True)
|
||||
source_task_id = Column(String(32), nullable=False, default="", index=True)
|
||||
output_width = Column(Integer, nullable=False, default=1280)
|
||||
output_height = Column(Integer, nullable=False, default=720)
|
||||
cover_url = Column(String(1000), nullable=False, default="")
|
||||
custom_title = Column(String(500), nullable=False, default="")
|
||||
bgm_config = Column(JSON, nullable=False, default=dict)
|
||||
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
|
||||
logs = Column(Text, nullable=False, default="[]", server_default="[]")
|
||||
|
||||
@@ -27,6 +27,11 @@ class CreateGenerationTaskCommand:
|
||||
auto_retry_enabled: bool = False
|
||||
auto_retry_max: int = 0
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
|
||||
|
||||
class CreateGenerationTaskUseCase:
|
||||
@@ -58,6 +63,11 @@ class CreateGenerationTaskUseCase:
|
||||
auto_retry_enabled=command.auto_retry_enabled,
|
||||
auto_retry_max=command.auto_retry_max,
|
||||
is_preview=command.is_preview,
|
||||
source_task_id=command.source_task_id,
|
||||
output_width=command.output_width,
|
||||
output_height=command.output_height,
|
||||
cover_url=command.cover_url,
|
||||
custom_title=command.custom_title,
|
||||
)
|
||||
return self.generation_task_repository.create(task)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from .entities import (
|
||||
from .generated_video import GeneratedVideo
|
||||
from .generation_task import GenerationTask, GenerationTaskStatus
|
||||
from .job import Job, JobStatus, JobType
|
||||
from .smart_match import SmartMatchResult, score_asset, smart_select_assets
|
||||
from .tag import Tag
|
||||
from .template_clip_config import ClipType, TemplateClipConfig, TransitionEffect
|
||||
from .title_library import TitleLibraryItem
|
||||
@@ -61,6 +62,9 @@ __all__ = [
|
||||
"TemplateClipConfig",
|
||||
"TransitionEffect",
|
||||
"User",
|
||||
"SmartMatchResult",
|
||||
"TitleLibraryItem",
|
||||
"VoiceLibraryItem",
|
||||
"score_asset",
|
||||
"smart_select_assets",
|
||||
]
|
||||
|
||||
@@ -175,6 +175,48 @@ def format_ass_time(seconds: float) -> str:
|
||||
# ── 完整 ASS 内容生成 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
def _wrap_title_text(
|
||||
text: str,
|
||||
video_width: int,
|
||||
font_size: int,
|
||||
margin_l: int = TITLE_MARGIN_SIDE,
|
||||
margin_r: int = TITLE_MARGIN_SIDE,
|
||||
) -> str:
|
||||
"""根据视频宽度和字号自动换行标题文本。
|
||||
|
||||
中文字符按 font_size 像素宽度估算,英文/数字按半角估算。
|
||||
超过可用宽度时插入 \\N (ASS 硬换行)。
|
||||
"""
|
||||
if not text or video_width <= 0 or font_size <= 0:
|
||||
return text
|
||||
|
||||
available_width = video_width - margin_l - margin_r
|
||||
if available_width <= 0:
|
||||
return text
|
||||
|
||||
lines: list[str] = []
|
||||
current_line = ""
|
||||
current_width = 0.0
|
||||
|
||||
for ch in text:
|
||||
# CJK 字符按全角估算,其他按半角
|
||||
char_width = float(font_size) if ord(ch) > 0x2E80 else font_size * 0.55
|
||||
|
||||
if current_width + char_width > available_width and current_line:
|
||||
lines.append(current_line)
|
||||
current_line = ch
|
||||
current_width = char_width
|
||||
else:
|
||||
current_line += ch
|
||||
current_width += char_width
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return "\\N".join(lines)
|
||||
|
||||
|
||||
def build_ass_content(
|
||||
*,
|
||||
video_width: int,
|
||||
@@ -248,7 +290,11 @@ def build_ass_content(
|
||||
)
|
||||
)
|
||||
|
||||
safe_title_text = escape_ass_text(title_text)
|
||||
# 根据视频宽度和字号自动换行标题,防止超出画面
|
||||
# 先 escape 特殊字符,再插入换行符 \N,避免顺序颠倒导致 \N 被转义
|
||||
title_font_size = int(title_config.get("size", 48))
|
||||
safe_title_text_raw = escape_ass_text(title_text)
|
||||
safe_title_text = _wrap_title_text(safe_title_text_raw, video_width, title_font_size)
|
||||
|
||||
events.append(
|
||||
"Dialogue: 0,0:00:00.00," f"{format_ass_time(video_duration)}," "TitleStyle,,0,0,0,," f"{safe_title_text}"
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
"""Asset scoring pure logic — multi-dimensional scoring + diverse selection.
|
||||
|
||||
从 smart_asset_selector.py 抽出来的纯逻辑模块:
|
||||
- 评分维度:质量分、分辨率、时长、码率(加权求和,总分 0-1)
|
||||
- 多样性选择:按时长分桶(短/中/长)保证分布均匀
|
||||
- 数据类:AssetScoreDetail, SmartSelectResult
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# ── 评分权重(总和 = 1.0) ────────────────────────────────────────────────────
|
||||
|
||||
WEIGHT_QUALITY = 0.5
|
||||
WEIGHT_RESOLUTION = 0.2
|
||||
WEIGHT_DURATION = 0.2
|
||||
WEIGHT_BITRATE = 0.1
|
||||
|
||||
# ── 评分参数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
TARGET_WIDTH = 1920 # 目标分辨率宽度基准
|
||||
TARGET_HEIGHT = 1080 # 目标分辨率高度基准
|
||||
MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
|
||||
OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
|
||||
OPTIMAL_DURATION_MAX = 30.0
|
||||
|
||||
# ── 多样性分桶阈值 ───────────────────────────────────────────────────────────
|
||||
|
||||
SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
|
||||
MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
|
||||
# 长素材:>= 15s
|
||||
|
||||
|
||||
# ── 数据类 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetScoreDetail:
|
||||
"""单个素材的评分详情."""
|
||||
|
||||
asset_id: str
|
||||
total_score: float
|
||||
quality_score: float
|
||||
resolution_score: float
|
||||
duration_score: float
|
||||
bitrate_score: float
|
||||
duration: float | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartSelectResult:
|
||||
"""智能选择结果."""
|
||||
|
||||
selected_ids: list[str]
|
||||
total_candidates: int
|
||||
filtered_out: int # 被质量门槛过滤的数量
|
||||
avg_score: float
|
||||
details: list[AssetScoreDetail] = field(default_factory=list)
|
||||
|
||||
|
||||
# ── 评分函数 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def score_resolution(
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
target_width: int = TARGET_WIDTH,
|
||||
target_height: int = TARGET_HEIGHT,
|
||||
) -> float:
|
||||
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重.
|
||||
|
||||
Args:
|
||||
width: 素材宽度(像素)
|
||||
height: 素材高度(像素)
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
|
||||
Returns:
|
||||
0.0 - 1.0 之间的评分
|
||||
"""
|
||||
if width is None or height is None or width <= 0 or height <= 0:
|
||||
return 0.5 # 未知分辨率给中评分
|
||||
|
||||
target_pixels = target_width * target_height
|
||||
actual_pixels = width * height
|
||||
|
||||
# 计算像素数比例
|
||||
ratio = actual_pixels / target_pixels
|
||||
|
||||
if ratio >= 1.0:
|
||||
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
|
||||
return 1.0
|
||||
else:
|
||||
# 低于目标分辨率:线性衰减,但最低不低于 0.1
|
||||
score = 0.3 + 0.7 * ratio
|
||||
return max(0.1, min(1.0, score))
|
||||
|
||||
|
||||
def score_duration(duration: float | None) -> float:
|
||||
"""时长评分:3-30秒最佳,过短或过长都扣分.
|
||||
|
||||
Args:
|
||||
duration: 时长(秒)
|
||||
|
||||
Returns:
|
||||
0.0 - 1.0 之间的评分
|
||||
"""
|
||||
if duration is None or duration <= 0:
|
||||
return 0.5 # 未知时长给中评分
|
||||
|
||||
if OPTIMAL_DURATION_MIN <= duration <= OPTIMAL_DURATION_MAX:
|
||||
# 最佳区间:满分
|
||||
return 1.0
|
||||
|
||||
if duration < OPTIMAL_DURATION_MIN:
|
||||
# 太短:线性衰减,趋近于 0.3
|
||||
ratio = duration / OPTIMAL_DURATION_MIN
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
|
||||
excess = duration - OPTIMAL_DURATION_MAX
|
||||
penalty = min(0.8, excess / 10.0 * 0.1)
|
||||
return max(0.2, 1.0 - penalty)
|
||||
|
||||
|
||||
def score_bitrate(file_size: int, duration: float | None) -> float:
|
||||
"""码率评分:根据文件大小和时长估算码率,适中得分高.
|
||||
|
||||
Args:
|
||||
file_size: 文件大小(字节)
|
||||
duration: 时长(秒)
|
||||
|
||||
Returns:
|
||||
0.0 - 1.0 之间的评分
|
||||
"""
|
||||
if not file_size or not duration or duration <= 0:
|
||||
return 0.5 # 未知给中评分
|
||||
|
||||
# 估算码率(bps)
|
||||
bitrate = (file_size * 8) / duration
|
||||
|
||||
# 最佳码率范围:2-8 Mbps
|
||||
optimal_low = 2_000_000 # 2 Mbps
|
||||
optimal_high = 8_000_000 # 8 Mbps
|
||||
|
||||
if optimal_low <= bitrate <= optimal_high:
|
||||
return 1.0
|
||||
|
||||
if bitrate < optimal_low:
|
||||
# 码率太低:线性衰减
|
||||
ratio = bitrate / optimal_low
|
||||
return 0.3 + 0.7 * ratio
|
||||
|
||||
# 码率太高(文件太大):适度扣分,最低 0.5
|
||||
excess = bitrate / optimal_high - 1.0
|
||||
penalty = min(0.5, excess * 0.2)
|
||||
return max(0.5, 1.0 - penalty)
|
||||
|
||||
|
||||
def calculate_total_score(
|
||||
quality_score: float,
|
||||
resolution_score: float,
|
||||
duration_score: float,
|
||||
bitrate_score: float,
|
||||
) -> float:
|
||||
"""计算加权总分.
|
||||
|
||||
Args:
|
||||
quality_score: 质量分(0-1)
|
||||
resolution_score: 分辨率分(0-1)
|
||||
duration_score: 时长分(0-1)
|
||||
bitrate_score: 码率分(0-1)
|
||||
|
||||
Returns:
|
||||
加权总分(0-1)
|
||||
"""
|
||||
total = (
|
||||
WEIGHT_QUALITY * quality_score
|
||||
+ WEIGHT_RESOLUTION * resolution_score
|
||||
+ WEIGHT_DURATION * duration_score
|
||||
+ WEIGHT_BITRATE * bitrate_score
|
||||
)
|
||||
return round(total, 4)
|
||||
|
||||
|
||||
def score_asset_detail(
|
||||
asset_id: str,
|
||||
quality: float | None,
|
||||
width: int | None,
|
||||
height: int | None,
|
||||
duration: float | None,
|
||||
file_size: int,
|
||||
target_width: int = TARGET_WIDTH,
|
||||
target_height: int = TARGET_HEIGHT,
|
||||
) -> AssetScoreDetail:
|
||||
"""对单个素材进行多维度评分,返回详细评分结果.
|
||||
|
||||
Args:
|
||||
asset_id: 素材ID
|
||||
quality: 质量分(0-100,None表示未知)
|
||||
width: 宽度
|
||||
height: 高度
|
||||
duration: 时长
|
||||
file_size: 文件大小
|
||||
target_width: 目标宽度
|
||||
target_height: 目标高度
|
||||
|
||||
Returns:
|
||||
AssetScoreDetail 评分详情
|
||||
"""
|
||||
# 质量分归一化到 0-1
|
||||
quality_score = (quality / 100.0) if quality is not None else 0.5
|
||||
|
||||
resolution_score = score_resolution(width, height, target_width, target_height)
|
||||
duration_score = score_duration(duration)
|
||||
bitrate_score = score_bitrate(file_size, duration)
|
||||
|
||||
total_score = calculate_total_score(
|
||||
quality_score,
|
||||
resolution_score,
|
||||
duration_score,
|
||||
bitrate_score,
|
||||
)
|
||||
|
||||
return AssetScoreDetail(
|
||||
asset_id=asset_id,
|
||||
total_score=total_score,
|
||||
quality_score=round(quality_score, 4),
|
||||
resolution_score=round(resolution_score, 4),
|
||||
duration_score=round(duration_score, 4),
|
||||
bitrate_score=round(bitrate_score, 4),
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
# ── 多样性选择 ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _bucket_by_duration(item: AssetScoreDetail) -> str:
|
||||
"""根据时长判断所属桶.
|
||||
|
||||
Returns:
|
||||
'short' / 'medium' / 'long' / 'unknown'
|
||||
"""
|
||||
if item.duration is None:
|
||||
return "unknown"
|
||||
if item.duration < SHORT_BUCKET_MAX:
|
||||
return "short"
|
||||
if item.duration < MEDIUM_BUCKET_MAX:
|
||||
return "medium"
|
||||
return "long"
|
||||
|
||||
|
||||
def diverse_selection(
|
||||
scored: list[AssetScoreDetail],
|
||||
count: int,
|
||||
) -> list[AssetScoreDetail]:
|
||||
"""多样性选择:按时长分桶,保证每个桶都有素材.
|
||||
|
||||
策略:
|
||||
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>=15s)
|
||||
2. 每个桶配额 = max(1, count // 3)
|
||||
3. 先从每桶按配额取最高分的
|
||||
4. 剩余名额从全局最高分中取(不重复)
|
||||
5. 如果还不够,加上未知时长的
|
||||
|
||||
Args:
|
||||
scored: 已按总分降序排列的评分列表
|
||||
count: 需要选取的数量
|
||||
|
||||
Returns:
|
||||
选中的评分列表(不超过 count 个)
|
||||
"""
|
||||
if count <= 0 or not scored:
|
||||
return []
|
||||
|
||||
# 分桶
|
||||
short_bucket = [d for d in scored if _bucket_by_duration(d) == "short"]
|
||||
medium_bucket = [d for d in scored if _bucket_by_duration(d) == "medium"]
|
||||
long_bucket = [d for d in scored if _bucket_by_duration(d) == "long"]
|
||||
unknown_bucket = [d for d in scored if _bucket_by_duration(d) == "unknown"]
|
||||
|
||||
buckets = [short_bucket, medium_bucket, long_bucket]
|
||||
|
||||
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
|
||||
base_quota = max(1, count // 3)
|
||||
|
||||
selected: list[AssetScoreDetail] = []
|
||||
selected_ids: set[str] = set()
|
||||
|
||||
# 先按配额从每个桶取
|
||||
for bucket in buckets:
|
||||
quota = min(base_quota, len(bucket))
|
||||
if quota <= 0:
|
||||
continue
|
||||
# 桶内已经按分数排好序了,直接取前 quota 个
|
||||
for item in bucket[:quota]:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
return selected
|
||||
|
||||
# 剩余名额:从全局(未被选中的)中按分数高低取
|
||||
remaining_needed = count - len(selected)
|
||||
if remaining_needed > 0:
|
||||
for item in scored:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
# 如果还不够(不应该发生),加上未知时长的
|
||||
if len(selected) < count and unknown_bucket:
|
||||
for item in unknown_bucket:
|
||||
if item.asset_id not in selected_ids:
|
||||
selected.append(item)
|
||||
selected_ids.add(item.asset_id)
|
||||
if len(selected) >= count:
|
||||
break
|
||||
|
||||
return selected[:count]
|
||||
|
||||
|
||||
# ── 候选过滤 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def filter_candidates(
|
||||
assets: list[Any],
|
||||
min_quality_score: float = MIN_QUALITY_SCORE,
|
||||
) -> tuple[list[Any], int]:
|
||||
"""从素材列表中筛选出合格的候选素材.
|
||||
|
||||
筛选条件:
|
||||
- status == 'ready'
|
||||
- mime_type 以 'video' 开头
|
||||
- quality_score >= min_quality_score(如果quality不为None)
|
||||
|
||||
Args:
|
||||
assets: 素材列表
|
||||
min_quality_score: 最低质量分门槛
|
||||
|
||||
Returns:
|
||||
(合格素材列表, 被质量门槛过滤的数量)
|
||||
"""
|
||||
candidates = []
|
||||
filtered_out = 0
|
||||
|
||||
for asset in assets:
|
||||
# 状态检查
|
||||
status = getattr(asset, "status", None)
|
||||
status_val = status.value if hasattr(status, "value") else str(status)
|
||||
if status_val != "ready":
|
||||
continue
|
||||
|
||||
# 类型检查
|
||||
mime_type = getattr(asset, "mime_type", "") or ""
|
||||
if not mime_type.startswith("video"):
|
||||
continue
|
||||
|
||||
# 质量分门槛
|
||||
quality = getattr(asset, "quality_score", None)
|
||||
if quality is not None and quality < min_quality_score:
|
||||
filtered_out += 1
|
||||
continue
|
||||
|
||||
candidates.append(asset)
|
||||
|
||||
return candidates, filtered_out
|
||||
@@ -127,11 +127,18 @@ class EditPlanClip:
|
||||
config=config or {},
|
||||
)
|
||||
|
||||
def assign_asset(self, asset_id: str) -> None:
|
||||
"""分配素材"""
|
||||
def assign_asset(self, asset_id: str, *, start_time: float | None = None) -> None:
|
||||
"""分配素材
|
||||
|
||||
Args:
|
||||
asset_id: 素材 ID
|
||||
start_time: 可选,素材播放起始时间(秒)。如果提供且在有效范围内,则设置;否则保持默认 0.0
|
||||
"""
|
||||
if not asset_id.strip():
|
||||
raise ValueError("asset_id 不能为空")
|
||||
self.asset_id = asset_id.strip()
|
||||
if start_time is not None and start_time >= 0:
|
||||
self.start_time = start_time
|
||||
self.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
def mark_ready(self) -> None:
|
||||
|
||||
@@ -116,6 +116,11 @@ class GenerationTask:
|
||||
resolution: str = ""
|
||||
bgm_config: dict = field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
custom_title: str = ""
|
||||
logs: str = "[]"
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -142,6 +147,11 @@ class GenerationTask:
|
||||
auto_retry_enabled: bool = False,
|
||||
auto_retry_max: int = 0,
|
||||
is_preview: bool = False,
|
||||
source_task_id: str = "",
|
||||
output_width: int = 1280,
|
||||
output_height: int = 720,
|
||||
cover_url: str = "",
|
||||
custom_title: str = "",
|
||||
) -> "GenerationTask":
|
||||
if not project_id.strip() and not template_id.strip():
|
||||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||||
@@ -167,6 +177,11 @@ class GenerationTask:
|
||||
auto_retry_enabled=auto_retry_enabled,
|
||||
auto_retry_max=auto_retry_max,
|
||||
is_preview=is_preview,
|
||||
source_task_id=source_task_id,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
cover_url=cover_url,
|
||||
custom_title=custom_title,
|
||||
)
|
||||
|
||||
# ── 状态查询 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import List
|
||||
|
||||
from packages.domain.edit_plan_clip import EditPlanClip
|
||||
@@ -30,6 +31,9 @@ def distribute_assets(
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
*,
|
||||
random_selection: bool = False,
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改).
|
||||
|
||||
@@ -43,66 +47,85 @@ def distribute_assets(
|
||||
clips: 剪辑片段列表(就地修改 asset_id)
|
||||
asset_ids: 素材 ID 列表
|
||||
editing_mode: 剪辑模式字符串
|
||||
random_selection: 是否随机选择素材(用于预览生成)
|
||||
asset_durations: 素材 ID -> 时长(秒)映射,用于设置随机 start_time
|
||||
"""
|
||||
if not asset_ids or not clips:
|
||||
return
|
||||
|
||||
# 如果需要随机选择,先打乱素材顺序
|
||||
if random_selection:
|
||||
asset_ids = list(asset_ids) # 复制避免修改原列表
|
||||
random.shuffle(asset_ids)
|
||||
|
||||
if editing_mode == EditingMode.ONE_TAKE.value:
|
||||
_distribute_one_take(clips, asset_ids)
|
||||
_distribute_one_take(clips, asset_ids, asset_durations)
|
||||
elif editing_mode == EditingMode.PIP.value:
|
||||
_distribute_pip(clips, asset_ids)
|
||||
_distribute_pip(clips, asset_ids, asset_durations)
|
||||
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||
_distribute_voice_over(clips, asset_ids)
|
||||
_distribute_voice_over(clips, asset_ids, asset_durations)
|
||||
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||
_distribute_voice_pip(clips, asset_ids)
|
||||
_distribute_voice_pip(clips, asset_ids, asset_durations)
|
||||
else:
|
||||
# 未知模式,退化为 one_take
|
||||
_distribute_one_take(clips, asset_ids)
|
||||
_distribute_one_take(clips, asset_ids, asset_durations)
|
||||
|
||||
|
||||
def _distribute_one_take(
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips."""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
asset_id = asset_ids[i]
|
||||
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
|
||||
clip.assign_asset(asset_id, start_time=start_time)
|
||||
|
||||
|
||||
def _distribute_pip(
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips."""
|
||||
# 第1个素材 → main clip
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
if main_clips and asset_ids:
|
||||
main_clips[0].assign_asset(asset_ids[0])
|
||||
asset_id = asset_ids[0]
|
||||
start_time = _calc_random_start_time(asset_id, main_clips[0].duration, asset_durations)
|
||||
main_clips[0].assign_asset(asset_id, start_time=start_time)
|
||||
|
||||
# 其余素材 → overlay clips
|
||||
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
|
||||
remaining = asset_ids[1:]
|
||||
for i, clip in enumerate(overlay_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
asset_id = remaining[i]
|
||||
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
|
||||
clip.assign_asset(asset_id, start_time=start_time)
|
||||
|
||||
|
||||
def _distribute_voice_over(
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""VOICE_OVER: 素材→main clips (B-roll)."""
|
||||
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||
for i, clip in enumerate(main_clips):
|
||||
if i < len(asset_ids):
|
||||
clip.assign_asset(asset_ids[i])
|
||||
asset_id = asset_ids[i]
|
||||
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
|
||||
clip.assign_asset(asset_id, start_time=start_time)
|
||||
|
||||
|
||||
def _distribute_voice_pip(
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
) -> None:
|
||||
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll."""
|
||||
bg_clips = [c for c in clips if c.clip_type == "background"]
|
||||
@@ -113,19 +136,61 @@ def _distribute_voice_pip(
|
||||
|
||||
# 第1个 → background
|
||||
if idx < len(asset_ids) and bg_clips:
|
||||
bg_clips[0].assign_asset(asset_ids[idx])
|
||||
asset_id = asset_ids[idx]
|
||||
start_time = _calc_random_start_time(asset_id, bg_clips[0].duration, asset_durations)
|
||||
bg_clips[0].assign_asset(asset_id, start_time=start_time)
|
||||
idx += 1
|
||||
|
||||
# 第2个 → corner_voice
|
||||
if idx < len(asset_ids) and voice_clips:
|
||||
voice_clips[0].assign_asset(asset_ids[idx])
|
||||
asset_id = asset_ids[idx]
|
||||
start_time = _calc_random_start_time(asset_id, voice_clips[0].duration, asset_durations)
|
||||
voice_clips[0].assign_asset(asset_id, start_time=start_time)
|
||||
idx += 1
|
||||
|
||||
# 剩余 → b_roll clips
|
||||
remaining = asset_ids[idx:]
|
||||
for i, clip in enumerate(broll_clips):
|
||||
if i < len(remaining):
|
||||
clip.assign_asset(remaining[i])
|
||||
asset_id = remaining[i]
|
||||
start_time = _calc_random_start_time(asset_id, clip.duration, asset_durations)
|
||||
clip.assign_asset(asset_id, start_time=start_time)
|
||||
|
||||
|
||||
# ── 随机 start_time 计算 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _calc_random_start_time(
|
||||
asset_id: str,
|
||||
clip_duration: float,
|
||||
asset_durations: dict[str, float] | None,
|
||||
) -> float | None:
|
||||
"""计算随机 start_time.
|
||||
|
||||
在素材总时长范围内随机取点,确保 clip_duration 不超出素材边界。
|
||||
如果 asset_durations 为 None 或素材不在其中,返回 None(使用默认 0.0)。
|
||||
|
||||
Args:
|
||||
asset_id: 素材 ID
|
||||
clip_duration: 片段时长(秒)
|
||||
asset_durations: 素材 ID -> 时长映射
|
||||
|
||||
Returns:
|
||||
随机 start_time 或 None
|
||||
"""
|
||||
if asset_durations is None:
|
||||
return None
|
||||
|
||||
total_duration = asset_durations.get(asset_id)
|
||||
if total_duration is None or total_duration <= 0:
|
||||
return None
|
||||
|
||||
# 最大起始点 = 素材总时长 - 片段时长
|
||||
max_start = max(0.0, total_duration - clip_duration)
|
||||
if max_start <= 0:
|
||||
return 0.0
|
||||
|
||||
return random.uniform(0.0, max_start)
|
||||
|
||||
|
||||
# ── clip_type 映射 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Quota system with registry pattern.
|
||||
|
||||
Three subscription tiers with different limits:
|
||||
Four subscription tiers with different limits:
|
||||
- free: 2GB storage, 5 videos/month, 3 concurrent, 3 templates, 50 titles, 10 voiceovers, no AI voice
|
||||
- basic: 20GB storage, 30 videos/month, 10 concurrent, 15 templates, 500 titles, 100 voiceovers, AI voice
|
||||
- premium: 100GB storage, 100 videos/month, 20 concurrent, unlimited templates, 500 titles, 100 voiceovers, AI voice
|
||||
- pro: Same as premium (alias for premium tier)
|
||||
|
||||
Quota dimensions are registered by modules via the ModuleRegistry,
|
||||
and checked against the user's subscription plan.
|
||||
@@ -100,6 +101,8 @@ QUOTA_TIERS: Dict[str, QuotaTier] = {
|
||||
},
|
||||
),
|
||||
}
|
||||
# pro 套餐与 premium 配额相同,使用别名引用避免重复维护
|
||||
QUOTA_TIERS["pro"] = QUOTA_TIERS["premium"]
|
||||
|
||||
|
||||
class QuotaWarningLevel:
|
||||
|
||||
Executable
+207
@@ -0,0 +1,207 @@
|
||||
"""统一智能选素材算法 — 合并 _helpers / generation_tasks / auto_clip_service 的重叠逻辑。
|
||||
|
||||
设计目标:
|
||||
- 单一入口,替代 3 套分散的选素材代码
|
||||
- 多维度加权评分:质量分 + 时长均衡 + 新鲜度 + 未使用偏好
|
||||
- 多样性保障:按时长分桶(短/中/长)均衡选取,避免同质化
|
||||
- 可扩展:后续接入 AI 模型时只需替换 score_asset()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SmartMatchResult:
|
||||
"""单条素材的匹配结果。"""
|
||||
|
||||
asset: Any # Asset entity
|
||||
score: float # 综合得分 0-100
|
||||
breakdown: dict[str, float] = field(default_factory=dict) # 各维度得分明细
|
||||
|
||||
|
||||
def _get_enum_value(obj: Any, attr: str) -> str:
|
||||
"""安全获取属性值,兼容 StrEnum / 普通字符串。"""
|
||||
val = getattr(obj, attr, None)
|
||||
if val is None:
|
||||
return ""
|
||||
return val.value if hasattr(val, "value") else str(val)
|
||||
|
||||
|
||||
def _duration_bucket(duration: float | None) -> str:
|
||||
"""将素材时长分为 3 档:short(<10s) / medium(10-30s) / long(>30s)。"""
|
||||
if duration is None or duration <= 0:
|
||||
return "unknown"
|
||||
if duration < 10:
|
||||
return "short"
|
||||
if duration <= 30:
|
||||
return "medium"
|
||||
return "long"
|
||||
|
||||
|
||||
def score_asset(
|
||||
asset: Any,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[float, dict[str, float]]:
|
||||
"""为单个素材计算综合得分(0-100)。
|
||||
|
||||
维度权重:
|
||||
- quality_score (40%):素材质量分(0-100),无质量分按 50 计
|
||||
- duration_fitness (30%):时长适配度,5-30s 为最优区间
|
||||
- recency (20%):新鲜度,30 天内衰减
|
||||
- unused_bonus (10%):未被使用过的素材加分
|
||||
|
||||
Returns:
|
||||
(total_score, breakdown_dict)
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
breakdown: dict[str, float] = {}
|
||||
|
||||
# 1. 质量分 (0-100) → 权重 40%
|
||||
raw_quality = asset.quality_score if asset.quality_score is not None else 50.0
|
||||
quality_component = raw_quality * 0.4
|
||||
breakdown["quality"] = round(quality_component, 2)
|
||||
|
||||
# 2. 时长适配度 (0-100) → 权重 30%
|
||||
# 最优区间 5-30s 得满分,越偏离越低
|
||||
duration = getattr(asset, "duration", None) or 0.0
|
||||
if duration <= 0:
|
||||
duration_fitness = 30.0 # 未知时长给中等分
|
||||
elif 5 <= duration <= 30:
|
||||
duration_fitness = 100.0
|
||||
elif duration < 5:
|
||||
# 0-5s: 线性增长 20→100
|
||||
duration_fitness = 20.0 + (duration / 5) * 80
|
||||
else:
|
||||
# >30s: 指数衰减,60s 时约 50 分
|
||||
duration_fitness = 100.0 * math.exp(-0.02 * (duration - 30))
|
||||
duration_fitness = max(duration_fitness, 10.0)
|
||||
duration_component = duration_fitness * 0.3
|
||||
breakdown["duration"] = round(duration_component, 2)
|
||||
|
||||
# 3. 新鲜度 (0-100) → 权重 20%
|
||||
# 30 天半衰期
|
||||
created_at = getattr(asset, "created_at", None)
|
||||
if created_at is None:
|
||||
recency = 50.0
|
||||
else:
|
||||
if created_at.tzinfo is None:
|
||||
created_at = created_at.replace(tzinfo=timezone.utc)
|
||||
age_days = max(0, (now - created_at).total_seconds() / 86400)
|
||||
recency = 100.0 * math.exp(-0.05 * age_days) # ~14天半衰期
|
||||
recency_component = recency * 0.2
|
||||
breakdown["recency"] = round(recency_component, 2)
|
||||
|
||||
# 4. 未使用偏好 (0-100) → 权重 10%
|
||||
metadata = getattr(asset, "metadata", None) or {}
|
||||
try:
|
||||
use_count = int(metadata.get("generation_use_count") or 0)
|
||||
except (ValueError, TypeError):
|
||||
use_count = 0 # 脏数据时按未使用处理(保守策略:给未使用加分)
|
||||
if use_count == 0:
|
||||
unused_score = 100.0
|
||||
elif use_count <= 3:
|
||||
unused_score = 70.0
|
||||
else:
|
||||
unused_score = 30.0
|
||||
unused_component = unused_score * 0.1
|
||||
breakdown["unused"] = round(unused_component, 2)
|
||||
|
||||
total = quality_component + duration_component + recency_component + unused_component
|
||||
return round(total, 2), breakdown
|
||||
|
||||
|
||||
def smart_select_assets(
|
||||
assets: list[Any],
|
||||
*,
|
||||
limit: int | None = None,
|
||||
kind: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> list[SmartMatchResult]:
|
||||
"""从素材列表中智能选取素材。
|
||||
|
||||
Args:
|
||||
assets: 候选素材列表(Asset 实体)
|
||||
limit: 最大返回数量,None 表示不限制
|
||||
kind: 按文件类型过滤(video/image/audio),None 表示不过滤
|
||||
now: 当前时间(用于测试注入)
|
||||
|
||||
Returns:
|
||||
按得分降序排列的 SmartMatchResult 列表
|
||||
"""
|
||||
# Step 1: 过滤 ready 状态
|
||||
ready_assets = [a for a in assets if _get_enum_value(a, "status") == "ready"]
|
||||
|
||||
# Step 2: 按 kind 过滤
|
||||
if kind:
|
||||
ready_assets = [a for a in ready_assets if a.file_type == kind]
|
||||
|
||||
if not ready_assets:
|
||||
return []
|
||||
|
||||
# Step 3: 评分
|
||||
scored: list[SmartMatchResult] = []
|
||||
for a in ready_assets:
|
||||
total, breakdown = score_asset(a, now=now)
|
||||
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
|
||||
|
||||
# Step 4: 按得分降序排序
|
||||
scored.sort(key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Step 5: 多样性保障 — 时长分桶均衡选取
|
||||
if limit and limit > 0 and len(scored) > limit:
|
||||
scored = _diversity_select(scored, limit)
|
||||
elif limit and limit > 0:
|
||||
scored = scored[:limit]
|
||||
|
||||
return scored
|
||||
|
||||
|
||||
def _diversity_select(scored: list[SmartMatchResult], limit: int) -> list[SmartMatchResult]:
|
||||
"""从已排序的候选中按分桶均衡选取,避免全选中同一时长档。
|
||||
|
||||
策略:轮流从 short/medium/long 桶中按得分顺序取,直到凑满 limit。
|
||||
"""
|
||||
buckets: dict[str, list[SmartMatchResult]] = {
|
||||
"short": [],
|
||||
"medium": [],
|
||||
"long": [],
|
||||
"unknown": [],
|
||||
}
|
||||
for r in scored:
|
||||
bucket = _duration_bucket(getattr(r.asset, "duration", None))
|
||||
buckets.setdefault(bucket, []).append(r)
|
||||
|
||||
selected: list[SmartMatchResult] = []
|
||||
selected_ids: set[str] = set()
|
||||
bucket_order = ["medium", "short", "long", "unknown"] # medium 优先
|
||||
bucket_idx = {b: 0 for b in bucket_order}
|
||||
|
||||
while len(selected) < limit:
|
||||
added = False
|
||||
for b in bucket_order:
|
||||
if len(selected) >= limit:
|
||||
break
|
||||
items = buckets.get(b, [])
|
||||
idx = bucket_idx[b]
|
||||
while idx < len(items):
|
||||
candidate = items[idx]
|
||||
idx += 1
|
||||
if candidate.asset.id not in selected_ids:
|
||||
selected.append(candidate)
|
||||
selected_ids.add(candidate.asset.id)
|
||||
added = True
|
||||
break
|
||||
bucket_idx[b] = idx
|
||||
if not added:
|
||||
break
|
||||
|
||||
# 按原始得分降序输出
|
||||
selected.sort(key=lambda r: r.score, reverse=True)
|
||||
return selected
|
||||
@@ -332,7 +332,8 @@ def main():
|
||||
print(f" {line}")
|
||||
|
||||
# 提交修复
|
||||
run("git add -A")
|
||||
run("git clean -fd")
|
||||
run("git add -u")
|
||||
run('git commit -m "style: auto-format with black + isort + prettier [skip ci-format-check]"')
|
||||
|
||||
# 推送(head_branch已从ensure_git_repo获取)
|
||||
|
||||
@@ -75,7 +75,7 @@ def check_required_contexts(token, repo, sha, contexts):
|
||||
|
||||
for ctx in contexts:
|
||||
state = statuses.get(ctx, "pending")
|
||||
if state != "success":
|
||||
if state not in ("success", "skipped"):
|
||||
all_success = False
|
||||
if state == "pending":
|
||||
any_pending = True
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ci/run_staging_tests.sh
|
||||
# 在 staging 环境中运行 Playwright 测试
|
||||
# 用法: bash scripts/ci/run_staging_tests.sh <mode>
|
||||
# mode: e2e | api
|
||||
#
|
||||
# 解决 .gitea/workflows/ci-pipeline.yml 中多层引号嵌套问题:
|
||||
# - 外层 YAML → bash → docker create → 容器内 sh/bash → 字符串解析
|
||||
# - 提取为脚本后,只有两层:bash → 容器内 bash(单引号保护)
|
||||
|
||||
set -eu
|
||||
|
||||
MODE="${1:-e2e}"
|
||||
CONTAINER_NAME="staging-${MODE}-$$"
|
||||
|
||||
# 强制清理可能残留的同名容器
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
if [ "$MODE" = "e2e" ]; then
|
||||
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-e E2E_BROWSER_CHANNEL=chromium \
|
||||
-e PLAYWRIGHT_HEADLESS=1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying in 15s..."; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts'
|
||||
elif [ "$MODE" = "api" ]; then
|
||||
docker create --name "$CONTAINER_NAME" \
|
||||
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||
-w /workspace/apps/web \
|
||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying in 15s..."; sleep 15; done && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||
else
|
||||
echo "ERROR: Unknown mode '$MODE'. Use 'e2e' or 'api'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 把代码拷进容器
|
||||
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||
|
||||
# 启动并等待
|
||||
docker start -a "$CONTAINER_NAME"
|
||||
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||
|
||||
# 清理容器
|
||||
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
exit "$EXIT_CODE"
|
||||
@@ -100,7 +100,7 @@ else
|
||||
-m pytest tests/unit -q
|
||||
python3 -m coverage report --show-missing
|
||||
python3 -m coverage xml -o coverage.xml
|
||||
python3 -m coverage report --fail-under=65 > /dev/null
|
||||
python3 -m coverage report --fail-under=55 > /dev/null
|
||||
fi
|
||||
|
||||
# --- Diff 覆盖率检查(仅PR) ---
|
||||
|
||||
@@ -9,10 +9,7 @@ echo "=== 前端依赖安装开始 (模式: $MODE) ==="
|
||||
|
||||
cd apps/web
|
||||
|
||||
# 配置国内镜像源加速
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 安装依赖
|
||||
npm ci --no-audit --no-fund
|
||||
# 安装依赖(使用国内镜像加速)
|
||||
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund
|
||||
|
||||
echo "=== 前端依赖安装完成 ==="
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""四模式渲染集成测试.
|
||||
"""剪辑模式渲染集成测试.
|
||||
|
||||
验证 4 种剪辑模式(ONE_TAKE / PIP / VOICE_OVER / VOICE_PIP)通过
|
||||
验证剪辑模式(ONE_TAKE / VOICE_OVER)通过
|
||||
_build_plan_and_clips_from_task + UnifiedRenderService 的完整渲染流程。
|
||||
注:PIP / VOICE_PIP 已下线,统一映射为 ONE_TAKE。
|
||||
|
||||
需要 ffmpeg 可用;CI 无 ffmpeg 时自动跳过。
|
||||
"""
|
||||
@@ -109,14 +110,13 @@ class TestBuildPlanAndClips:
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
assert len(asset_map) == 3
|
||||
|
||||
def test_pip_mode(self):
|
||||
def test_pip_mode_maps_to_one_take(self):
|
||||
"""PIP 已下线,映射为 one_take → 全部 main clips。"""
|
||||
paths = self._make_paths(3)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t2", paths, "pip")
|
||||
|
||||
assert len(clips) == 3
|
||||
assert clips[0].clip_type == "main"
|
||||
assert clips[1].clip_type == "overlay"
|
||||
assert clips[2].clip_type == "overlay"
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
|
||||
def test_voice_over_mode(self):
|
||||
paths = self._make_paths(3)
|
||||
@@ -126,15 +126,13 @@ class TestBuildPlanAndClips:
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
assert all(c.config.get("role") == "b_roll" for c in clips)
|
||||
|
||||
def test_voice_pip_mode(self):
|
||||
def test_voice_pip_mode_maps_to_one_take(self):
|
||||
"""VOICE_PIP 已下线,映射为 one_take → 全部 main clips。"""
|
||||
paths = self._make_paths(4)
|
||||
plan, clips, asset_map = _build_plan_and_clips_from_task("t4", paths, "voice_pip")
|
||||
|
||||
assert len(clips) == 4
|
||||
assert clips[0].clip_type == "background"
|
||||
assert clips[1].clip_type == "corner_voice"
|
||||
assert clips[2].clip_type == "b_roll"
|
||||
assert clips[3].clip_type == "b_roll"
|
||||
assert all(c.clip_type == "main" for c in clips)
|
||||
|
||||
def test_unknown_mode_defaults_to_one_take(self):
|
||||
paths = self._make_paths(2)
|
||||
@@ -166,13 +164,13 @@ class TestFourModeLayerGrouping:
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"main"}
|
||||
|
||||
def test_pip_layers(self):
|
||||
"""PIP: 1 main + 2 overlay → main + overlay。"""
|
||||
def test_pip_layers_now_one_take(self):
|
||||
"""PIP 已下线 → one_take: 3 main → 1 main layer。"""
|
||||
paths = [Path(f"/tmp/pip_{i}.mp4") for i in range(3)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("pip", paths, "pip")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"main", "overlay"}
|
||||
assert roles == {"main"}
|
||||
|
||||
def test_voice_over_layers(self):
|
||||
"""VOICE_OVER: 3 main(b_roll) → broll。"""
|
||||
@@ -182,13 +180,13 @@ class TestFourModeLayerGrouping:
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"broll"}
|
||||
|
||||
def test_voice_pip_layers(self):
|
||||
"""VOICE_PIP: 1 bg + 1 corner_voice + 2 b_roll → 3 个图层。"""
|
||||
def test_voice_pip_layers_now_one_take(self):
|
||||
"""VOICE_PIP 已下线 → one_take: 4 main → main layer。"""
|
||||
paths = [Path(f"/tmp/vpip_{i}.mp4") for i in range(4)]
|
||||
_, clips, _ = _build_plan_and_clips_from_task("vpip", paths, "voice_pip")
|
||||
|
||||
roles = {_resolve_layer_role(c.clip_type, c.config) for c in clips}
|
||||
assert roles == {"background", "corner_voice", "broll"}
|
||||
assert roles == {"main"}
|
||||
|
||||
|
||||
# ── 端到端渲染测试(需要 ffmpeg) ─────────────────────────────────────────────
|
||||
|
||||
@@ -1,530 +0,0 @@
|
||||
"""asset_scoring 单元测试 - wave162
|
||||
|
||||
覆盖:
|
||||
- 分辨率评分 score_resolution
|
||||
- 时长评分 score_duration
|
||||
- 码率评分 score_bitrate
|
||||
- 加权总分 calculate_total_score
|
||||
- 单个素材评分 score_asset_detail
|
||||
- 时长分桶 _bucket_by_duration
|
||||
- 多样性选择 diverse_selection
|
||||
- 候选过滤 filter_candidates
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.asset_scoring import (
|
||||
AssetScoreDetail,
|
||||
SmartSelectResult,
|
||||
_bucket_by_duration,
|
||||
calculate_total_score,
|
||||
diverse_selection,
|
||||
filter_candidates,
|
||||
score_asset_detail,
|
||||
score_bitrate,
|
||||
score_duration,
|
||||
score_resolution,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# score_resolution
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestScoreResolution:
|
||||
def test_none_width_returns_mid(self):
|
||||
assert score_resolution(None, 1080) == 0.5
|
||||
|
||||
def test_none_height_returns_mid(self):
|
||||
assert score_resolution(1920, None) == 0.5
|
||||
|
||||
def test_zero_dimension_returns_mid(self):
|
||||
assert score_resolution(0, 1080) == 0.5
|
||||
assert score_resolution(1920, 0) == 0.5
|
||||
assert score_resolution(-1, 1080) == 0.5
|
||||
|
||||
def test_exact_target_returns_1(self):
|
||||
assert score_resolution(1920, 1080) == 1.0
|
||||
|
||||
def test_higher_than_target_returns_1(self):
|
||||
assert score_resolution(3840, 2160) == 1.0 # 4K
|
||||
assert score_resolution(2560, 1440) == 1.0 # 2K
|
||||
|
||||
def test_lower_than_target_linear_decay(self):
|
||||
# 720p = 1280*720 / 1920*1080 = 0.444 ratio
|
||||
# score = 0.3 + 0.7 * 0.444 = 0.611
|
||||
score = score_resolution(1280, 720)
|
||||
assert 0.55 < score < 0.7
|
||||
|
||||
def test_very_low_has_floor(self):
|
||||
# 最低不低于 0.1
|
||||
score = score_resolution(100, 100)
|
||||
assert score >= 0.1
|
||||
|
||||
def test_480p_still_reasonable(self):
|
||||
score = score_resolution(640, 480)
|
||||
assert 0.3 < score < 0.5
|
||||
|
||||
def test_custom_target(self):
|
||||
score = score_resolution(1280, 720, target_width=1280, target_height=720)
|
||||
assert score == 1.0
|
||||
|
||||
def test_between_0_and_1(self):
|
||||
for w, h in [(1920, 1080), (1280, 720), (640, 480), (3840, 2160)]:
|
||||
s = score_resolution(w, h)
|
||||
assert 0.0 <= s <= 1.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# score_duration
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestScoreDuration:
|
||||
def test_none_returns_mid(self):
|
||||
assert score_duration(None) == 0.5
|
||||
|
||||
def test_zero_or_negative_returns_mid(self):
|
||||
assert score_duration(0) == 0.5
|
||||
assert score_duration(-1) == 0.5
|
||||
|
||||
def test_optimal_range_returns_1(self):
|
||||
assert score_duration(3.0) == 1.0
|
||||
assert score_duration(10.0) == 1.0
|
||||
assert score_duration(30.0) == 1.0
|
||||
assert score_duration(15.0) == 1.0
|
||||
|
||||
def test_short_duration_linear_decay(self):
|
||||
# 1.5s: ratio = 1.5/3 = 0.5, score = 0.3 + 0.7*0.5 = 0.65
|
||||
score = score_duration(1.5)
|
||||
assert score == pytest.approx(0.65)
|
||||
|
||||
def test_very_short_above_floor(self):
|
||||
score = score_duration(0.1)
|
||||
assert 0.3 <= score < 0.5
|
||||
|
||||
def test_long_duration_penalty(self):
|
||||
# 40s: excess=10, penalty=10/10*0.1=0.1, score=0.9
|
||||
score = score_duration(40.0)
|
||||
assert score == pytest.approx(0.9)
|
||||
|
||||
def test_very_long_minimum_floor(self):
|
||||
# 超过很多,最低 0.2
|
||||
score = score_duration(1000.0)
|
||||
assert score >= 0.2
|
||||
assert score < 0.5
|
||||
|
||||
def test_just_below_optimal(self):
|
||||
score = score_duration(2.9)
|
||||
assert 0.9 < score < 1.0
|
||||
|
||||
def test_just_above_optimal(self):
|
||||
score = score_duration(30.1)
|
||||
assert 0.9 < score < 1.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# score_bitrate
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestScoreBitrate:
|
||||
def test_no_file_size_returns_mid(self):
|
||||
assert score_bitrate(0, 10.0) == 0.5
|
||||
|
||||
def test_no_duration_returns_mid(self):
|
||||
assert score_bitrate(1000000, None) == 0.5
|
||||
assert score_bitrate(1000000, 0) == 0.5
|
||||
assert score_bitrate(1000000, -1) == 0.5
|
||||
|
||||
def test_optimal_range_returns_1(self):
|
||||
# 5 Mbps for 10s = 5*10^6 * 10 / 8 = 6,250,000 bytes
|
||||
size_5mbps_10s = int(5_000_000 * 10 / 8)
|
||||
assert score_bitrate(size_5mbps_10s, 10.0) == 1.0
|
||||
|
||||
def test_low_bitrate_decay(self):
|
||||
# 500 Kbps for 10s
|
||||
size_500kbps = int(500_000 * 10 / 8)
|
||||
score = score_bitrate(size_500kbps, 10.0)
|
||||
assert 0.3 < score < 0.7
|
||||
|
||||
def test_high_bitrate_moderate_penalty(self):
|
||||
# 16 Mbps (2x optimal high), excess=1.0, penalty=min(0.5, 1.0*0.2)=0.2
|
||||
# score = 0.8
|
||||
size_16mbps = int(16_000_000 * 10 / 8)
|
||||
score = score_bitrate(size_16mbps, 10.0)
|
||||
assert 0.7 < score < 0.9
|
||||
|
||||
def test_very_high_bitrate_floor(self):
|
||||
# 极高码率,最低 0.5
|
||||
huge_size = 10**9 # 1GB for 1s = 8Gbps
|
||||
score = score_bitrate(huge_size, 1.0)
|
||||
assert score >= 0.5
|
||||
|
||||
def test_between_0_and_1(self):
|
||||
for size, dur in [(1000, 1), (1000000, 10), (100000000, 5)]:
|
||||
s = score_bitrate(size, dur)
|
||||
assert 0.0 <= s <= 1.0
|
||||
|
||||
|
||||
# ============================================================
|
||||
# calculate_total_score
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestCalculateTotalScore:
|
||||
def test_all_perfect_equals_1(self):
|
||||
assert calculate_total_score(1.0, 1.0, 1.0, 1.0) == 1.0
|
||||
|
||||
def test_all_zero_equals_0(self):
|
||||
assert calculate_total_score(0.0, 0.0, 0.0, 0.0) == 0.0
|
||||
|
||||
def test_weighted_sum(self):
|
||||
# 0.5*0.5 + 0.2*0.5 + 0.2*0.5 + 0.1*0.5 = 0.25+0.1+0.1+0.05 = 0.5
|
||||
assert calculate_total_score(0.5, 0.5, 0.5, 0.5) == pytest.approx(0.5)
|
||||
|
||||
def test_quality_has_highest_weight(self):
|
||||
# 只提高质量分,对比只提高其他
|
||||
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
|
||||
r_high = calculate_total_score(0.0, 1.0, 0.0, 0.0)
|
||||
assert q_high > r_high # 0.5 > 0.2
|
||||
|
||||
def test_bitrate_has_lowest_weight(self):
|
||||
b_high = calculate_total_score(0.0, 0.0, 0.0, 1.0)
|
||||
q_high = calculate_total_score(1.0, 0.0, 0.0, 0.0)
|
||||
assert b_high < q_high # 0.1 < 0.5
|
||||
|
||||
def test_rounded_to_4_decimals(self):
|
||||
result = calculate_total_score(0.3333, 0.3333, 0.3333, 0.3333)
|
||||
assert round(result, 4) == result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# score_asset_detail
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestScoreAssetDetail:
|
||||
def test_returns_detail_object(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=80.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
assert isinstance(detail, AssetScoreDetail)
|
||||
assert detail.asset_id == "a1"
|
||||
assert 0.0 <= detail.total_score <= 1.0
|
||||
|
||||
def test_perfect_asset_high_score(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="perfect",
|
||||
quality=100.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=6_250_000, # 5Mbps for 10s
|
||||
)
|
||||
assert detail.total_score > 0.9
|
||||
|
||||
def test_quality_none_defaults_mid(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=None,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
assert detail.quality_score == 0.5
|
||||
|
||||
def test_quality_normalized(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=50.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
assert detail.quality_score == pytest.approx(0.5)
|
||||
|
||||
def test_custom_target_resolution(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=100.0,
|
||||
width=1280,
|
||||
height=720,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
target_width=1280,
|
||||
target_height=720,
|
||||
)
|
||||
assert detail.resolution_score == 1.0
|
||||
|
||||
def test_total_score_matches_components(self):
|
||||
detail = score_asset_detail(
|
||||
asset_id="a1",
|
||||
quality=80.0,
|
||||
width=1920,
|
||||
height=1080,
|
||||
duration=10.0,
|
||||
file_size=5_000_000,
|
||||
)
|
||||
expected = calculate_total_score(
|
||||
detail.quality_score,
|
||||
detail.resolution_score,
|
||||
detail.duration_score,
|
||||
detail.bitrate_score,
|
||||
)
|
||||
assert detail.total_score == pytest.approx(expected, abs=0.001)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _bucket_by_duration
|
||||
# ============================================================
|
||||
|
||||
|
||||
class TestBucketByDuration:
|
||||
def test_none_is_unknown(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, None)
|
||||
assert _bucket_by_duration(item) == "unknown"
|
||||
|
||||
def test_short(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 3.0)
|
||||
assert _bucket_by_duration(item) == "short"
|
||||
|
||||
def test_short_boundary(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 4.9)
|
||||
assert _bucket_by_duration(item) == "short"
|
||||
|
||||
def test_medium(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 10.0)
|
||||
assert _bucket_by_duration(item) == "medium"
|
||||
|
||||
def test_medium_boundary(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 5.0)
|
||||
assert _bucket_by_duration(item) == "medium"
|
||||
|
||||
def test_medium_upper_boundary(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 14.9)
|
||||
assert _bucket_by_duration(item) == "medium"
|
||||
|
||||
def test_long(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 20.0)
|
||||
assert _bucket_by_duration(item) == "long"
|
||||
|
||||
def test_long_boundary(self):
|
||||
item = AssetScoreDetail("a", 0.5, 0.5, 0.5, 0.5, 0.5, 15.0)
|
||||
assert _bucket_by_duration(item) == "long"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# diverse_selection
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _make_detail(asset_id: str, score: float, duration: float) -> AssetScoreDetail:
|
||||
return AssetScoreDetail(
|
||||
asset_id=asset_id,
|
||||
total_score=score,
|
||||
quality_score=score,
|
||||
resolution_score=score,
|
||||
duration_score=score,
|
||||
bitrate_score=score,
|
||||
duration=duration,
|
||||
)
|
||||
|
||||
|
||||
class TestDiverseSelection:
|
||||
def test_empty_input_returns_empty(self):
|
||||
assert diverse_selection([], 5) == []
|
||||
|
||||
def test_zero_count_returns_empty(self):
|
||||
items = [_make_detail("a1", 0.9, 10.0)]
|
||||
assert diverse_selection(items, 0) == []
|
||||
|
||||
def test_negative_count_returns_empty(self):
|
||||
items = [_make_detail("a1", 0.9, 10.0)]
|
||||
assert diverse_selection(items, -1) == []
|
||||
|
||||
def test_fewer_items_than_count(self):
|
||||
items = [_make_detail("a1", 0.9, 10.0), _make_detail("a2", 0.8, 3.0)]
|
||||
result = diverse_selection(items, 10)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_picks_top_from_each_bucket(self):
|
||||
# 3个桶各有3个素材,选3个
|
||||
items = [
|
||||
_make_detail("s1", 0.95, 2.0),
|
||||
_make_detail("m1", 0.9, 10.0),
|
||||
_make_detail("l1", 0.85, 20.0),
|
||||
_make_detail("s2", 0.8, 3.0),
|
||||
_make_detail("m2", 0.75, 8.0),
|
||||
_make_detail("l2", 0.7, 25.0),
|
||||
]
|
||||
result = diverse_selection(items, 3)
|
||||
assert len(result) == 3
|
||||
ids = [d.asset_id for d in result]
|
||||
assert "s1" in ids
|
||||
assert "m1" in ids
|
||||
assert "l1" in ids
|
||||
|
||||
def test_base_quota_when_count_large(self):
|
||||
# count=6, base_quota=max(1, 6//3)=2
|
||||
items = [
|
||||
_make_detail("s1", 1.0, 2.0),
|
||||
_make_detail("s2", 0.9, 3.0),
|
||||
_make_detail("s3", 0.8, 4.0),
|
||||
_make_detail("m1", 0.95, 10.0),
|
||||
_make_detail("m2", 0.85, 12.0),
|
||||
_make_detail("l1", 0.92, 20.0),
|
||||
_make_detail("l2", 0.82, 30.0),
|
||||
]
|
||||
result = diverse_selection(items, 6)
|
||||
assert len(result) == 6
|
||||
ids = [d.asset_id for d in result]
|
||||
# 每桶至少2个
|
||||
short_count = sum(1 for d in result if d.duration and d.duration < 5)
|
||||
assert short_count >= 2
|
||||
|
||||
def test_remaining_filled_by_global_score(self):
|
||||
# 只有2个桶有内容,count=5,配额用完后剩余从全局取
|
||||
items = [
|
||||
_make_detail("s1", 1.0, 2.0),
|
||||
_make_detail("s2", 0.9, 3.0),
|
||||
_make_detail("m1", 0.95, 10.0),
|
||||
_make_detail("m2", 0.8, 12.0),
|
||||
_make_detail("s3", 0.7, 4.0),
|
||||
_make_detail("s4", 0.6, 1.0),
|
||||
_make_detail("m3", 0.5, 8.0),
|
||||
]
|
||||
result = diverse_selection(items, 5)
|
||||
assert len(result) == 5
|
||||
# 最高分的都应该在
|
||||
ids = [d.asset_id for d in result]
|
||||
assert "s1" in ids
|
||||
assert "m1" in ids
|
||||
|
||||
def test_single_bucket(self):
|
||||
items = [
|
||||
_make_detail("s1", 1.0, 2.0),
|
||||
_make_detail("s2", 0.9, 3.0),
|
||||
_make_detail("s3", 0.8, 4.0),
|
||||
]
|
||||
result = diverse_selection(items, 2)
|
||||
assert len(result) == 2
|
||||
assert result[0].asset_id == "s1"
|
||||
assert result[1].asset_id == "s2"
|
||||
|
||||
def test_unknown_duration_fallback(self):
|
||||
# 已知素材不够时用未知时长的补充
|
||||
items = [
|
||||
_make_detail("s1", 1.0, 2.0),
|
||||
_make_detail("u1", 0.95, None),
|
||||
_make_detail("u2", 0.9, None),
|
||||
]
|
||||
result = diverse_selection(items, 3)
|
||||
assert len(result) == 3
|
||||
ids = [d.asset_id for d in result]
|
||||
assert "s1" in ids
|
||||
assert "u1" in ids
|
||||
|
||||
def test_no_duplicates(self):
|
||||
items = [
|
||||
_make_detail("s1", 1.0, 2.0),
|
||||
_make_detail("m1", 0.9, 10.0),
|
||||
]
|
||||
result = diverse_selection(items, 5)
|
||||
ids = [d.asset_id for d in result]
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# filter_candidates
|
||||
# ============================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeAsset:
|
||||
status: str = "ready"
|
||||
mime_type: str = "video/mp4"
|
||||
quality_score: float | None = 50.0
|
||||
|
||||
|
||||
class TestFilterCandidates:
|
||||
def test_ready_video_passes(self):
|
||||
assets = [FakeAsset()]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
assert filtered == 0
|
||||
|
||||
def test_non_ready_filtered(self):
|
||||
assets = [FakeAsset(status="uploading"), FakeAsset(status="processing")]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 0
|
||||
assert filtered == 0 # 被状态过滤的不计入质量门槛
|
||||
|
||||
def test_non_video_filtered(self):
|
||||
assets = [FakeAsset(mime_type="image/jpeg"), FakeAsset(mime_type="audio/mp3")]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_low_quality_filtered(self):
|
||||
assets = [FakeAsset(quality_score=10.0), FakeAsset(quality_score=80.0)]
|
||||
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||||
assert len(candidates) == 1
|
||||
assert filtered == 1
|
||||
|
||||
def test_quality_none_passes(self):
|
||||
assets = [FakeAsset(quality_score=None)]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
assert filtered == 0
|
||||
|
||||
def test_exactly_min_quality_passes(self):
|
||||
assets = [FakeAsset(quality_score=30.0)]
|
||||
candidates, filtered = filter_candidates(assets, min_quality_score=30.0)
|
||||
assert len(candidates) == 1
|
||||
|
||||
def test_custom_min_quality(self):
|
||||
assets = [
|
||||
FakeAsset(quality_score=40.0),
|
||||
FakeAsset(quality_score=60.0),
|
||||
FakeAsset(quality_score=80.0),
|
||||
]
|
||||
candidates, filtered = filter_candidates(assets, min_quality_score=50.0)
|
||||
assert len(candidates) == 2
|
||||
assert filtered == 1
|
||||
|
||||
def test_empty_input(self):
|
||||
candidates, filtered = filter_candidates([])
|
||||
assert candidates == []
|
||||
assert filtered == 0
|
||||
|
||||
def test_mime_type_none(self):
|
||||
# None 的 mime_type 也应该被过滤掉(不是video开头)
|
||||
asset = FakeAsset(mime_type="")
|
||||
candidates, _ = filter_candidates([asset])
|
||||
assert len(candidates) == 0
|
||||
|
||||
def test_with_enum_status(self):
|
||||
from enum import Enum
|
||||
|
||||
class StatusEnum(Enum):
|
||||
READY = "ready"
|
||||
UPLOADING = "uploading"
|
||||
|
||||
@dataclass
|
||||
class EnumAsset:
|
||||
status: StatusEnum = StatusEnum.READY
|
||||
mime_type: str = "video/mp4"
|
||||
quality_score: float = 50.0
|
||||
|
||||
assets = [EnumAsset()]
|
||||
candidates, filtered = filter_candidates(assets)
|
||||
assert len(candidates) == 1
|
||||
@@ -354,13 +354,13 @@ class TestQuotaRegistry:
|
||||
assert len(reg.list_dimensions()) == len(QuotaDimension)
|
||||
|
||||
def test_list_tiers(self):
|
||||
"""三个套餐等级."""
|
||||
"""四个套餐等级."""
|
||||
reg = QuotaRegistry()
|
||||
tiers = reg.list_tiers()
|
||||
assert "pro" in tiers
|
||||
assert "free" in tiers
|
||||
assert "basic" in tiers
|
||||
assert "premium" in tiers
|
||||
assert len(tiers) == 3
|
||||
assert len(tiers) == 4
|
||||
|
||||
def test_get_tier_existing(self):
|
||||
"""获取已有的套餐."""
|
||||
@@ -370,7 +370,7 @@ class TestQuotaRegistry:
|
||||
assert tier.name == "free"
|
||||
|
||||
def test_get_tier_nonexistent(self):
|
||||
"""获取不存在的套餐返回 None."""
|
||||
"""不存在的套餐返回 None"""
|
||||
reg = QuotaRegistry()
|
||||
assert reg.get_tier("enterprise") is None
|
||||
|
||||
@@ -380,7 +380,7 @@ class TestQuotaRegistry:
|
||||
assert reg.get_limit("free", QuotaDimension.STORAGE_GB) == 2
|
||||
|
||||
def test_get_limit_nonexistent_plan(self):
|
||||
"""不存在的套餐返回 0."""
|
||||
"""不存在的套餐 fallback 到 free 配额"""
|
||||
reg = QuotaRegistry()
|
||||
assert reg.get_limit("enterprise", QuotaDimension.STORAGE_GB) == 0
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user