Compare commits

..

1 Commits

Author SHA1 Message Date
xiaoxia 862340ace5 fix(ci): 修复CI Gate失败时返回exit 0而非exit 1的P0 Bug
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 37s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m13s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m29s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m39s
CI/CD Pipeline / Validate - Code Quality (pull_request) Failing after 2m44s
AI Code Review / AI Code Review (pull_request) Successful in 1m17s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m31s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 5m19s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 7m24s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Failing after 4m18s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m3s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 46m54s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 5s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 37s
2026-07-28 10:13:32 +08:00
740 changed files with 26023 additions and 104551 deletions
-84
View File
@@ -1,84 +0,0 @@
name: API Base Image Build
on:
push:
branches:
- develop
- main
paths:
- 'requirements-base.txt'
- 'requirements.txt'
- 'infra/docker/api-base.Dockerfile'
workflow_dispatch:
jobs:
build-api-base:
name: Build API Base Image
runs-on: runtime-builder
timeout-minutes: 45
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Registry
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
GITEA_REGISTRY_USER: xiaoxia
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -eu
for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful"
break
fi
echo "❌ Docker login 失败(尝试 $i/3),5s 后重试..."
sleep 5
done
- name: Build and push API base image
shell: sh
run: |
set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-api-base:latest"
echo "=== Building API base image ==="
# 使用普通 docker build(单平台不需要 buildx
docker build \
-f infra/docker/api-base.Dockerfile \
-t "${ACR_IMAGE}" \
.
echo ""
echo "✅ Image built successfully"
# 推送到 ACR
echo "=== Pushing to ACR ==="
docker push "${ACR_IMAGE}"
echo "✅ Pushed to ACR"
# 打标签并推送到 Gitea Packages 作为备份
echo "=== Pushing to Gitea Packages ==="
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
echo "✅ Gitea backup push completed"
- name: Cleanup
if: always()
shell: sh
run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-api-base:latest"
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
echo "Cleanup done"
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -2,13 +2,13 @@ name: CI Trigger Monitor
on: on:
schedule: schedule:
- cron: '*/10 * * * *' # 每10分钟检查一次(与pr-auto-scan同步降频) - cron: '*/5 * * * *' # 每5分钟检查一次
workflow_dispatch: workflow_dispatch:
inputs: inputs:
stale_threshold: stale_threshold:
description: 'CI未触发告警阈值(分钟)' description: 'CI未触发告警阈值(分钟)'
required: false required: false
default: '10' default: '5'
permissions: permissions:
contents: read contents: read
+2 -2
View File
@@ -87,7 +87,7 @@ jobs:
runs-on: ci-l2 runs-on: ci-l2
timeout-minutes: 10 timeout-minutes: 10
outputs: outputs:
report: ${{ steps.report.outputs.report }} report: ${{ steps.smoke.outputs.report }}
steps: steps:
- name: Checkout code - name: Checkout code
@@ -201,7 +201,7 @@ jobs:
runs-on: ci-l2 runs-on: ci-l2
timeout-minutes: 15 timeout-minutes: 15
outputs: outputs:
report: ${{ steps.e2e.outputs.report }} report: ${{ steps.smoke.outputs.report }}
steps: steps:
- name: Checkout code - name: Checkout code
+1 -1
View File
@@ -3,7 +3,7 @@ name: PR Auto Scan
# 作为短作业模式的兜底,防止事件驱动遗漏 # 作为短作业模式的兜底,防止事件驱动遗漏
on: on:
schedule: schedule:
- cron: "*/10 * * * *" # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负) - cron: "*/5 * * * *" # 每5分钟扫描一次
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+1 -6
View File
@@ -8,11 +8,6 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: pr-automation-${{ gitea.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
auto-approve: auto-approve:
name: Auto Approve on CI Green name: Auto Approve on CI Green
@@ -61,7 +56,7 @@ jobs:
name: Auto Merge on CI Green + Approved name: Auto Merge on CI Green + Approved
runs-on: ci-check runs-on: ci-check
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop' if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底 timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
steps: steps:
- name: Checkout code - name: Checkout code
shell: sh shell: sh
+1 -1
View File
@@ -120,7 +120,7 @@ jobs:
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }} PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
run: | run: |
set -eux set -eux
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}" preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
preview_user="${PREVIEW_SSH_USER:-deploy}" preview_user="${PREVIEW_SSH_USER:-deploy}"
preview_port="${PREVIEW_SSH_PORT:-22222}" preview_port="${PREVIEW_SSH_PORT:-22222}"
preview_dir="/var/www/preview/pr-${PR_NUMBER}" preview_dir="/var/www/preview/pr-${PR_NUMBER}"
+7 -4
View File
@@ -95,10 +95,13 @@ jobs:
set -eu set -eu
cd apps/web cd apps/web
# Config npm mirror for speed
npm config set registry https://registry.npmmirror.com
# Install dependencies with retry # Install dependencies with retry
for i in 1 2 3; do for i in 1 2 3; do
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break npm ci --no-audit --no-fund && break
echo "npm install failed, retry $i/3..." echo "npm ci failed, retry $i/3..."
[ $i -eq 3 ] && exit 1 [ $i -eq 3 ] && exit 1
rm -rf node_modules rm -rf node_modules
sleep 5 sleep 5
@@ -106,12 +109,12 @@ jobs:
# TypeScript check # TypeScript check
echo "=== TypeScript check ===" echo "=== TypeScript check ==="
./node_modules/.bin/tsc --noEmit npx --no-install tsc --noEmit
# Vite build # Vite build
echo "=== Vite build ===" echo "=== Vite build ==="
export VITE_API_URL=https://staging-api.xiaoxiajianji.com export VITE_API_URL=https://staging-api.xiaoxiajianji.com
./node_modules/.bin/vite build npx --no-install vite build
echo "=== Build completed ===" echo "=== Build completed ==="
ls -la dist/ ls -la dist/
+55 -38
View File
@@ -7,25 +7,35 @@ on:
- main - main
paths: paths:
- 'requirements-base.txt' - 'requirements-base.txt'
- 'requirements.txt'
- 'requirements-worker.txt' - 'requirements-worker.txt'
- 'infra/docker/worker-base.Dockerfile' - 'infra/docker/worker-base-builder.Dockerfile'
workflow_dispatch: - 'infra/docker/worker-base-runtime.Dockerfile'
workflow_dispatch: # 支持手动触发
jobs: jobs:
build-worker-base: build-worker-base:
name: Build Worker Base Image name: Build Worker Base Images
runs-on: runtime-builder runs-on: runtime-builder
timeout-minutes: 45 timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- name: builder
dockerfile: infra/docker/worker-base-builder.Dockerfile
image_name: worker-base-builder
cache_name: worker-base-builder-cache
- name: runtime
dockerfile: infra/docker/worker-base-runtime.Dockerfile
image_name: worker-base-runtime
cache_name: worker-base-runtime-cache
steps: steps:
- name: Checkout code - name: Checkout code
shell: sh shell: sh
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
run: | run: |
curl -sH "Authorization: token $GITHUB_TOKEN" \ curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
| bash
- name: Docker login to Registry - name: Docker login to Registry
shell: sh shell: sh
@@ -38,8 +48,7 @@ jobs:
set -eu set -eu
for i in 1 2 3; do for i in 1 2 3; do
echo "=== Docker login 尝试 $i/3 ===" echo "=== Docker login 尝试 $i/3 ==="
if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin \ if printf '%s' "${ACR_PASSWORD}" | docker login xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com -u "${ACR_USERNAME}" --password-stdin && docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
&& docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" -p "${GITEA_REGISTRY_TOKEN}"; then
echo "✅ Docker login successful" echo "✅ Docker login successful"
break break
fi fi
@@ -47,40 +56,48 @@ jobs:
sleep 5 sleep 5
done done
- name: Build and push Worker base image - name: Setup buildx builder
shell: sh shell: sh
run: | run: |
set -eu set -eu
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest" BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-worker-base:latest" if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
echo "=== Building Worker base image ===" echo "Created $BUILDER_NAME"
else
# 使用普通 docker build(单平台不需要 buildx docker buildx use "$BUILDER_NAME"
docker build \ echo "Using existing $BUILDER_NAME"
-f infra/docker/worker-base.Dockerfile \ fi
-t "${ACR_IMAGE}" \ docker buildx inspect --bootstrap
.
- name: Build and push base image
shell: sh
run: |
set -eu
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:latest"
SAFE_REF_NAME=$(echo "${GITHUB_REF_NAME}" | tr '/' '-')
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:${SAFE_REF_NAME}"
echo "=== Building ${{ matrix.name }} base image ==="
echo "Image: ${IMAGE_TAG}"
echo "Cache: ${CACHE_REF}"
# 用通用构建脚本
bash scripts/ci/docker_build_push.sh ${{ matrix.dockerfile }} "${IMAGE_TAG}" "${CACHE_REF}"
# 同时推送到 Gitea Packages 作为备份(可选)
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/${{ matrix.image_name }}:latest"
docker tag "${IMAGE_TAG}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "Gitea Packages push failed (non-fatal)"
echo "" echo ""
echo "✅ Image built successfully" echo "✅ ${{ matrix.name }} base image built and pushed"
# 推送到 ACR - name: Cleanup buildx builder
echo "=== Pushing to ACR ==="
docker push "${ACR_IMAGE}"
echo "✅ Pushed to ACR"
# 打标签并推送到 Gitea Packages 作为备份
echo "=== Pushing to Gitea Packages ==="
docker tag "${ACR_IMAGE}" "${GITEA_IMAGE}"
docker push "${GITEA_IMAGE}" || echo "⚠️ Gitea Packages push failed (non-fatal)"
echo "✅ Gitea backup push completed"
- name: Cleanup
if: always() if: always()
shell: sh shell: sh
run: | run: |
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest" docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
docker rmi "${ACR_IMAGE}" 2>/dev/null || true docker buildx prune -f 2>/dev/null || true
docker image prune -f 2>/dev/null || true echo "Builder cleanup done"
echo "Cleanup done"
-222
View File
@@ -1,222 +0,0 @@
---
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 1MVP2天):**
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 生成,请遵循相关法律法规及《人工智能生成合成内容标识办法》使用与传播。
-382
View File
@@ -1,382 +0,0 @@
# #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 个 GenerationTaskis_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:单版本预览(MVP2 天)
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 天(后端)
@@ -1,61 +0,0 @@
"""#1197 - 预览生成:generation_tasks 表新增 is_preview 字段
Revision ID: 053
Revises: 052
Create Date: 2026-08-15
Changes:
1. generation_tasks 表新增 is_preview 字段,标记是否为预览生成任务(低清 480p)
2. 默认 False,与现有正式生成任务兼容
3. 加索引以支持按预览/正式任务筛选
"""
import sqlalchemy as sa
from alembic import context, op
revision = "053_generation_task_is_preview"
down_revision = "052_generation_task_bgm_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
# 检查列是否已存在(幂等)
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
)
)
if result.scalar() is not None:
return
op.add_column(
"generation_tasks",
sa.Column("is_preview", sa.Boolean, nullable=False, server_default=sa.text("false")),
)
# 加索引
op.create_index(
"ix_generation_tasks_is_preview",
"generation_tasks",
["is_preview"],
)
def downgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
result = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
)
)
if result.scalar() is None:
return
op.drop_index("ix_generation_tasks_is_preview", table_name="generation_tasks")
op.drop_column("generation_tasks", "is_preview")
@@ -1,82 +0,0 @@
"""确认生成 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")
-82
View File
@@ -1,82 +0,0 @@
"""封面模板表 cover_templates
Revision ID: 055_cover_templates
Revises: 054_confirm_gen_fields
Create Date: 2026-08-09
Changes:
1. 新建 cover_templates 表,支持系统预置和用户自定义封面模板
2. user_id 为 NULL 表示系统模板,is_system 标记区分
3. config 为 JSON 字段,存储封面配置信息
"""
import sqlalchemy as sa
from alembic import context, op
revision = "055_cover_templates"
down_revision = "054_confirm_gen_fields"
branch_labels = None
depends_on = None
SYSTEM_TEMPLATES = [
("a8b0120fd98e44788f5a6590f983d327", "默认模板", {}),
("6d8c501b11424432b3df3a45ae89b1a9", "大胆红", {"background_color": "#ef4444"}),
("04937fb57fea4bad95e7883e71a6b246", "优雅黑", {"background_color": "#111827"}),
("3ff9cc821174437ca53931073e7f536e", "渐变蓝", {"background_color": "#3b82f6"}),
("db51b3ea8f1a4f4caa94bf2d51f27d11", "渐变紫", {"background_color": "#8b5cf6"}),
("5027d113432a4f798a3b4ee1644d66af", "暖橙", {"background_color": "#f97316"}),
("0e10def2b5a148d686416494474726c2", "清新绿", {"background_color": "#22c55e"}),
("38ea98ac00c04bada064006d880546f0", "科技蓝", {"background_color": "#06b6d4"}),
]
def upgrade() -> None:
conn = op.get_bind()
if context.get_context().dialect.name == "postgresql":
result = conn.execute(sa.text("SELECT to_regclass('public.cover_templates')"))
if result.scalar() is not None:
return
op.create_table(
"cover_templates",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("user_id", sa.String(36), nullable=True, index=True),
sa.Column("name", sa.String(200), nullable=False),
sa.Column("thumbnail_url", sa.String(1000), nullable=False, server_default=""),
sa.Column("is_system", sa.Boolean, nullable=False, server_default=sa.false(), index=True),
sa.Column("config", sa.JSON, nullable=False, server_default="{}"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# 预置系统模板 seed 数据
cover_templates = sa.table(
"cover_templates",
sa.column("id", sa.String),
sa.column("user_id", sa.String),
sa.column("name", sa.String),
sa.column("thumbnail_url", sa.String),
sa.column("is_system", sa.Boolean),
sa.column("config", sa.JSON),
sa.column("created_at", sa.DateTime),
sa.column("updated_at", sa.DateTime),
)
for tid, name, config in SYSTEM_TEMPLATES:
conn.execute(
cover_templates.insert().values(
id=tid,
user_id=None,
name=name,
thumbnail_url="",
is_system=True,
config=config,
created_at=sa.func.now(),
updated_at=sa.func.now(),
)
)
def downgrade() -> None:
op.drop_table("cover_templates")
@@ -1,39 +0,0 @@
"""修复 cover_templates.config 双重序列化
Revision ID: 056_fix_cover_templates_config
Revises: 055_cover_templates
Create Date: 2026-08-13
问题: 055 迁移 seed 数据时 json.dumps(config) 导致 config 被双重序列化为 JSON 字符串
例如 "{}"(字符串)而不是 {}(对象),导致 Pydantic CoverTemplateResponse 校验失败 500。
修复: 从 JSON 字符串中提取文本值,再 cast 回 json 对象类型。
"""
import sqlalchemy as sa
from alembic import op
revision = "056_fix_cover_templates_config"
down_revision = "055_cover_templates"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# PostgreSQL: 从 JSON string scalar 中提取文本内容,cast 为 json object
# 例如: JSON string "{}" -> text "{}" -> JSON object {}
if conn.dialect.name == "postgresql":
conn.execute(
sa.text(
"UPDATE cover_templates SET config = (config#>>'{}')::json "
"WHERE jsonb_typeof(config::jsonb) = 'string'"
)
)
def downgrade() -> None:
# No safe rollback — the original data was incorrect
pass
@@ -1,26 +0,0 @@
"""Add title_config to generation_tasks
Revision ID: 057_title_config
Revises: 056_fix_cover_templates_config
Create Date: 2026-08-23
"""
import sqlalchemy as sa
from alembic import op
revision = "057_title_config"
down_revision = "056_fix_cover_templates_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "title_config")
@@ -1,49 +0,0 @@
"""Add unique index on asset_libraries(project_id, kind)
Revision ID: 058_uq_asset_lib_project_kind
Revises: 057_title_config
Create Date: 2026-08-30
同一项目下同 kind 的素材库业务上唯一(前端 getOrCreate 语义、TTS 保存自动建库)。
加唯一索引兜底并发创建竞态,避免重复素材库。
"""
import sqlalchemy as sa
from alembic import op
revision = "058_uq_asset_lib_project_kind"
down_revision = "057_title_config"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 建唯一索引前清洗历史重复:同 (project_id, kind) 只保留 created_at 最新的一条。
# project_id 为 NULL 的系统级行不参与去重(NULL 在唯一索引中互不冲突)。
op.execute("""
DELETE FROM asset_libraries
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY project_id, kind
ORDER BY created_at DESC, id DESC
) AS rn
FROM asset_libraries
WHERE project_id IS NOT NULL
) t
WHERE t.rn > 1
)
""")
# 与 model 的 UniqueConstraint 定义保持一致(pg_constraint + pg_index 同时注册),
# 避免 Alembic autogenerate 检测到 schema drift
op.create_unique_constraint(
"uq_asset_libraries_project_kind",
"asset_libraries",
["project_id", "kind"],
)
def downgrade() -> None:
op.drop_constraint("uq_asset_libraries_project_kind", "asset_libraries", type_="unique")
@@ -1,23 +0,0 @@
"""add duplicate_rate to generated_videos
Revision ID: 059_duplicate_rate
Revises: 058_uq_asset_lib_project_kind
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "059_duplicate_rate"
down_revision = "058_uq_asset_lib_project_kind"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("generated_videos", sa.Column("duplicate_rate", sa.Float(), nullable=True))
def downgrade() -> None:
op.drop_column("generated_videos", "duplicate_rate")
@@ -1,57 +0,0 @@
"""migrate template_segments data to template_clip_configs
Revision ID: 060_migrate_segments
Revises: 059_duplicate_rate
Create Date: 2026-08-31
"""
import sqlalchemy as sa
from alembic import op
revision = "060_migrate_segments"
down_revision = "059_duplicate_rate"
branch_labels = None
depends_on = None
def upgrade() -> None:
dialect = op.get_bind().dialect.name
if dialect == "postgresql":
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN json_build_object('material_type', s.material_type)::jsonb "
"ELSE '{}'::jsonb END"
)
empty_json = "'{}'::jsonb"
else:
config_expr = (
"CASE WHEN s.material_type IS NOT NULL AND s.material_type != '' "
"THEN JSON_OBJECT('material_type', s.material_type) "
"ELSE '{}' END"
)
empty_json = "'{}'"
sql_str = (
"INSERT INTO template_clip_configs "
'(id, template_id, clip_type, "order", min_duration, max_duration, '
"text_template, material_requirements, transition_effect, config, "
"created_at, updated_at) "
"SELECT "
"s.id, s.template_id, 'main', s.segment_order, "
"s.duration_min, s.duration_max, "
"'', " + empty_json + ", "
"'cut', " + config_expr + ", "
"s.created_at, s.updated_at "
"FROM template_segments s "
"WHERE NOT EXISTS ("
" SELECT 1 FROM template_clip_configs c "
" WHERE c.template_id = s.template_id"
")"
)
op.execute(sa.text(sql_str))
def downgrade() -> None:
pass
-17
View File
@@ -5,11 +5,8 @@ from app.api.routes.assets import router as assets_router
from app.api.routes.auth import router as auth_router from app.api.routes.auth import router as auth_router
from app.api.routes.chunked_upload import router as chunked_upload_router from app.api.routes.chunked_upload import router as chunked_upload_router
from app.api.routes.classification_jobs import router as classification_jobs_router from app.api.routes.classification_jobs import router as classification_jobs_router
from app.api.routes.cover_templates import router as cover_templates_router
from app.api.routes.duplication import router as duplication_router from app.api.routes.duplication import router as duplication_router
from app.api.routes.feature_flags import router as feature_flags_router from app.api.routes.feature_flags import router as feature_flags_router
from app.api.routes.generation_cover import router as generation_cover_router
from app.api.routes.generation_preview import router as generation_preview_router
from app.api.routes.generation_tasks import router as generation_tasks_router from app.api.routes.generation_tasks import router as generation_tasks_router
from app.api.routes.health import router as health_check_router from app.api.routes.health import router as health_check_router
from app.api.routes.ingest_jobs import router as ingest_jobs_router from app.api.routes.ingest_jobs import router as ingest_jobs_router
@@ -47,10 +44,6 @@ api_router.include_router(
prefix="/tags", prefix="/tags",
tags=["Tag"], tags=["Tag"],
) )
api_router.include_router(
cover_templates_router,
tags=["CoverTemplate"],
)
api_router.include_router( api_router.include_router(
task_center_router, task_center_router,
tags=["TaskCenter"], tags=["TaskCenter"],
@@ -94,16 +87,6 @@ api_router.include_router(
prefix="/generation", prefix="/generation",
tags=["Generation"], tags=["Generation"],
) )
api_router.include_router(
generation_preview_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router(
generation_cover_router,
prefix="/generation",
tags=["Generation"],
)
api_router.include_router( api_router.include_router(
titles_router, titles_router,
prefix="/titles", prefix="/titles",
+50 -154
View File
@@ -1,5 +1,5 @@
import logging import logging
from typing import Any, List, Optional from typing import Any, Optional
from app.api.routes._helpers import check_project_access, format_utc_datetime from app.api.routes._helpers import check_project_access, format_utc_datetime
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
@@ -14,45 +14,28 @@ from app.schemas.asset import (
AssetResponse, AssetResponse,
BatchClassifyRequest, BatchClassifyRequest,
BatchDeleteRequest, BatchDeleteRequest,
BatchGetRequest,
BatchMarkRequest, BatchMarkRequest,
BatchOperationResponse, BatchOperationResponse,
BatchTagRequest, BatchTagRequest,
CreateAssetRequest,
ListAssetsResponse, ListAssetsResponse,
SmartMatchItem,
SmartMatchRequest,
SmartMatchResponse,
UpdateAssetRequest, UpdateAssetRequest,
UpdateAssetReviewRequest, UpdateAssetReviewRequest,
) )
from app.schemas.tag import TagAssetsRequest from app.schemas.tag import TagAssetsRequest
from app.services.asset_segment_tracker import compute_asset_availability, get_asset_recent_use_counts
from fastapi import APIRouter, Depends, HTTPException, Query, Response from fastapi import APIRouter, Depends, HTTPException, Query, Response
from packages.domain.smart_match import smart_select_assets from packages.application import (
CreateAssetCommand,
CreateAssetUseCase,
)
from packages.domain import AssetStatus, ClassificationStatus
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _asset_availability_fields(item) -> dict:
"""视频素材返回余量四字段;非视频/无时长/异常时返回 None + usable=True(零影响)。"""
try:
info = compute_asset_availability(item)
except Exception:
logger.warning("计算素材余量失败,按可用处理: asset_id=%s", getattr(item, "id", "?"), exc_info=True)
info = None
if info is None:
return {
"used_duration": None,
"available_duration": None,
"used_ratio": None,
"usable": True,
}
return info
def _to_asset_response(item, storage_service=None) -> AssetResponse: def _to_asset_response(item, storage_service=None) -> AssetResponse:
# 生成签名文件 URL(用于视频播放 / 文件下载) # 生成签名文件 URL(用于视频播放 / 文件下载)
file_url = None file_url = None
@@ -64,16 +47,10 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True) logger.warning("生成签名URL失败: storage_key=%s", item.storage_key, exc_info=True)
file_url = None file_url = None
# 缩略图:存储的是 storage_key,需要生成签名 URL 供前端使用 # 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL
# 不再降级使用视频文件 URL(浏览器 <img> 无法渲染 .mp4,会显示黑屏) thumbnail_url = item.thumbnail_url
thumbnail_url = None if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url:
if item.thumbnail_url: thumbnail_url = file_url
try:
svc = storage_service or get_storage_service()
thumbnail_url = svc.get_download_url(item.thumbnail_url)
except Exception:
logger.warning("生成缩略图签名URL失败: key=%s", item.thumbnail_url, exc_info=True)
thumbnail_url = None
return AssetResponse( return AssetResponse(
id=item.id, id=item.id,
@@ -97,7 +74,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
created_at=format_utc_datetime(item.created_at), created_at=format_utc_datetime(item.created_at),
uploaded_by_user_id=item.uploaded_by_user_id, uploaded_by_user_id=item.uploaded_by_user_id,
tag_ids=getattr(item, "tag_ids", []), tag_ids=getattr(item, "tag_ids", []),
**_asset_availability_fields(item),
) )
@@ -390,18 +366,6 @@ def update_asset_review_status(
return _to_asset_response(updated) return _to_asset_response(updated)
@router.post("/batch", response_model=List[AssetResponse])
def batch_get_assets(
request: BatchGetRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
asset_repository: Any = Depends(get_asset_repository),
) -> list[AssetResponse]:
"""批量获取素材详情(根据 ID 列表)。"""
items = asset_repository.find_by_ids(request.ids)
storage_service = get_storage_service()
return [_to_asset_response(item, storage_service) for item in items]
@router.post("/batch-delete", response_model=BatchOperationResponse) @router.post("/batch-delete", response_model=BatchOperationResponse)
def batch_delete_assets( def batch_delete_assets(
request: BatchDeleteRequest, request: BatchDeleteRequest,
@@ -555,105 +519,6 @@ 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,
)
# 结果层过滤:usable=false(零重复可切区间耗尽且历史区间均达复用上限)的素材
# 不返回给前端;不动 smart_select_assets 评分逻辑本身
filtered_results = []
for r in results:
try:
avail = compute_asset_availability(r.asset)
except Exception:
logger.warning(
"smart-match 余量计算失败,按可用处理: asset_id=%s",
getattr(r.asset, "id", "?"),
exc_info=True,
)
avail = None
if avail is not None and not avail["usable"]:
logger.info(
"smart-match 排除已用尽素材: asset_id=%s name=%s",
getattr(r.asset, "id", "?"),
getattr(r.asset, "name", ""),
)
continue
filtered_results.append(r)
# 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
MAX_RECENT_USE_COUNT = 3
if filtered_results:
asset_ids = [getattr(r.asset, "id", "") for r in filtered_results if getattr(r.asset, "id", "")]
if asset_ids:
try:
use_counts = get_asset_recent_use_counts(
db=asset_repository.session,
asset_ids=asset_ids,
recent_video_count=5,
)
high_use_excluded = set()
for r in filtered_results:
aid = getattr(r.asset, "id", "")
count = use_counts.get(aid, 0)
if count > MAX_RECENT_USE_COUNT:
logger.info(
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
aid, count, MAX_RECENT_USE_COUNT,
)
high_use_excluded.add(id(r))
else:
pass
# 如果排除后不够 limit,放宽到不限制
remaining = [r for r in filtered_results if id(r) not in high_use_excluded]
if len(remaining) >= request.limit:
filtered_results = remaining
else:
logger.info("smart-match 高频排除后素材不足(%d<%d),保留全部", len(remaining), request.limit)
except Exception:
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
# 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层,
# 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读
items = [
SmartMatchItem(
**_to_asset_response(r.asset).model_dump(),
score=r.score,
breakdown=r.breakdown,
)
for r in filtered_results
]
return SmartMatchResponse(items=items, total_candidates=total_candidates)
@router.get("/{asset_id}", response_model=AssetResponse) @router.get("/{asset_id}", response_model=AssetResponse)
def get_asset( def get_asset(
asset_id: str, asset_id: str,
@@ -750,12 +615,43 @@ def untag_asset(
@router.post("", response_model=AssetResponse) @router.post("", response_model=AssetResponse)
def create_asset() -> None: def create_asset(
""" request: CreateAssetRequest,
已废弃接口。 authenticated_user: AuthenticatedUser = Depends(get_current_user),
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。 asset_repository: Any = Depends(get_asset_repository),
""" asset_library_repository: Any = Depends(get_asset_library_repository),
raise HTTPException( project_repository: Any = Depends(get_project_repository),
status_code=410, ) -> AssetResponse:
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。", project = project_repository.find_by_id(request.project_id)
if project is None:
raise HTTPException(status_code=404, detail=f"Project {request.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")
use_case = CreateAssetUseCase(asset_repository)
item = use_case.execute(
CreateAssetCommand(
project_id=request.project_id,
library_id=request.library_id,
name=request.name,
storage_key=request.storage_key,
mime_type=request.mime_type,
metadata=request.metadata,
file_size=request.file_size,
thumbnail_url=request.thumbnail_url,
duration=request.duration,
width=request.width,
height=request.height,
fps=request.fps,
codec=request.codec,
status=AssetStatus(request.status),
classification_status=ClassificationStatus(request.classification_status),
quality_score=request.quality_score,
uploaded_by_user_id=authenticated_user.user.id,
)
) )
return _to_asset_response(item)
-153
View File
@@ -1,153 +0,0 @@
"""封面模板 CRUD 路由。
API:
GET /api/v1/cover-templates - 列出当前用户可见的模板
POST /api/v1/cover-templates - 创建自定义模板
PUT /api/v1/cover-templates/{id} - 更新模板
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
"""
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_cover_template_repository
from app.schemas.cover_template import (
CoverTemplateResponse,
CreateCoverTemplateRequest,
ListCoverTemplatesResponse,
UpdateCoverTemplateRequest,
)
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.exc import OperationalError, ProgrammingError
from packages.domain.cover_template import CoverTemplate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/cover-templates", tags=["CoverTemplate"])
@router.get("", response_model=ListCoverTemplatesResponse)
def list_cover_templates(
skip: int = 0,
limit: int = 100,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> ListCoverTemplatesResponse:
"""列出当前用户可见的封面模板(系统模板 + 用户自定义模板)。
当数据库表不存在时(迁移未执行),降级返回空列表而非 500。
"""
user_id = authenticated_user.user.id
try:
items = repo.list_for_user(user_id, skip=skip, limit=limit)
total = repo.count_for_user(user_id)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表查询失败(可能未迁移),返回空列表: %s", exc)
return ListCoverTemplatesResponse(items=[], total=0)
return ListCoverTemplatesResponse(
items=[
CoverTemplateResponse(
id=t.id,
name=t.name,
thumbnail_url=t.thumbnail_url,
is_system=t.is_system,
created_at=t.created_at,
config=t.config or {},
)
for t in items
],
total=total,
)
@router.post("", response_model=CoverTemplateResponse, status_code=201)
def create_cover_template(
request: CreateCoverTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> CoverTemplateResponse:
"""创建用户自定义封面模板。"""
user_id = authenticated_user.user.id
config_dict = request.config.model_dump() if request.config else {}
template = CoverTemplate.create_user(
user_id=user_id,
name=request.name,
config=config_dict,
thumbnail_url=request.thumbnail_url,
)
try:
created = repo.create(template)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表不可用(可能未迁移): %s", exc)
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
return CoverTemplateResponse(
id=created.id,
name=created.name,
thumbnail_url=created.thumbnail_url,
is_system=created.is_system,
created_at=created.created_at,
config=created.config,
)
@router.put("/{template_id}", response_model=CoverTemplateResponse)
def update_cover_template(
template_id: str,
request: UpdateCoverTemplateRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> CoverTemplateResponse:
"""更新封面模板(仅允许更新自己的模板)。"""
user_id = authenticated_user.user.id
try:
template = repo.get(template_id)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表不可用: %s", exc)
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
if template.is_system:
raise HTTPException(status_code=403, detail="系统模板不可修改")
if template.user_id != user_id:
raise HTTPException(status_code=403, detail="无权修改该模板")
if request.name is not None:
template.update(name=request.name)
if request.config is not None:
template.update(config=request.config.model_dump())
if request.thumbnail_url is not None:
template.update(thumbnail_url=request.thumbnail_url)
updated = repo.update(template)
return CoverTemplateResponse(
id=updated.id,
name=updated.name,
thumbnail_url=updated.thumbnail_url,
is_system=updated.is_system,
created_at=updated.created_at,
config=updated.config,
)
@router.delete("/{template_id}", status_code=204, response_class=Response)
def delete_cover_template(
template_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repo: Any = Depends(get_cover_template_repository),
) -> None:
"""删除用户自定义封面模板(系统模板不可删除)。"""
user_id = authenticated_user.user.id
try:
template = repo.get(template_id)
except (OperationalError, ProgrammingError) as exc:
logger.warning("cover_templates 表不可用: %s", exc)
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
if template is None:
raise HTTPException(status_code=404, detail="模板不存在")
if template.is_system:
raise HTTPException(status_code=403, detail="系统模板不可删除")
if template.user_id != user_id:
raise HTTPException(status_code=403, detail="无权删除该模板")
repo.delete(template_id)
-799
View File
@@ -1,799 +0,0 @@
"""封面生成路由 — Generation 模块.
端点:
- POST /generate-cover AI 生成封面(从最终成片视频中抽帧,兼容预览片段回退)
挂载路径: /api/v1/generation/generate-cover
"""
from __future__ import annotations
import ipaddress
import logging
import re
from typing import Any, List, Optional
from urllib.parse import urlparse
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_db_session, get_generated_video_repository
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application import ListGeneratedVideosByTaskUseCase
from packages.domain.config_schemas import normalize_plan_config
from packages.shared.storage import get_shared_storage_service
from .templates_editor.dependencies import get_draft_plan_id, get_editor_services
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Generation"])
# ── Schemas ──────────────────────────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
cover_url: Optional[str] = Field(
default=None,
description="上传的封面图片 URL,仅 cover_type=upload 时有效",
)
generated_video_id: Optional[str] = Field(
default=None,
description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,而非预览片段。",
)
video_url: Optional[str] = Field(
default=None,
description="最终视频 URL(兜底)。当 generated_video_id 不可用时,直接从此 URL 对应的视频抽帧。",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── Route ────────────────────────────────────────────────────────────────
def _persist_cover_frame(
frame_url: str,
plan_id: str,
title_text: str = "",
*,
title_color: str = "#ffffff",
title_position: str = "bottom",
title_font_size: int | None = None,
) -> str:
"""下载 MediaKit 返回的临时帧图,可选叠加标题后转存到 OSS covers/ 路径。
Args:
frame_url: MediaKit 返回的临时帧图 URL
plan_id: 剪辑计划 ID(生成 OSS key
title_text: 非空时用 Pillow 在帧上叠加标题(用于 E2 从源素材抽帧,
因为源素材本身没有烧录标题)
title_color: 标题字体颜色(#RRGGBB
title_position: 标题位置 top/center/bottom
title_font_size: 标题字号,None 时自动计算
"""
import tempfile
import uuid
from pathlib import Path
tmp_path: str | None = None
try:
import httpx
resp = httpx.get(frame_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
if not resp.content:
return frame_url
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
tmp.write(resp.content)
tmp_path = tmp.name
# E2 从源素材抽帧时,源素材无标题,叠加标题文字
if title_text and title_text.strip():
try:
from packages.shared.title_overlay import apply_title_to_image
applied = apply_title_to_image(
tmp_path,
title_text,
color=title_color,
position=title_position,
font_size=title_font_size,
)
if applied:
logger.info("[封面生成] E2 帧图已叠加标题: plan_id=%s", plan_id)
except Exception:
logger.warning(
"[封面生成] E2 标题叠加失败(返回无标题帧): plan_id=%s",
plan_id,
exc_info=True,
)
storage = get_shared_storage_service()
cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg"
storage.upload_file(
file_or_path=tmp_path,
storage_key=cover_key,
content_type="image/jpeg",
)
public_url = storage.get_url(cover_key)
return public_url or frame_url
except Exception:
logger.warning("封面帧转存失败,返回原始 URL: plan_id=%s", plan_id, exc_info=True)
return frame_url
finally:
if tmp_path:
Path(tmp_path).unlink(missing_ok=True)
def _get_task_video_url(db: Session, task_id: str) -> Optional[str]:
"""从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL."""
try:
video_repo = get_generated_video_repository(db)
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
videos = use_case.execute(task_id)
if videos:
return getattr(videos[0], "file_url", "") or ""
except Exception:
logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True)
return None
def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]:
"""将 storage_key 或完整 URL 转换为可访问的裸 URL。"""
if not storage_key:
return None
try:
if storage_key.startswith("http"):
url = storage_key
else:
storage_svc = get_shared_storage_service()
url = storage_svc.get_url(storage_key)
if url:
url = re.sub(r"(?<!:)//", "/", url)
return url
except Exception as e:
logger.warning("[封面生成] storage_key 转 URL 失败: key=%s err=%s", storage_key, e)
return None
def _endpoint_host(value: str) -> str:
"""从 endpoint / URL 字符串中安全提取主机名(兼容有无 scheme 两种配置)。"""
v = (value or "").strip().lower()
if not v:
return ""
if "://" in v:
return (urlparse(v).hostname or "").lower()
# 无 scheme:去掉可能的端口(host:port),urlparse 补 // 以正确解析
return (urlparse("//" + v).hostname or "").lower()
def _is_private_or_reserved_host(host: str) -> bool:
"""判断主机名是否为内网/回环/链路本地/保留地址(IPv4 与 IPv6 统一处理)。
使用标准库 ipaddress 判定;非 IP 主机名(如 localhost)单独处理。
"""
h = host.strip().lower()
if h in {"localhost", "0.0.0.0", "::", "::1"}:
return True
try:
addr = ipaddress.ip_address(h)
# is_private 覆盖 10/8、172.16/12、192.168/16、127/8、169.254/16、
# ::1、fc00::/7、fe80::/10 等全部私有/保留段
return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved)
except ValueError:
return False
def _is_trusted_media_url(url: str) -> bool:
"""校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。
用户可通过 video_url 传入视频地址,但服务端(MediaKit)会主动请求该 URL
因此必须限制为自家存储域名,拒绝内网地址、元数据地址等任意主机。
"""
if not url:
return False
try:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https"):
return False
host = (parsed.hostname or "").lower()
if not host:
return False
# 拒绝一切内网/回环/链路本地/保留地址(IPv4 + IPv6,标准库判定)
if _is_private_or_reserved_host(host):
return False
# 允许:自家 OSS bucket 域名(<bucket>.<endpoint>)或 endpoint 自身及其子域
try:
storage_svc = get_shared_storage_service()
trusted_hosts = set()
public_base = getattr(storage_svc, "public_url", "") or ""
h1 = _endpoint_host(public_base)
if h1:
trusted_hosts.add(h1)
h2 = _endpoint_host(getattr(storage_svc, "endpoint", "") or "")
if h2:
trusted_hosts.add(h2)
for trusted in trusted_hosts:
if host == trusted or host.endswith("." + trusted):
return True
except Exception:
logger.warning("[封面生成] 存储域名白名单初始化失败,URL 校验从严拒绝", exc_info=True)
return False
return False
except Exception:
logger.warning("[封面生成] video_url 白名单校验异常,从严拒绝: url=%s", url[:80], exc_info=True)
return False
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def generate_cover(
body: GenerateCoverRequest,
template_id: str = Query(..., description="模板 ID"),
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> GenerateCoverResponse:
"""AI 生成封面 — 优先从最终成片视频中抽帧,回退到预览片段.
流程(串行):
1. 优先使用前端传入的 generation_task_id 定位最终成片任务,
或自动查找 plan 关联的已完成最终成片任务(is_preview=False
2. 回退:从预览片段获取视频 URL(兼容旧流程)
3. 用裸 URL 让 MediaKit 下载视频并抽帧
4. 帧图下载后上传到 OSS covers/ 路径
MediaKit 的调用方式(strategy / max_frames / 轮询 / 重试 / 降级)不变。
"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
# ── upload 类型:直接保存前端上传的封面图片,不需要预览视频 ──────
if body.cover_type == "upload":
if not body.cover_url:
raise HTTPException(
status_code=400,
detail="cover_type=upload 时必须提供 cover_url",
)
cover_data = {
"type": "upload",
"image_url": body.cover_url,
}
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面上传完成: plan_id=%s cover_url=%s by user=%s",
plan_id,
body.cover_url[:80] if body.cover_url else "",
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
# ── 查找用于抽帧的视频 URL ────────────────────────────────────────
# 优先级:
# 0. 请求体显式传入的 generation_task_id(最终成片任务)
# 1. plan.config.rendered_storage_key
# 2. plan.config.generation_task_id 对应的任务
# 3. source_edit_plan_id 关联的已完成「最终成片」任务(is_preview=False
# 4. source_edit_plan_id 关联的已完成预览任务(is_preview=True,兼容回退)
# 5. user + template 最近的已完成预览任务(兜底)
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
# 步骤 0:请求体传入最终视频标识(generated_video_id 或 video_url
if not rendered_storage_key:
# 0a:通过 generated_video_id 查找最终成片视频
if body.generated_video_id:
logger.info(
"[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
)
try:
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
file_url = getattr(gv, "file_url", "") or ""
if file_url:
# 权限校验(双重,任何一层确认归属不符即拒绝):
# 1) GeneratedVideo.user_id 直接归属(老数据可能为空,为空时不据此放行)
gv_owner = (getattr(gv, "user_id", "") or "").strip()
if gv_owner and gv_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
# 2) 关联 generation_task 归属校验;关联任务缺失时不可静默放行:
# 若 video 自身无 owner 信息且关联任务也查不到,拒绝访问
gv_task_id = getattr(gv, "generation_task_id", "") or ""
task0 = None
if gv_task_id:
try:
task0 = SQLAlchemyGenerationTaskRepository(db).get(gv_task_id)
except Exception:
logger.warning(
"[封面生成] 步骤0a关联任务查询异常: plan_id=%s task_id=%s",
plan_id,
gv_task_id,
exc_info=True,
)
if task0 is not None:
task_owner = (getattr(task0, "created_by_user_id", "") or "").strip()
if task_owner and task_owner != current_user.user.id:
raise HTTPException(status_code=403, detail="无权访问该视频")
elif not gv_owner:
# video 无 owner 且关联任务不存在/无法确认归属 → 拒绝,防止越权
logger.warning(
"[封面生成] 步骤0a视频归属无法确认,拒绝访问: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
)
raise HTTPException(status_code=403, detail="无权访问该视频")
rendered_storage_key = file_url
logger.info(
"[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s",
plan_id,
body.generated_video_id,
file_url[:80],
)
except HTTPException:
raise
except Exception:
logger.warning(
"[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
exc_info=True,
)
# 0b:直接使用 video_url(兜底)— 必须通过存储域名白名单校验,防止 SSRF
if not rendered_storage_key and body.video_url:
if _is_trusted_media_url(body.video_url):
logger.info(
"[封面生成] 步骤0b: 使用请求体传入的 video_url(白名单通过): plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
rendered_storage_key = body.video_url
else:
logger.warning(
"[封面生成] 步骤0b: video_url 不在受信任存储域名白名单内,已忽略: plan_id=%s url=%s",
plan_id,
body.video_url[:80],
)
# 步骤 2:通过 plan.config.generation_task_id 查找
if not rendered_storage_key:
generation_task_id = (plan.config or {}).get("generation_task_id", "")
if generation_task_id:
logger.info(
"[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
)
try:
_repo = SQLAlchemyGenerationTaskRepository(db)
task = _repo.get(generation_task_id)
if task:
rendered_storage_key = _get_task_video_url(db, task.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
rendered_storage_key[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤2查找失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False
if not rendered_storage_key:
try:
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id)
all_tasks = _repo.list_by_source_edit_plan(plan_id)
for pt in all_tasks:
if getattr(pt, "status", "") == "completed" and not getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
rendered_storage_key[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤3查找最终成片失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务
if not rendered_storage_key:
try:
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id)
preview_tasks = _repo.list_by_source_edit_plan(plan_id)
for pt in preview_tasks:
if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False):
rendered_storage_key = _get_task_video_url(db, pt.id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
rendered_storage_key[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤4查找预览任务失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 5:按 user + template 查找最近的已完成预览任务(兜底)
if not rendered_storage_key:
try:
_repo = SQLAlchemyGenerationTaskRepository(db)
logger.info(
"[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s",
plan_id,
template_id,
)
preview_tasks = _repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
)
if preview_tasks:
rendered_storage_key = _get_task_video_url(db, preview_tasks[0].id) or ""
if rendered_storage_key:
logger.info(
"[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s",
plan_id,
preview_tasks[0].id,
)
except Exception:
logger.warning(
"[封面生成] 步骤5 user+template 查找失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 将 storage_key 转换为可访问 URL;找不到视频时不立即报错,
# 因为步骤 E2 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底)
primary_video_url = None
if rendered_storage_key:
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
primary_video_url = _resolve_storage_key_to_url(rendered_storage_key)
logger.info(
"[封面生成] 封面抽帧视频URL: plan_id=%s url=%s",
plan_id,
primary_video_url[:80] if primary_video_url else "",
)
# 统一封面管道:优先从 GenerationTask.cover_url 读取渲染后视频抽帧的封面
# 多步查找 cover_url,和查找视频 URL 一样的 fallback 逻辑
if body.cover_type in ("ai_frame", "ai_regenerate"):
cover_url_from_task = None
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
# 步骤 A:通过 generation_task_id 直接查找
generation_task_id = (plan.config or {}).get("generation_task_id", "")
if generation_task_id:
try:
task = gen_task_repo.get(generation_task_id)
if task and getattr(task, "cover_url", ""):
cover_url_from_task = task.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤A-direct): plan_id=%s task_id=%s url=%s",
plan_id,
generation_task_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤A读取 cover_url 失败: plan_id=%s task_id=%s",
plan_id,
generation_task_id,
exc_info=True,
)
# 步骤 A2:通过 generated_video_id 查找其关联任务的 cover_url
if not cover_url_from_task and body.generated_video_id:
try:
gv_repo = get_generated_video_repository(db)
gv = gv_repo.get(body.generated_video_id)
if gv:
gv_task_id = getattr(gv, "generation_task_id", "") or ""
if gv_task_id:
task_a2 = gen_task_repo.get(gv_task_id)
if task_a2 and getattr(task_a2, "cover_url", ""):
cover_url_from_task = task_a2.cover_url
logger.info(
"[封面生成] 封面(步骤A2-video-task): plan_id=%s video_id=%s url=%s",
plan_id,
body.generated_video_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤A2读取 cover_url 失败: plan_id=%s video_id=%s",
plan_id,
body.generated_video_id,
exc_info=True,
)
# 步骤 B:通过 source_edit_plan_id 查找关联任务的 cover_url
# 优先最终成片任务(is_preview=False),其次预览任务
if not cover_url_from_task:
try:
all_tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
# 先找最终成片
for pt in all_tasks:
if (
getattr(pt, "status", "") == "completed"
and not getattr(pt, "is_preview", False)
and getattr(pt, "cover_url", "")
):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 封面(步骤B-final): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
# 再找预览
if not cover_url_from_task:
for pt in all_tasks:
if (
getattr(pt, "status", "") == "completed"
and getattr(pt, "is_preview", False)
and getattr(pt, "cover_url", "")
):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 封面(步骤B-preview): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤B查找 cover_url 失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 C:通过 user+template 查找最近的已完成预览任务的 cover_url
if not cover_url_from_task:
try:
preview_tasks = gen_task_repo.list_latest_completed_preview(
user_id=str(current_user.user.id),
template_id=template_id,
)
for pt in preview_tasks:
if getattr(pt, "cover_url", ""):
cover_url_from_task = pt.cover_url
logger.info(
"[封面生成] 统一管道封面(步骤C-user+template): plan_id=%s task_id=%s url=%s",
plan_id,
pt.id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤C查找 cover_url 失败: plan_id=%s template_id=%s",
plan_id,
template_id,
exc_info=True,
)
# 步骤 D:从 plan.config.cover_candidates 读取(Worker 渲染时写入)
if not cover_url_from_task:
_candidates = (plan.config or {}).get("cover_candidates") or []
if isinstance(_candidates, list) and _candidates:
_first = _candidates[0]
if isinstance(_first, dict):
cover_url_from_task = _first.get("image_url") or _first.get("url") or ""
if cover_url_from_task:
logger.info(
"[封面生成] 统一管道封面(步骤D-cover_candidates): plan_id=%s url=%s",
plan_id,
cover_url_from_task[:80],
)
# 步骤 E1:如果有已渲染的预览视频 URL 但 cover_url 未持久化(历史数据),
# 直接从渲染视频抽帧
if not cover_url_from_task and primary_video_url:
try:
from packages.shared.mediakit_client import get_mediakit_client
mk_client = get_mediakit_client()
if mk_client.is_available:
logger.info(
"[封面生成] 步骤E1-从渲染视频抽帧: plan_id=%s url=%s",
plan_id,
primary_video_url[:80],
)
snapshots = mk_client.extract_frames(
video_url=primary_video_url,
strategy="SpecifiedFrames",
max_frames=1,
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
if raw:
cover_url_from_task = _persist_cover_frame(raw, plan_id)
logger.info(
"[封面生成] 统一管道封面(步骤E1-rendered-video): plan_id=%s url=%s",
plan_id,
cover_url_from_task[:80],
)
except Exception:
logger.warning(
"[封面生成] 步骤E1从渲染视频抽帧失败: plan_id=%s",
plan_id,
exc_info=True,
)
# 步骤 E2:当 A/B/C/D/E1 均未命中(如历史预览任务无 cover_url)时,
# 直接从用户选择的第一个视频素材中抽取封面帧作为兜底。API 请求内短超时,不阻塞。
if not cover_url_from_task and body.asset_ids:
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
from packages.shared.mediakit_client import get_mediakit_client
from packages.shared.storage import get_shared_storage_service
asset_repo = SQLAlchemyAssetRepository(db)
storage_svc = get_shared_storage_service()
mk_client = get_mediakit_client()
# 从 plan.config 读取完整标题样式,E2 从源素材抽帧时叠加(源素材本身无标题)
_e2_title_cfg = (plan.config or {}).get("title", {}) or {}
if not isinstance(_e2_title_cfg, dict):
_e2_title_cfg = {}
_e2_title_text = (_e2_title_cfg.get("text", "") or "").strip() if _e2_title_cfg.get("enabled", True) else ""
# 读取标题样式:前端可能传 color 或 font_color,都兼容
_e2_title_color = _e2_title_cfg.get("color") or _e2_title_cfg.get("font_color") or "#ffffff"
_e2_title_position = _e2_title_cfg.get("position", "bottom") or "bottom"
_e2_title_font_size = _e2_title_cfg.get("font_size") or _e2_title_cfg.get("size")
if mk_client.is_available:
for aid in body.asset_ids:
try:
asset = asset_repo.get(aid)
if not asset or asset.file_type != "video":
continue
sk = asset.storage_key or ""
if not sk:
continue
src_url = sk if sk.startswith("http") else storage_svc.get_url(sk)
if not src_url:
continue
logger.info(
"[封面生成] 步骤E-从素材抽帧: plan_id=%s asset_id=%s url=%s",
plan_id,
aid,
src_url[:80],
)
snapshots = mk_client.extract_frames(
video_url=src_url,
strategy="SpecifiedFrames",
max_frames=1,
poll_interval=2.0,
max_poll_attempts=5,
max_retries=0,
)
if snapshots:
raw = snapshots[0].get("image_url") or snapshots[0].get("url") or ""
if raw:
cover_url_from_task = _persist_cover_frame(
raw,
plan_id,
title_text=_e2_title_text,
title_color=_e2_title_color,
title_position=_e2_title_position,
title_font_size=_e2_title_font_size,
)
logger.info(
"[封面生成] 统一管道封面(步骤E-source-asset): plan_id=%s url=%s",
plan_id,
cover_url_from_task[:80],
)
break
except Exception:
logger.warning(
"[封面生成] 步骤E从素材抽帧失败: plan_id=%s asset_id=%s",
plan_id,
aid,
exc_info=True,
)
if cover_url_from_task:
# 标题已在预览视频渲染时烧录(ASS字幕),封面帧自然包含标题
cover_data = {
"type": "ai_frame",
"image_url": cover_url_from_task,
"frame_time": 0.0,
"confidence": 0.95,
}
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
logger.warning(
"[封面生成] 统一管道未找到 cover_url (A/B/C/D均未命中): plan_id=%s",
plan_id,
)
# ai_frame/ai_regenerate 类型必须从渲染管道获取,不再回退到 AI 服务
raise HTTPException(
status_code=400,
detail="封面生成失败:未找到可抽帧的视频素材,请确认已上传视频素材后重试",
)
from packages.shared.ai_service import run_generate_cover
try:
logger.info("[封面生成] 开始调用 AI 封面生成服务: plan_id=%s", plan_id)
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
primary_video_url=primary_video_url,
)
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e)) from e
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"封面生成完成: template_id=%s plan_id=%s type=%s by user=%s",
template_id,
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
@@ -1,460 +0,0 @@
"""预览生成路由 — Phase 1:单版本预览接口(创建 + 查询)。
路径前缀:/api/v1/generation/preview(与 /generation/tasks 同体系)
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.core.task_enqueue import (
GLOBAL_PENDING_LIMIT,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
safe_enqueue_generation_task,
)
from app.dependencies import (
get_asset_repository,
get_db_session,
get_generated_video_repository,
get_generation_task_repository,
)
from app.schemas.generation_task import (
CreatePreviewGenerationTaskRequest,
PreviewGenerationTaskResponse,
)
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,
)
from packages.application import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# 模板 mode → 视频比例映射
_TEMPLATE_MODE_TO_RATIO = {
"pip": "9:16",
"standard": "16:9",
"square": "1:1",
}
def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str = "") -> str:
"""从模板 mode 推断视频比例,前端未传 video_ratio 时使用。
Returns:
视频比例字符串(如 "9:16"),查询失败返回空字符串。
"""
if not template_id:
return ""
try:
repo = SQLAlchemyTemplateRepository(db)
template = repo.get(template_id, user_id)
if template:
mode = getattr(template, "mode", "") or ""
ratio = _TEMPLATE_MODE_TO_RATIO.get(mode.strip(), "")
if ratio:
logger.info(
"[预览生成] 从模板 mode=%s 推断 video_ratio=%s",
mode,
ratio,
)
return ratio
except Exception:
logger.warning(
"[预览生成] 查询模板失败,跳过 video_ratio 推断: template_id=%s",
template_id,
exc_info=True,
)
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:
task.mark_failed(error_message=f"入队失败:{reason}")
repo.update(task)
except Exception:
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
"""将领域任务对象转换为预览响应 DTO。
Args:
task: GenerationTask 领域对象
generated_videos: 生成的视频列表(可选),取第一个作为 video_url
Returns:
PreviewGenerationTaskResponse
"""
video_url = ""
duration = 0.0
file_size = 0
if generated_videos:
first_video = generated_videos[0]
raw_url = getattr(first_video, "file_url", "") or ""
# rendered/* 已配置公开读,直接用裸 URL
if raw_url.startswith("http"):
video_url = raw_url
else:
storage = get_storage_service()
video_url = storage.get_url(raw_url)
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
file_size = int(getattr(first_video, "file_size", 0) or 0)
# 从 extra_meta / metadata 中提取统计信息(如果有)
extra_meta = getattr(task, "extra_meta", {}) or {}
clip_count = int(extra_meta.get("clip_count", len(getattr(task, "asset_ids", [])) or 0))
transition_count = int(extra_meta.get("transition_count", max(0, clip_count - 1)))
material_usage = extra_meta.get("material_usage", {}) or {}
# 计算生成耗时
generate_duration = 0.0
started_at = getattr(task, "started_at", None)
completed_at = getattr(task, "completed_at", None)
if started_at and completed_at:
generate_duration = (completed_at - started_at).total_seconds()
return PreviewGenerationTaskResponse(
task_id=task.id,
status=task.status.value if hasattr(task.status, "value") else str(task.status),
progress=float(task.progress or 0.0),
is_preview=bool(getattr(task, "is_preview", True)),
resolution=getattr(task, "resolution", "") or "",
video_url=video_url,
duration=duration,
file_size=file_size,
clip_count=clip_count,
transition_count=transition_count,
material_usage=material_usage,
error_message=task.error_message or "",
created_at=task.created_at,
started_at=started_at,
finished_at=completed_at,
generate_duration=generate_duration,
)
@router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201)
def create_preview_generation_task(
request: CreatePreviewGenerationTaskRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository=Depends(get_generation_task_repository),
db: Session = Depends(get_db_session),
asset_repo=Depends(get_asset_repository),
) -> PreviewGenerationTaskResponse:
"""创建预览生成任务。
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
Args:
request: 预览任务创建请求(template_id + asset_ids 等)
Returns:
201 + 预览任务详情
"""
user_id = authenticated_user.user.id
logger.info(
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
user_id,
request.template_id,
len(request.asset_ids),
request.preview_count,
)
# 预检查队列限流
try:
user_pending = generation_task_repository.count_pending_by_user(user_id)
global_pending = generation_task_repository.count_pending_total()
if user_pending + 1 > USER_PENDING_LIMIT:
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
if global_pending + 1 > GLOBAL_PENDING_LIMIT:
raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
except UserPendingLimitExceeded as e:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
) from e
except GlobalQueueFull as e:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from e
# 确定视频比例:优先前端传入,否则从模板 mode 推断
video_ratio = request.video_ratio or ""
if not video_ratio and request.template_id:
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
# 根据 video_ratio 计算输出分辨率(默认竖屏 1080x1920)
output_width, output_height = 1080, 1920
if video_ratio:
parts = video_ratio.split(":")
if len(parts) == 2:
try:
w, h = int(parts[0]), int(parts[1])
base = 1920
if w < h:
# 竖屏
output_width = round(base * w / h)
output_height = base
else:
# 横屏
output_width = base
output_height = round(base * h / w)
# 对齐到偶数
output_width = output_width - output_width % 2
output_height = output_height - output_height % 2
except (ValueError, ZeroDivisionError):
output_width, output_height = 1080, 1920
resolution = f"{output_width}x{output_height}"
logger.info(
"[预览生成] 分辨率: video_ratio=%s%s (%dx%d)",
video_ratio, resolution, output_width, output_height,
)
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
title_config = request.title_config or {}
use_case = CreateGenerationTaskUseCase(generation_task_repository)
try:
task = use_case.execute(
CreateGenerationTaskCommand(
project_id="",
asset_library_id="",
strategy_id=strategy_id,
voice_library_id=request.voice_library_id,
template_id=request.template_id,
asset_ids=list(request.asset_ids),
title_ids=list(request.title_ids),
voice_ids=list(request.voice_ids),
created_by_user_id=user_id,
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode="",
batch_id="",
video_title=request.video_title,
resolution=resolution,
bgm_config=request.bgm_config or {},
auto_retry_enabled=False,
auto_retry_max=0,
is_preview=True,
title_config=title_config,
output_width=output_width,
output_height=output_height,
)
)
except ValueError as e:
logger.warning("[预览生成] 创建失败: %s", e)
raise HTTPException(status_code=400, detail=str(e)) from e
except Exception as e:
logger.error("[预览生成] 创建失败: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="创建预览生成任务失败,请稍后再试") from e
# 关联编辑计划:如果前端未传 source_edit_plan_id,通过 template_id + user_id 查找
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
SQLAlchemyEditPlanRepository,
)
_plan_repo = SQLAlchemyEditPlanRepository(db)
_plans = _plan_repo.list_by_template(request.template_id, limit=20)
for _p in _plans:
if (_p.created_by_user_id or "") == user_id:
task.source_edit_plan_id = _p.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_p.id,
)
break
except Exception:
logger.warning(
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 每条预览都关联独立克隆 plan:多预览前端为 N 次并发调用,若共用同一 plan
# 则 N 条预览片段完全相同;克隆时片段起点按持久化历史区间重算(含受控复用),
# 保证各预览版本内容不同
if task.source_edit_plan_id:
try:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
_preview_plan = _plan_svc.clone_plan_for_variant(
task.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix="预览变体",
)
task.source_edit_plan_id = _preview_plan.id
generation_task_repository.update(task)
logger.info(
"[预览生成] 预览关联独立克隆 plan: task_id=%s clone_plan_id=%s",
task.id,
_preview_plan.id,
)
except Exception as clone_err:
# 不退回共用原 plan(否则多条预览内容相同,违反去重诉求):
# 标记任务失败并中断,前端可重新发起预览
logger.error(
"[预览生成] 克隆预览变体 plan 失败,任务标记失败: task_id=%s error=%s",
task.id,
clone_err,
exc_info=True,
)
_mark_task_failed(generation_task_repository, task, "预览变体计划创建失败")
raise HTTPException(
status_code=500,
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
) from clone_err
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
try:
if not safe_enqueue_generation_task(
task,
generation_task_repository,
user_id=user_id,
log_prefix="[预览生成]",
log_task_status=True,
):
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
_mark_task_failed(generation_task_repository, task, "任务入队失败")
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
except UserPendingLimitExceeded as e:
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
) from None
except GlobalQueueFull:
_mark_task_failed(generation_task_repository, task, "系统队列已满")
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
) from None
return _to_preview_response(task)
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
def get_preview_generation_task(
task_id: str,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
generation_task_repository=Depends(get_generation_task_repository),
generated_video_repository=Depends(get_generated_video_repository),
) -> PreviewGenerationTaskResponse:
"""查询预览生成任务状态。
Args:
task_id: 任务 ID
Returns:
预览任务详情(含状态、进度、结果 URL 等)
"""
use_case = GetGenerationTaskUseCase(generation_task_repository)
task = use_case.execute(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
# 权限校验:任务必须属于当前用户(统一转 str 比较,避免 UUID/str 类型差异)
task_user_id = str(getattr(task, "created_by_user_id", "") or "")
if not task_user_id or task_user_id != str(authenticated_user.user.id):
raise HTTPException(status_code=403, detail="无权访问该任务")
# 校验是否为预览任务
if not getattr(task, "is_preview", False):
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
# 查询生成的视频(取第一个)
generated_videos = []
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
if status_val == "completed":
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
generated_videos = list_use_case.execute(task_id)
return _to_preview_response(task, generated_videos=generated_videos)
+27 -406
View File
@@ -1,4 +1,5 @@
import logging import logging
import random
import uuid import uuid
from typing import Any from typing import Any
@@ -15,7 +16,6 @@ from app.core.task_enqueue import (
from app.dependencies import ( from app.dependencies import (
get_asset_library_repository, get_asset_library_repository,
get_asset_repository, get_asset_repository,
get_db_session,
get_generated_video_repository, get_generated_video_repository,
get_generation_task_repository, get_generation_task_repository,
get_project_repository, get_project_repository,
@@ -26,13 +26,11 @@ from app.schemas.generated_video import (
) )
from app.schemas.generation_task import ( from app.schemas.generation_task import (
BatchGenerationTaskResponse, BatchGenerationTaskResponse,
ConfirmGenerationRequest,
CreateGenerationTaskRequest, CreateGenerationTaskRequest,
GenerationTaskResponse, GenerationTaskResponse,
ListGenerationTasksResponse, ListGenerationTasksResponse,
) )
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from packages.application import ( from packages.application import (
CreateGenerationTaskCommand, CreateGenerationTaskCommand,
@@ -40,7 +38,6 @@ from packages.application import (
GetGenerationTaskUseCase, GetGenerationTaskUseCase,
ListGeneratedVideosByTaskUseCase, ListGeneratedVideosByTaskUseCase,
) )
from packages.domain.smart_match import smart_select_assets
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -64,12 +61,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
video_title=getattr(task, "video_title", ""), video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""), resolution=getattr(task, "resolution", ""),
bgm_config=getattr(task, "bgm_config", {}) or {}, 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", ""),
title_config=getattr(task, "title_config", {}) or {},
logs=getattr(task, "logs", "[]"), logs=getattr(task, "logs", "[]"),
status=task.status, status=task.status,
progress=task.progress, progress=task.progress,
@@ -115,8 +106,8 @@ def _select_assets_from_library(
Args: Args:
assets: 素材库中所有素材(Asset 实体列表) assets: 素材库中所有素材(Asset 实体列表)
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性) mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
count: 选取数量,0 表示全部(仅 smart 模式有效) count: 选取数量,0 表示全部(仅 random/smart 模式有效)
Returns: Returns:
选中的素材 ID 列表 选中的素材 ID 列表
@@ -126,79 +117,31 @@ def _select_assets_from_library(
if not ready_video_assets: if not ready_video_assets:
return [] return []
if mode == "random":
selected = (
ready_video_assets if count <= 0 else random.sample(ready_video_assets, min(count, len(ready_video_assets)))
)
return [a.id for a in selected]
if mode == "smart": if mode == "smart":
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取 # 智能匹配:按质量分降序 + 时长降序作为tiebreaker
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%) # 注意:这里使用简单的 quality_score 排序保持向后兼容
limit = count if count > 0 else None # 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
results = smart_select_assets(ready_video_assets, limit=limit, kind="video") scored_assets = sorted(
return [r.asset.id for r in results] 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]
# 默认 all 模式:返回全部 ready 视频素材 # 默认 all 模式:返回全部 ready 视频素材
return [a.id for a in ready_video_assets] return [a.id for a in ready_video_assets]
def _writeback_edit_plan_config(
plan_id: str,
task_id: str,
title_config: dict | None,
db: Session,
) -> None:
"""任务入队成功后,回写 EditPlan.configgeneration_task_id + title_config。
用 merge 方式更新,不整体覆盖 config,避免丢失其他字段。
失败只记日志,不影响任务创建。
"""
if not plan_id:
return
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
plan_model = db.query(EditPlanModel).filter(EditPlanModel.id == plan_id).first()
if plan_model is None:
logger.warning("[生成任务] 回写plan.config失败: plan不存在 plan_id=%s", plan_id)
return
current_config = plan_model.config if isinstance(plan_model.config, dict) else {}
merged = dict(current_config)
merged["generation_task_id"] = task_id
# 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面
if title_config:
old_title_config = merged.get("title_config", {}) or {}
old_title_text = (old_title_config.get("text") or "").strip()
new_title_text = (title_config.get("text") or "").strip()
if old_title_text != new_title_text:
# 标题变化,清除旧封面
if "cover" in merged:
del merged["cover"]
logger.info(
"[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s",
plan_id, old_title_text, new_title_text,
)
merged["title_config"] = title_config
plan_model.config = merged
db.commit()
logger.info(
"[生成任务] 回写plan.config成功: plan_id=%s task_id=%s keys=%s",
plan_id,
task_id,
list(merged.keys()),
)
except Exception as e:
logger.warning(
"[生成任务] 回写plan.config异常(不影响任务创建): plan_id=%s error=%s",
plan_id,
e,
exc_info=True,
)
try:
db.rollback()
except Exception:
pass
def _resolve_project_and_library( def _resolve_project_and_library(
request: CreateGenerationTaskRequest, request: CreateGenerationTaskRequest,
project_repository: Any, project_repository: Any,
@@ -244,7 +187,6 @@ def create_generation_task(
project_repository: Any = Depends(get_project_repository), project_repository: Any = Depends(get_project_repository),
asset_library_repository: Any = Depends(get_asset_library_repository), asset_library_repository: Any = Depends(get_asset_library_repository),
asset_repository: Any = Depends(get_asset_repository), asset_repository: Any = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse: ) -> BatchGenerationTaskResponse:
logger.info( logger.info(
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d", "[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
@@ -285,8 +227,8 @@ def create_generation_task(
mode=request.asset_select_mode, mode=request.asset_select_mode,
count=request.asset_select_count, count=request.asset_select_count,
) )
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",): elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取 # 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
assets = asset_repository.find_by_project(project_id) assets = asset_repository.find_by_project(project_id)
if assets: if assets:
resolved_asset_ids = _select_assets_from_library( resolved_asset_ids = _select_assets_from_library(
@@ -300,89 +242,6 @@ def create_generation_task(
detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。", detail="当前项目没有符合条件的视频素材,请先上传并等待导入完成后再生成。",
) )
# ── 兜底复用预览产物 ──
# 前端刷新后 previewTaskId 丢失,降级调 create 接口时,
# 如果同一 edit_plan 有已完成的预览任务,直接复用(秒出)。
if request.source_edit_plan_id and not request.is_preview:
try:
from packages.adapters.sqlalchemy_impl.models import (
GenerationTaskModel,
)
_preview_model = (
db.query(GenerationTaskModel)
.filter(
GenerationTaskModel.source_edit_plan_id == request.source_edit_plan_id,
GenerationTaskModel.is_preview.is_(True),
GenerationTaskModel.status == "completed",
GenerationTaskModel.created_by_user_id == authenticated_user.user.id,
)
.order_by(GenerationTaskModel.created_at.desc())
.first()
)
if _preview_model is not None:
# 校验分辨率一致性(与 confirm 端点逻辑相同)
req_w = request.output_width or 0
req_h = request.output_height or 0
src_w = getattr(_preview_model, "output_width", 0) or 0
src_h = getattr(_preview_model, "output_height", 0) or 0
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
_to_domain,
)
preview_task = _to_domain(_preview_model)
# 如果传了标题,更新 title_config
fallback_title_config = None
if request.title_config and request.title_config.get("text", "").strip():
fallback_title_config = dict(preview_task.title_config or {})
fallback_title_config.update(request.title_config)
preview_task.mark_confirmed(
cover_url=request.cover_url or preview_task.cover_url,
output_width=request.output_width or preview_task.output_width,
output_height=request.output_height or preview_task.output_height,
title_config=fallback_title_config,
)
generation_task_repository.update(preview_task)
# 同步标题到 EditPlan.config
if fallback_title_config:
_writeback_edit_plan_config(
plan_id=request.source_edit_plan_id,
task_id=preview_task.id,
title_config=fallback_title_config,
db=db,
)
logger.info(
"[生成任务] 兜底复用预览产物: preview_task_id=%s, plan_id=%s",
preview_task.id,
request.source_edit_plan_id,
)
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(preview_task)],
total=1,
)
else:
logger.info(
"[生成任务] 兜底复用跳过(分辨率不一致): plan_id=%s, src=%sx%s, req=%sx%s",
request.source_edit_plan_id,
src_w,
src_h,
req_w,
req_h,
)
except Exception:
logger.warning(
"[生成任务] 兜底复用预览产物异常(不影响主流程): plan_id=%s",
request.source_edit_plan_id,
exc_info=True,
)
use_case = CreateGenerationTaskUseCase(generation_task_repository) use_case = CreateGenerationTaskUseCase(generation_task_repository)
count = request.count count = request.count
created_tasks = [] created_tasks = []
@@ -412,74 +271,20 @@ def create_generation_task(
detail="系统繁忙,请稍后再试", detail="系统繁忙,请稍后再试",
) from e ) 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"
# 批量生成时每个任务关联独立克隆 plan(片段起点重算),
# 禁止 N 条任务共用同一 source_edit_plan_id 导致片段一模一样。
# 在创建任何任务【之前】预克隆全部变体:克隆失败直接中断(此时无脏数据),
# 绝不静默退回共用源 plan(否则批量视频内容重复,违反去重诉求)。
variant_plan_ids: list[str] = []
if count > 1 and request.source_edit_plan_id:
from app.services.edit_plan_service import EditPlanService
_plan_svc = EditPlanService(db)
for task_index in range(1, count):
variant = None
last_err: Exception | None = None
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
try:
variant = _plan_svc.clone_plan_for_variant(
request.source_edit_plan_id,
created_by_user_id=user_id,
name_suffix=f"批量{task_index + 1}",
)
break
except Exception as clone_err: # noqa: PERF203
last_err = clone_err
logger.warning(
"[生成任务] 克隆变体 plan 失败(尝试%d/2): source=%s error=%s",
_attempt + 1,
request.source_edit_plan_id,
clone_err,
exc_info=True,
)
if variant is None:
logger.error(
"[生成任务] 克隆变体 plan 重试仍失败,中断批量创建: source=%s",
request.source_edit_plan_id,
exc_info=last_err,
)
raise HTTPException(
status_code=500,
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
) from last_err
variant_plan_ids.append(variant.id)
try: try:
for task_index in range(count): for _ in range(count):
# 第 1 条复用源 plan(保留用户编辑结果);其余使用预克隆的独立变体 plan。
# 无源 plansource_edit_plan_id 为空)时无可克隆对象,variant_plan_ids
# 为空列表:各任务走自身随机选片流程,不做索引访问(防 IndexError)
effective_plan_id = request.source_edit_plan_id
if task_index > 0 and variant_plan_ids:
effective_plan_id = variant_plan_ids[task_index - 1]
task = use_case.execute( task = use_case.execute(
CreateGenerationTaskCommand( CreateGenerationTaskCommand(
project_id=project_id, project_id=project_id,
asset_library_id=asset_library_id, asset_library_id=asset_library_id,
strategy_id=effective_strategy_id, strategy_id=request.strategy_id,
voice_library_id=request.voice_library_id, voice_library_id=request.voice_library_id,
template_id=request.template_id, template_id=request.template_id,
asset_ids=resolved_asset_ids, asset_ids=resolved_asset_ids,
title_ids=request.title_ids, title_ids=request.title_ids,
voice_ids=request.voice_ids, voice_ids=request.voice_ids,
created_by_user_id=user_id, created_by_user_id=user_id,
source_edit_plan_id=effective_plan_id, source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode, asset_select_mode=request.asset_select_mode,
batch_id=batch_id, batch_id=batch_id,
video_title=request.video_title, video_title=request.video_title,
@@ -487,58 +292,9 @@ def create_generation_task(
bgm_config=request.bgm_config, bgm_config=request.bgm_config,
auto_retry_enabled=request.auto_retry_enabled, auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max, 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,
title_config=request.title_config or {},
) )
) )
try: try:
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)
if not task.source_edit_plan_id and request.template_id:
try:
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
_plan_model = (
db.query(EditPlanModel)
.filter(
EditPlanModel.template_id == request.template_id,
EditPlanModel.created_by_user_id == user_id,
)
.order_by(EditPlanModel.created_at.desc())
.first()
)
if _plan_model:
task.source_edit_plan_id = _plan_model.id
generation_task_repository.update(task)
logger.info(
"[生成任务] 自动关联编辑计划: task_id=%s plan_id=%s",
task.id,
_plan_model.id,
)
except Exception:
logger.warning(
"[生成任务] 查找关联编辑计划失败(不影响主流程): task_id=%s",
task.id,
exc_info=True,
)
# 回写 plan.config:必须在 enqueue 之前执行,
# 确保 worker 读取 plan 时 config 中已包含 generation_task_id。
# 只在首个任务时回写一次,避免批量生成时循环覆盖。
_effective_plan_id = task.source_edit_plan_id
if _effective_plan_id and len(created_tasks) == 0:
_writeback_edit_plan_config(
plan_id=_effective_plan_id,
task_id=task.id,
title_config=request.title_config,
db=db,
)
if safe_enqueue_generation_task( if safe_enqueue_generation_task(
task, task,
generation_task_repository, generation_task_repository,
@@ -576,136 +332,6 @@ def create_generation_task(
return BatchGenerationTaskResponse(items=items, total=len(items)) 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),
db: Session = Depends(get_db_session),
) -> BatchGenerationTaskResponse:
"""确认生成 -- 复用预览渲染产物(预览与正式品质一致)。
预览已使用 1080p / CRF 23 / medium 渲染,品质与正式生成一致。
确认时直接将预览任务标记为正式产出,无需重新渲染,实现秒出。
仅当预览任务未完成时,才创建新的正式任务走渲染流程。
"""
# 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. 如果预览任务已完成,检查分辨率一致性后复用产物(秒出)
if source_task.is_completed and getattr(source_task, "is_preview", False):
# 校验请求的分辨率是否与预览实际渲染的分辨率一致
req_w = request.output_width or 0
req_h = request.output_height or 0
src_w = getattr(source_task, "output_width", 0) or 0
src_h = getattr(source_task, "output_height", 0) or 0
resolution_match = (req_w == 0 or req_w == src_w) and (req_h == 0 or req_h == src_h)
if resolution_match:
# 如果用户传了 custom_title,同步更新 title_config
confirmed_title_config = None
if request.custom_title and request.custom_title.strip():
confirmed_title_config = dict(getattr(source_task, "title_config", {}) or {})
confirmed_title_config["text"] = request.custom_title.strip()
source_task.mark_confirmed(
cover_url=request.cover_url,
output_width=request.output_width,
output_height=request.output_height,
title_config=confirmed_title_config,
)
generation_task_repository.update(source_task)
# 同步标题到 EditPlan.config
if confirmed_title_config and source_task.source_edit_plan_id:
_writeback_edit_plan_config(
plan_id=source_task.source_edit_plan_id,
task_id=source_task.id,
title_config=confirmed_title_config,
db=db,
)
logger.info(
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
task_id,
authenticated_user.user.id,
)
return BatchGenerationTaskResponse(
items=[_to_generation_task_response(source_task)],
total=1,
)
# 分辨率不一致,跳过复用,走新建任务流程
logger.info(
"[确认生成] 分辨率不一致,跳过复用: task_id=%s, src=%sx%s, req=%sx%s",
task_id,
src_w,
src_h,
req_w,
req_h,
)
# 4. 预览任务未完成,创建新的正式任务走渲染流程
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,
)
)
# 5. 调度 worker
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) @router.get("/tasks", response_model=ListGenerationTasksResponse)
def list_generation_tasks( def list_generation_tasks(
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
@@ -803,11 +429,6 @@ def retry_generation_task(
asset_select_mode=getattr(task, "asset_select_mode", ""), asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""), video_title=getattr(task, "video_title", ""),
resolution=getattr(task, "resolution", ""), 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", ""),
) )
) )
try: try:
+3 -3
View File
@@ -1,6 +1,6 @@
from datetime import datetime, timezone from datetime import datetime, timezone
import psycopg import psycopg2
import redis import redis
from app.config import settings from app.config import settings
from fastapi import APIRouter, status from fastapi import APIRouter, status
@@ -49,7 +49,7 @@ async def _check_database() -> dict:
"message": "Using in-memory database", "message": "Using in-memory database",
} }
try: try:
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3) conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute("SELECT 1") cur.execute("SELECT 1")
cur.fetchone() cur.fetchone()
@@ -124,7 +124,7 @@ async def _check_migrations() -> dict:
"message": "Using in-memory database, no migrations needed", "message": "Using in-memory database, no migrations needed",
} }
try: try:
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3) conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(""" cur.execute("""
SELECT COUNT(*) FROM information_schema.tables SELECT COUNT(*) FROM information_schema.tables
+2 -2
View File
@@ -3,7 +3,7 @@ from typing import Any
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.dependencies import get_ingest_job_repository from app.dependencies import get_ingest_job_repository
from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest from app.schemas.ingest_job import IngestJobResponse, SubmitIngestJobRequest
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
@@ -17,7 +17,7 @@ def get_ingest_job(
) -> IngestJobResponse: ) -> IngestJobResponse:
job = ingest_job_repository.get(job_id) job = ingest_job_repository.get(job_id)
if job is None: if job is None:
raise HTTPException(status_code=404, detail=f"IngestJob {job_id} not found") raise ValueError(f"IngestJob {job_id} not found")
return IngestJobResponse( return IngestJobResponse(
id=job.id, id=job.id,
project_id=job.project_id, project_id=job.project_id,
@@ -1,17 +1,20 @@
"""模板编辑器 API 路由包. """模板编辑器 API 路由包.
模块拆分 将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
- schemas.py: 所有 Pydantic model - schemas.py: 所有 Pydantic model
- dependencies.py: 依赖注入 - dependencies.py: 依赖注入
- _utils.py: 工具函数 - _utils.py: 工具函数
- _fallback.py: 自动兜底逻辑
- draft.py: 草稿管理(详情/更新/发布/版本/回滚) - draft.py: 草稿管理(详情/更新/发布/版本/回滚)
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建) - clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速) - adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
- bgm.py: BGM 管理 - bgm.py: BGM 管理
- effects.py: 转场 + 滤镜 - effects.py: 转场 + 滤镜
- export.py: 导出配置 - export.py: 导出配置
- cover.py: 封面管理 + AI 生成封面
- subtitles.py: 字幕管理 - subtitles.py: 字幕管理
- ai_features.py: AI 推荐 - ai_features.py: AI 推荐
- generation.py: 生成(触发/进度/记录)
- timeline.py: 时间线 - timeline.py: 时间线
挂载路径: /api/v1/templates/{template_id}/editor/ 挂载路径: /api/v1/templates/{template_id}/editor/
@@ -28,10 +31,12 @@ from .adjustments import router as adjustments_router
from .ai_features import router as ai_features_router from .ai_features import router as ai_features_router
from .bgm import router as bgm_router from .bgm import router as bgm_router
from .clips import router as clips_router from .clips import router as clips_router
from .cover import router as cover_router
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401 from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
from .draft import router as draft_router from .draft import router as draft_router
from .effects import router as effects_router from .effects import router as effects_router
from .export import router as export_router from .export import router as export_router
from .generation import router as generation_router
from .subtitles import router as subtitles_router from .subtitles import router as subtitles_router
from .timeline import router as timeline_router from .timeline import router as timeline_router
@@ -46,8 +51,10 @@ _sub_routers = [
bgm_router, bgm_router,
effects_router, effects_router,
export_router, export_router,
cover_router,
subtitles_router, subtitles_router,
ai_features_router, ai_features_router,
generation_router,
timeline_router, timeline_router,
] ]
+163
View File
@@ -0,0 +1,163 @@
"""模板编辑器自动兜底逻辑.
generate_editor_draft 触发生成前的自动修复流程:
1. draft → editing 状态迁移
2. 无片段时从模板复制片段配置
3. 为无素材片段分配指定素材
4. 项目有素材库时自动选素材
"""
from __future__ import annotations
import logging
import random
from typing import Any
from app.services.edit_plan_service import EditPlanService
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.template_clip_config_repository import (
SQLAlchemyTemplateClipConfigRepository,
)
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.edit_plan import EditPlanStatus
logger = logging.getLogger(__name__)
def _auto_fallback_draft_to_editing(
svc: EditPlanService, plan_id: str, plan_check
) -> None:
"""自动兜底 1: draft → editing"""
if plan_check.status == EditPlanStatus.DRAFT:
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
svc.transition_status(plan_id, EditPlanStatus.EDITING)
def _auto_fallback_copy_template_clips(
svc: EditPlanService, plan_id: str, plan_check, db: Session
) -> None:
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
existing_clips = svc.count_clips(plan_id)
if existing_clips == 0 and plan_check.template_id:
logger.info(
"模板编辑器自动兜底: plan=%s 无片段,从模板 %s 复制片段配置",
plan_id,
plan_check.template_id,
)
clip_config_repo = SQLAlchemyTemplateClipConfigRepository(db)
configs = clip_config_repo.list_by_template(plan_check.template_id)
if configs:
for cfg in configs:
svc.create_clip(
plan_id=plan_id,
clip_type=cfg.clip_type.value
if hasattr(cfg.clip_type, "value")
else cfg.clip_type,
order=cfg.order,
template_clip_config_id=cfg.id,
duration=cfg.default_duration,
transition_effect=cfg.transition_effect.value
if hasattr(cfg.transition_effect, "value")
else cfg.transition_effect,
)
logger.info(
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
plan_id,
len(configs),
)
else:
tpl_repo = SQLAlchemyTemplateRepository(db)
segments = tpl_repo.list_segments(plan_check.template_id)
for seg in segments:
avg_duration = (seg.duration_min + seg.duration_max) / 2
svc.create_clip(
plan_id=plan_id,
clip_type="main",
order=seg.segment_order,
duration=avg_duration,
config={
"material_type": seg.material_type or "",
"template_segment_id": seg.id,
},
)
logger.info(
"模板编辑器自动兜底: plan=%s 从旧模板 segments 复制了 %d 个片段",
plan_id,
len(segments),
)
def _auto_fallback_assign_assets(
svc: EditPlanService, plan_id: str, plan_check
) -> list:
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
all_clips = svc.list_clips(plan_id)
clips_without_asset = [c for c in all_clips if not c.asset_id]
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
if clips_without_asset and config_asset_ids:
logger.info(
"模板编辑器自动兜底3: plan=%s%d 个无素材片段分配 %d 个指定素材",
plan_id,
len(clips_without_asset),
len(config_asset_ids),
)
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 = []
return clips_without_asset
def _auto_fallback_auto_material_mode(
svc: EditPlanService,
plan_id: str,
plan_check,
clips_without_asset: list,
asset_library_repo: Any,
asset_repo: Any,
) -> None:
"""自动兜底 4: 项目有视频素材库时自动选素材"""
if not clips_without_asset:
return
if not plan_check.project_id:
return
logger.info(
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
plan_id,
len(clips_without_asset),
)
libs = asset_library_repo.find_by_project(plan_check.project_id)
video_lib = None
for lib in libs:
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if lib_kind == "video":
video_lib = lib
break
if video_lib:
assets = asset_repo.find_by_library(video_lib.id)
ready_videos = [
a
for a in assets
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
and a.mime_type
and a.mime_type.startswith("video")
]
if ready_videos:
random.shuffle(ready_videos)
for i, clip in enumerate(clips_without_asset):
asset = ready_videos[i % len(ready_videos)]
svc.assign_asset(clip.id, asset.id)
logger.info(
"模板编辑器自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材",
plan_id,
video_lib.name,
len(ready_videos),
)
@@ -24,94 +24,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"]) router = APIRouter(tags=["Template Editor"])
def _build_asset_analyses(
asset_ids: list[str],
db: Session,
) -> dict[str, str]:
"""调用 MediaKit 视频理解,返回 {asset_id: 分析文本}.
如果 MediaKit 不可用或分析失败,返回空 dict(调用方降级处理)。
"""
if not asset_ids:
return {}
try:
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.shared.mediakit_client import get_mediakit_client
from packages.shared.storage import get_shared_storage_service
client = get_mediakit_client()
if not client.is_available:
logger.info("MediaKit 未配置,跳过视频理解分析")
return {}
asset_repo = SQLAlchemyAssetRepository(db)
storage_svc = get_shared_storage_service()
# 查找素材并获取下载 URL(使用并行列表保持索引对应,避免 URL 重复导致映射覆盖)
video_urls: list[str] = []
valid_asset_ids: list[str] = []
for aid in asset_ids[:10]: # MediaKit 单次最多 10 个视频
asset = asset_repo.get(aid)
if not asset or not asset.storage_key:
continue
# 只处理视频素材
mime = getattr(asset, "mime_type", "")
if not mime.startswith("video/"):
continue
try:
url = storage_svc.get_download_url(asset.storage_key)
if url:
video_urls.append(url)
valid_asset_ids.append(aid)
except Exception as e:
logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e))
if not video_urls:
logger.info("无可用视频素材,跳过视频理解分析")
return {}
# 调用 MediaKit 视频理解
prompt = (
"请简要描述这段视频的主要内容,包括:场景(室内/室外/具体场所)、"
"主体(人物/物体/动物)、动作/活动、氛围/情绪、主要色调。"
"控制在100字以内。"
)
# 限制轮询参数以适配 API 网关超时(nginx 60s
# 视频理解最多 30spoll_interval=2s * max_poll_attempts=15
# 剩余 30s 留给 LLM 调用
contents = client.analyze_videos(
video_urls=video_urls,
prompt=prompt,
level="Economy",
poll_interval=2.0,
max_poll_attempts=15,
)
if not contents:
logger.warning("MediaKit 视频理解未返回结果")
return {}
# 将结果映射回 asset_id(通过索引对应)
analyses: dict[str, str] = {}
for i, content in enumerate(contents):
if i < len(valid_asset_ids) and content:
analyses[valid_asset_ids[i]] = content
logger.info(
"MediaKit 视频理解完成: total=%d analyzed=%d",
len(video_urls),
len(analyses),
)
return analyses
except Exception as e:
logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e))
return {}
@router.post("/ai-recommend", response_model=AIRecommendResponse) @router.post("/ai-recommend", response_model=AIRecommendResponse)
def editor_ai_recommend( def editor_ai_recommend(
template_id: str, template_id: str,
@@ -134,16 +46,12 @@ def editor_ai_recommend(
from packages.shared.ai_service import run_ai_recommend from packages.shared.ai_service import run_ai_recommend
# 调用 MediaKit 视频理解,获取素材内容分析
asset_analyses = _build_asset_analyses(body.asset_ids, db)
result = run_ai_recommend( result = run_ai_recommend(
plan_id=plan_id, plan_id=plan_id,
template_id=plan.template_id, template_id=plan.template_id,
asset_ids=body.asset_ids, asset_ids=body.asset_ids,
editing_mode=body.editing_mode, editing_mode=body.editing_mode,
target_duration=body.target_duration, target_duration=body.target_duration,
asset_analyses=asset_analyses,
) )
try: try:
+60 -673
View File
@@ -15,33 +15,13 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import random from typing import Any
import re
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import get_asset_repository, get_db_session
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
SEGMENT_EDGE_GAP,
get_used_segments,
make_reuse_callback,
record_used_segments,
remove_used_segment,
)
from app.services.edit_plan_service import EditPlanService from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.template_repository import (
SQLAlchemyTemplateRepository,
)
from packages.domain.plan_generator_utils import _calc_random_start_time
from packages.shared.mediakit_client import get_mediakit_client
from .dependencies import get_draft_plan_id, get_editor_services from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import ( from .schemas import (
@@ -62,104 +42,31 @@ from .schemas import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"]) router = APIRouter(tags=["Template Editor"])
# 编辑器默认片段时长(秒)
_DEFAULT_EDITOR_CLIP_DURATION = 5.0
def _clip_to_response(clip, asset_url: str | None = None) -> EditorClipResponse:
"""统一构造片段响应 — 与 edit_plan_clips 表字段完全对齐"""
def _enum_str(val) -> str:
return val.value if hasattr(val, "value") else str(val)
def _fmt_dt(val) -> str:
if val is None:
return ""
if hasattr(val, "isoformat"):
return val.isoformat()
return str(val)
def _clip_to_response(clip) -> EditorClipResponse:
"""统一构造片段响应"""
return EditorClipResponse( return EditorClipResponse(
id=clip.id, id=clip.id,
plan_id=clip.plan_id, plan_id=clip.plan_id,
clip_type=_enum_str(getattr(clip, "clip_type", "")), clip_type=clip.clip_type.value
if hasattr(clip.clip_type, "value")
else str(clip.clip_type),
order=clip.order, order=clip.order,
duration=clip.duration, duration=clip.duration,
start_time=getattr(clip, "start_time", 0.0) or 0.0,
text_content=clip.text_content or "", text_content=clip.text_content or "",
transition_effect=_enum_str(getattr(clip, "transition_effect", "cut")), transition_effect=clip.transition_effect.value
transition_duration=getattr(clip, "transition_duration", 0.0) or 0.0, if hasattr(clip.transition_effect, "value")
else str(clip.transition_effect),
playback_speed=clip.playback_speed or 1.0, playback_speed=clip.playback_speed or 1.0,
asset_id=getattr(clip, "asset_id", "") or "",
asset_url=asset_url,
status=getattr(clip, "status", "pending") or "pending",
template_clip_config_id=getattr(clip, "template_clip_config_id", "") or "",
config=clip.config or {}, config=clip.config or {},
created_at=_fmt_dt(getattr(clip, "created_at", None)),
updated_at=_fmt_dt(getattr(clip, "updated_at", None)),
) )
def _build_asset_url_map(
asset_ids: list[str],
asset_repo: SQLAlchemyAssetRepository,
) -> dict[str, str | None]:
"""批量查询素材并生成签名URL映射.
Returns:
{asset_id: signed_url_or_None}
"""
if not asset_ids:
return {}
# 去重:多个 clip 可能引用同一个素材
# 去重并保持顺序
seen: set[str] = set()
unique_ids = []
for aid in asset_ids:
if aid and aid not in seen:
seen.add(aid)
unique_ids.append(aid)
result: dict[str, str | None] = {}
try:
storage = get_storage_service()
except Exception:
logger.warning("获取存储服务失败,跳过asset_url生成")
return {aid: None for aid in asset_ids}
# 批量查询所有 Asset(单次 SQL IN 查询,避免 N+1)
try:
assets = asset_repo.find_by_ids(unique_ids)
asset_map = {a.id: a for a in assets}
except Exception:
logger.warning("批量查询素材失败: asset_ids=%s", asset_ids, exc_info=True)
return {aid: None for aid in asset_ids if aid}
for aid in unique_ids:
try:
asset = asset_map.get(aid)
if asset is None:
result[aid] = None
continue
storage_key = getattr(asset, "storage_key", None) or ""
if not storage_key:
result[aid] = None
continue
result[aid] = storage.get_download_url(storage_key, expires_seconds=3600)
except Exception:
logger.warning("生成素材签名URL失败: asset_id=%s", aid, exc_info=True)
result[aid] = None
return result
@router.get("/clips", response_model=EditorClipListResponse) @router.get("/clips", response_model=EditorClipListResponse)
def list_draft_clips( def list_draft_clips(
template_id: str, template_id: str,
plan_id: str = Depends(get_draft_plan_id), plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
skip: int = Query(default=0, ge=0), skip: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=500), limit: int = Query(default=100, ge=1, le=500),
_: AuthenticatedUser = Depends(get_current_user), _: AuthenticatedUser = Depends(get_current_user),
@@ -168,14 +75,8 @@ def list_draft_clips(
_, plan_svc = services _, plan_svc = services
clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit) clips = plan_svc.list_clips(plan_id, skip=skip, limit=limit)
total = plan_svc.count_clips(plan_id) total = plan_svc.count_clips(plan_id)
# 批量解析素材签名URL
asset_ids = [getattr(c, "asset_id", "") or "" for c in clips]
asset_ids = [aid for aid in asset_ids if aid]
url_map = _build_asset_url_map(asset_ids, asset_repo)
return EditorClipListResponse( return EditorClipListResponse(
items=[_clip_to_response(c, asset_url=url_map.get(getattr(c, "asset_id", "") or "")) for c in clips], items=[_clip_to_response(c) for c in clips],
total=total, total=total,
) )
@@ -255,7 +156,6 @@ def get_draft_clip_detail(
clip_id: str, clip_id: str,
plan_id: str = Depends(get_draft_plan_id), plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user), _: AuthenticatedUser = Depends(get_current_user),
): ):
"""获取草稿中的片段详情""" """获取草稿中的片段详情"""
@@ -265,20 +165,16 @@ def get_draft_clip_detail(
raise HTTPException(status_code=404, detail="片段不存在") raise HTTPException(status_code=404, detail="片段不存在")
if clip.plan_id != plan_id: if clip.plan_id != plan_id:
raise HTTPException(status_code=404, detail="片段不存在") raise HTTPException(status_code=404, detail="片段不存在")
return _clip_to_response(clip)
asset_id = getattr(clip, "asset_id", "") or ""
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {}
return _clip_to_response(clip, asset_url=url_map.get(asset_id))
@router.post("/clips/{clip_id}/split", status_code=status.HTTP_200_OK) @router.post("/clips/{clip_id}/split", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
def split_draft_clip( def split_draft_clip(
template_id: str, template_id: str,
clip_id: str, clip_id: str,
body: SplitClipRequest, body: SplitClipRequest,
plan_id: str = Depends(get_draft_plan_id), plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user), _: AuthenticatedUser = Depends(get_current_user),
): ):
"""将一个片段从指定时间点分割为两个片段""" """将一个片段从指定时间点分割为两个片段"""
@@ -289,25 +185,37 @@ def split_draft_clip(
try: try:
result = plan_svc.split_clip(clip_id, body.split_time) result = plan_svc.split_clip(clip_id, body.split_time)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
) from exc
left = result["left_clip"] left = result["left_clip"]
right = result["right_clip"] right = result["right_clip"]
asset_ids = [getattr(left, "asset_id", "") or "", getattr(right, "asset_id", "") or ""]
asset_ids = [a for a in asset_ids if a]
url_map = _build_asset_url_map(asset_ids, asset_repo)
return { return {
"left_clip": _clip_to_response(left, asset_url=url_map.get(getattr(left, "asset_id", "") or "")), "left_clip": {
"right_clip": _clip_to_response(right, asset_url=url_map.get(getattr(right, "asset_id", "") or "")), "id": left.id,
"plan_id": left.plan_id,
"clip_type": left.clip_type,
"order": left.order,
"duration": left.duration,
"start_time": left.start_time,
},
"right_clip": {
"id": right.id,
"plan_id": right.plan_id,
"clip_type": right.clip_type,
"order": right.order,
"duration": right.duration,
"start_time": right.start_time,
},
} }
@router.post("/clips/merge", status_code=status.HTTP_200_OK) @router.post("/clips/merge", response_model=dict[str, Any], status_code=status.HTTP_200_OK)
def merge_draft_clips( def merge_draft_clips(
template_id: str, template_id: str,
body: MergeClipsRequest, body: MergeClipsRequest,
plan_id: str = Depends(get_draft_plan_id), plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
_: AuthenticatedUser = Depends(get_current_user), _: AuthenticatedUser = Depends(get_current_user),
): ):
"""将多个连续的同类型片段合并为一个片段""" """将多个连续的同类型片段合并为一个片段"""
@@ -319,12 +227,16 @@ def merge_draft_clips(
try: try:
merged = plan_svc.merge_clips(body.clip_ids) merged = plan_svc.merge_clips(body.clip_ids)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(
asset_id = getattr(merged, "asset_id", "") or "" status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
url_map = _build_asset_url_map([asset_id], asset_repo) if asset_id else {} ) from exc
return { return {
"merged_clip": _clip_to_response(merged, asset_url=url_map.get(asset_id)), "id": merged.id,
"deleted_clip_ids": body.clip_ids, "plan_id": merged.plan_id,
"clip_type": merged.clip_type,
"order": merged.order,
"duration": merged.duration,
"text_content": merged.text_content,
} }
@@ -367,565 +279,40 @@ def batch_delete_editor_clips(
return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id) return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id)
def _safe_segment_duration(value, default: float) -> float:
"""安全地将数据库中的时长值转换为正浮点数.
处理 None、无效类型、负数、NaN 等异常情况。
"""
if value is None:
return default
try:
result = float(value)
except (ValueError, TypeError):
return default
if result != result or result <= 0: # NaN check or non-positive
return default
return result
def _get_template_segments(
template_id: str,
tpl_svc: EditTemplateService,
db: Session,
) -> list[tuple[int, float, float]]:
"""获取模板的片段配置(顺序、最短时长、最长时长).
优先从新模板系统(template_clip_configs)查询,
若不存在则回退到旧模板系统(template_segments)。
Returns:
[(segment_order, duration_min, duration_max), ...] 按 order 排序
"""
# 优先查新模板系统
try:
clip_configs = tpl_svc.list_clip_configs(template_id)
if clip_configs:
result = []
for cc in clip_configs:
dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(
cc.max_duration or cc.min_duration,
_DEFAULT_EDITOR_CLIP_DURATION,
)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((cc.order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("新模板系统查询clip_configs失败,回退到旧系统", exc_info=True)
# 回退到旧模板系统(template_segments表)
try:
old_repo = SQLAlchemyTemplateRepository(db)
segments = old_repo.list_segments(template_id)
if segments:
result = []
for s in segments:
dur_min = _safe_segment_duration(s.duration_min, _DEFAULT_EDITOR_CLIP_DURATION)
dur_max = _safe_segment_duration(s.duration_max, _DEFAULT_EDITOR_CLIP_DURATION)
dur_min, dur_max = min(dur_min, dur_max), max(dur_min, dur_max)
result.append((s.segment_order, dur_min, dur_max))
return sorted(result, key=lambda x: x[0])
except Exception:
logger.warning("旧模板系统查询segments失败", exc_info=True)
return []
def _recommended_time_conflicts(
start: float,
duration: float,
used: list[tuple[float, float]],
edge_gap: float = SEGMENT_EDGE_GAP,
) -> bool:
"""检查推荐起始时间是否与已使用时间段冲突.
冲突检测统一加 ``edge_gap`` 秒边缘间隙:已用区间按 [s-gap, e+gap] 扩边后判定,
避免推荐片段与已用片段首尾紧贴导致画面观感重复。
"""
end = start + duration
for used_start, used_end in used:
if start < used_end + edge_gap and end > used_start - edge_gap:
return True
return False
def _get_mediakit_recommendations(
asset_ids: list[str],
asset_repo,
) -> dict[str, float]:
"""调用 MediaKit 视频理解,获取智能选片推荐起始时间.
尝试让 MediaKit 分析视频内容,返回每个素材的推荐起始时间。
任何异常都优雅降级,返回空字典(调用方降级到随机选择)。
"""
try:
client = get_mediakit_client()
if not client.is_available:
logger.info("MediaKit 未配置,使用随机起始时间")
return {}
storage = get_storage_service()
video_urls: list[str] = []
valid_asset_ids: list[str] = []
for asset_id in asset_ids[:10]:
asset = asset_repo.get(asset_id)
if not asset or not getattr(asset, "storage_key", None):
continue
mime = getattr(asset, "mime_type", "")
if not mime.startswith("video/"):
continue
try:
url = storage.get_download_url(asset.storage_key)
if url:
video_urls.append(url)
valid_asset_ids.append(asset_id)
except Exception as e:
logger.warning("获取素材URL失败: asset_id=%s error=%s", asset_id, e)
if not video_urls:
return {}
prompt = (
"请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。"
"考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。"
"请严格以JSON数组格式返回,不要包含其他文字:"
'[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]'
)
contents = client.analyze_videos(
video_urls=video_urls,
prompt=prompt,
level="Economy",
poll_interval=2.0,
max_poll_attempts=15,
)
if not contents:
logger.info("MediaKit 分析无结果,降级为随机选择")
return {}
# 按索引映射结果:contents[i] 对应 valid_asset_ids[i]
recommendations: dict[str, float] = {}
for idx, content_text in enumerate(contents):
if idx >= len(valid_asset_ids):
break
asset_id = valid_asset_ids[idx]
if not content_text:
continue
# 尝试从文本中提取 JSON
parsed = False
# 尝试直接解析
try:
data = json.loads(content_text.strip())
if isinstance(data, list) and data:
for item in data:
if isinstance(item, dict) and "recommended_start_time" in item:
recommendations[asset_id] = float(item["recommended_start_time"])
parsed = True
break
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 尝试从 markdown 代码块中提取 JSON
if not parsed:
json_match = re.search(r"\[\s*(\{.*?\})\s*\]", content_text, re.DOTALL)
if json_match:
try:
item = json.loads(json_match.group(1))
if isinstance(item, dict) and "recommended_start_time" in item:
recommendations[asset_id] = float(item["recommended_start_time"])
parsed = True
except (json.JSONDecodeError, ValueError, TypeError):
pass
# 尝试正则提取
if not parsed:
time_match = re.search(r'recommended_start_time["\s:]+([\d.]+)', content_text)
if time_match:
try:
recommendations[asset_id] = float(time_match.group(1))
except (ValueError, TypeError):
pass
if recommendations:
logger.info("MediaKit 智能选片推荐: %s", recommendations)
else:
logger.info("MediaKit 结果解析失败,降级为随机选择")
return recommendations
except Exception as e:
logger.warning("MediaKit 智能选片异常,降级为随机选择: %s", e)
return {}
@router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse) @router.post("/clips/from-assets", response_model=ClipsFromAssetsResponse)
def create_clips_from_assets_editor( def create_clips_from_assets_editor(
template_id: str, template_id: str,
body: ClipsFromAssetsRequest, body: ClipsFromAssetsRequest,
background_tasks: BackgroundTasks,
plan_id: str = Depends(get_draft_plan_id), plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services), services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
asset_repo: SQLAlchemyAssetRepository = Depends(get_asset_repository),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user), current_user: AuthenticatedUser = Depends(get_current_user),
) -> ClipsFromAssetsResponse: ) -> ClipsFromAssetsResponse:
"""从素材批量创建片段(按模板segment配置创建,MediaKit异步更新). """从素材批量创建片段"""
_, plan_svc = services
逻辑: clips = []
1. 从模板读取 segments,片段数量 = segment 数量(忽略前端传的 required_clips_count for i, asset_id in enumerate(body.asset_ids):
2. 每个片段时长在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数) try:
3. 素材按片段顺序轮询分配,素材不够时同一素材切多个片段 clip = plan_svc.create_clip(
4. 使用 replace_all_clips_transactional 原子性地清空旧片段并创建新的(随机起始时间) plan_id,
5. 立即返回响应(目标 <1秒) clip_type="main",
6. 后台异步任务:调用 MediaKit 智能选片并更新片段的 start_time order=body.start_order + i if hasattr(body, "start_order") else i,
7. 素材时长为 0 或缺失时报 400,不创建无效片段 duration=5.0,
""" asset_id=asset_id,
tpl_svc, plan_svc = services
# 1. 查询模板 segments
segments = _get_template_segments(template_id, tpl_svc, db)
if not segments:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="模板没有片段配置,无法创建片段",
)
# 防御:schema validator 已过滤 null/空串,这里再归一化一次,
# 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422
asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()]
if not asset_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材列表为空,无法创建片段",
)
# 2. 获取素材实际时长(去重查询)
unique_asset_ids = list(dict.fromkeys(asset_ids))
asset_durations: dict[str, float] = {}
for asset_id in unique_asset_ids:
asset = asset_repo.get(asset_id)
if asset and hasattr(asset, "duration"):
asset_durations[asset_id] = float(asset.duration or 0.0)
# 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit)
# 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重),
# 格式与 _calc_random_start_time 的 used_segments 参数一致
used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(db, unique_asset_ids)
# 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间,
# 复用片段时长累加到 reused_durations 供 15% 占比控制
reused_durations: dict[str, float] = {}
# 本条成片中每个素材被分配的片段总时长(复用占比分母)
asset_assigned_durations: dict[str, float] = {}
# 受控复用回调:区间耗尽时复用最久未用且 use_count<3 的历史区间;
# 回调内部预判复用后占比是否超 15%,超限拒绝复用(返回 None)
reuse_cb = make_reuse_callback(
db,
asset_durations,
reused_durations,
assigned_tracker=asset_assigned_durations,
)
clips_data: list[dict] = []
def _reuse_ratio_exceeded(aid: str, extra: float = 0.0) -> bool:
"""该素材在本条成片中「已复用片段时长 / 已分配片段总时长」是否已超 15%
在为下一片段选素材时调用:本片段尚未分配,复用状态只在分配后的回调里
更新,因此直接检查当前占比——一旦已超 15%,该素材不再参与后续分配。
assigned=0(首个片段)放行;reused=0(尚未发生复用)时不误拦正常分配。
"""
assigned = asset_assigned_durations.get(aid, 0.0)
if assigned <= 0:
return False
return reused_durations.get(aid, 0.0) / assigned > REUSE_RATIO_LIMIT
for i, (_seg_order, dur_min, dur_max) in enumerate(segments):
# 在 segment 的 duration_min ~ duration_max 之间随机取值(保留一位小数)
raw_duration = random.uniform(dur_min, dur_max)
# 轮询分配素材:跳过时长缺失、复用占比已超 15% 阈值的素材;
# 选中后计算起点,若该素材可用区间耗尽且复用被闸门拒绝(calc 返回 None),
# 继续轮询下一个素材
asset_id = ""
clip_duration = 0.0
start_time: float | None = None
n_assets = len(asset_ids)
for offset in range(n_assets):
candidate = asset_ids[(i + offset) % n_assets]
candidate_total = asset_durations.get(candidate, 0.0)
if candidate_total <= 0:
continue
candidate_duration = min(round(raw_duration, 1), candidate_total)
if candidate_duration <= 0:
continue
if _reuse_ratio_exceeded(candidate, candidate_duration):
logger.info(
"from-assets 素材复用占比超 %.0f%% 阈值,跳过分配: asset_id=%s",
REUSE_RATIO_LIMIT * 100,
candidate,
)
continue
# 随机起始时间(不调用 MediaKit,保证接口快速返回);100 次避不开
# 历史区间时走受控复用回调(复用片段累加 reused_durations,回调内部
# 预判复用后占比超 15% 则拒绝并返回 None)
candidate_start = _calc_random_start_time(
candidate,
candidate_duration,
asset_durations,
used_segments,
on_exhausted=reuse_cb,
) )
if candidate_start is None: clips.append(clip)
# 该素材可用区间耗尽且复用被闸门/use_count 上限拒绝 → 尝试下一素材 except ValueError:
logger.info( pass
"from-assets 素材无可用可切区间(复用被拒),轮询下一素材: asset_id=%s",
candidate,
)
continue
asset_id = candidate
clip_duration = candidate_duration
start_time = candidate_start
break
if not asset_id or start_time is None:
# 所有素材时长缺失、复用占比超阈值,或区间耗尽且复用被拒 → 素材可切区间不足
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="素材可切区间不足,请补充新素材",
)
# 记录已使用时间段(内存,供本次后续片段避开)
used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration))
asset_assigned_durations[asset_id] = asset_assigned_durations.get(asset_id, 0.0) + clip_duration
# 同步写入素材 metadata(不 commit,与下方 replace_all_clips_transactional
# 处于同一事务,任一步失败整体回滚,不留脏数据);
# 复用区间与历史记录高度重叠时 record 内部自动累加 use_count
record_used_segments(db, asset_id, start_time, start_time + clip_duration, plan_id)
clips_data.append(
{
"order": i,
"asset_id": asset_id,
"start_time": start_time,
"duration": clip_duration,
"clip_type": body.clip_type or "main",
}
)
# 4. 事务性替换:清空旧片段 → 创建新片段 → 标记ready(单事务,失败自动回滚)
created_count = plan_svc.replace_all_clips_transactional(plan_id, clips_data)
logger.info( logger.info(
"from-assets按模板创建片段(异步): template_id=%s plan_id=%s segments=%d created=%d by user=%s", "模板编辑器从素材创建片段: template_id=%s plan_id=%s count=%d by user=%s",
template_id, template_id,
plan_id, plan_id,
len(segments), len(clips),
created_count,
current_user.user.id, current_user.user.id,
) )
# 5. 触发后台任务:异步调用 MediaKit 并更新片段起始时间
background_tasks.add_task(
_update_mediakit_recommendations_async,
plan_id,
unique_asset_ids,
)
# 6. 立即返回响应
return ClipsFromAssetsResponse( return ClipsFromAssetsResponse(
created_count=created_count, created_count=len(clips),
plan_id=plan_id, plan_id=plan_id,
clip_ids=[], clip_ids=[c.id for c in clips],
) )
def _update_mediakit_recommendations_async( # pragma: no cover
plan_id: str,
asset_ids: list[str],
) -> None:
"""后台任务:调用 MediaKit 智能选片并更新片段的起始时间.
此函数在后台异步执行,不影响接口响应时间。
失败时静默处理,不影响已创建的片段。
"""
from collections import defaultdict
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.session import SessionLocal
db = None
try:
# 复用应用全局 Session(避免每次创建新连接池导致资源泄漏)
if SessionLocal is None:
logger.warning("后台任务: SessionLocal 未初始化,跳过 MediaKit 更新")
return
db = SessionLocal()
# 初始化服务
asset_repo = SQLAlchemyAssetRepository(db)
plan_svc = EditPlanService(db)
# 调用 MediaKit 获取推荐时间
recommendations = _get_mediakit_recommendations(asset_ids, asset_repo)
if not recommendations:
logger.info("后台任务: MediaKit 无推荐结果,跳过更新")
return
# 查询该 plan 的所有片段(分批获取,避免硬编码 limit 截断)
batch_size = 500
all_clips = []
offset = 0
while True:
batch = plan_svc.list_clips(plan_id, skip=offset, limit=batch_size)
if not batch:
break
all_clips.extend(batch)
if len(batch) < batch_size:
break
offset += batch_size
clips = all_clips
if not clips:
logger.info("后台任务: plan_id=%s 无片段,跳过更新", plan_id)
return
# 批量预加载所有涉及的素材(消除 N+1 查询)
unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""})
assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)}
# 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环)
clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list)
for clip in clips:
aid = getattr(clip, "asset_id", "") or ""
if aid and clip.start_time is not None:
clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration))
# 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录):
# MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面
historical_segments = get_used_segments(db, unique_asset_ids)
# 已更新的片段ID(用于排除已移动的旧时间段)
updated_clip_ids: set[str] = set()
# 已更新的时间段
updated_segments: dict[str, list[tuple[float, float]]] = {}
updated_count = 0
# 遍历片段,按 asset_id 匹配推荐时间
for clip in clips:
asset_id = getattr(clip, "asset_id", "") or ""
if not asset_id or asset_id not in recommendations:
continue
recommended_start = recommendations[asset_id]
clip_duration = clip.duration
# 从预加载字典获取素材(O(1) 查找)
asset = assets_map.get(asset_id)
if not asset:
continue
asset_total = float(getattr(asset, "duration", 0.0) or 0.0)
if asset_total <= 0:
continue
# 推荐时间 + 片段时长不能超过素材总时长
if recommended_start + clip_duration > asset_total:
logger.info(
"后台任务: 推荐时间越界,跳过: asset_id=%s recommended=%.2f duration=%.1f total=%.1f",
asset_id,
recommended_start,
clip_duration,
asset_total,
)
continue
# 构建排除当前片段及已更新片段后的占用列表(O(M),M=同素材片段数)
other_segments: list[tuple[float, float]] = [
(cs, ce)
for cid, cs, ce in clips_by_asset.get(asset_id, [])
if cid != clip.id and cid not in updated_clip_ids
]
other_segments.extend(updated_segments.get(asset_id, []))
# 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重:
# 本 plan 片段创建时已写入历史记录
# 并入该素材全部历史已用区间(含其他 plan/其他任务)。
# set 去重前先归一化精度(round 3 位),避免浮点尾差导致逻辑相同的
# 区间(如 1.0 与 1.0000000001)被误判为不同区间
def _norm(segs):
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs}
other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, [])))
# 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙):
# 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面)
if _recommended_time_conflicts(recommended_start, clip_duration, other_segments):
logger.info(
"后台任务: 推荐时间与同片/历史区间冲突,保留原起点: asset_id=%s recommended=%.2f",
asset_id,
recommended_start,
)
continue
# 逐个更新并捕获异常(单点失败不影响其他片段)
try:
old_start = clip.start_time
old_end = old_start + clip_duration
# MediaKit 移动片段起点 + 同步素材 metadata 区间记录放在同一事务:
# 删旧区间记录(按 plan_id + 旧 start 匹配,兼容无 plan_id 的旧数据)、
# 写新区间,最后统一 commit;任一步失败整体 rollback
# 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。
plan_svc.update_clip(clip.id, start_time=recommended_start)
try:
if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id):
record_used_segments(
db,
asset_id,
recommended_start,
recommended_start + clip_duration,
plan_id,
)
except Exception as me:
logger.warning(
"后台任务: 同步素材区间记录失败,回滚本次片段更新: clip_id=%s error=%s",
clip.id,
me,
)
db.rollback()
continue
db.commit()
updated_count += 1
updated_clip_ids.add(clip.id)
except Exception as ue:
logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue)
try:
db.rollback()
except Exception:
pass
continue
updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration))
logger.info(
"后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f",
clip.id,
asset_id,
recommended_start,
)
logger.info("后台任务完成: plan_id=%s 成功更新 %d 个片段", plan_id, updated_count)
except Exception as e:
# 后台任务失败不影响已创建的片段,静默处理
logger.warning("后台任务异常: plan_id=%s error=%s", plan_id, e, exc_info=True)
if db:
try:
db.rollback()
except Exception:
pass
finally:
if db:
try:
db.close()
except Exception:
pass
+209
View File
@@ -0,0 +1,209 @@
"""封面管理路由.
端点:
- GET /cover 封面配置
- PUT /cover 更新封面
- POST /cover/extract 抽帧生成封面
- POST /cover/smart 智能选帧
- POST /generate-cover AI 生成封面
"""
from __future__ import annotations
import logging
from app.auth import AuthenticatedUser, get_current_user
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException
from packages.domain.config_schemas import normalize_plan_config
from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import (
CoverConfigResponse,
CoverExtractRequest,
CoverGenerateResponse,
CoverSmartRequest,
CoverUpdateRequest,
GenerateCoverRequest,
GenerateCoverResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.get("/cover", response_model=CoverConfigResponse)
def get_editor_cover(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> CoverConfigResponse:
"""获取草稿封面配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = plan.config or {}
cover_config = config.get("cover", {})
return CoverConfigResponse(
type=cover_config.get("cover_type", "auto"),
image_url=cover_config.get("cover_image_url", ""),
frame_time=cover_config.get("frame_time", 0.0),
)
@router.put("/cover", response_model=CoverConfigResponse)
def update_editor_cover(
template_id: str,
body: CoverUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
) -> CoverConfigResponse:
"""更新草稿封面配置"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
config = dict(plan.config) if plan.config else {}
current_cover = dict(config.get("cover", {}))
update_data = body.model_dump(exclude_none=True)
current_cover.update(update_data)
config["cover"] = current_cover
normalized = normalize_plan_config(config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
return CoverConfigResponse(
type=current_cover.get("cover_type", "auto"),
image_url=current_cover.get("cover_image_url", ""),
frame_time=current_cover.get("frame_time", 0.0),
)
@router.post("/cover/extract", response_model=CoverGenerateResponse)
def extract_editor_cover(
template_id: str,
body: CoverExtractRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> CoverGenerateResponse:
"""从指定片段抽帧生成封面"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
clip = plan_svc.get_clip(body.clip_id)
if not clip or clip.plan_id != plan_id:
raise HTTPException(status_code=400, detail="片段不存在或不属于当前草稿")
cover_url = f"cover/extract/{plan_id}_{body.clip_id}_{body.frame_time}.jpg"
config = dict(plan.config) if plan.config else {}
cover_config = dict(config.get("cover", {}))
cover_config.update(
{
"cover_type": "extract",
"cover_image_url": cover_url,
"clip_id": body.clip_id,
"frame_time": body.frame_time,
}
)
config["cover"] = cover_config
normalized = normalize_plan_config(config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"模板编辑器封面抽帧: template_id=%s plan_id=%s clip_id=%s by user=%s",
template_id,
plan_id,
body.clip_id,
current_user.user.id,
)
return CoverGenerateResponse(
type="extract",
image_url=cover_url,
frame_time=body.frame_time,
)
@router.post("/cover/smart", response_model=CoverGenerateResponse)
def smart_editor_cover(
template_id: str,
body: CoverSmartRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> CoverGenerateResponse:
"""智能选帧生成封面"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
cover_url = f"cover/smart/{plan_id}_smart.jpg"
strategy = getattr(body, "strategy", "auto")
config = dict(plan.config) if plan.config else {}
cover_config = dict(config.get("cover", {}))
cover_config.update(
{
"cover_type": "smart",
"cover_image_url": cover_url,
"strategy": strategy,
}
)
config["cover"] = cover_config
normalized = normalize_plan_config(config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"模板编辑器智能封面: template_id=%s plan_id=%s strategy=%s by user=%s",
template_id,
plan_id,
strategy,
current_user.user.id,
)
return CoverGenerateResponse(
type="smart",
image_url=cover_url,
frame_time=None,
)
@router.post("/generate-cover", response_model=GenerateCoverResponse)
def editor_generate_cover(
template_id: str,
body: GenerateCoverRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
current_user: AuthenticatedUser = Depends(get_current_user),
) -> GenerateCoverResponse:
"""AI 生成封面"""
_, plan_svc = services
plan = plan_svc.get_plan_or_raise(plan_id)
from packages.shared.ai_service import run_generate_cover
cover_data = run_generate_cover(
plan_id=plan_id,
asset_ids=body.asset_ids,
cover_type=body.cover_type,
frame_time=body.frame_time,
)
current_config = dict(plan.config) if plan.config else {}
current_config["cover"] = cover_data
normalized = normalize_plan_config(current_config)
plan_svc.update_plan_config(plan_id, {"cover": normalized["cover"]})
logger.info(
"模板编辑器封面生成: template_id=%s plan_id=%s type=%s by user=%s",
template_id,
plan_id,
body.cover_type,
current_user.user.id,
)
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
@@ -3,6 +3,7 @@
核心依赖: 核心依赖:
- get_editor_services: 获取模板+计划服务 - get_editor_services: 获取模板+计划服务
- get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id - get_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
- _check_queue_limits: 生成队列限流检查
""" """
from __future__ import annotations from __future__ import annotations
@@ -10,6 +11,7 @@ from __future__ import annotations
import logging import logging
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.task_enqueue import GLOBAL_PENDING_LIMIT, USER_PENDING_LIMIT
from app.dependencies import get_db_session from app.dependencies import get_db_session
from app.services.edit_plan_service import EditPlanService from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService from app.services.edit_template_service import EditTemplateService
@@ -111,3 +113,29 @@ def get_draft_plan_id(
user_id, user_id,
) )
return plan.id return plan.id
def _check_queue_limits(gen_task_repo, user_id: str) -> None:
"""队列限流预检查"""
try:
has_count = (
hasattr(gen_task_repo, "count_pending_by_user")
and hasattr(gen_task_repo, "count_pending_total")
)
if has_count:
user_pending = gen_task_repo.count_pending_by_user(user_id)
global_pending = gen_task_repo.count_pending_total()
if user_pending >= USER_PENDING_LIMIT:
raise HTTPException(
status_code=429,
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
)
if global_pending >= GLOBAL_PENDING_LIMIT:
raise HTTPException(
status_code=503,
detail="系统繁忙,请稍后再试",
)
except HTTPException:
raise
except Exception as e:
logger.warning("[模板编辑器队列限流] 检查失败,跳过: %s", e)
@@ -17,8 +17,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from .dependencies import get_draft_plan_id, get_editor_services from .dependencies import get_draft_plan_id, get_editor_services
from .schemas import ( from .schemas import (
EditorClipBatchUpdateRequest,
EditorClipBatchUpdateResponse,
EditorDraftResponse, EditorDraftResponse,
EditorPublishResponse, EditorPublishResponse,
EditorRollbackRequest, EditorRollbackRequest,
@@ -128,7 +126,11 @@ def list_template_versions(
clip_count=len(v.clip_configs), clip_count=len(v.clip_configs),
change_note=v.change_note, change_note=v.change_note,
published_by=v.published_by, published_by=v.published_by,
created_at=(v.created_at.isoformat() if hasattr(v.created_at, "isoformat") else str(v.created_at)), created_at=(
v.created_at.isoformat()
if hasattr(v.created_at, "isoformat")
else str(v.created_at)
),
) )
for v in versions for v in versions
] ]
@@ -160,35 +162,3 @@ def rollback_template(
new_version=tpl.version, new_version=tpl.version,
clip_count=len(clip_configs), clip_count=len(clip_configs),
) )
@router.put("/clips", response_model=EditorClipBatchUpdateResponse)
def batch_update_clips(
template_id: str,
req: EditorClipBatchUpdateRequest,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
_: AuthenticatedUser = Depends(get_current_user),
):
"""批量替换草稿clips(全量覆盖,用于前端选择素材后同步片段)
事务保证:清空→创建→标记ready 在同一数据库事务内完成,
任何步骤失败时自动回滚,避免数据不一致。
"""
_, plan_svc = services
plan_svc.get_plan_or_raise(plan_id)
clips_data = []
for clip_item in req.clips:
item = {
"asset_id": clip_item.asset_id,
"start_time": clip_item.start_time,
"duration": clip_item.duration,
}
if clip_item.order is not None:
item["order"] = clip_item.order
clips_data.append(item)
plan_svc.replace_all_clips_transactional(plan_id, clips_data)
return EditorClipBatchUpdateResponse(plan_id=plan_id, clip_count=len(req.clips))
+250
View File
@@ -0,0 +1,250 @@
"""草稿生成路由.
端点:
- POST /generate 触发生成
- GET /generation-status 生成进度
- GET /generations 生成记录列表
"""
from __future__ import annotations
import logging
from typing import Any
from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app
from app.core.storage import OSSStorageService, get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_db_session,
)
from app.schemas.generation_task import GenerationTaskResponse
from app.services.edit_plan_service import EditPlanService
from app.services.edit_template_service import EditTemplateService
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
SQLAlchemyGenerationTaskRepository,
)
from packages.application.generation_tasks import (
CreateGenerationTaskCommand,
CreateGenerationTaskUseCase,
)
from packages.domain.edit_plan import EditPlanStatus
from ._fallback import (
_auto_fallback_assign_assets,
_auto_fallback_auto_material_mode,
_auto_fallback_copy_template_clips,
_auto_fallback_draft_to_editing,
)
from .dependencies import _check_queue_limits, get_draft_plan_id, get_editor_services
from .schemas import (
ClipStatusItem,
EditPlanGenerateResponse,
EditPlanGenerationsResponse,
EditPlanGenerationStatusResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Template Editor"])
@router.post("/generate", response_model=EditPlanGenerateResponse)
def generate_editor_draft(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
current_user: AuthenticatedUser = Depends(get_current_user),
asset_library_repo: Any = Depends(get_asset_library_repository),
asset_repo: Any = Depends(get_asset_repository),
) -> EditPlanGenerateResponse:
"""触发模板草稿渲染生成"""
_, plan_svc = services
plan_check = plan_svc.get_plan_or_raise(plan_id)
# 自动兜底流程
_auto_fallback_draft_to_editing(plan_svc, plan_id, plan_check)
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
_auto_fallback_auto_material_mode(
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
if not can_gen:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=reason
)
try:
clip_count = plan_svc.mark_clips_ready(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
user_id = current_user.user.id
_check_queue_limits(gen_task_repo, user_id)
gen_task_use_case = CreateGenerationTaskUseCase(gen_task_repo)
plan = plan_svc.get_plan_or_raise(plan_id)
config_asset_ids = (plan.config or {}).get("asset_ids", [])
gen_task = gen_task_use_case.execute(
CreateGenerationTaskCommand(
project_id=plan.project_id or "",
template_id=plan.template_id,
created_by_user_id=current_user.user.id,
source_edit_plan_id=plan_id,
asset_ids=list(config_asset_ids) if config_asset_ids else [],
),
)
plan_svc.update_plan_config(plan_id, {"generation_task_id": gen_task.id})
plan_svc.transition_status(plan_id, EditPlanStatus.RENDERING)
celery_app.send_task("worker.render_edit_plan", args=[plan_id])
updated_plan = plan_svc.get_plan_or_raise(plan_id)
logger.info(
"模板编辑器触发生成: template_id=%s plan_id=%s gen_task_id=%s clips=%d by user=%s",
template_id,
plan_id,
gen_task.id,
clip_count,
current_user.user.id,
)
return EditPlanGenerateResponse(
plan_id=plan_id,
plan_status=updated_plan.status.value
if hasattr(updated_plan.status, "value")
else updated_plan.status,
generation_task_id=gen_task.id,
clip_count=clip_count,
)
except HTTPException:
raise
except Exception as _e:
logger.exception(
"模板编辑器触发生成失败: template_id=%s plan_id=%s",
template_id,
plan_id,
)
try:
plan_svc.transition_status(plan_id, EditPlanStatus.FAILED)
except Exception:
pass
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="生成失败,请稍后重试",
) from _e
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
def get_editor_generation_status(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
storage_service: OSSStorageService = Depends(get_storage_service),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationStatusResponse:
"""查询草稿生成进度"""
_, plan_svc = services
try:
gen_status = plan_svc.get_generation_status(plan_id)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
) from exc
plan = gen_status["plan"]
clips = gen_status["clips"]
clip_items = [
ClipStatusItem(
clip_id=c.id,
clip_type=c.clip_type,
order=c.order,
status=c.status.value if hasattr(c.status, "value") else c.status,
asset_id=c.asset_id or "",
text_content=c.text_content or "",
duration=c.duration,
)
for c in clips
]
raw_video_url = (plan.config or {}).get("rendered_url", "")
video_url = ""
if raw_video_url:
try:
video_url = storage_service.get_download_url(
raw_video_url, expires_seconds=86400
)
except Exception as e:
logger.warning(
"生成视频签名URL失败: template_id=%s error=%s", template_id, e
)
video_url = raw_video_url
progress = gen_status.get("progress", 0.0)
error_message = gen_status.get("error_message", "")
gen_task_status = gen_status.get("generation_task_status")
plan_status_val = (
plan.status.value if hasattr(plan.status, "value") else plan.status
)
if plan_status_val == "completed" and progress < 100:
progress = 100.0
return EditPlanGenerationStatusResponse(
plan_id=plan_id,
plan_status=plan_status_val,
generation_task_id=gen_status["generation_task_id"],
generation_task_status=gen_task_status,
progress=progress,
video_url=video_url,
error_message=error_message,
clips=clip_items,
)
@router.get("/generations", response_model=EditPlanGenerationsResponse)
def list_editor_generations(
template_id: str,
plan_id: str = Depends(get_draft_plan_id),
services: tuple[EditTemplateService, EditPlanService] = Depends(get_editor_services),
db: Session = Depends(get_db_session),
_: AuthenticatedUser = Depends(get_current_user),
) -> EditPlanGenerationsResponse:
"""查询草稿关联的生成记录列表"""
_, plan_svc = services
plan_svc.get_plan_or_raise(plan_id)
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
items = [
GenerationTaskResponse(
id=t.id,
project_id=t.project_id,
asset_library_id=t.asset_library_id,
strategy_id=t.strategy_id,
voice_library_id=t.voice_library_id,
template_id=t.template_id,
asset_ids=t.asset_ids,
title_ids=t.title_ids,
voice_ids=t.voice_ids,
source_edit_plan_id=t.source_edit_plan_id or "",
status=t.status.value if hasattr(t.status, "value") else t.status,
progress=t.progress,
result_count=t.result_count,
error_message=t.error_message,
)
for t in tasks
]
return EditPlanGenerationsResponse(items=items, total=len(items))
@@ -8,6 +8,7 @@ from __future__ import annotations
import re as _re import re as _re
from typing import Any, List, Optional from typing import Any, List, Optional
from app.schemas.generation_task import GenerationTaskResponse
from pydantic import BaseModel, Field, validator from pydantic import BaseModel, Field, validator
_EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$") _EXPORT_RESOLUTION_PATTERN = _re.compile(r"^\d+x\d+$")
@@ -15,6 +16,50 @@ _EXPORT_VALID_QUALITY_PRESETS = {"ultra_fast", "fast", "balanced", "high", "best
_EXPORT_VALID_FORMATS = {"mp4", "mov"} _EXPORT_VALID_FORMATS = {"mp4", "mov"}
# ── 生成状态相关 ────────────────────────────────────────────────────────────
class ClipStatusItem(BaseModel):
"""片段生成状态"""
clip_id: str
clip_type: str
order: int
status: str
asset_id: str
text_content: str
duration: float
class EditPlanGenerationStatusResponse(BaseModel):
"""剪辑计划生成进度响应体"""
plan_id: str
plan_status: str
generation_task_id: Optional[str] = None
generation_task_status: Optional[str] = None
progress: float = 0.0
video_url: str = ""
error_message: str = ""
clips: List[ClipStatusItem]
class EditPlanGenerateResponse(BaseModel):
"""剪辑计划触发生成响应体"""
plan_id: str
plan_status: str
generation_task_id: str
clip_count: int
class EditPlanGenerationsResponse(BaseModel):
"""剪辑计划关联的生成记录列表响应体"""
items: List[GenerationTaskResponse]
total: int
# ── AI 推荐 ──────────────────────────────────────────────────────────────── # ── AI 推荐 ────────────────────────────────────────────────────────────────
@@ -22,8 +67,12 @@ class AIRecommendRequest(BaseModel):
"""AI 推荐片段方案请求体""" """AI 推荐片段方案请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表") asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表")
editing_mode: str = Field(default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip") editing_mode: str = Field(
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)") default="one_take", description="剪辑模式: one_take / pip / voice_over / voice_pip"
)
target_duration: float = Field(
default=30.0, ge=1.0, le=600.0, description="目标时长(秒)"
)
class AIRecommendClipItem(BaseModel): class AIRecommendClipItem(BaseModel):
@@ -50,6 +99,31 @@ class AIRecommendResponse(BaseModel):
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)") confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
# ── 封面生成 ────────────────────────────────────────────────────────────────
class GenerateCoverRequest(BaseModel):
"""AI 封面生成请求体"""
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
cover_type: str = Field(
default="ai_frame",
description="封面类型: ai_frame / manual / upload / ai_regenerate",
)
frame_time: Optional[float] = Field(
default=None,
ge=0.0,
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
)
class GenerateCoverResponse(BaseModel):
"""AI 封面生成响应体"""
plan_id: str = Field(..., description="剪辑计划 ID")
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
# ── BGM ──────────────────────────────────────────────────────────────────── # ── BGM ────────────────────────────────────────────────────────────────────
@@ -165,20 +239,10 @@ class ClipBatchDeleteResponse(BaseModel):
class ClipsFromAssetsRequest(BaseModel): class ClipsFromAssetsRequest(BaseModel):
"""从素材批量创建片段请求""" """从素材批量创建片段请求"""
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") asset_ids: List[str] = Field(
clip_type: str = Field(default="main", description="片段类型,默认 main") ..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾"
required_clips_count: Optional[int] = Field(
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
) )
clip_type: str = Field(default="main", description="片段类型,默认 main")
@validator("asset_ids", pre=True)
def _drop_invalid_asset_ids(cls, v): # noqa: N805
"""容错过滤:前端异常情况下可能把 undefined 序列化成 null 或空串混入
asset_ids(会直接 422 或导致后续 /assets/{id} 404),这里统一剔除。
过滤后为空时由 Field(min_length=1) / 路由层 400 兜底。"""
if not isinstance(v, list):
return v
return [x for x in v if isinstance(x, str) and x.strip()]
class ClipsFromAssetsResponse(BaseModel): class ClipsFromAssetsResponse(BaseModel):
@@ -186,7 +250,6 @@ class ClipsFromAssetsResponse(BaseModel):
success: bool = True success: bool = True
created_count: int created_count: int
plan_id: str = ""
message: str = "" message: str = ""
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表") clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
@@ -194,6 +257,43 @@ class ClipsFromAssetsResponse(BaseModel):
# ── 封面配置 ──────────────────────────────────────────────────────────────── # ── 封面配置 ────────────────────────────────────────────────────────────────
class CoverConfigResponse(BaseModel):
"""封面配置响应"""
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
image_url: str = Field(default="", description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
class CoverUpdateRequest(BaseModel):
"""更新封面配置请求"""
type: Optional[str] = Field(default=None, description="封面类型")
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
class CoverExtractRequest(BaseModel):
"""从片段抽帧生成封面请求"""
clip_id: str = Field(..., description="片段 ID")
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
class CoverSmartRequest(BaseModel):
"""智能选帧请求"""
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
class CoverGenerateResponse(BaseModel):
"""封面生成响应"""
type: str = Field(..., description="封面类型")
image_url: str = Field(..., description="封面图片 URL")
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
# ── 导出配置 ──────────────────────────────────────────────────────────────── # ── 导出配置 ────────────────────────────────────────────────────────────────
@@ -399,28 +499,17 @@ class EditorUpdateRequest(BaseModel):
class EditorClipResponse(BaseModel): class EditorClipResponse(BaseModel):
"""片段响应 — 与数据库 edit_plan_clips 表字段对齐""" """片段响应"""
id: str id: str
plan_id: str plan_id: str
clip_type: str clip_type: str
order: int order: int
duration: float duration: float
start_time: float = 0.0
text_content: str = "" text_content: str = ""
transition_effect: str = "cut" transition_effect: str = "cut"
transition_duration: float = 0.0
playback_speed: float = 1.0 playback_speed: float = 1.0
asset_id: str = ""
asset_url: str | None = Field(
default=None,
description="素材视频签名URL(1小时有效),用于前端预览播放",
)
status: str = "pending"
template_clip_config_id: str = ""
config: dict[str, Any] = Field(default_factory=dict) config: dict[str, Any] = Field(default_factory=dict)
created_at: str = ""
updated_at: str = ""
class EditorClipListResponse(BaseModel): class EditorClipListResponse(BaseModel):
@@ -452,28 +541,6 @@ class EditorClipUpdateRequest(BaseModel):
config: Optional[dict[str, Any]] = None config: Optional[dict[str, Any]] = None
class EditorClipBatchItem(BaseModel):
"""批量更新clips的单个片段"""
asset_id: str = Field(default="", max_length=100, description="关联素材ID,可为空(占位片段)")
start_time: float = Field(default=0.0, ge=0.0)
duration: float = Field(default=0.0, ge=0.0)
order: Optional[int] = Field(default=None, ge=0, description="排序,None表示按数组顺序")
class EditorClipBatchUpdateRequest(BaseModel):
"""批量替换clips请求(全量覆盖)"""
clips: List[EditorClipBatchItem] = Field(default_factory=list)
class EditorClipBatchUpdateResponse(BaseModel):
"""批量更新clips响应"""
plan_id: str
clip_count: int
class EditorPublishResponse(BaseModel): class EditorPublishResponse(BaseModel):
"""发布草稿响应""" """发布草稿响应"""
+56 -247
View File
@@ -3,41 +3,35 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import tempfile from typing import Optional
from pathlib import Path
from typing import Any, Optional
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import get_storage_service
from app.dependencies import ( from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_audio_url_signer, get_audio_url_signer,
get_cosyvoice_service, get_cosyvoice_service,
get_db_session, get_db_session,
get_project_repository, get_user_repository,
get_voice_clone_profile_repository, get_voice_clone_profile_repository,
get_voice_library_repository,
) )
from app.schemas.tts import ( from app.schemas.tts import (
ListTTSJobResponse, ListTTSJobResponse,
SaveToLibraryRequest, SaveToLibraryRequest,
SaveToLibraryResponse, SaveToLibraryResponse,
TTSJobResponse, TTSJobResponse,
TTSPreviewRequest,
TTSPreviewResponse,
TTSStatusResponse, TTSStatusResponse,
TTSSynthesizeRequest, TTSSynthesizeRequest,
TTSSynthesizeResponse, TTSSynthesizeResponse,
) )
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.tts_job_repository import ( from packages.adapters.sqlalchemy_impl.tts_job_repository import (
SQLAlchemyTTSJobRepository, SQLAlchemyTTSJobRepository,
) )
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.tts_job.streaming_service import TTSStreamingService from packages.application.tts_job.streaming_service import TTSStreamingService
from packages.application.tts_job.use_cases import ( from packages.application.tts_job.use_cases import (
CreateTTSJobUseCase, CreateTTSJobUseCase,
@@ -48,12 +42,13 @@ from packages.application.tts_job.use_cases import (
TTSJobNotFoundError, TTSJobNotFoundError,
) )
from packages.application.tts_job.workflow import TTSWorkflowService from packages.application.tts_job.workflow import TTSWorkflowService
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, ClassificationStatus from packages.application.voice_library.commands import CreateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
QuotaExceededError,
)
from packages.domain.voice_presets import list_voices from packages.domain.voice_presets import list_voices
from packages.ports.asset_library_repository import AssetLibraryRepository from packages.ports.user_repository import UserRepository
from packages.ports.asset_repository import AssetRepository
from packages.ports.project_repository import ProjectRepository
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -137,47 +132,27 @@ def synthesize(
""" """
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id), # 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id if request.voice_clone_profile_id:
actual_voice_id = request.voice_id profile = voice_clone_repo.get(request.voice_clone_profile_id)
voice_clone_profile_id = request.voice_clone_profile_id if profile is None:
resolved_profile = None
if actual_voice_id:
resolved_profile = voice_clone_repo.get(actual_voice_id)
if resolved_profile is not None:
voice_clone_profile_id = actual_voice_id
# 显式传了 voice_clone_profile_id(且与 voice_id 不同)时再查一次归属
if voice_clone_profile_id and (resolved_profile is None or resolved_profile.id != voice_clone_profile_id):
resolved_profile = voice_clone_repo.get(voice_clone_profile_id)
if resolved_profile is None:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="Voice clone profile not found", detail="Voice clone profile not found",
) )
if profile.user_id != user_id:
if resolved_profile is not None:
if resolved_profile.user_id != user_id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问该音色", detail="Access denied to voice clone profile",
) )
if not resolved_profile.voice_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="音色克隆尚未完成,请稍后再试",
)
# 命中克隆音色:无论 voice_id 直接传 profile UUID 还是显式传 voice_clone_profile_id
# job.voice_id 统一存解析后的 CosyVoice voice_id
actual_voice_id = resolved_profile.voice_id
use_case = CreateTTSJobUseCase(repository) use_case = CreateTTSJobUseCase(repository)
job = use_case.execute( job = use_case.execute(
user_id=user_id, user_id=user_id,
input_text=request.text, input_text=request.text,
voice_id=actual_voice_id, voice_id=request.voice_id,
voice_model=request.voice_model, voice_model=request.voice_model,
voice_clone_profile_id=voice_clone_profile_id, voice_clone_profile_id=request.voice_clone_profile_id,
metadata=request.metadata_, metadata=request.metadata_,
) )
@@ -307,62 +282,6 @@ def delete_tts_job(
return return
def _find_or_create_voice_library(
*,
user_id: str,
project_repository: ProjectRepository,
asset_library_repository: Any, # port Protocol 声明为 asyncSQLAlchemy 实现为同步,与 upload/asset_libraries 路由惯例一致用 Any
) -> AssetLibrary:
"""在用户可访问的项目中找到(或自动创建)voice 素材库。
与前端配音素材页逻辑一致:素材库挂在项目下,配音素材读取
getAssetsByKind("voice") → 用户所有可访问项目中的 voice 库。
优先使用已有 voice 库;没有则在第一个可访问项目中自动创建。
"""
projects = project_repository.find_accessible_projects(user_id)
if not projects:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="没有可用的项目,请先创建项目后再保存配音素材",
)
for project in projects:
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
# 所有项目都没有 voice 库 → 在第一个可访问项目中自动创建默认配音素材库。
# asset_libraries 有 (project_id, kind) 唯一索引兜底并发:若两个请求同时创建,
# 落败方捕获 IntegrityError 回滚后重新查询,返回抢先创建成功的库。
project = projects[0]
library = AssetLibrary.create(
project_id=project.id,
name="配音素材库",
kind=AssetLibraryKind.VOICE,
)
try:
return asset_library_repository.create(library)
except IntegrityError:
# 并发下另一个请求已抢先创建:回滚当前事务(立即 commit 模式下 session 已
# 自动回滚,rollback 为幂等 no-opUoW/flush 模式下必须显式回滚才能继续查询),
# 再重查返回抢先创建成功的库。
session = getattr(asset_library_repository, "session", None)
if session is not None:
try:
session.rollback()
except Exception:
logger.warning("IntegrityError 后回滚 session 失败(可能已关闭)", exc_info=True)
for lib in asset_library_repository.find_by_project(project.id):
kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
if kind == AssetLibraryKind.VOICE.value:
return lib
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="配音素材库创建失败,请重试",
) from None # IntegrityError 已处理,不保留异常链
@router.post( @router.post(
"/jobs/{job_id}/save-to-library", "/jobs/{job_id}/save-to-library",
response_model=SaveToLibraryResponse, response_model=SaveToLibraryResponse,
@@ -373,17 +292,13 @@ def save_tts_job_to_library(
request: SaveToLibraryRequest = SaveToLibraryRequest(), request: SaveToLibraryRequest = SaveToLibraryRequest(),
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository), tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
asset_repository: AssetRepository = Depends(get_asset_repository), voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
asset_library_repository: AssetLibraryRepository = Depends(get_asset_library_repository), user_repository: UserRepository = Depends(get_user_repository),
project_repository: ProjectRepository = Depends(get_project_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
sign_url=Depends(get_audio_url_signer), sign_url=Depends(get_audio_url_signer),
) -> SaveToLibraryResponse: ) -> SaveToLibraryResponse:
"""将已完成的 TTS 合成结果保存到配音素材库(assets 表新素材体系) """将已完成的 TTS 合成结果保存到配音
流程:把 TTS 输出音频转存到用户素材 OSS 路径 → 创建 file_type=audio、 自动携带音色名、时长、语速等元信息。
status=ready 的 asset(挂用户 voice 素材库)→ 返回前端可用结构。
配额策略与素材上传一致(上传/ingest 链路无额外配额拦截)。
""" """
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
@@ -401,166 +316,60 @@ def save_tts_job_to_library(
detail="TTS job is not completed yet", detail="TTS job is not completed yet",
) )
if not job.output_audio_url and not job.output_audio_key: # 构建配音素材名称
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="TTS job 缺少输出音频,无法保存",
)
# 素材名称
name = request.name or f"TTS-{job.id[:8]}" name = request.name or f"TTS-{job.id[:8]}"
# 找到(或自动创建)用户 voice 素材库 # 构建元信息
library = _find_or_create_voice_library( metadata_ = {
user_id=user_id,
project_repository=project_repository,
asset_library_repository=asset_library_repository,
)
# 转存音频到素材 OSS 路径(tts-outputs/ 下的产物归 TTS 任务所有,
# 素材独立持有副本,删除 TTS 任务不影响配音库素材)
audio_format = (job.format or "mp3").strip() or "mp3"
content_type_map = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
}
content_type = content_type_map.get(audio_format, "audio/mpeg")
storage_key = f"uploads/voice/tts/{job.id}.{audio_format}"
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(suffix=f".{audio_format}", delete=False) as tmp:
tmp_path = Path(tmp.name)
# 优先用 OSS storage_key(走 oss2 SDK,私有 bucket 也可下载);
# 兜底用 output_audio_url(旧任务可能没有 key)。
# download_asset 自动识别输入:http(s):// 开头走 HTTP 下载,否则按 OSS key 走 SDK。
download_source = job.output_audio_key or job.output_audio_url
downloaded = storage_service.download_asset(download_source, tmp_path)
if not downloaded or not tmp_path.exists() or tmp_path.stat().st_size == 0:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="TTS 音频下载失败,无法保存到配音库",
)
file_size = tmp_path.stat().st_size
storage_service.upload_file(tmp_path, storage_key, content_type=content_type)
except HTTPException:
raise
except Exception as e:
logger.error("TTS 音频转存素材失败: job_id=%s, error=%s", job.id, e, exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="TTS 音频转存失败,无法保存到配音库",
) from e
finally:
if tmp_path and tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
# 构建素材元信息
metadata_: dict[str, object] = {
"source": "tts_job", "source": "tts_job",
"tts_job_id": job.id, "tts_job_id": job.id,
"format": job.format, "format": job.format,
"sample_rate": job.sample_rate, "sample_rate": job.sample_rate,
"voice_id": job.voice_id,
"voice_name": job.voice_model or "",
} }
if job.metadata: if job.metadata:
# 保留原始 job 的有用元信息
for key in ("speed", "language"): for key in ("speed", "language"):
if key in job.metadata: if key in job.metadata:
metadata_[key] = job.metadata[key] metadata_[key] = job.metadata[key]
asset = Asset.create( # 获取用户套餐(用于配额检查)
project_id=library.project_id, user = user_repository.find_by_id(user_id)
library_id=library.id, plan_name = getattr(user, "subscription_plan", "free") if user else "free"
name=name,
storage_key=storage_key,
mime_type=content_type,
metadata=metadata_,
file_size=file_size,
duration=job.duration or None,
status=AssetStatus.READY,
classification_status=ClassificationStatus.PENDING, # 音频不参与内容分类,保持 pending 与 ingest 链路一致
uploaded_by_user_id=user_id,
)
try:
asset = asset_repository.create(asset)
except Exception as e:
# DB 写入失败:清理已上传到 OSS 的素材文件,避免产生无法索引的孤儿文件
logger.error("素材记录创建失败,清理 OSS 文件: %s, error=%s", storage_key, e, exc_info=True)
try:
storage_service.delete_file(storage_key)
except Exception:
logger.warning("清理孤儿 OSS 文件失败: %s", storage_key, exc_info=True)
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="素材保存失败,请重试",
) from e
return SaveToLibraryResponse( # 构建命令并执行
id=asset.id, command = CreateVoiceLibraryCommand(
name=asset.name, user_id=user_id,
audio_url=sign_url(storage_key), name=name,
duration=asset.duration or 0.0, text=job.input_text,
voice_provider="cosyvoice",
voice_id=job.voice_id, voice_id=job.voice_id,
voice_name=job.voice_model or "", voice_name=job.voice_model or "",
audio_url=job.output_audio_url,
duration=job.duration,
file_size=job.file_size,
status="completed", status="completed",
project_id=job.project_id or "",
tags=[],
metadata_=metadata_,
) )
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
@router.post("/preview", response_model=TTSPreviewResponse)
def preview_tts(
request: TTSPreviewRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user),
cosyvoice_service: CosyVoiceService = Depends(get_cosyvoice_service),
voice_clone_repo=Depends(get_voice_clone_profile_repository),
) -> TTSPreviewResponse:
"""TTS 预览(试听)——同步合成,立即返回音频 URL。
用于前端预览配音效果,限制文本长度 200 字以内。
支持预设音色和克隆音色:克隆音色传的是 profile UUID,需解析为 CosyVoice voice_id。
"""
# 解析 voice_id:前端可能传 VoiceCloneProfile UUID 或预设音色 ID
actual_voice_id = request.voice_id
profile = voice_clone_repo.get(request.voice_id)
if profile is not None:
# 命中克隆音色 profile — 校验归属权限
if profile.user_id != authenticated_user.user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权访问该音色",
)
if not profile.voice_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="音色克隆尚未完成,请稍后再试",
)
actual_voice_id = profile.voice_id
try: try:
result = cosyvoice_service.synthesize_speech( item = use_case.execute(command, plan_name=plan_name or "free")
text=request.text, except QuotaExceededError as exc:
voice_id=actual_voice_id,
speed=request.speed,
)
except CosyVoiceError as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"TTS 合成失败: {e}", detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
) from e ) from exc
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
) from e
return TTSPreviewResponse( return SaveToLibraryResponse(
audio_url=result.audio_url, id=item.id,
duration=result.duration if result.duration and result.duration > 0 else None, name=item.name,
audio_url=sign_url(item.audio_url) if item.audio_url else "",
duration=item.duration,
voice_id=item.voice_id,
voice_name=item.voice_name,
status=item.status,
) )
+1 -2
View File
@@ -206,7 +206,6 @@ async def complete_direct_upload(
ingest_job_id="", ingest_job_id="",
duplicated=True, duplicated=True,
asset_id=existing.id, asset_id=existing.id,
url=storage_service.get_url(normalized_key),
) )
job = _submit_ingest_job( job = _submit_ingest_job(
@@ -216,7 +215,7 @@ async def complete_direct_upload(
ingest_job_repository=ingest_job_repository, ingest_job_repository=ingest_job_repository,
file_hash=request.file_hash, file_hash=request.file_hash,
) )
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id, url=storage_service.get_url(normalized_key)) return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
@router.post( @router.post(
-1
View File
@@ -52,7 +52,6 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
generation_params=item.generation_params, generation_params=item.generation_params,
download_url=download_url, download_url=download_url,
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "", generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
duplicate_rate=getattr(item, "duplicate_rate", None),
) )
+5 -142
View File
@@ -7,17 +7,10 @@ from typing import Optional
from app.auth import AuthenticatedUser, get_current_user from app.auth import AuthenticatedUser, get_current_user
from app.core.celery_app import celery_app from app.core.celery_app import celery_app
from app.core.storage import get_storage_service from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
from app.dependencies import (
get_asset_repository,
get_cosyvoice_service,
get_project_repository,
get_voice_clone_profile_repository,
)
from app.schemas.voice_clone import ( from app.schemas.voice_clone import (
CreateVoiceCloneRequest, CreateVoiceCloneRequest,
ListVoiceCloneResponse, ListVoiceCloneResponse,
VoiceClonePreviewResponse,
VoiceCloneProfileResponse, VoiceCloneProfileResponse,
VoiceCloneStatusResponse, VoiceCloneStatusResponse,
) )
@@ -26,7 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import ( from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
SQLAlchemyVoiceCloneProfileRepository, SQLAlchemyVoiceCloneProfileRepository,
) )
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService from packages.application.cosyvoice_service import CosyVoiceService
from packages.application.voice_clone.use_cases import ( from packages.application.voice_clone.use_cases import (
DeleteVoiceCloneUseCase, DeleteVoiceCloneUseCase,
GetVoiceCloneStatusUseCase, GetVoiceCloneStatusUseCase,
@@ -38,21 +31,11 @@ from packages.application.voice_clone.use_cases import (
from packages.application.voice_clone.workflow import ( from packages.application.voice_clone.workflow import (
VoiceCloneWorkflowService, VoiceCloneWorkflowService,
) )
from packages.ports.asset_repository import AssetRepository
from packages.ports.project_repository import ProjectRepository
from packages.shared.storage import SharedStorageService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# 克隆音色试听缓存(减少重复TTS调用)
# key: clone_id, value: (audio_url, duration, file_size, text, timestamp)
_clone_preview_cache: dict[str, tuple[str, float, int, str, float]] = {}
CLONE_PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
# 默认试听文本
CLONE_PREVIEW_TEMPLATE = "你好,这是我的克隆音色,很高兴能为你配音。"
def _to_response(profile) -> VoiceCloneProfileResponse: def _to_response(profile) -> VoiceCloneProfileResponse:
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换 # source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
@@ -92,68 +75,23 @@ def create_voice_clone(
request: CreateVoiceCloneRequest, request: CreateVoiceCloneRequest,
authenticated_user: AuthenticatedUser = Depends(get_current_user), authenticated_user: AuthenticatedUser = Depends(get_current_user),
workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service), workflow: VoiceCloneWorkflowService = Depends(_get_workflow_service),
asset_repository: AssetRepository = Depends(get_asset_repository),
project_repository: ProjectRepository = Depends(get_project_repository),
storage_service: SharedStorageService = Depends(get_storage_service),
) -> VoiceCloneProfileResponse: ) -> VoiceCloneProfileResponse:
"""创建音色克隆任务。 """创建音色克隆任务。
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。 创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
参考音频两种来源(二选一): 如果有 source_audio_url,状态会变为 processing;否则保持 pending。
- source_audio_url:前端直传后的音频 URL(兼容旧流程)
- asset_id:配音素材库中的音频素材,服务端用其 OSS storage_key 生成
预签名下载 URL(不依赖前端签名,避免签名过期导致克隆失败)
如果有参考音频,状态会变为 processing;否则保持 pending。
""" """
user_id = authenticated_user.user.id user_id = authenticated_user.user.id
source_audio_url = request.source_audio_url
clone_metadata = dict(request.metadata_ or {})
if request.asset_id:
if source_audio_url:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="asset_id 与 source_audio_url 只能传一个",
)
asset = asset_repository.find_by_id(request.asset_id)
if asset is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="素材不存在",
)
# 归属校验:素材挂在项目素材库下,用户必须能访问该项目
project = project_repository.find_by_id(asset.project_id)
if project is None or not project.can_access(user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权使用该素材",
)
# 类型校验:仅支持音频素材
if asset.file_type != "audio":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="仅支持音频素材进行音色克隆",
)
if not asset.storage_key:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该素材缺少音频文件,无法用于克隆",
)
# 用 OSS storage_key 生成服务端预签名 URL(7 天有效,覆盖克隆重试周期)
source_audio_url = storage_service.get_download_url(asset.storage_key, expires_seconds=7 * 24 * 3600)
clone_metadata["source_asset_id"] = asset.id
profile = workflow.start_clone( profile = workflow.start_clone(
user_id=user_id, user_id=user_id,
name=request.name, name=request.name,
description=request.description, description=request.description,
source_audio_url=source_audio_url, source_audio_url=request.source_audio_url,
voice_model=request.voice_model, voice_model=request.voice_model,
language=request.language, language=request.language,
gender=request.gender, gender=request.gender,
max_retries=request.max_retries, max_retries=request.max_retries,
metadata=clone_metadata, metadata=request.metadata_,
) )
# 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询 # 如果 profile 处于 processing 且有 task_id,触发 Celery 异步轮询
@@ -285,78 +223,3 @@ def retry_voice_clone(
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}") logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
return _to_response(profile) return _to_response(profile)
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
def get_voice_clone_preview(
clone_id: str,
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
authenticated_user: AuthenticatedUser = Depends(get_current_user),
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> VoiceClonePreviewResponse:
"""获取克隆音色试听音频(实时 TTS 合成)。
- 克隆音色必须处于 ready 状态
- 使用默认试听文本时,结果缓存 7 天
- 可传入自定义 text 参数试听不同文本
"""
import time
use_case = GetVoiceCloneUseCase(repository)
try:
profile = use_case.execute(clone_id, authenticated_user.user.id)
except VoiceCloneNotFoundError as _e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
if not profile.is_ready:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Voice clone is not ready (current status: {profile.status})",
)
# 有自定义文本时不缓存
use_cache = not text.strip()
if use_cache and clone_id in _clone_preview_cache:
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
if time.time() - cached_at < CLONE_PREVIEW_CACHE_TTL:
return VoiceClonePreviewResponse(
clone_id=clone_id,
voice_id=profile.voice_id,
audio_url=audio_url,
text=cached_text,
duration=duration,
file_size=file_size,
)
# 合成试听音频
preview_text = text.strip() or CLONE_PREVIEW_TEMPLATE
try:
result = cosyvoice.synthesize_speech(
text=preview_text,
voice_id=profile.voice_id,
format="mp3",
speed=1.0,
)
except CosyVoiceError as e:
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
# 缓存(仅默认试听文本)
if use_cache:
_clone_preview_cache[clone_id] = (
result.audio_url,
result.duration,
result.file_size,
preview_text,
time.time(),
)
return VoiceClonePreviewResponse(
clone_id=clone_id,
voice_id=profile.voice_id,
audio_url=result.audio_url,
text=preview_text,
duration=result.duration,
file_size=result.file_size,
)
+16 -129
View File
@@ -5,8 +5,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import time
from typing import Literal, Optional from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan from app.api.routes._helpers import get_user_plan
@@ -44,7 +42,6 @@ from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
from packages.ports.user_repository import UserRepository from packages.ports.user_repository import UserRepository
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用) # 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
# key: voice_id, value: (audio_url, timestamp) # key: voice_id, value: (audio_url, timestamp)
@@ -54,65 +51,6 @@ PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。" PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
def _resolve_preset_preview_url(
voice_id: str,
fallback_url: str,
cosyvoice: CosyVoiceService,
) -> str:
"""为预置音色获取有效的 preview_url.
优先从内存缓存读取;缓存失效时调用 CosyVoice 重新合成;
合成失败时降级返回硬编码 URL(可能已过期,但不会报错)。
"""
# 检查缓存
if voice_id in _preset_preview_cache:
audio_url, cached_at = _preset_preview_cache[voice_id]
if time.time() - cached_at < PREVIEW_CACHE_TTL:
return audio_url
# 缓存失效,调用 CosyVoice 合成
preset = get_preset_voice_by_id(voice_id)
if preset is None:
return fallback_url
preview_text = PREVIEW_TEMPLATE.format(name=preset.name)
try:
result = cosyvoice.synthesize_speech(
text=preview_text,
voice_id=voice_id,
format="mp3",
speed=1.0,
)
audio_url = result.audio_url
_preset_preview_cache[voice_id] = (audio_url, time.time())
logger.info("Preset voice preview generated: %s", voice_id)
return audio_url
except Exception as e:
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
return fallback_url
def _resolve_all_preset_preview_urls(
presets: list,
cosyvoice: CosyVoiceService,
) -> dict[str, str]:
"""顺序解析所有预置音色的 preview_url.
采用顺序调用(而非并行)以避免触发 DashScope API 速率限制。
首次调用后结果缓存 7 天,后续请求直接命中缓存。
Returns:
voice_id -> preview_url 映射
"""
result_map: dict[str, str] = {}
for p in presets:
try:
result_map[p.voice_id] = _resolve_preset_preview_url(p.voice_id, p.preview_url, cosyvoice)
except Exception:
result_map[p.voice_id] = p.preview_url
return result_map
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository: def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
return SQLAlchemyVoiceLibraryRepository(session) return SQLAlchemyVoiceLibraryRepository(session)
@@ -180,16 +118,8 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
) )
def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None = None) -> UnifiedVoiceItemResponse: def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
"""将预置音色转换为统一响应格式。 """将预置音色转换为统一响应格式。"""
Args:
preset: 预置音色对象
preview_url_map: voice_id -> preview_url 动态映射,优先使用
"""
preview_url = preset.preview_url
if preview_url_map and preset.voice_id in preview_url_map:
preview_url = preview_url_map[preset.voice_id]
return UnifiedVoiceItemResponse( return UnifiedVoiceItemResponse(
id=preset.voice_id, id=preset.voice_id,
type="preset", type="preset",
@@ -199,40 +129,11 @@ def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None =
language=preset.language, language=preset.language,
voice_id=preset.voice_id, voice_id=preset.voice_id,
voice_provider="cosyvoice", voice_provider="cosyvoice",
preview_url=preview_url, preview_url=preset.preview_url,
tags=preset.tags or [], tags=preset.tags or [],
) )
def _clone_profile_to_unified_response(profile) -> UnifiedVoiceItemResponse:
"""将克隆音色档案转换为统一响应格式。
注意:克隆音色是「音色模型」(可用于 TTS 合成任意文本),
不同于配音库条目(具体的配音作品)。
"""
return UnifiedVoiceItemResponse(
id=profile.id,
type="clone",
name=profile.name,
description=profile.description or "",
gender=profile.gender or "unknown",
language=profile.language or "zh-CN",
voice_id=profile.voice_id or "",
voice_provider=profile.voice_model or "cosyvoice",
audio_url="", # 克隆音色没有预合成音频,需通过 /voice-clones/{id}/preview 试听
preview_url="", # 试听需实时合成,前端调用 preview 接口
duration=0,
file_size=0,
status=profile.status.value if hasattr(profile.status, "value") else str(profile.status),
tags=[],
user_id=profile.user_id,
project_id=None,
voice_clone_profile_id=profile.id,
created_at=profile.created_at,
updated_at=profile.updated_at,
)
# ==================== 统一配音列表(预置 + 克隆)==================== # ==================== 统一配音列表(预置 + 克隆)====================
@@ -249,7 +150,6 @@ def list_voices_unified(
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository), voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository), clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
sign_url=Depends(get_audio_url_signer), sign_url=Depends(get_audio_url_signer),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> UnifiedVoiceListResponse: ) -> UnifiedVoiceListResponse:
"""获取配音列表(预置音色 + 用户克隆音色)。 """获取配音列表(预置音色 + 用户克隆音色)。
@@ -265,30 +165,19 @@ def list_voices_unified(
has_preset = type is None or type == "preset" has_preset = type is None or type == "preset"
has_clone = type is None or type == "clone" has_clone = type is None or type == "clone"
# 获取预置音色(动态生成 preview_url # 获取预置音色
if has_preset: if has_preset:
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice) preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
preset_items = [_preset_to_unified_response(p, preview_url_map) for p in PRESET_VOICES]
preset_count = len(preset_items) preset_count = len(preset_items)
# 获取克隆音色(从 voice_clone_profile 读取,ready 状态的克隆音色) # 获取克隆音色
if has_clone: if has_clone:
# status_filter 映射:不传则默认只返回 ready 状态(可用的克隆音色) use_case = ListVoiceLibraryUseCase(voice_repository)
# 前端可以传 status=all 获取所有状态,或传具体状态过滤 clone_items_raw, clone_count = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
filter_status = None # 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
if status_filter and status_filter != "all": voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
filter_status = status_filter profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
elif not status_filter: clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
filter_status = "ready"
clone_profiles = clone_profile_repository.list_by_user(
user_id,
status=filter_status,
limit=limit,
offset=skip,
)
clone_count = clone_profile_repository.count_by_user(user_id, status=filter_status)
clone_items = [_clone_profile_to_unified_response(p) for p in clone_profiles]
# 组装结果 # 组装结果
if type == "preset": if type == "preset":
@@ -315,15 +204,11 @@ def list_voices_unified(
@router.get("/presets", response_model=PresetVoiceListResponse) @router.get("/presets", response_model=PresetVoiceListResponse)
def list_preset_voices( def list_preset_voices() -> PresetVoiceListResponse:
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> PresetVoiceListResponse:
"""获取预置音色列表。 """获取预置音色列表。
不需要认证,返回所有系统预置的 CosyVoice 音色。 不需要认证,返回所有系统预置的 CosyVoice 音色。
preview_url 通过 CosyVoice 动态生成,不依赖硬编码的过期 URL。
""" """
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
items = [ items = [
PresetVoiceItemResponse( PresetVoiceItemResponse(
voice_id=p.voice_id, voice_id=p.voice_id,
@@ -331,7 +216,7 @@ def list_preset_voices(
description=p.description, description=p.description,
gender=p.gender, gender=p.gender,
language=p.language, language=p.language,
preview_url=preview_url_map.get(p.voice_id, p.preview_url), preview_url=p.preview_url,
tags=p.tags or [], tags=p.tags or [],
) )
for p in PRESET_VOICES for p in PRESET_VOICES
@@ -351,6 +236,8 @@ def get_preset_voice_preview(
- 相同 voice_id 重复调用直接返回缓存的音频URL - 相同 voice_id 重复调用直接返回缓存的音频URL
- 可传入自定义 text 参数试听不同文本 - 可传入自定义 text 参数试听不同文本
""" """
import time
preset = get_preset_voice_by_id(voice_id) preset = get_preset_voice_by_id(voice_id)
if preset is None: if preset is None:
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}") raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
-10
View File
@@ -22,9 +22,6 @@ from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRe
from packages.adapters.sqlalchemy_impl.classification_job_repository import ( from packages.adapters.sqlalchemy_impl.classification_job_repository import (
SQLAlchemyClassificationJobRepository, SQLAlchemyClassificationJobRepository,
) )
from packages.adapters.sqlalchemy_impl.cover_template_repository import (
SQLAlchemyCoverTemplateRepository,
)
from packages.adapters.sqlalchemy_impl.duplication_repository import ( from packages.adapters.sqlalchemy_impl.duplication_repository import (
SQLAlchemyDuplicationRecordRepository, SQLAlchemyDuplicationRecordRepository,
) )
@@ -131,13 +128,6 @@ def get_project_repository(
return SQLAlchemyProjectRepository(session) return SQLAlchemyProjectRepository(session)
def get_cover_template_repository(
session: Session = Depends(get_db_session),
) -> SQLAlchemyCoverTemplateRepository:
"""Provide the SQLAlchemy cover template repository implementation."""
return SQLAlchemyCoverTemplateRepository(session)
def get_tag_repository( def get_tag_repository(
session: Session = Depends(get_db_session), session: Session = Depends(get_db_session),
) -> TagRepository: ) -> TagRepository:
+1 -45
View File
@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
class CreateAssetRequest(BaseModel): class CreateAssetRequest(BaseModel):
project_id: str | None = Field(default=None, description="可选,不传时从 library.project_id 自动推导") project_id: str = Field(..., min_length=1)
library_id: str = Field(..., min_length=1) library_id: str = Field(..., min_length=1)
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
storage_key: str = Field(..., min_length=1, max_length=255) storage_key: str = Field(..., min_length=1, max_length=255)
@@ -53,25 +53,11 @@ class AssetResponse(BaseModel):
created_at: str created_at: str
uploaded_by_user_id: str uploaded_by_user_id: str
tag_ids: list[str] = Field(default_factory=list) tag_ids: list[str] = Field(default_factory=list)
# 片段级余量信息(仅视频素材返回,非视频/无时长记录为 None,前端按可用处理)
used_duration: float | None = Field(default=None, description="已使用片段时长(秒,历史区间合并去重后)")
available_duration: float | None = Field(default=None, description="剩余可用时长(秒)= 素材总时长 - 已用时长")
used_ratio: float | None = Field(default=None, description="已用时长占比(0~1")
usable: bool = Field(
default=True,
description="是否仍可用于新片段:零重复可切区间耗尽且所有历史区间复用次数" "use_count)均达上限时为 false",
)
MAX_BATCH_SIZE = 200 MAX_BATCH_SIZE = 200
class BatchGetRequest(BaseModel):
"""批量获取素材详情请求。"""
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
class BatchDeleteRequest(BaseModel): class BatchDeleteRequest(BaseModel):
"""批量删除请求(软删除)。""" """批量删除请求(软删除)。"""
@@ -115,33 +101,3 @@ class ListAssetsResponse(BaseModel):
total: int = Field(default=0, ge=0) total: int = Field(default=0, ge=0)
skip: int = Field(default=0, ge=0) skip: int = Field(default=0, ge=0)
limit: int = Field(default=100, ge=1) 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(AssetResponse):
"""智能选素材结果条目(扁平结构)。
素材字段(id/usable/余量等)直接挂在条目顶层,前端拿到 item 即可读 item.id
与 AssetResponse 字段完全一致;score/breakdown 为智能匹配附加的评分字段。
"""
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="参与评分的候选素材总数")
-54
View File
@@ -1,54 +0,0 @@
"""封面模板 Schema。"""
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class CoverTemplateConfig(BaseModel):
"""封面模板配置。"""
background_enabled: bool = Field(default=True, description="是否启用背景")
background_color: str = Field(default="#000000", description="背景颜色")
portrait_enabled: bool = Field(default=True, description="是否显示人像")
title_text: str = Field(default="", description="主标题文字")
subtitle_text: str = Field(default="", description="副标题文字")
mask_enabled: bool = Field(default=False, description="是否启用蒙版")
class CreateCoverTemplateRequest(BaseModel):
"""创建封面模板请求。"""
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
thumbnail_url: str = Field(default="", description="缩略图 URL")
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
class UpdateCoverTemplateRequest(BaseModel):
"""更新封面模板请求。"""
name: str | None = Field(default=None, min_length=1, max_length=200, description="模板名称")
thumbnail_url: str | None = Field(default=None, description="缩略图 URL")
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
class CoverTemplateResponse(BaseModel):
"""封面模板响应。"""
id: str
name: str
thumbnail_url: str
is_system: bool
created_at: datetime
config: dict[str, Any] = Field(default_factory=dict)
class Config:
from_attributes = True
class ListCoverTemplatesResponse(BaseModel):
"""封面模板列表响应。"""
items: list[CoverTemplateResponse]
total: int = Field(default=0, ge=0)
+4 -105
View File
@@ -1,18 +1,8 @@
import json import json
from datetime import datetime
from pydantic import BaseModel, Field, field_validator, model_validator from pydantic import BaseModel, Field, field_validator, model_validator
class ConfirmGenerationRequest(BaseModel):
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
output_width: int = Field(default=1080, ge=100, description="输出视频宽度")
output_height: int = Field(default=1920, ge=100, description="输出视频高度")
cover_url: str = Field(default="", description="自定义封面图片 URL")
custom_title: str = Field(default="", description="用户自定义标题文本,非空时同步到任务和编辑计划")
class CreateGenerationTaskRequest(BaseModel): class CreateGenerationTaskRequest(BaseModel):
"""创建生成任务请求。 """创建生成任务请求。
@@ -33,11 +23,6 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list) voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ── # ── 来源剪辑计划 ──
source_edit_plan_id: str = "" source_edit_plan_id: str = ""
# ── 标题配置(结构化)──
title_config: dict | None = Field(
default=None,
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
)
# ── 视频标题 ── # ── 视频标题 ──
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名") video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
# ── 批量生成 ── # ── 批量生成 ──
@@ -45,9 +30,11 @@ class CreateGenerationTaskRequest(BaseModel):
# ── 素材库自动匹配 ── # ── 素材库自动匹配 ──
asset_select_mode: str = Field( asset_select_mode: str = Field(
default="all", default="all",
description="素材选取模式:all=全部ready视频, smart=智能匹配(按质量/时长评分)", description="素材选取模式:all=全部ready视频, random=随机选取, smart=智能匹配(按质量/时长评分)",
)
asset_select_count: int = Field(
default=0, ge=0, le=100, description="选取数量,0表示全部(仅 random/smart 模式有效)"
) )
asset_select_count: int = Field(default=0, ge=0, le=100, description="选取数量,0表示全部(仅 smart 模式有效)")
# ── 自动重试 ── # ── 自动重试 ──
auto_retry_enabled: bool = Field( auto_retry_enabled: bool = Field(
default=False, default=False,
@@ -69,12 +56,6 @@ class CreateGenerationTaskRequest(BaseModel):
default_factory=dict, default_factory=dict,
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段", 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")
@model_validator(mode="after") @model_validator(mode="after")
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest": def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
@@ -105,12 +86,6 @@ class GenerationTaskResponse(BaseModel):
video_title: str = "" video_title: str = ""
resolution: str = "" resolution: str = ""
bgm_config: dict = Field(default_factory=dict) 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 = ""
title_config: dict = Field(default_factory=dict)
status: str status: str
progress: float progress: float
result_count: int result_count: int
@@ -147,79 +122,3 @@ class ListGenerationTasksResponse(BaseModel):
"""用户级生成任务列表响应(跨 project)。""" """用户级生成任务列表响应(跨 project)。"""
items: list[GenerationTaskResponse] items: list[GenerationTaskResponse]
# ── 预览生成(Phase 1) ───────────────────────────────────────────────────────
class CreatePreviewGenerationTaskRequest(BaseModel):
"""创建预览生成任务请求。
仅支持模板模式:template_id + asset_ids 等素材 ID 列表。
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset)。
"""
template_id: str
asset_ids: list[str] = Field(default_factory=list)
title_ids: list[str] = Field(default_factory=list)
voice_ids: list[str] = Field(default_factory=list)
voice_library_id: str = Field(
default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材"
)
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
duration: float = Field(default=0.0, ge=0, description="期望视频时长(秒),0 表示由模板决定")
video_ratio: str = Field(default="", description="视频比例,如 16:9 / 9:16,为空使用模板默认")
bgm_config: dict = Field(
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",
)
source_edit_plan_id: str = Field(
default="",
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
)
title_config: dict = Field(
default_factory=dict,
description="标题配置(可选),渲染时烧录到预览视频中。支持字段: text/font/font_size/font_color/position/bold/stroke/shadow",
)
@model_validator(mode="after")
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
if not self.template_id.strip():
raise ValueError("template_id 不能为空")
return self
@model_validator(mode="after")
def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest":
if not self.asset_ids and not self.title_ids and not self.voice_ids:
raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个")
return self
class PreviewGenerationTaskResponse(BaseModel):
"""预览生成任务响应。
包含任务状态、进度、分辨率、生成结果 URL 等关键字段。
"""
task_id: str
status: str
progress: float
is_preview: bool = True
resolution: str = ""
video_url: str = ""
duration: float = 0.0
file_size: int = 0
clip_count: int = 0
transition_count: int = 0
material_usage: dict = Field(default_factory=dict)
error_message: str = ""
created_at: datetime | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
generate_duration: float = 0.0
-16
View File
@@ -101,19 +101,3 @@ class SaveToLibraryResponse(BaseModel):
voice_id: str voice_id: str
voice_name: str voice_name: str
status: str status: str
class TTSPreviewRequest(BaseModel):
"""TTS 预览(试听)请求。"""
text: str = Field(..., min_length=1, max_length=200, description="合成文本,限制 200 字")
voice_id: str = Field(..., min_length=1, description="音色 ID")
speed: float = Field(1.0, ge=0.5, le=2.0, description="语速")
pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)")
class TTSPreviewResponse(BaseModel):
"""TTS 预览(试听)响应。"""
audio_url: str = Field(..., description="合成音频 URL")
duration: Optional[float] = Field(default=None, description="音频时长(秒)")
-1
View File
@@ -39,7 +39,6 @@ class DirectUploadCompleteResponse(BaseModel):
ingest_job_id: str ingest_job_id: str
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)") duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)") asset_id: str = Field(default="", description="重复素材的 asset_idduplicated=true 时返回)")
url: str = Field(default="", description="Public URL of uploaded file")
class UploadAssetResponse(BaseModel): class UploadAssetResponse(BaseModel):
-1
View File
@@ -22,7 +22,6 @@ class VideoItemResponse(BaseModel):
generation_params: dict = Field(default_factory=dict) generation_params: dict = Field(default_factory=dict)
download_url: str | None = None download_url: str | None = None
generated_at: str = "" generated_at: str = ""
duplicate_rate: float | None = None
class ListVideosResponse(BaseModel): class ListVideosResponse(BaseModel):
+1 -24
View File
@@ -13,8 +13,7 @@ class CreateVoiceCloneRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=100, description="音色名称") name: str = Field(..., min_length=1, max_length=100, description="音色名称")
description: str = Field("", description="音色描述") description: str = Field("", description="音色描述")
source_audio_url: str = Field("", description="参考音频 URL(与 asset_id 二选一)") source_audio_url: str = Field("", description="参考音频 URL")
asset_id: str = Field("", description="参考音频素材 ID(配音素材库中的音频 asset,与 source_audio_url 二选一)")
voice_model: str = Field("", description="语音模型名称") voice_model: str = Field("", description="语音模型名称")
language: str = Field("zh-CN", description="语言") language: str = Field("zh-CN", description="语言")
gender: str = Field("unknown", description="性别") gender: str = Field("unknown", description="性别")
@@ -64,25 +63,3 @@ class ListVoiceCloneResponse(BaseModel):
items: List[VoiceCloneProfileResponse] items: List[VoiceCloneProfileResponse]
total: int total: int
class VoiceClonePreviewResponse(BaseModel):
"""克隆音色试听响应。"""
clone_id: str
"""音色克隆档案 ID"""
voice_id: str
"""CosyVoice 音色 ID"""
audio_url: str
"""试听音频 URL"""
text: str
"""试听文本"""
duration: float = 0.0
"""音频时长(秒)"""
file_size: int = 0
"""文件大小(字节)"""
@@ -1,494 +0,0 @@
"""素材片段级使用记录追踪与受控复用.
在素材 metadataassets.classification_result JSON)中持久化已使用的片段时间区间,
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 15%),把任意两条
成片的画面重复率控制在阈值内。
metadata 中的记录字段 ``used_time_ranges``::
"used_time_ranges": [
{
"start": 12.5, "end": 20.3,
"plan_id": "plan-xxx",
"created_at": "2026-08-29T12:00:00+00:00",
"use_count": 1, # 该区间累计被使用次数(复用一次 +1)
"last_used_at": "2026-08-29T12:00:00+00:00" # 最近一次使用时间
},
...
]
注意:本模块所有函数都不自行 commit,由调用方控制事务边界
from-assets 与 replace_all_clips_transactional 同事务;异步任务各自 commit)。
历史记录永不自动清空(自动轮回重置已下线,reset_used_segments 仅保留给运维/测试)。
"""
from __future__ import annotations
import json
import logging
from datetime import datetime, timezone
from typing import Callable
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.models import AssetModel
logger = logging.getLogger(__name__)
USED_RANGES_KEY = "used_time_ranges"
# ── 受控复用配置常量 ─────────────────────────────────────────────────────────
MAX_RANGE_USE_COUNT = 3
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
REUSE_RATIO_LIMIT = 0.15
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(15%)。
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
SEGMENT_EDGE_GAP = 0.3
"""冲突判定边缘间隙(秒):历史区间按 [start-gap, end+gap] 扩边后参与冲突检测,
避免两条片段首尾紧贴导致画面观感重复;记录仍存实际值。"""
# 判定"新片段与历史区间为同一次使用(复用)"的重叠率阈值:
# 重叠时长 / 新区间时长超过该比例视为复用该历史区间(累加 use_count)而非新增记录。
_REUSE_OVERLAP_RATIO = 0.6
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _read_meta(model) -> dict:
"""读取素材 metadata dict。
兼容两种对象:
- ORM ``AssetModel``metadata 以 JSON 字符串存在 ``classification_result`` 列;
- 领域实体 ``Asset``(路由层 repository 返回):metadata 直接是 dict 属性
repository 与 classification_result 互转,见 asset_repository.py)。
"""
# 领域实体:metadata 已是 dict
meta = getattr(model, "metadata", None)
if isinstance(meta, dict):
return meta
raw = getattr(model, "classification_result", None)
if not raw:
return {}
try:
data = json.loads(raw) if isinstance(raw, str) else raw
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _get_model(db: Session, asset_id: str, for_update: bool = False) -> AssetModel | None:
query = db.query(AssetModel).filter(AssetModel.id == asset_id)
if for_update:
# 行级锁(PostgreSQL SELECT ... FOR UPDATE):序列化同一素材的
# classification_result 读-改-写,避免并发事务丢失使用记录。
# SQLite 不支持时 SQLAlchemy 会忽略该子句(no-op)。
query = query.with_for_update()
return query.first()
def get_used_segments(db: Session, asset_ids: list[str]) -> dict[str, list[tuple[float, float]]]:
"""聚合多个素材的历史已用片段区间。
Returns:
``{asset_id: [(start, end), ...]}`` 格式,与 ``_calc_random_start_time`` 的
``used_segments`` 参数格式一致,可直接传入。
"""
if not asset_ids:
return {}
result: dict[str, list[tuple[float, float]]] = {}
models = db.query(AssetModel).filter(AssetModel.id.in_(list(set(asset_ids)))).all()
for model in models:
meta = _read_meta(model)
ranges = meta.get(USED_RANGES_KEY) or []
segments: list[tuple[float, float]] = []
for r in ranges:
try:
segments.append((float(r["start"]), float(r["end"])))
except (KeyError, TypeError, ValueError):
continue
if segments:
result[model.id] = segments
return result
def record_used_segments(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str,
) -> None:
"""记录一次片段使用(不 commit.
若新区间与某条历史区间高度重叠(复用场景,如受控复用回调返回的区间、
MediaKit 挪到历史区间),则累加该记录的 ``use_count`` 并刷新 ``last_used_at``
不新增记录;否则追加一条新记录(use_count=1)。
"""
# 行级锁读取:与并发生成任务互斥,保证区间记录读-改-写一致
model = _get_model(db, asset_id, for_update=True)
if model is None:
logger.warning("[片段追踪] 素材不存在,跳过记录: asset_id=%s", asset_id)
return
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
new_start = round(float(start), 3)
new_end = round(float(end), 3)
new_dur = max(new_end - new_start, 1e-6)
now = _now_iso()
for r in ranges:
try:
rs, re_ = float(r["start"]), float(r["end"])
except (KeyError, TypeError, ValueError):
continue
overlap = max(0.0, min(new_end, re_) - max(new_start, rs))
if overlap / new_dur >= _REUSE_OVERLAP_RATIO:
# 复用同一条历史区间:累加次数、刷新时间
r["use_count"] = int(r.get("use_count", 1)) + 1
r["last_used_at"] = now
r["plan_id"] = plan_id
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return
ranges.append(
{
"start": new_start,
"end": new_end,
"plan_id": plan_id,
"created_at": now,
"use_count": 1,
"last_used_at": now,
}
)
meta[USED_RANGES_KEY] = ranges
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
def remove_used_segment(
db: Session,
asset_id: str,
start: float,
end: float,
plan_id: str | None = None,
tolerance: float = 0.5,
) -> bool:
"""删除素材 metadata 中匹配的一条使用记录(不 commit).
匹配规则:start/end 与记录值相差不超过 tolerance 秒;plan_id 非空时,
记录有 plan_id 则需相等,记录缺 plan_id(本功能上线前的旧数据)时按时间匹配。
Returns:
是否找到并删除了记录。
"""
model = _get_model(db, asset_id)
if model is None:
return False
meta = _read_meta(model)
ranges = list(meta.get(USED_RANGES_KEY) or [])
remaining: list[dict] = []
removed = False
for r in ranges:
try:
match = (
abs(float(r["start"]) - float(start)) <= tolerance and abs(float(r["end"]) - float(end)) <= tolerance
)
except (KeyError, TypeError, ValueError):
remaining.append(r)
continue
# plan_id 校验:传入 plan_id 时,记录有 plan_id 则必须相等;
# 记录本身缺 plan_id(旧数据)时退化为按时间匹配,避免旧区间永远删不掉
if plan_id is not None and r.get("plan_id") is not None and r.get("plan_id") != plan_id:
match = False
if match and not removed:
removed = True
continue
remaining.append(r)
if removed:
meta[USED_RANGES_KEY] = remaining
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
return removed
def reset_used_segments(db: Session, asset_id: str) -> None:
"""清空单个素材的历史片段使用记录(不 commit).
仅供运维/测试使用;正常生成流程中历史记录永不自动清空(受控复用取代自动轮回)。
"""
model = _get_model(db, asset_id)
if model is None:
return
meta = _read_meta(model)
if meta.get(USED_RANGES_KEY):
meta[USED_RANGES_KEY] = []
model.classification_result = json.dumps(meta, ensure_ascii=False)
model.updated_at = datetime.now(timezone.utc)
logger.info("[片段追踪] 素材区间记录手动清空: asset_id=%s", asset_id)
# ── 素材余量/可用性计算(Task H:素材库角标 + smart-match 过滤)──────────────
# 判定「是否还有空闲可切区间」时使用的最小片段时长(秒):空闲段长于此值才视为可切
_MIN_FREE_CLIP_DURATION = 3.0
def _merge_intervals(intervals: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""合并重叠/相接的时间区间,返回升序不重叠区间列表。"""
if not intervals:
return []
ordered = sorted((float(a), float(b)) for a, b in intervals if b > a)
merged: list[tuple[float, float]] = [ordered[0]]
for start, end in ordered[1:]:
last_start, last_end = merged[-1]
if start <= last_end:
merged[-1] = (last_start, max(last_end, end))
else:
merged.append((start, end))
return merged
def _has_free_gap(used: list[tuple[float, float]], total: float, min_free: float = _MIN_FREE_CLIP_DURATION) -> bool:
"""素材 [0, total] 中是否存在长度 ≥ min_free 的空闲段(考虑边缘间隙)。"""
if total <= 0:
return False
# 历史区间按边缘间隙扩边后判定空闲(与选片冲突检测同一口径)
expanded = [(max(0.0, s - SEGMENT_EDGE_GAP), min(total, e + SEGMENT_EDGE_GAP)) for s, e in used]
merged = _merge_intervals(expanded)
cursor = 0.0
for start, end in merged:
if start - cursor >= min_free:
return True
cursor = max(cursor, end)
return total - cursor >= min_free
def compute_asset_availability(
model: "AssetModel | None",
min_free_clip_duration: float = _MIN_FREE_CLIP_DURATION,
) -> dict | None:
"""计算单个素材的余量与可用性(纯函数,不读写 DB)。
Returns:
视频素材返回 ``{"used_duration", "available_duration", "used_ratio", "usable"}``
非视频 / 无 model / 无时长信息返回 None(调用方按可用处理,零影响)。
usable=False 条件(与受控复用机制一致):
零重复可切区间已耗尽(不存在 ≥ min_free 的空闲段)且
所有历史区间 use_count 均达 MAX_RANGE_USE_COUNT 上限(无区间可复用)。
"""
if model is None:
return None
file_type = getattr(model, "file_type", None) or getattr(model, "mime_type", "") or ""
if file_type != "video" and not str(file_type).startswith("video/"):
return None
total = float(getattr(model, "duration", 0.0) or 0.0)
if total <= 0:
return None
meta = _read_meta(model)
raw_ranges = meta.get(USED_RANGES_KEY) or []
intervals: list[tuple[float, float]] = []
use_counts: list[int] = []
for r in raw_ranges:
try:
start = float(r["start"])
end = float(r["end"])
except (KeyError, TypeError, ValueError):
continue
if end <= start:
continue
intervals.append((start, end))
try:
use_counts.append(int(r.get("use_count", 1)))
except (TypeError, ValueError):
use_counts.append(1)
merged = _merge_intervals(intervals)
used_duration = round(sum(e - s for s, e in merged), 3)
used_duration = min(used_duration, total)
available_duration = round(max(total - used_duration, 0.0), 3)
used_ratio = round(min(used_duration / total, 1.0), 4)
has_free = _has_free_gap(intervals, total, min_free_clip_duration)
if has_free:
usable = True
else:
# 空闲段耗尽:仅当存在历史区间且全部达复用上限时才判定不可用;
# 无历史区间(理论上不会走到,因为 has_free=True)按可用处理
if not use_counts:
usable = True
else:
usable = any(uc < MAX_RANGE_USE_COUNT for uc in use_counts)
return {
"used_duration": used_duration,
"available_duration": available_duration,
"used_ratio": used_ratio,
"usable": usable,
}
def find_reusable_range(
db: Session,
asset_id: str,
clip_duration: float,
asset_total: float,
*,
max_use_count: int = MAX_RANGE_USE_COUNT,
) -> tuple[float, float] | None:
"""受控复用:在素材历史区间中选一条可复用区间返回 (start, end)。
选择规则:
1. 仅选 ``use_count < max_use_count`` 的历史区间;
2. 优先返回能完整容纳当前 clip_duration(起点后不越素材边界)的最久未用区间;
3. 没有能容纳的,则返回 last_used_at 最老(或缺失 last_used_at 的旧数据优先)
且 use_count 最低的区间起点(可能与其他历史区间重叠,属降级复用);
4. 无任何可复用区间(记录为空或全部达上限)返回 None。
本函数只读不写;复用次数的累加由后续 record_used_segments 完成。
"""
model = _get_model(db, asset_id)
if model is None:
return None
meta = _read_meta(model)
ranges = [r for r in (meta.get(USED_RANGES_KEY) or []) if int(r.get("use_count", 1)) < max_use_count]
if not ranges:
return None
def _last_used(r: dict) -> str:
return str(r.get("last_used_at") or r.get("created_at") or "")
max_start = max(0.0, asset_total - clip_duration)
# 2. 能完整容纳当前片段的候选:按 last_used_at 升序(最久未用优先)
fit = sorted(
[r for r in ranges if float(r["start"]) <= max_start + 1e-6],
key=_last_used,
)
if fit:
start = min(float(fit[0]["start"]), max_start)
return (start, start + clip_duration)
# 3. 降级:最久未用 + use_count 最低的区间起点
fallback = sorted(ranges, key=lambda r: (_last_used(r), int(r.get("use_count", 1))))[0]
start = min(float(fallback["start"]), max_start)
return (start, start + clip_duration)
def make_reuse_callback(
db: Session,
asset_durations: dict[str, float],
reused_tracker: dict[str, float] | None = None,
assigned_tracker: dict[str, float] | None = None,
ratio_limit: float = REUSE_RATIO_LIMIT,
) -> Callable[[str, float], tuple[float, float] | None]:
"""构造给 ``_calc_random_start_time`` 用的受控复用回调.
Args:
db: SQLAlchemy session
asset_durations: 素材 ID -> 总时长(回调需要素材总时长做边界约束)
reused_tracker: 可选的 ``{asset_id: 累计复用时长}``,回调成功返回复用区间时
会把本次片段时长累加进去,供调用方统计成片复用占比(15% 阈值)。
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
(保证成片复用占比不超阈值)。
ratio_limit: 单条成片复用时长占比上限,默认 15%
Returns:
回调函数 ``(asset_id, clip_duration) -> (start, end) | None``。
回调内吞掉 DB 异常返回 None,不影响主生成流程。
"""
def _reuse(asset_id: str, clip_duration: float) -> tuple[float, float] | None:
try:
total = float(asset_durations.get(asset_id, 0.0) or 0.0)
if total <= 0:
return None
# 占比闸门:预判复用本片段后是否超限(仅当调用方提供了 assigned tracker
if assigned_tracker is not None:
assigned = float(assigned_tracker.get(asset_id, 0.0) or 0.0)
reused_amt = float((reused_tracker or {}).get(asset_id, 0.0) or 0.0)
if assigned > 0 and (reused_amt + clip_duration) / (assigned + clip_duration) > ratio_limit:
logger.info(
"[片段追踪] 复用占比预判超 %.0f%% 阈值,拒绝复用: asset_id=%s "
"reused=%.1f assigned=%.1f clip=%.1f",
ratio_limit * 100,
asset_id,
reused_amt,
assigned,
clip_duration,
)
return None
result = find_reusable_range(db, asset_id, clip_duration, total)
except Exception:
logger.warning("[片段追踪] 受控复用查询异常: asset_id=%s", asset_id, exc_info=True)
return None
if result is not None and reused_tracker is not None:
reused_tracker[asset_id] = reused_tracker.get(asset_id, 0.0) + clip_duration
return result
return _reuse
def get_asset_recent_use_counts(
db: Session,
asset_ids: list[str],
recent_video_count: int = 5,
) -> dict[str, int]:
"""统计每个素材在最近 N 个不同 plan_id 中的使用次数。
遍历素材 metadata 中的 used_time_ranges,统计有多少个不同的 plan_id(去重),
返回 {asset_id: count}。只统计最近 recent_video_count 个不同 plan_id 的使用次数。
Args:
db: 数据库会话
asset_ids: 素材 ID 列表
recent_video_count: 统计最近多少个不同 plan_id
Returns:
{asset_id: 在最近 recent_video_count 个 plan 中的使用次数}
"""
if not asset_ids:
return {}
result: dict[str, int] = {}
models = db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all()
for model in models:
meta = _read_meta(model)
ranges = meta.get(USED_RANGES_KEY) or []
if not ranges:
result[model.id] = 0
continue
# 按 created_at 倒序收集不同 plan_id
sorted_ranges = sorted(
ranges,
key=lambda r: r.get("created_at") or "",
reverse=True,
)
recent_plan_ids: set[str] = set()
for r in sorted_ranges:
plan_id = r.get("plan_id")
if plan_id:
recent_plan_ids.add(plan_id)
if len(recent_plan_ids) >= recent_video_count:
break
result[model.id] = len(recent_plan_ids)
# 未找到的素材计为 0
for aid in asset_ids:
if aid not in result:
result[aid] = 0
return result
+276
View File
@@ -0,0 +1,276 @@
"""封面管理服务.
提供封面配置管理和从视频抽帧生成封面的能力。
抽帧使用 FFmpeg,上传使用共享存储服务。
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from typing import Any, Dict
logger = logging.getLogger(__name__)
# ── 常量 ──────────────────────────────────────────────────────────────────────
DEFAULT_COVER_WIDTH = 1080
DEFAULT_COVER_HEIGHT = 1920
DEFAULT_COVER_QUALITY = 5 # JPEG quality (1-31, 越小越好)
COVER_STORAGE_PREFIX = "covers"
class CoverService:
"""封面管理服务."""
def __init__(self, storage_service: Any, asset_repository: Any) -> None:
self._storage = storage_service
self._asset_repo = asset_repository
# ── 配置读写 ──────────────────────────────────────────────────────────
@staticmethod
def get_cover_config(plan_config: Dict[str, Any]) -> Dict[str, Any]:
"""从 plan.config 中提取封面配置.
Args:
plan_config: 剪辑计划的 config 字段
Returns:
封面配置 dict
"""
cover = plan_config.get("cover", {})
if not isinstance(cover, dict):
cover = {}
# 确保默认字段存在
return {
"type": cover.get("type", "ai_frame"),
"image_url": cover.get("image_url", ""),
"frame_time": cover.get("frame_time"),
}
# ── 抽帧生成封面 ──────────────────────────────────────────────────────
def extract_cover_from_clip(
self,
plan_id: str,
asset_id: str,
frame_time: float = 1.0,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""从指定素材的指定时间点抽取一帧作为封面.
Args:
plan_id: 剪辑计划 ID(用于生成存储路径)
asset_id: 素材 ID
frame_time: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict,包含 type / image_url / frame_time
Raises:
ValueError: 素材不存在或不是视频
RuntimeError: 抽帧或上传失败
"""
# 1. 获取素材
asset = self._asset_repo.get(asset_id) if self._asset_repo else None
if not asset:
raise ValueError(f"素材不存在: {asset_id}")
storage_key = getattr(asset, "storage_key", "")
if not storage_key:
raise ValueError(f"素材没有文件: {asset_id}")
mime_type = getattr(asset, "mime_type", "")
if mime_type and not mime_type.startswith("video"):
raise ValueError(f"素材不是视频类型: {mime_type}")
# 2. 下载视频到临时目录
with tempfile.TemporaryDirectory(prefix="cover_extract_") as tmp_dir:
tmp_path = Path(tmp_dir)
video_path = tmp_path / f"source_{asset_id[:8]}"
logger.info("下载素材用于封面抽帧: asset_id=%s", asset_id)
try:
self._storage.download_file(storage_key, str(video_path))
except Exception as e:
raise RuntimeError(f"下载素材失败: {e}") from e
if not video_path.exists() or video_path.stat().st_size == 0:
raise RuntimeError("下载的素材文件为空")
# 3. FFmpeg 抽帧
output_path = tmp_path / "cover.jpg"
self._extract_frame(
video_path=video_path,
output_path=output_path,
time_sec=frame_time,
width=width,
height=height,
quality=quality,
)
if not output_path.exists() or output_path.stat().st_size == 0:
raise RuntimeError("封面抽帧失败")
# 4. 上传到 OSS
cover_key = f"{COVER_STORAGE_PREFIX}/{plan_id}/cover_{int(frame_time * 1000)}.jpg"
logger.info("上传封面到存储: key=%s", cover_key)
try:
self._storage.upload_file(
file_or_path=str(output_path),
storage_key=cover_key,
content_type="image/jpeg",
)
except Exception as e:
raise RuntimeError(f"上传封面失败: {e}") from e
# 5. 获取访问 URL
try:
image_url = self._storage.get_url(cover_key)
except Exception:
image_url = cover_key # 降级为 storage_key
logger.info(
"封面抽帧完成: plan_id=%s asset_id=%s time=%.2fs size=%d",
plan_id,
asset_id,
frame_time,
output_path.stat().st_size if output_path.exists() else 0,
)
return {
"type": "manual",
"image_url": image_url,
"frame_time": frame_time,
}
def generate_smart_cover(
self,
plan_id: str,
asset_id: str,
*,
width: int = DEFAULT_COVER_WIDTH,
height: int = DEFAULT_COVER_HEIGHT,
quality: int = DEFAULT_COVER_QUALITY,
) -> Dict[str, Any]:
"""智能选帧:从视频中选取多帧,选最清晰的一帧.
Args:
plan_id: 剪辑计划 ID
asset_id: 素材 ID
width: 输出宽度
height: 输出高度
quality: JPEG 质量
Returns:
封面数据 dict
"""
# 简单实现:取视频 1/3 处的帧作为智能封面
# 更复杂的多帧选清晰帧可以后续优化
frame_time = 3.0 # 默认第3秒,后续可以根据视频时长动态计算
result = self.extract_cover_from_clip(
plan_id=plan_id,
asset_id=asset_id,
frame_time=frame_time,
width=width,
height=height,
quality=quality,
)
result["type"] = "ai_frame"
return result
# ── 内部方法 ──────────────────────────────────────────────────────────
@staticmethod
def _extract_frame(
video_path: Path,
output_path: Path,
*,
time_sec: float,
width: int,
height: int,
quality: int,
) -> None:
"""使用 FFmpeg 从视频中抽取一帧.
Args:
video_path: 视频文件路径
output_path: 输出图片路径
time_sec: 抽帧时间点(秒)
width: 输出宽度
height: 输出高度
quality: JPEG 质量
"""
import subprocess
# scale + crop 实现 cover 裁剪
vf = f"scale={width}:{height}:force_original_aspect_ratio=increase," f"crop={width}:{height}"
command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-vf",
vf,
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
logger.debug("FFmpeg 抽帧命令: %s", " ".join(command))
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
logger.warning("FFmpeg 抽帧返回非零: %s\nstderr: %s", result.returncode, result.stderr[-500:])
# 尝试不使用 scale+crop 的简化命令
simple_command = [
"ffmpeg",
"-y",
"-ss",
f"{time_sec:.3f}",
"-i",
str(video_path),
"-vframes",
"1",
"-q:v",
str(quality),
"-f",
"mjpeg",
str(output_path),
]
result2 = subprocess.run(
simple_command,
capture_output=True,
text=True,
timeout=60,
)
if result2.returncode != 0:
raise RuntimeError(f"FFmpeg 抽帧失败: {result2.stderr[-300:]}")
except subprocess.TimeoutExpired as e:
raise RuntimeError("FFmpeg 抽帧超时") from e
except FileNotFoundError as e:
raise RuntimeError("FFmpeg 不可用") from e
+5 -272
View File
@@ -9,12 +9,6 @@ from __future__ import annotations
import logging import logging
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from app.services.asset_segment_tracker import (
REUSE_RATIO_LIMIT,
get_used_segments,
make_reuse_callback,
record_used_segments,
)
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl import ( from packages.adapters.sqlalchemy_impl import (
@@ -377,198 +371,6 @@ class EditPlanService:
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count) logger.info("删除所有片段: plan_id=%s count=%d", plan_id, count)
return count return count
def replace_all_clips_transactional(
self,
plan_id: str,
clips_data: list[dict],
) -> int:
"""事务性地替换所有片段:清空→创建→标记ready,单事务保证原子性。
Args:
plan_id: 计划 ID
clips_data: 片段数据列表,每项包含 asset_id/start_time/duration/order
Returns:
int: 创建的片段数量
Raises:
Exception: 任何步骤失败时自动回滚
"""
from packages.adapters.sqlalchemy_impl.models import EditPlanClipModel
db = self._clip_repo.session
try:
# 1. 清空现有 clips(不 commit
deleted_count = db.query(EditPlanClipModel).filter(EditPlanClipModel.plan_id == plan_id).delete()
# 2. 批量创建新 clips(不 commit
for i, clip_item in enumerate(clips_data):
order = clip_item.get("order") or i
clip = EditPlanClip.create(
plan_id=plan_id,
clip_type=clip_item.get("clip_type", "main"),
order=order,
asset_id=clip_item.get("asset_id", ""),
start_time=clip_item.get("start_time", 0.0),
duration=clip_item.get("duration", 0.0),
)
model = EditPlanClipModel(
id=clip.id,
plan_id=clip.plan_id,
clip_type=clip.clip_type,
order=clip.order,
asset_id=clip.asset_id,
text_content=clip.text_content,
start_time=clip.start_time,
duration=clip.duration,
transition_effect=clip.transition_effect,
transition_duration=clip.transition_duration,
playback_speed=clip.playback_speed,
status=clip.status.value,
config=clip.config,
)
db.add(model)
# flush 让新建 clip 写入当前事务(未 commit),后续查询才能找到它们
db.flush()
# 3. 标记有 asset_id 的 clips 为 ready(不 commit
pending_with_asset = (
db.query(EditPlanClipModel)
.filter(
EditPlanClipModel.plan_id == plan_id,
EditPlanClipModel.status == "pending",
EditPlanClipModel.asset_id != "",
)
.all()
)
for m in pending_with_asset:
m.status = "ready"
# 4. 一次性提交
db.commit()
logger.info(
"事务性替换片段: plan_id=%s deleted=%d created=%d",
plan_id,
deleted_count,
len(clips_data),
)
return len(clips_data)
except Exception:
db.rollback()
logger.exception("事务性替换片段失败: plan_id=%s", plan_id)
raise
def clone_plan_for_variant(
self,
source_plan_id: str,
*,
created_by_user_id: str = "",
name_suffix: str = "变体",
reuse_tracker: Optional[dict] = None,
) -> EditPlan:
"""为批量/多预览场景克隆一份独立 plan,片段起点全部重算(受控随机/复用)。
复制源 plan 的模板归属、config 与片段结构(asset_id / duration / clip_type /
order 不变),每个片段重新调用 ``_calc_random_start_time``:读取素材持久化的
历史已用区间避让,耗尽时受控复用(use_count<3、最久未用),从而保证 N 条
成片片段区间互不相同,且复用占比受控。
- 不替换/不修改源 plan,源 plan 保留用户手动编辑结果。
- 片段区间记录(record_used_segments)随新片段写入素材 metadata,与新 plan
同事务;复用历史区间时由 record 自动累加 use_count。
- 克隆的 clips 复用区间累计时长写入 reuse_tracker(可选),供调用方统计占比。
Raises:
ValueError: 源 plan 不存在或无可用片段。
"""
from packages.adapters.sqlalchemy_impl.models import AssetModel
from packages.domain.plan_generator_utils import _calc_random_start_time
source = self.get_plan_or_raise(source_plan_id)
# 分页读取源 plan 全部片段
clips: List[EditPlanClip] = []
skip, page = 0, 500
while True:
batch = self._clip_repo.list_by_plan(source_plan_id, skip=skip, limit=page)
if not batch:
break
clips.extend(batch)
if len(batch) < page:
break
skip += page
if not clips:
raise ValueError(f"源 plan 无片段,无法克隆变体: {source_plan_id}")
# 创建新 plan(复制模板归属与 config
new_plan = self.create_plan(
template_id=source.template_id,
name=f"{source.name or '剪辑计划'} · {name_suffix}",
config=dict(source.config or {}),
total_duration=source.total_duration,
project_id=source.project_id or "",
created_by_user_id=created_by_user_id or (source.created_by_user_id or ""),
)
# 素材时长映射(O(N) 单查)
asset_ids = list({c.asset_id for c in clips if c.asset_id})
db = self._clip_repo.session
durations: dict[str, float] = {}
if asset_ids:
for m in db.query(AssetModel).filter(AssetModel.id.in_(asset_ids)).all():
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
used_segments = get_used_segments(db, asset_ids)
reused: dict[str, float] = reuse_tracker if reuse_tracker is not None else {}
asset_assigned: dict[str, float] = {}
# 回调内部预判复用后占比超 15% 则拒绝复用(calc 返回 None → 保留原起点)
reuse_cb = make_reuse_callback(db, durations, reused, assigned_tracker=asset_assigned)
clips_data: list[dict] = []
for i, c in enumerate(clips):
aid = c.asset_id
dur = float(c.duration or 0.0)
total = durations.get(aid, 0.0)
if aid and total > 0 and dur > 0:
# 复用占比闸门:本片段尚未分配,检查当前已复用占比
# reused / assigned 是否超 15%,超则该素材不再分配(保留原起点);
# assigned=0(首个片段)放行,reused=0 时不误拦正常分配
assigned = asset_assigned.get(aid, 0.0)
eff_dur = min(dur, total)
reused_amt = reused.get(aid, 0.0)
ratio_blocked = assigned > 0 and reused_amt / assigned > REUSE_RATIO_LIMIT
start = None
if not ratio_blocked:
start = _calc_random_start_time(aid, eff_dur, durations, used_segments, on_exhausted=reuse_cb)
if start is None:
start = float(c.start_time or 0.0)
asset_assigned[aid] = assigned + eff_dur
used_segments.setdefault(aid, []).append((start, start + eff_dur))
record_used_segments(db, aid, start, start + eff_dur, new_plan.id)
else:
start = float(c.start_time or 0.0)
clips_data.append(
{
"order": c.order if c.order is not None else i,
"asset_id": aid,
"start_time": start,
"duration": dur,
"clip_type": c.clip_type,
}
)
# 事务性写入新 plan 的片段(内部统一 commit/rollback
self.replace_all_clips_transactional(new_plan.id, clips_data)
logger.info(
"克隆变体 plan: source=%s new=%s clips=%d",
source_plan_id,
new_plan.id,
len(clips_data),
)
return new_plan
# ── 片段分割与合并 ────────────────────────────────────────────────────── # ── 片段分割与合并 ──────────────────────────────────────────────────────
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]: def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
@@ -769,10 +571,6 @@ class EditPlanService:
def can_generate(self, plan_id: str) -> tuple[bool, str]: def can_generate(self, plan_id: str) -> tuple[bool, str]:
"""检查是否可以触发渲染 """检查是否可以触发渲染
包含最后一道防线的自动修复:
- 如果 clips 存在但都没有 asset_id,且 config.asset_ids 非空,
直接在内部执行素材分配,不再依赖前置 fallback 链路。
Returns: Returns:
tuple: (can_generate, reason) tuple: (can_generate, reason)
""" """
@@ -787,69 +585,10 @@ class EditPlanService:
if not clips: if not clips:
return False, "请先添加片段后再生成视频" 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, "" return True, ""
def mark_clips_ready(self, plan_id: str) -> int: def mark_clips_ready(self, plan_id: str) -> int:
"""已分配素材的 pending 片段标记为 ready """所有 pending 状态的片段标记为 ready
只标记同时满足以下条件的片段:
- status == PENDING
- asset_id 非空(已分配素材)
Returns: Returns:
int: 标记的片段数量 int: 标记的片段数量
@@ -860,16 +599,10 @@ class EditPlanService:
) )
count = 0 count = 0
for clip in clips: for clip in clips:
if clip.asset_id: clip.mark_ready()
clip.mark_ready() self._clip_repo.update(clip)
self._clip_repo.update(clip) count += 1
count += 1 logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
logger.info(
"标记片段就绪: plan_id=%s marked=%d total_pending=%d",
plan_id,
count,
len(clips),
)
return count return count
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan: def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
@@ -49,10 +49,9 @@ class PlanGeneratorService:
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。 基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
""" """
def __init__(self, db: Session, asset_repo=None) -> None: def __init__(self, db: Session) -> None:
self._plan_repo = SQLAlchemyEditPlanRepository(db) self._plan_repo = SQLAlchemyEditPlanRepository(db)
self._clip_repo = SQLAlchemyEditPlanClipRepository(db) self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
self._asset_repo = asset_repo
# ── 公开接口 ───────────────────────────────────────────────────────────── # ── 公开接口 ─────────────────────────────────────────────────────────────
@@ -65,7 +64,6 @@ class PlanGeneratorService:
project_id: str = "", project_id: str = "",
created_by_user_id: str = "", created_by_user_id: str = "",
name: str = "", name: str = "",
random_preview: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""基于模板+素材生成剪辑计划 """基于模板+素材生成剪辑计划
@@ -76,7 +74,6 @@ class PlanGeneratorService:
project_id: 所属项目 ID project_id: 所属项目 ID
created_by_user_id: 创建者用户 ID created_by_user_id: 创建者用户 ID
name: 计划名称(为空则自动取模板名) name: 计划名称(为空则自动取模板名)
random_preview: 是否启用随机预览模式(随机选素材+随机截取片段)
Returns: Returns:
dict: {"plan": EditPlan, "clips": List[EditPlanClip]} dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
@@ -118,17 +115,7 @@ class PlanGeneratorService:
# 4. 按 editing_mode 分配素材 # 4. 按 editing_mode 分配素材
if asset_ids: if asset_ids:
# 获取素材时长信息,用于随机起始时间 self._distribute_assets(clips, asset_ids, editing_mode)
asset_durations = None
if 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 并计算总时长 # 5. 持久化所有 clips 并计算总时长
created_clips: List[EditPlanClip] = [] created_clips: List[EditPlanClip] = []
@@ -212,34 +199,9 @@ class PlanGeneratorService:
clips: List[EditPlanClip], clips: List[EditPlanClip],
asset_ids: List[str], asset_ids: List[str],
editing_mode: str, editing_mode: str,
*,
random_selection: bool = False,
asset_durations: dict[str, float] | None = None,
) -> None: ) -> None:
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化). """按 editing_mode 将素材分配到 clips(就地修改,未持久化).
委托给 plan_generator_utils.distribute_assets 纯函数。 委托给 plan_generator_utils.distribute_assets 纯函数。
""" """
distribute_assets( distribute_assets(clips, asset_ids, editing_mode)
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
+164
View File
@@ -0,0 +1,164 @@
"""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)
@@ -374,7 +374,7 @@ class VideoComposeService:
EditPlanStatus.EDITING, EditPlanStatus.EDITING,
EditPlanStatus.RENDERING, EditPlanStatus.RENDERING,
), ),
"rendered_url": plan.config.get("rendered_storage_key", "") or plan.config.get("rendered_url", ""), "rendered_url": plan.config.get("rendered_url", ""),
} }
# ── 内部方法 ────────────────────────────────────────────────────────── # ── 内部方法 ──────────────────────────────────────────────────────────
+45 -79
View File
@@ -50,10 +50,10 @@ type AssetListResponse = {
} }
test.describe("Core generation flow", () => { test.describe("Core generation flow", () => {
test.describe.configure({ timeout: 360_000 }) test.describe.configure({ timeout: 180_000 })
test("walks through 6-step wizard and starts generation", async ({ page, request }) => { test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
test.setTimeout(360_000) test.setTimeout(180_000)
await routeBrowserApiToTestApi(page) await routeBrowserApiToTestApi(page)
const suffix = Date.now().toString(36) const suffix = Date.now().toString(36)
@@ -185,59 +185,56 @@ test.describe("Core generation flow", () => {
await expect(page.locator(".xx-choice-item.selected")).toBeVisible() await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click()
// Step 2: select material (card grid UI) // Step 2: select material
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible() await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
const librarySelect = page.locator("select").first() const librarySelect = page.locator("select").first()
await librarySelect.selectOption({ label: libraryName }) await librarySelect.selectOption({ label: libraryName })
// 新 UI: 素材以 9:16 竖屏卡片展示,点击卡片选中 const materialLabel = page.getByText(sourceFileName).locator("..")
// 注意:卡片中心是播放按钮(stopPropagation 会阻止选中),所以点击左上角避开 await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
const materialCard = page.getByTestId("material-card").filter({ hasText: sourceFileName }) timeout: 10_000,
await expect(materialCard).toBeVisible({ timeout: 10_000 }) })
await materialCard.click({ position: { x: 15, y: 15 } }) await materialLabel.locator("input[type='checkbox']").check()
// 验证选中:卡片应出现勾选标记(用 testid 定位,避免 ✓ 字符文本匹配不稳定)
await expect(materialCard.getByTestId("material-card-check")).toBeVisible({ timeout: 5_000 })
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click()
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过) // Step 3: preview (纯展示页,AI 智能匹配预览)
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 }) await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click() await page.getByRole("button", { name: "下一步" }).click()
// Step 4: title(新顺序:标题在预览之前) // Step 4: title
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 }) await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
// 等待组件完全渲染
await page.waitForTimeout(2000)
// Antd AutoComplete 的 placeholder 渲染在 span 上,input 无 placeholder 属性
// 使用 Antd AutoComplete 特有的 class 定位输入框
const titleInput = page.locator(".ant-select-auto-complete input")
await expect(titleInput).toBeVisible({ timeout: 5000 })
const titleText = `E2E Test ${suffix}` const titleText = `E2E Test ${suffix}`
await titleInput.fill(titleText) await page.getByPlaceholder("输入自定义标题…").fill(titleText)
await page.getByRole("button", { name: "下一步" }).click()
// Step 4(标题+实时预览):确认生成按钮已移到标题页,点击直接创建最终渲染任务 // Step 5: voice
// 等待前端实时预览就绪:未就绪时右侧 FrontendPreviewPlayer 显示「准备预览素材...」占位, await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
// 就绪(previewReady:素材已解析 + 模板已选中)后占位消失;否则按钮会被校验拦截弹 warning const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
await page await firstVoiceCard.click()
.getByText("准备预览素材") await page.getByRole("button", { name: "下一步" }).click()
.waitFor({ state: "detached", timeout: 30_000 })
.catch(() => {}) // Step 6: cover (默认 AI 智能选帧模式,直接下一步)
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
await page.getByRole("button", { name: "下一步" }).click()
// Step 7: confirm and generate
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
// Wait for generation API to be called // Wait for generation API to be called
// 前端直接创建生成任务:POST /generation/tasks // 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
// 等 generate 接口返回,确认生成流程启动
const generatePromise = page.waitForResponse( const generatePromise = page.waitForResponse(
(response) => { (response) => {
const url = response.url() const url = response.url()
const path = new URL(url).pathname const path = new URL(url).pathname
return response.request().method() === "POST" && path.endsWith("/generation/tasks") return response.request().method() === "POST" && path.endsWith("/editor/generate")
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
// 点击「确认生成视频」 // Click generate button
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成视频" }).first().click() await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
// Verify generation was triggered // Verify generation was triggered successfully
const genResp = await generatePromise const genResp = await generatePromise
if (!genResp.ok()) { if (!genResp.ok()) {
const body = await genResp.text() const body = await genResp.text()
@@ -245,50 +242,19 @@ test.describe("Core generation flow", () => {
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`, `[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
) )
} }
// Generate API may return 400 in test env if template has no ready segments expect(genResp.ok()).toBeTruthy()
// That is OK for a wizard flow smoke test const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
if (genResp.ok()) { expect(genData.plan_id).toBeTruthy()
const genData = (await genResp.json()) as { expect(genData.generation_task_id).toBeTruthy()
items: Array<{ id: string; status: string }>
total: number
}
expect(genData.items.length).toBeGreaterThan(0)
expect(genData.items[0].id).toBeTruthy()
// Step 5: 确认生成页 — 任务创建成功后自动跳转,展示渲染进度 // Generation may fail in test env (no worker), that's OK
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible({ // Just verify the flow started - check page shows generation-related UI
timeout: 15_000, await page
}) .getByText(/生成中|生成完成|生成失败/)
.isVisible({ timeout: 15_000 })
// Step 5 → Step 6:等待渲染终态 .catch(() => false)
// - 完成:页面出现「视频生成完成」,步骤5「下一步」按钮解锁,点击进入封面 // If we see progress or result, great; if not, flow still reached the end
// - 失败:出现「生成失败」,停在确认生成页也算向导流程走通 // which is sufficient for an E2E smoke test
// - 超时未终态(测试环境 worker 可能不处理任务):进度仍在轮询,同样算走通
const renderSucceeded = await page
.getByText("视频生成完成", { exact: false })
.waitFor({ timeout: 180_000 })
.then(() => true)
.catch(() => false)
if (renderSucceeded) {
// 渲染完成:手动点「下一步」进入封面步骤(渲染完不自动跳转)
await page.getByRole("button", { name: "下一步" }).click()
// Step 6: 封面(最后一步,无主按钮),仅验证页面渲染
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({
timeout: 15_000,
})
} else {
// 失败或超时:仍在确认生成页(进度展示或失败提示),向导流程已完整走通
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
console.log("[E2E] 渲染任务失败或未在 180s 内完成,冒烟测试仍通过(已达确认生成页)")
}
} else {
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
// 创建失败时停留在标题页并展示错误提示
await page
.getByText(/生成失败|重新生成/)
.isVisible({ timeout: 15_000 })
.catch(() => false)
}
// Verify product library page loads (smoke: just verify page renders) // Verify product library page loads (smoke: just verify page renders)
await page.goto("/app/products") await page.goto("/app/products")
+42 -53
View File
@@ -37,33 +37,14 @@ async function loginWithRetry(
}) })
} }
async function registerWithRetry(
request: APIRequestContext,
email: string,
username: string,
password: string,
displayName: string,
maxRetries = 2,
) {
for (let i = 0; i <= maxRetries; i++) {
const response = await request.post(`${apiBase}/auth/register`, {
data: { email, password, username, display_name: displayName },
})
if (response.status() !== 429) return response
console.log(`[register] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
await new Promise((r) => setTimeout(r, 65000))
}
return request.post(`${apiBase}/auth/register`, {
data: { email, password, username, display_name: displayName },
})
}
/** 注册并登录,返回 { headers, email, username, userId } */ /** 注册并登录,返回 { headers, email, username, userId } */
async function createAuthedUser(request: APIRequestContext, label: string) { async function createAuthedUser(request: APIRequestContext, label: string) {
const email = uniqueEmail(label) const email = uniqueEmail(label)
const username = uniqueUsername(label) const username = uniqueUsername(label)
const reg = await registerWithRetry(request, email, username, PASSWORD, `E2E ${label}`) const reg = await request.post(`${apiBase}/auth/register`, {
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
})
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
const regData = await reg.json() const regData = await reg.json()
@@ -197,7 +178,7 @@ test.describe("素材库流程", () => {
expect(kinds).toContain("image") expect(kinds).toContain("image")
}) })
test("创建素材记录 — POST /assets 已废弃返回 410", async ({ request }) => { test("创建素材记录", async ({ request }) => {
const { headers, userId } = await createAuthedUser(request, "asset-create") const { headers, userId } = await createAuthedUser(request, "asset-create")
const projectId = await createProject(request, headers, Date.now().toString()) const projectId = await createProject(request, headers, Date.now().toString())
@@ -213,7 +194,7 @@ test.describe("素材库流程", () => {
expect(lib.ok()).toBeTruthy() expect(lib.ok()).toBeTruthy()
const libData = await lib.json() const libData = await lib.json()
// POST /assets 已废弃,应返回 410 Gone // 创建素材记录
const response = await request.post(`${apiBase}/assets`, { const response = await request.post(`${apiBase}/assets`, {
headers, headers,
data: { data: {
@@ -229,9 +210,16 @@ test.describe("素材库流程", () => {
}, },
}) })
expect(response.status()).toBe(410) expect(
response.ok(),
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy()
const data = await response.json() const data = await response.json()
expect(data.error?.code).toBe("HTTP_410") expect(data.id, "应返回素材 ID").toBeTruthy()
expect(data.name).toContain("test_video")
expect(data.mime_type).toBe("video/mp4")
expect(data.library_id).toBe(libData.id)
}) })
test("列出素材", async ({ request }) => { test("列出素材", async ({ request }) => {
@@ -244,50 +232,51 @@ test.describe("素材库流程", () => {
data: { data: {
project_id: projectId, project_id: projectId,
name: `List Lib ${Date.now()}`, name: `List Lib ${Date.now()}`,
kind: "image", kind: "video",
}, },
}) })
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy() expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
const libData = await lib.json() const libData = await lib.json()
// 通过 multipart upload 上传 2 个小图片作为测试素材 // 创建 2 个素材
// 创建一个 1x1 的 PNG buffer await request.post(`${apiBase}/assets`, {
const tinyPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"base64",
)
await request.post(`${apiBase}/upload`, {
headers, headers,
multipart: { data: {
project_id: projectId, project_id: projectId,
library_id: libData.id, library_id: libData.id,
file: { name: "clip_a.png", mimeType: "image/png", buffer: tinyPng }, name: `clip_a_${Date.now()}.mp4`,
storage_key: `uploads/e2e/clip_a.mp4`,
mime_type: "video/mp4",
status: "ready",
uploaded_by_user_id: userId,
}, },
}) })
await request.post(`${apiBase}/upload`, { await request.post(`${apiBase}/assets`, {
headers, headers,
multipart: { data: {
project_id: projectId, project_id: projectId,
library_id: libData.id, library_id: libData.id,
file: { name: "clip_b.png", mimeType: "image/png", buffer: tinyPng }, name: `clip_b_${Date.now()}.mp4`,
storage_key: `uploads/e2e/clip_b.mp4`,
mime_type: "video/mp4",
status: "ready",
uploaded_by_user_id: userId,
}, },
}) })
// 列出素材(可能需要等待 ingest job 完成) // 列出素材
let items: any[] = [] const response = await request.get(`${apiBase}/assets`, {
for (let i = 0; i < 10; i++) { headers,
const response = await request.get(`${apiBase}/assets`, { params: { library_id: libData.id },
headers, })
params: { library_id: libData.id },
})
expect(response.ok(), `列出素材应返回 2xx`).toBeTruthy()
const data = await response.json()
items = data.items || []
if (items.length >= 2) break
await new Promise((r) => setTimeout(r, 2000))
}
expect(
response.ok(),
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
).toBeTruthy()
const data = await response.json()
const items = data.items || []
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2) expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
}) })
+26 -24
View File
@@ -12,7 +12,6 @@
"@tanstack/react-query": "^5.45.0", "@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0", "antd": "^5.18.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.0", "react-router-dom": "^6.24.0",
@@ -1848,7 +1847,7 @@
}, },
"node_modules/@testing-library/dom": { "node_modules/@testing-library/dom": {
"version": "10.4.1", "version": "10.4.1",
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -1938,7 +1937,7 @@
}, },
"node_modules/@types/aria-query": { "node_modules/@types/aria-query": {
"version": "5.0.4", "version": "5.0.4",
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -3113,7 +3112,7 @@
}, },
"node_modules/dom-accessibility-api": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -4029,6 +4028,18 @@
"node": ">= 4" "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": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4457,7 +4468,7 @@
}, },
"node_modules/lz-string": { "node_modules/lz-string": {
"version": "1.5.0", "version": "1.5.0",
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -4624,15 +4635,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mp4box": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/mp4box/-/mp4box-2.4.1.tgz",
"integrity": "sha512-0HGX7nXoDIX6FKLVl4a3wtYjBlwqsN3xuQC3GXzNtKp98FXUOhDSq623azsz8DG5ptd9ZXcXodDkgbdMZOjWvw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=20.8.1"
}
},
"node_modules/mrmime": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -5008,7 +5010,7 @@
}, },
"node_modules/pretty-format": { "node_modules/pretty-format": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -5024,7 +5026,7 @@
}, },
"node_modules/pretty-format/node_modules/ansi-styles": { "node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
@@ -5036,6 +5038,14 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/proxy-from-env": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
@@ -5733,14 +5743,6 @@
"react": "^18.3.1" "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": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
-1
View File
@@ -23,7 +23,6 @@
"@tanstack/react-query": "^5.45.0", "@tanstack/react-query": "^5.45.0",
"antd": "^5.18.0", "antd": "^5.18.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"mp4box": "^2.4.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^6.24.0", "react-router-dom": "^6.24.0",
-2
View File
@@ -1,2 +0,0 @@
allowBuilds:
esbuild: set this to true or false
+10 -37
View File
@@ -49,43 +49,16 @@ export const getAssetsByKind = async (
return response.data.items || [] return response.data.items || []
} }
/** /** 创建素材(上传文件后调用,附带 metadata) */
* 智能匹配素材(后端 AI 选素材) export const createAsset = async (data: {
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材 library_id: string
* name: string
* 后端返回 items 元素兼容两种结构(过渡期): storage_key: string
* - 扁平结构:AssetItem 本身(id 在顶层) mime_type: string
* - 包装结构:{ asset: AssetItem, score, breakdown }id 需从 .asset 取) metadata?: AssetMetadata
* 这里统一归一化为 AssetItem[],调用方无需关心包装层。 }): Promise<AssetItem> => {
*/ const response = await apiClient.post("/assets", data)
export interface SmartMatchResult { return response.data
items: AssetItem[]
}
interface SmartMatchWrappedItem {
asset?: AssetItem
id?: string
score?: number
breakdown?: unknown
}
export const smartMatchAssets = async (
libraryId: string,
limit?: number,
): Promise<SmartMatchResult> => {
const payload: Record<string, unknown> = { library_id: libraryId }
if (limit && limit > 0) payload.limit = limit
const response = await apiClient.post("/assets/smart-match", payload)
const rawItems: SmartMatchWrappedItem[] = response.data?.items ?? []
const items = rawItems
.map((it) =>
// 包装结构 { asset: {...} } 优先解包;否则视其本身为扁平 AssetItem
it?.asset && typeof it.asset === "object" && "id" in it.asset
? it.asset
: (it as unknown as AssetItem),
)
.filter((it): it is AssetItem => !!it && typeof it.id === "string" && it.id.length > 0)
return { items }
} }
/** 更新素材(名称、metadata 等) */ /** 更新素材(名称、metadata 等) */
+2 -7
View File
@@ -2,17 +2,12 @@
* 素材诊断 API * 素材诊断 API
*/ */
import apiClient from "../client" import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects"
import type { AssetDiagnosis } from "./types" import type { AssetDiagnosis } from "./types"
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ /** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
export const getAssetDiagnosis = async ( export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
assetId?: string,
projectId?: string,
): Promise<AssetDiagnosis> => {
const pid = projectId ?? (await getOrCreateDefaultProject()).id
const params: Record<string, string> = {} const params: Record<string, string> = {}
if (assetId) params.asset_id = assetId if (assetId) params.asset_id = assetId
const response = await apiClient.get(`/projects/${pid}/asset-diagnosis`, { params }) const response = await apiClient.get("/asset-diagnosis", { params })
return response.data return response.data
} }
+4 -13
View File
@@ -13,6 +13,7 @@ export type {
ClassificationJob, ClassificationJob,
AssetDiagnosis, AssetDiagnosis,
BatchOperationResult, BatchOperationResult,
UploadResult,
DirectUploadPrepareResult, DirectUploadPrepareResult,
DirectUploadCompleteResult, DirectUploadCompleteResult,
} from "./types" } from "./types"
@@ -20,10 +21,6 @@ export type {
// 素材诊断 // 素材诊断
export { getAssetDiagnosis } from "./diagnosis" export { getAssetDiagnosis } from "./diagnosis"
// 素材余量/可用性判断
export { isAssetUsable } from "./usage"
export type { AssetUsageLike } from "./usage"
// 素材库 // 素材库
export { export {
getAssetLibraries, getAssetLibraries,
@@ -32,24 +29,18 @@ export {
deleteAssetLibrary, deleteAssetLibrary,
} from "./libraries" } from "./libraries"
// 素材 CRUD + 智能匹配 // 素材 CRUD
export { export {
getAssets, getAssets,
getAssetsByKind, getAssetsByKind,
smartMatchAssets, createAsset,
updateAsset, updateAsset,
updateAssetReviewStatus, updateAssetReviewStatus,
deleteAsset, deleteAsset,
} from "./assets" } from "./assets"
// 上传 // 上传
export { export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
prepareDirectUpload,
completeDirectUpload,
uploadAssetDirect,
prepareDirectUploadHandle,
type DirectUploadHandle,
} from "./upload"
// 任务 // 任务
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs" export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
-15
View File
@@ -40,10 +40,6 @@ export interface AssetItem {
thumbnail_url?: string thumbnail_url?: string
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
duration?: number duration?: number
/** 已切片段占用时长占比(0~1,后端片段重复率控制机制返回;字段缺失视为未统计) */
used_ratio?: number | null
/** 是否已彻底用尽(无新区间且历史区间复用次数均达上限);false 的素材不参与生成选片 */
usable?: boolean | null
status?: string status?: string
classification_status?: AssetClassificationStatus | null classification_status?: AssetClassificationStatus | null
quality_score?: number | null quality_score?: number | null
@@ -133,21 +129,10 @@ export interface DirectUploadPrepareResult {
expires_at: string expires_at: string
fields: Record<string, string> fields: Record<string, string>
max_size_bytes: number max_size_bytes: number
/**
* prepare 阶段预创建的素材记录 id(后端改造后返回:status=uploading)。
* 前端拿到后立即刷新列表,卡片以「上传中」态出现在素材网格中。
* 旧后端不返回该字段,前端降级为无预建卡片的原有行为。
*/
asset_id?: string
} }
/** 直传完成确认返回 */ /** 直传完成确认返回 */
export interface DirectUploadCompleteResult { export interface DirectUploadCompleteResult {
storage_key: string storage_key: string
ingest_job_id: string ingest_job_id: string
url: string
/** 同库已存在相同 file_hash 的素材时为 trueingest_job_id 为空 */
duplicated?: boolean
/** duplicated 为 true 时返回已存在素材的 id */
asset_id?: string
} }
+35 -60
View File
@@ -3,7 +3,16 @@
*/ */
import apiClient from "../client" import apiClient from "../client"
import { getOrCreateDefaultProject } from "../projects" import { getOrCreateDefaultProject } from "../projects"
import type { DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types" import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
/** 表单上传素材(小文件) */
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
const response = await apiClient.post("/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
timeout: 30 * 60 * 1000,
})
return response.data
}
/** 预签名直传准备 */ /** 预签名直传准备 */
export const prepareDirectUpload = async (data: { export const prepareDirectUpload = async (data: {
@@ -27,18 +36,28 @@ export const completeDirectUpload = async (data: {
return response.data return response.data
} }
/** 直传 OSS 的底层传输(POST 表单到 OSS),带进度回调 */ /** 直传上传(大文件推荐),支持可选进度回调 */
const putToOSS = ( export const uploadAssetDirect = async (data: {
prepared: DirectUploadPrepareResult, file: File
file: File, library_id: string
onProgress?: (percent: number) => void, onProgress?: (percent: number) => void
): Promise<void> => }): Promise<DirectUploadCompleteResult> => {
new Promise<void>((resolve, reject) => { const project = await getOrCreateDefaultProject()
const directForm = new FormData()
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
directForm.append("file", file)
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 const prepared = await prepareDirectUpload({
project_id: project.id,
library_id: data.library_id,
filename: data.file.name,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
})
const directForm = new FormData()
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
directForm.append("file", data.file)
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
xhr.open(prepared.method, prepared.upload_url) xhr.open(prepared.method, prepared.upload_url)
@@ -46,8 +65,8 @@ const putToOSS = (
xhr.timeout = 10 * 60 * 1000 xhr.timeout = 10 * 60 * 1000
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) { if (e.lengthComputable && data.onProgress) {
onProgress(Math.round((e.loaded / e.total) * 100)) data.onProgress(Math.round((e.loaded / e.total) * 100))
} }
} }
xhr.onload = () => { xhr.onload = () => {
@@ -92,53 +111,9 @@ const putToOSS = (
xhr.send(directForm) xhr.send(directForm)
}) })
/** 单个文件的上传阶段信息(供批量上传队列做状态绑定) */ return completeDirectUpload({
export interface DirectUploadHandle {
/** prepare 返回(含可能的预建 asset_id */
prepared: DirectUploadPrepareResult
/** 直传 OSS(可重复调用用于重试) */
transfer: (onProgress?: (percent: number) => void) => Promise<void>
/** 直传完成后调用 complete 确认入库 */
complete: () => Promise<DirectUploadCompleteResult>
}
/**
* 准备一次直传:调 prepare 拿到签名表单(后端可能同时预建 uploading 态 asset),
* 返回分段执行的 handle,调用方自行控制 transfer/complete 时机(便于队列并发与重试)。
*/
export const prepareDirectUploadHandle = async (data: {
file: File
library_id: string
}): Promise<DirectUploadHandle> => {
const project = await getOrCreateDefaultProject()
const prepared = await prepareDirectUpload({
project_id: project.id, project_id: project.id,
library_id: data.library_id, library_id: data.library_id,
filename: data.file.name, storage_key: prepared.storage_key,
content_type: data.file.type || "application/octet-stream",
file_size: data.file.size,
}) })
return {
prepared,
transfer: (onProgress) => putToOSS(prepared, data.file, onProgress),
complete: () =>
completeDirectUpload({
project_id: project.id,
library_id: data.library_id,
storage_key: prepared.storage_key,
}),
}
}
/** 直传上传(大文件推荐),支持可选进度回调;一次性完成 prepare→transfer→complete */
export const uploadAssetDirect = async (data: {
file: File
library_id: string
onProgress?: (percent: number) => void
}): Promise<DirectUploadCompleteResult> => {
const handle = await prepareDirectUploadHandle({ file: data.file, library_id: data.library_id })
await handle.transfer(data.onProgress)
return handle.complete()
} }
-18
View File
@@ -1,18 +0,0 @@
/**
* 素材余量/可用性判断
* 后端片段重复率控制机制(任意两条成片画面重复率 ≤15%)上线后,
* 素材列表会附加 usable / used_ratio 字段。字段未上线前一律按可用处理。
*/
/** 仅依赖素材余量相关字段的最小结构,api 层与 pages 层 AssetItem 均可传入 */
export interface AssetUsageLike {
usable?: boolean | null
used_ratio?: number | null
}
/**
* 素材是否仍可参与生成选片。
* usable === false 表示已彻底用尽(无新区间且复用次数全部达上限);
* 字段缺失(undefined/null)时降级为可用,保证后端字段上线前零影响。
*/
export const isAssetUsable = (asset: AssetUsageLike): boolean => asset.usable !== false
+188
View File
@@ -0,0 +1,188 @@
/**
* 认证相关 API
*/
import axios from "axios"
import apiClient from "./client"
// 类型定义
export interface LoginRequest {
email: string
password: string
}
export interface LoginResponse {
access_token: string
refresh_token?: string | null
token_type: string
expires_in: number
user_id: string
email: string
username: string
display_name: string
}
export interface RegisterRequest {
email: string
password: string
username: string
display_name?: string
}
export interface User {
id: string
user_id: string
email: string
username: string
display_name: string
is_email_verified: boolean
email_verified: boolean
created_at?: string
}
export interface UserResponse {
id?: string
user_id?: string
email: string
username: string
display_name: string
is_email_verified?: boolean
email_verified?: boolean
created_at?: string
}
export const normalizeUser = (data: UserResponse): User => {
const userId = data.id ?? data.user_id ?? ""
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
return {
id: userId,
user_id: userId,
email: data.email,
username: data.username,
display_name: data.display_name,
is_email_verified: emailVerified,
email_verified: emailVerified,
created_at: data.created_at,
}
}
// 登录
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post("/auth/login", data)
return response.data
}
// 刷新 access_token(使用裸 axios 避免拦截器递归)
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
const baseURL = apiClient.defaults.baseURL ?? ""
const response = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken,
})
return response.data
}
// 注册
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/register", data)
return response.data
}
// 登出
export const logout = async (): Promise<void> => {
await apiClient.post("/auth/logout")
}
// 获取当前用户
export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<UserResponse>("/auth/me")
return normalizeUser(response.data)
}
// 请求密码重置
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/forgot-password", { email })
return response.data
}
// 重置密码
export const resetPassword = async (
token: string,
newPassword: string,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/reset-password", {
token,
new_password: newPassword,
})
return response.data
}
// 验证邮箱
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/verify-email", { token })
return response.data
}
/* ========== 微信登录 ========== */
export interface WechatAuthUrlResponse {
auth_url: string
state: string
}
export interface WechatCallbackResponse {
access_token: string
refresh_token?: string | null
user_id: string
display_name: string
avatar_url: string
is_new_user: boolean
binding_complete: boolean
expires_in: number
}
export interface SendVerificationCodeRequest {
target: "email" | "phone"
value: string
purpose: "bind" | "login" | "reset_password"
}
export interface BindContactRequest {
email?: string
email_code?: string
phone?: string
phone_code?: string
}
export interface BindContactResponse {
success: boolean
user: User
}
// 获取微信授权链接
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
return response.data
}
// 微信回调登录
export const wechatCallback = async (
code: string,
state: string,
): Promise<WechatCallbackResponse> => {
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
// 发送验证码
export const sendVerificationCode = async (
data: SendVerificationCodeRequest,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/send-verification-code", data)
return response.data
}
// 绑定联系方式
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
const response = await apiClient.post("/auth/bind-contact", data)
return response.data
}
-20
View File
@@ -1,20 +0,0 @@
import apiClient from "../client"
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
/**
* 发送验证码
*/
export const sendVerificationCode = async (
data: SendVerificationCodeRequest,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/send-verification-code", data)
return response.data
}
/**
* 绑定联系方式
*/
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
const response = await apiClient.post("/auth/bind-contact", data)
return response.data
}
-11
View File
@@ -1,11 +0,0 @@
import apiClient from "../client"
import type { User, UserResponse } from "./types"
import { normalizeUser } from "./user"
/**
* 获取当前用户
*/
export const getCurrentUser = async (): Promise<User> => {
const response = await apiClient.get<UserResponse>("/auth/me")
return normalizeUser(response.data)
}
-9
View File
@@ -1,9 +0,0 @@
import apiClient from "../client"
/**
* 验证邮箱
*/
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/verify-email", { token })
return response.data
}
-39
View File
@@ -1,39 +0,0 @@
/**
* 认证相关 API
* 保持向后兼容,从子模块 re-export
*/
// 类型
export type {
LoginRequest,
LoginResponse,
RegisterRequest,
User,
UserResponse,
WechatAuthUrlResponse,
WechatCallbackResponse,
SendVerificationCodeRequest,
BindContactRequest,
BindContactResponse,
} from "./types"
// 用户工具函数
export { normalizeUser } from "./user"
// 登录/注册/登出/刷新
export { login, refreshAccessToken, register, logout } from "./login"
// 当前用户
export { getCurrentUser } from "./currentUser"
// 密码重置
export { requestPasswordReset, resetPassword } from "./password"
// 邮箱验证
export { verifyEmail } from "./email"
// 微信登录
export { getWechatAuthUrl, wechatCallback } from "./wechat"
// 联系方式
export { sendVerificationCode, bindContact } from "./contact"
-37
View File
@@ -1,37 +0,0 @@
import axios from "axios"
import apiClient from "../client"
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
/**
* 登录
*/
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
const response = await apiClient.post("/auth/login", data)
return response.data
}
/**
* 刷新 access_token(使用裸 axios 避免拦截器递归)
*/
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
const baseURL = apiClient.defaults.baseURL ?? ""
const response = await axios.post(`${baseURL}/auth/refresh`, {
refresh_token: refreshToken,
})
return response.data
}
/**
* 注册
*/
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/register", data)
return response.data
}
/**
* 登出
*/
export const logout = async (): Promise<void> => {
await apiClient.post("/auth/logout")
}
-23
View File
@@ -1,23 +0,0 @@
import apiClient from "../client"
/**
* 请求密码重置
*/
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/forgot-password", { email })
return response.data
}
/**
* 重置密码
*/
export const resetPassword = async (
token: string,
newPassword: string,
): Promise<{ message: string }> => {
const response = await apiClient.post("/auth/reset-password", {
token,
new_password: newPassword,
})
return response.data
}
-109
View File
@@ -1,109 +0,0 @@
/**
* 主动 Token 刷新模块
*
* 在 access_token 过期前主动刷新,避免 API 请求触发 401。
* JWT payload 是 base64 编码的 JSON,无需第三方库即可解码。
*/
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./login"
let refreshTimer: ReturnType<typeof setTimeout> | null = null
/** 正在执行刷新操作的 Promise,防止主动刷新和 401 被动刷新并发竞争 */
let activeRefreshPromise: Promise<void> | null = null
/** 提前刷新的缓冲时间(秒) */
const REFRESH_BUFFER_SECONDS = 60
/**
* 解码 JWT payload(不验签,仅读取 exp 字段)
*/
function decodeJwtPayload(token: string): { exp?: number } | null {
try {
const parts = token.split(".")
if (parts.length !== 3) return null
// JWT 使用 base64url 编码,需要转换为标准 base64
const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/")
const padded = payload + "=".repeat((4 - (payload.length % 4)) % 4)
const decoded = atob(padded)
return JSON.parse(decoded)
} catch {
return null
}
}
/**
* 取消已调度的主动刷新
*/
export function cancelProactiveRefresh(): void {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
}
/**
* 执行 token 刷新(带并发锁,供主动刷新和被动 401 共用)
* 返回当前刷新操作的 Promise;若已有刷新进行中则复用该 Promise。
*/
export function executeTokenRefresh(): Promise<void> | null {
// 已有刷新进行中 → 复用
if (activeRefreshPromise) {
return activeRefreshPromise
}
const { user, refreshToken: refreshTokenValue } = useAuthStore.getState()
// 安全检查:user 或 refreshToken 为空时跳过刷新
if (!user || !refreshTokenValue) {
return null
}
activeRefreshPromise = (async () => {
try {
const data = await refreshAccessToken(refreshTokenValue)
const newAccessToken = data.access_token
const newRefreshToken = data.refresh_token ?? refreshTokenValue
// 更新 Zustand store + localStorage
useAuthStore.getState().setAuth(user, newAccessToken, newRefreshToken)
// 递归调度下一次刷新
scheduleProactiveRefresh()
} catch {
// 刷新失败 → 清除认证状态,跳转登录页
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/login"
} finally {
activeRefreshPromise = null
}
})()
return activeRefreshPromise
}
/**
* 调度主动刷新:在 token 过期前 REFRESH_BUFFER_SECONDS 秒自动刷新
*/
export function scheduleProactiveRefresh(): void {
cancelProactiveRefresh()
// 统一从 Zustand store 读取(与 setAuth 写入保持一致)
const { accessToken, refreshToken: refreshTokenValue } = useAuthStore.getState()
if (!accessToken || !refreshTokenValue) return
const payload = decodeJwtPayload(accessToken)
if (!payload?.exp) return
const now = Math.floor(Date.now() / 1000)
const secondsUntilExpiry = payload.exp - now
// 如果 token 已经过期或即将在缓冲时间内过期,立即刷新
const delaySeconds = Math.max(secondsUntilExpiry - REFRESH_BUFFER_SECONDS, 0)
refreshTimer = setTimeout(() => {
executeTokenRefresh()
}, delaySeconds * 1000)
}
-82
View File
@@ -1,82 +0,0 @@
/**
* 认证相关类型定义
*/
export interface LoginRequest {
email: string
password: string
}
export interface LoginResponse {
access_token: string
refresh_token?: string | null
token_type: string
expires_in: number
user_id: string
email: string
username: string
display_name: string
}
export interface RegisterRequest {
email: string
password: string
username: string
display_name?: string
}
export interface User {
id: string
user_id: string
email: string
username: string
display_name: string
is_email_verified: boolean
email_verified: boolean
created_at?: string
}
export interface UserResponse {
id?: string
user_id?: string
email: string
username: string
display_name: string
is_email_verified?: boolean
email_verified?: boolean
created_at?: string
}
export interface WechatAuthUrlResponse {
auth_url: string
state: string
}
export interface WechatCallbackResponse {
access_token: string
refresh_token?: string | null
user_id: string
display_name: string
avatar_url: string
is_new_user: boolean
binding_complete: boolean
expires_in: number
}
export interface SendVerificationCodeRequest {
target: "email" | "phone"
value: string
purpose: "bind" | "login" | "reset_password"
}
export interface BindContactRequest {
email?: string
email_code?: string
phone?: string
phone_code?: string
}
export interface BindContactResponse {
success: boolean
user: User
}
-20
View File
@@ -1,20 +0,0 @@
import type { User, UserResponse } from "./types"
/**
* 规范化用户数据,兼容不同后端返回格式
*/
export const normalizeUser = (data: UserResponse): User => {
const userId = data.id ?? data.user_id ?? ""
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
return {
id: userId,
user_id: userId,
email: data.email,
username: data.username,
display_name: data.display_name,
is_email_verified: emailVerified,
email_verified: emailVerified,
created_at: data.created_at,
}
}
-21
View File
@@ -1,21 +0,0 @@
import apiClient from "../client"
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
/**
* 获取微信授权链接
*/
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
const response = await apiClient.get("/auth/wechat/url")
return response.data
}
/**
* 微信回调登录
*/
export const wechatCallback = async (
code: string,
state: string,
): Promise<WechatCallbackResponse> => {
const response = await apiClient.post("/auth/wechat/callback", { code, state })
return response.data
}
+3 -12
View File
@@ -4,20 +4,11 @@
import apiClient from "../client" import apiClient from "../client"
import type { BgmPreset, BgmPresetsQuery } from "./types" import type { BgmPreset, BgmPresetsQuery } from "./types"
/** /** 获取 BGM 预设列表 */
* 获取 BGM 预设列表 export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
* @param templateId 模板/草稿 ID
* @param params 分类/关键词筛选
*/
export const getBgmPresets = async (
templateId: string,
params?: BgmPresetsQuery,
): Promise<BgmPreset[]> => {
const searchParams: Record<string, string> = {} const searchParams: Record<string, string> = {}
if (params?.category) searchParams.category = params.category if (params?.category) searchParams.category = params.category
if (params?.keyword) searchParams.keyword = params.keyword if (params?.keyword) searchParams.keyword = params.keyword
const res = await apiClient.get(`/templates/${templateId}/editor/bgm/presets`, { const res = await apiClient.get("/bgm/presets", { params: searchParams })
params: searchParams,
})
return res.data?.data ?? res.data ?? [] return res.data?.data ?? res.data ?? []
} }
+9 -33
View File
@@ -5,8 +5,7 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios" import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
import { message } from "antd" import { message } from "antd"
import { useAuthStore } from "@/store/authStore" import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "./auth"
import { cancelProactiveRefresh, executeTokenRefresh } from "./auth/tokenRefresh"
// 创建 Axios 实例 // 创建 Axios 实例
const apiClient = axios.create({ const apiClient = axios.create({
@@ -58,21 +57,7 @@ apiClient.interceptors.response.use(
} }
// 401 → 尝试刷新 Token // 401 → 尝试刷新 Token
// 排除 auth 端点:登录/注册/找回密码的 401 是正常业务响应(如密码错误), if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
// 不应触发 token 刷新或登出跳转,走后面的错误提示逻辑即可
const requestUrl = originalRequest?.url || ""
const isAuthEndpoint =
requestUrl.includes("/auth/login") ||
requestUrl.includes("/auth/register") ||
requestUrl.includes("/auth/forgot-password") ||
requestUrl.includes("/auth/reset-password")
if (
error.response?.status === 401 &&
originalRequest &&
!originalRequest._retry &&
!isAuthEndpoint
) {
const refreshToken = useAuthStore.getState().refreshToken const refreshToken = useAuthStore.getState().refreshToken
// 无 refresh_token → 直接登出 // 无 refresh_token → 直接登出
@@ -98,22 +83,14 @@ apiClient.interceptors.response.use(
isRefreshing = true isRefreshing = true
try { try {
// 使用共享的刷新函数(带并发锁 + 安全检查) const data = await refreshAccessToken(refreshToken)
const refreshPromise = executeTokenRefresh() const newAccessToken = data.access_token
if (!refreshPromise) { const newRefreshToken = data.refresh_token ?? refreshToken
// user 或 refreshToken 为空,无法刷新
cancelProactiveRefresh()
useAuthStore.getState().clearAuth()
window.location.href = "/"
return Promise.reject(new Error("Unable to refresh: missing user or refresh token"))
}
await refreshPromise
// 获取刷新后的新 token // 更新 Zustand + localStorage
const newAccessToken = useAuthStore.getState().accessToken useAuthStore
if (!newAccessToken) { .getState()
return Promise.reject(new Error("Token refresh failed: no new access token")) .setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
}
// 处理排队的请求 // 处理排队的请求
processQueue(null, newAccessToken) processQueue(null, newAccessToken)
@@ -125,7 +102,6 @@ apiClient.interceptors.response.use(
return apiClient(originalRequest) return apiClient(originalRequest)
} catch (refreshError) { } catch (refreshError) {
// 刷新失败 → 登出 // 刷新失败 → 登出
cancelProactiveRefresh()
processQueue(refreshError, null) processQueue(refreshError, null)
useAuthStore.getState().clearAuth() useAuthStore.getState().clearAuth()
window.location.href = "/" window.location.href = "/"
-53
View File
@@ -1,53 +0,0 @@
/**
* 封面模板 CRUD API
* 后端路由: /api/v1/cover-templates
*/
import apiClient from "./client"
import type { CoverTemplate } from "@/pages/generate/types/cover"
export interface CoverTemplateListResponse {
items: CoverTemplate[]
total: number
}
export interface CoverTemplateCreateRequest {
name: string
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
}
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
/** 获取封面模板列表 */
export async function fetchCoverTemplates(): Promise<CoverTemplateListResponse> {
const response = await apiClient.get<CoverTemplateListResponse>("/cover-templates")
return response.data
}
/** 创建封面模板 */
export async function createCoverTemplate(
data: CoverTemplateCreateRequest,
): Promise<CoverTemplate> {
const response = await apiClient.post<CoverTemplate>("/cover-templates", data)
return response.data
}
/** 更新封面模板 */
export async function updateCoverTemplate(
id: string,
data: CoverTemplateUpdateRequest,
): Promise<CoverTemplate> {
const response = await apiClient.put<CoverTemplate>(`/cover-templates/${id}`, data)
return response.data
}
/** 删除封面模板(系统模板不可删) */
export async function deleteCoverTemplate(id: string): Promise<void> {
await apiClient.delete(`/cover-templates/${id}`)
}
+1 -1
View File
@@ -8,8 +8,8 @@ import type {
FilterConfig, FilterConfig,
ChromaKeyConfig, ChromaKeyConfig,
StickerConfig, StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types" } from "@/pages/editing-planner/types"
import type { CoverConfig } from "@/pages/generate/types/cover"
/** 模板模式(后端枚举值) */ /** 模板模式(后端枚举值) */
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip" export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
-14
View File
@@ -1,14 +0,0 @@
import apiClient from "../client"
import type { ConfirmGenerationRequest, ConfirmGenerationResponse } from "./types"
/** 确认生成 — 基于预览任务创建正式生成任务 */
export const confirmGeneration = async (
taskId: string,
params: ConfirmGenerationRequest,
): Promise<ConfirmGenerationResponse> => {
const response = await apiClient.post<ConfirmGenerationResponse>(
`/generation/tasks/${taskId}/confirm`,
params,
)
return response.data
}
-51
View File
@@ -1,51 +0,0 @@
import apiClient from "../client"
export interface GenerateCoverTitleConfig {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
export interface GenerateCoverRequest {
/**
* 封面源视频标识(二选一):
* - generated_video_id:确认生成任务产出的最终视频 ID
* - video_url:最终视频 URL(兜底)
* 后端根据此标识定位最终成片文件并抽帧,MediaKit 选帧逻辑不变
*/
generated_video_id?: string
video_url?: string
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
/** 标题样式,用于在封面上叠加标题文字 */
title_config?: GenerateCoverTitleConfig
}
export interface GenerateCoverResponse {
plan_id: string
cover: {
scheme?: string
asset_id?: string
frame_time?: number
image_url?: string
thumbnail_url?: string
[key: string]: unknown
}
}
/** AI 生成封面 — 从最终成片中抽帧(MediaKit 选帧) */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post<GenerateCoverResponse>("/generation/generate-cover", data, {
timeout: 300000,
params: { template_id: templateId },
})
return response.data
}
-11
View File
@@ -1,11 +0,0 @@
export type {
PreviewStatus,
CreatePreviewRequest,
CreatePreviewResponse,
PreviewTaskResponse,
} from "./types"
export { createPreview, getPreviewStatus } from "./preview"
export { generateCover } from "./cover"
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
-16
View File
@@ -1,16 +0,0 @@
import apiClient from "../client"
import type { CreatePreviewRequest, CreatePreviewResponse, PreviewTaskResponse } from "./types"
/** 创建预览生成任务(单版本) */
export const createPreview = async (
params: CreatePreviewRequest,
): Promise<CreatePreviewResponse> => {
const response = await apiClient.post<CreatePreviewResponse>("/generation/preview", params)
return response.data
}
/** 查询预览任务状态及结果 */
export const getPreviewStatus = async (taskId: string): Promise<PreviewTaskResponse> => {
const response = await apiClient.get<PreviewTaskResponse>(`/generation/preview/${taskId}`)
return response.data
}
-111
View File
@@ -1,111 +0,0 @@
/** 预览任务状态 */
export type PreviewStatus = "pending" | "generating" | "completed" | "failed" | "cancelled"
/** 创建预览任务请求 */
export interface CreatePreviewRequest {
template_id: string
asset_ids: string[]
source_edit_plan_id?: string
title_ids?: string[]
voice_ids?: string[]
/** 配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材 */
voice_library_id?: string
video_title?: string
duration?: number
video_ratio?: string
/** 输出视频宽度(与 video_ratio 匹配,如 9:16 → 1080 */
output_width?: number
/** 输出视频高度(与 video_ratio 匹配,如 9:16 → 1920 */
output_height?: number
/* 标题烧录配置(可选,传入后 ASS 渲染标题到预览视频中) */
title_config?: {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
bgm_config?: {
enabled: boolean
preset_id?: string
volume?: number
}
}
/** 创建预览任务响应 */
export interface CreatePreviewResponse {
task_id: string
status: PreviewStatus
is_preview: boolean
resolution: string
created_at: string
/** 后端自动关联的编辑计划 ID(用于 fallback 路径传递 source_edit_plan_id */
source_edit_plan_id?: string
}
/** 预览任务详情响应 */
export interface PreviewTaskResponse {
task_id: string
status: PreviewStatus
progress: number
is_preview: boolean
resolution: string
video_url?: string
duration?: number
file_size?: number
clip_count?: number
transition_count?: number
material_usage?: number
error_message?: string
created_at: string
started_at?: string
finished_at?: string
generate_duration?: number
}
/** 确认生成请求体 — 基于预览任务创建正式生成任务 */
export interface ConfirmGenerationRequest {
/** 输出视频宽度,默认 1080 */
output_width?: number
/** 输出视频高度,默认 1920 */
output_height?: number
/** 自定义封面图片 URL */
cover_url?: string
/** 自定义视频标题 */
custom_title?: string
}
/** 确认生成响应 */
export interface ConfirmGenerationResponse {
items: ConfirmGenerationTaskItem[]
total: number
}
/** 确认生成返回的任务项 */
export interface ConfirmGenerationTaskItem {
id: string
project_id: string
asset_library_id: string
strategy_id: string
voice_library_id: string
template_id: string
asset_ids: string[]
title_ids: string[]
voice_ids: string[]
source_edit_plan_id: string
asset_select_mode: string
batch_id: string
is_preview: boolean
source_task_id: string
output_width: number
output_height: number
cover_url: string
custom_title: string
status: string
progress: number
result_count: number
error_message: string
}
-7
View File
@@ -6,7 +6,6 @@ import apiClient from "../client"
import type { import type {
CreateGenerationTaskRequest, CreateGenerationTaskRequest,
CreateGenerationTaskResponse, CreateGenerationTaskResponse,
GenerationTaskDetail,
TaskItem, TaskItem,
TaskListParams, TaskListParams,
TaskListResponse, TaskListResponse,
@@ -20,12 +19,6 @@ export const createGenerationTask = async (
return data return data
} }
/** 获取单个生成任务详情(轮询用) */
export const getGenerationTask = async (taskId: string): Promise<GenerationTaskDetail> => {
const { data } = await apiClient.get<GenerationTaskDetail>(`/generation/tasks/${taskId}`)
return data
}
/** 获取任务列表(支持分页和筛选) */ /** 获取任务列表(支持分页和筛选) */
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => { export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
const { data } = await apiClient.get<TaskListResponse>("/tasks", { const { data } = await apiClient.get<TaskListResponse>("/tasks", {
+4 -47
View File
@@ -57,45 +57,12 @@ export interface TaskListResponse {
export interface CreateGenerationTaskRequest { export interface CreateGenerationTaskRequest {
template_id: string template_id: string
asset_ids: string[] asset_ids: string[]
title_ids?: string[] title_ids: string[]
voice_ids?: string[] voice_ids: string[]
/** 输出视频宽度 */
output_width?: number
/** 输出视频高度 */
output_height?: number
/** 自定义封面图片 URL */
cover_url?: string
/** 自定义视频标题 */
custom_title?: string
/** 视频时长(秒) */
duration?: number
/** 视频宽高比,如 "9:16" */
video_ratio?: string
/** 标题烧录配置 */
title_config?: {
text?: string
font?: string
font_size?: number
font_color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
/** 关联的草稿 ID(编辑流程数据链路用) */
source_edit_plan_id?: string
/** 配音素材库 ID(用户上传的音频或 AI 配音素材) */
voice_library_id?: string
/** 自定义 BGM 配置,覆盖模板 BGM 设置 */
bgm_config?: {
enabled: boolean
preset_id?: string
volume?: number
}
} }
/** 单个生成任务详情(对齐后端 GenerationTaskResponse */ /** 创建生成任务响应(对齐后端 GenerationTaskResponse */
export interface GenerationTaskDetail { export interface CreateGenerationTaskResponse {
id: string id: string
project_id: string project_id: string
asset_library_id: string asset_library_id: string
@@ -105,18 +72,8 @@ export interface GenerationTaskDetail {
asset_ids: string[] asset_ids: string[]
title_ids: string[] title_ids: string[]
voice_ids: string[] voice_ids: string[]
source_edit_plan_id?: string
status: string status: string
progress: number progress: number
result_count: number result_count: number
error_message: string error_message: string
error_info?: TaskErrorInfo
created_at?: string | null
updated_at?: string | null
}
/** 创建生成任务响应(后端返回批量结构 {items, total} */
export interface CreateGenerationTaskResponse {
items: GenerationTaskDetail[]
total: number
} }
+16 -2
View File
@@ -1,8 +1,13 @@
/** /**
* AI 推荐 API * AI 推荐 + 封面生成 API
*/ */
import apiClient from "../client" import apiClient from "../client"
import type { AIRecommendRequest, AIRecommendResponse } from "./types" import type {
AIRecommendRequest,
AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
} from "./types"
/** AI 推荐片段方案 */ /** AI 推荐片段方案 */
export async function aiRecommendClips( export async function aiRecommendClips(
@@ -12,3 +17,12 @@ export async function aiRecommendClips(
const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data) const response = await apiClient.post(`/templates/${templateId}/editor/ai-recommend`, data)
return response.data return response.data
} }
/** AI 生成封面 */
export async function generateCover(
templateId: string,
data: GenerateCoverRequest,
): Promise<GenerateCoverResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate-cover`, data)
return response.data
}
+1 -12
View File
@@ -90,21 +90,10 @@ export async function createClipsFromAssets(
templateId: string, templateId: string,
assetIds: string[], assetIds: string[],
clipType = "main", clipType = "main",
requiredClipsCount?: number,
opts?: { signal?: AbortSignal },
): Promise<ClipsFromAssetsResponse> { ): Promise<ClipsFromAssetsResponse> {
const body: Record<string, unknown> = {
asset_ids: assetIds,
clip_type: clipType,
}
if (requiredClipsCount !== undefined) {
body.required_clips_count = requiredClipsCount
}
// from-assets 后端会调用 MediaKit 智能选片(最长 60s),单独延长超时
const response = await apiClient.post<ClipsFromAssetsResponse>( const response = await apiClient.post<ClipsFromAssetsResponse>(
`/templates/${templateId}/editor/clips/from-assets`, `/templates/${templateId}/editor/clips/from-assets`,
body, { asset_ids: assetIds, clip_type: clipType },
{ timeout: 60000, signal: opts?.signal },
) )
return response.data return response.data
} }
+62 -24
View File
@@ -1,8 +1,27 @@
/** /**
* 模板草稿 CRUD API * 模板草稿 CRUD + 生成相关 API
*/ */
import apiClient from "../client" import apiClient from "../client"
import type { EditPlan, UpdateEditPlanRequest, GeneratedVideo } from "./types" import type {
EditPlan,
EditPlanListParams,
EditPlanListResponse,
CreateEditPlanRequest,
UpdateEditPlanRequest,
GenerateResponse,
GenerationStatusResponse,
EditPlanGeneration,
GeneratedVideo,
CopyEditPlanRequest,
} from "./types"
/** 获取模板草稿列表(支持分页和筛选) */
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
const response = await apiClient.get<EditPlanListResponse>("/templates/drafts", {
params,
})
return response.data
}
/** 获取单个模板草稿 */ /** 获取单个模板草稿 */
export async function getEditPlan(templateId: string): Promise<EditPlan> { export async function getEditPlan(templateId: string): Promise<EditPlan> {
@@ -10,44 +29,63 @@ export async function getEditPlan(templateId: string): Promise<EditPlan> {
return response.data return response.data
} }
/** 更新模板草稿(支持传入 AbortSignal 用于自动保存竞态取消) */ /** 创建模板草稿 */
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
const response = await apiClient.post("/templates/drafts", data)
return response.data
}
/** 更新模板草稿 */
export async function updateEditPlan( export async function updateEditPlan(
templateId: string, templateId: string,
data: UpdateEditPlanRequest, data: UpdateEditPlanRequest,
signal?: AbortSignal,
): Promise<EditPlan> { ): Promise<EditPlan> {
const response = await apiClient.put(`/templates/${templateId}/editor`, data, { signal }) const response = await apiClient.put(`/templates/${templateId}/editor`, data)
return response.data return response.data
} }
/** 删除模板草稿 */
export async function deleteEditPlan(templateId: string): Promise<void> {
await apiClient.delete(`/templates/${templateId}/editor`)
}
/** 触发生成 */
export async function generateEditPlan(templateId: string): Promise<GenerateResponse> {
const response = await apiClient.post(`/templates/${templateId}/editor/generate`)
return response.data
}
/** 获取生成状态(轮询用) */
export async function getGenerationStatus(templateId: string): Promise<GenerationStatusResponse> {
const response = await apiClient.get(`/templates/${templateId}/editor/generation-status`)
return response.data
}
/** 获取模板草稿关联的生成记录 */
export async function getEditPlanGenerations(templateId: string): Promise<EditPlanGeneration[]> {
const response = await apiClient.get(`/templates/${templateId}/editor/generations`)
return response.data.items || []
}
/** 获取生成任务的视频结果列表 */ /** 获取生成任务的视频结果列表 */
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> { export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
const response = await apiClient.get(`/generation/tasks/${taskId}/results`) const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
return response.data.items || response.data || [] return response.data.items || response.data || []
} }
/** ── 草稿 clips 批量更新 ── */ /** 取消生成任务 */
export async function cancelGeneration(templateId: string): Promise<void> {
export interface EditPlanClipInput { await apiClient.post(`/templates/${templateId}/editor/cancel`)
asset_id: string
start_time: number
duration: number
order: number
} }
/** /** 复制模板草稿(含所有片段配置) */
* 批量替换草稿的 clips(先全删再批量插入) export async function copyEditPlan(
* 后端路由:PUT /templates/{template_id}/editor/clips
*/
export async function updateEditPlanClips(
templateId: string, templateId: string,
clips: EditPlanClipInput[], data?: CopyEditPlanRequest,
signal?: AbortSignal, ): Promise<EditPlan> {
): Promise<{ count: number }> { const response = await apiClient.post<EditPlan>(
const response = await apiClient.put( `/templates/${templateId}/editor/copy`,
`/templates/${templateId}/editor/clips`, data || {},
{ clips },
{ signal },
) )
return response.data return response.data
} }
+20 -4
View File
@@ -15,12 +15,21 @@ export type {
EditPlanSegment, EditPlanSegment,
EditPlanConfig, EditPlanConfig,
EditPlan, EditPlan,
CreateEditPlanRequest,
UpdateEditPlanRequest, UpdateEditPlanRequest,
EditPlanListParams,
EditPlanListResponse,
GenerateResponse,
EditPlanGeneration,
ClipStatusItem, ClipStatusItem,
GenerationStatusResponse,
GeneratedVideo, GeneratedVideo,
AIRecommendRequest, AIRecommendRequest,
AIRecommendClipItem, AIRecommendClipItem,
AIRecommendResponse, AIRecommendResponse,
GenerateCoverRequest,
GenerateCoverResponse,
CoverResult,
EditPlanClipStatus, EditPlanClipStatus,
EditPlanClip, EditPlanClip,
CreateEditPlanClipRequest, CreateEditPlanClipRequest,
@@ -31,6 +40,7 @@ export type {
ClipReorderResponse, ClipReorderResponse,
ClipBatchDeleteResponse, ClipBatchDeleteResponse,
ClipsFromAssetsResponse, ClipsFromAssetsResponse,
CopyEditPlanRequest,
TransitionEffect, TransitionEffect,
MediaAsset, MediaAsset,
} from "./types" } from "./types"
@@ -46,12 +56,18 @@ export {
// 模板草稿 CRUD + 生成 // 模板草稿 CRUD + 生成
export { export {
getEditPlans,
getEditPlan, getEditPlan,
createEditPlan,
updateEditPlan, updateEditPlan,
updateEditPlanClips, deleteEditPlan,
generateEditPlan,
getGenerationStatus,
getEditPlanGenerations,
getGenerationTaskResults, getGenerationTaskResults,
cancelGeneration,
copyEditPlan,
} from "./editPlans" } from "./editPlans"
export type { EditPlanClipInput } from "./editPlans"
// 片段 CRUD + 批量操作 // 片段 CRUD + 批量操作
export { export {
@@ -65,8 +81,8 @@ export {
createClipsFromAssets, createClipsFromAssets,
} from "./clips" } from "./clips"
// AI 推荐 // AI 推荐 + 封面生成
export { aiRecommendClips } from "./aiFeatures" export { aiRecommendClips, generateCover } from "./aiFeatures"
// 素材库 // 素材库
export { getMediaAssets, getMediaAsset } from "./mediaAssets" export { getMediaAssets, getMediaAsset } from "./mediaAssets"
@@ -18,7 +18,6 @@ const mapAssetToMediaAsset = (asset: AssetItem): MediaAsset => {
id: asset.id, id: asset.id,
name: asset.name, name: asset.name,
type: inferMediaType(asset.mime_type || ""), type: inferMediaType(asset.mime_type || ""),
source_url: asset.file_url,
thumbnail_url: asset.thumbnail_url, thumbnail_url: asset.thumbnail_url,
duration: asset.duration ?? metaDuration, duration: asset.duration ?? metaDuration,
size: asset.file_size ?? undefined, size: asset.file_size ?? undefined,
+61 -19
View File
@@ -9,8 +9,8 @@ import type {
FilterConfig, FilterConfig,
ChromaKeyConfig, ChromaKeyConfig,
StickerConfig, StickerConfig,
CoverConfig,
} from "@/pages/editing-planner/types" } from "@/pages/editing-planner/types"
import type { CoverConfig } from "@/pages/generate/types/cover"
/* ── 模板草稿状态 ── */ /* ── 模板草稿状态 ── */
@@ -114,23 +114,10 @@ export interface EditPlanConfig {
auto_subtitles?: boolean auto_subtitles?: boolean
/** 是否启用 BGM */ /** 是否启用 BGM */
bgm?: boolean bgm?: boolean
/** 生成数量 */
generate_count?: number
/** 素材模式 */ /** 素材模式 */
material_mode?: string material_mode?: string
/** 前端标题设置(Step4 自动保存,与 title_config 字段分离,不影响后端渲染) */
title?: {
text?: string
font?: string
font_size?: number
color?: string
position?: string
bold?: boolean
stroke?: boolean
shadow?: boolean
}
/** 预览视频 URL(封面生成用) */
rendered_storage_key?: string
/** 生成任务 ID */
generation_task_id?: string
} }
/* ── 模板草稿主体 ── */ /* ── 模板草稿主体 ── */
@@ -185,6 +172,31 @@ export interface EditPlanListResponse {
/* ── 生成相关 ── */ /* ── 生成相关 ── */
/** 生成响应 */
export interface GenerateResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id: string
clip_count: number
}
/** 模板草稿关联的生成记录 */
export interface EditPlanGeneration {
id: string
source_edit_plan_id: string
template_id: string
asset_ids: string[]
status: EditPlanStatus
progress: number
result_count: number
error_message: string
error_info: Record<string, unknown>
logs: Array<Record<string, unknown>>
retry_count: number
created_at?: string
updated_at?: string
}
/** 片段生成状态 */ /** 片段生成状态 */
export interface ClipStatusItem { export interface ClipStatusItem {
clip_id: string clip_id: string
@@ -197,6 +209,17 @@ export interface ClipStatusItem {
error_message?: string error_message?: string
} }
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string
plan_status: EditPlanStatus
generation_task_id?: string
error_message?: string
clips: ClipStatusItem[]
error?: string
message?: string
}
/** 生成视频详情 */ /** 生成视频详情 */
export interface GeneratedVideo { export interface GeneratedVideo {
id: string id: string
@@ -217,7 +240,7 @@ export interface GeneratedVideo {
updated_at?: string updated_at?: string
} }
/* ── AI 推荐 ── */ /* ── AI 推荐 & 封面生成 ── */
/** AI 推荐请求 */ /** AI 推荐请求 */
export interface AIRecommendRequest { export interface AIRecommendRequest {
@@ -247,6 +270,27 @@ export interface AIRecommendResponse {
confidence: number confidence: number
} }
/** AI 封面生成请求 */
export interface GenerateCoverRequest {
asset_ids: string[]
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
frame_time?: number
}
/** AI 封面生成响应 */
export interface GenerateCoverResponse {
plan_id: string
cover: CoverResult
}
/** 封面生成结果 */
export interface CoverResult {
scheme?: string
asset_id?: string
frame_time?: number
thumbnail_url?: string
}
/* ── 片段 CRUD 相关 ── */ /* ── 片段 CRUD 相关 ── */
/** 片段状态 */ /** 片段状态 */
@@ -380,8 +424,6 @@ export interface MediaAsset {
id: string id: string
name: string name: string
type: "video" | "image" | "audio" type: "video" | "image" | "audio"
/** 源文件 URL(视频/原图),用于悬浮预览播放 */
source_url?: string
/** 缩略图 URL */ /** 缩略图 URL */
thumbnail_url?: string thumbnail_url?: string
/** 时长(秒),仅 video/audio */ /** 时长(秒),仅 video/audio */
+3
View File
@@ -9,6 +9,8 @@ export type {
TemplateSegment, TemplateSegment,
TemplateListParams, TemplateListParams,
TemplateListResponse, TemplateListResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
CopyTemplateResponse, CopyTemplateResponse,
} from "./types" } from "./types"
@@ -22,4 +24,5 @@ export {
getTemplate, getTemplate,
toggleFavoriteTemplate, toggleFavoriteTemplate,
copyTemplate, copyTemplate,
generateFromTemplate,
} from "./templates" } from "./templates"
+14
View File
@@ -5,6 +5,8 @@
import apiClient from "../client" import apiClient from "../client"
import type { import type {
CopyTemplateResponse, CopyTemplateResponse,
GenerateFromTemplateRequest,
GenerateFromTemplateResponse,
TemplateItem, TemplateItem,
TemplateListParams, TemplateListParams,
TemplateListResponse, TemplateListResponse,
@@ -43,3 +45,15 @@ export const copyTemplate = async (templateId: string): Promise<CopyTemplateResp
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`) const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
return response.data return response.data
} }
/** 从模板生成 */
export const generateFromTemplate = async (
templateId: string,
data?: GenerateFromTemplateRequest,
): Promise<GenerateFromTemplateResponse> => {
const response = await apiClient.post<GenerateFromTemplateResponse>(
`/templates/${templateId}/generate`,
data,
)
return response.data
}
+2 -22
View File
@@ -11,7 +11,6 @@ import type {
VoiceCloneListParams, VoiceCloneListParams,
ListVoiceCloneResponse, ListVoiceCloneResponse,
VoiceCloneStatusResponse, VoiceCloneStatusResponse,
VoiceClonePreviewResponse,
} from "./types" } from "./types"
/** 获取克隆音色列表(返回前端兼容数组) */ /** 获取克隆音色列表(返回前端兼容数组) */
@@ -44,19 +43,14 @@ export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile
return response.data return response.data
} }
/** 创建克隆音色audio_url 与 asset_id 二选一) */ /** 创建克隆音色 */
export const createVoiceClone = async ( export const createVoiceClone = async (
data: CreateVoiceCloneRequest, data: CreateVoiceCloneRequest,
): Promise<VoiceCloneProfile> => { ): Promise<VoiceCloneProfile> => {
const payload: CreateVoiceCloneRequestFull = { const payload: CreateVoiceCloneRequestFull = {
name: data.name, name: data.name,
description: data.description, description: data.description,
} source_audio_url: data.audio_url,
// 从配音素材选择克隆:直接传 asset_id,后端用素材 OSS 路径克隆
if (data.asset_id) {
payload.asset_id = data.asset_id
} else {
payload.source_audio_url = data.audio_url
} }
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload) const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
return response.data return response.data
@@ -91,17 +85,3 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`) const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
return response.data return response.data
} }
/** 获取克隆音色试听音频(实时 TTS 合成) */
export const getVoiceClonePreview = async (
cloneId: string,
text?: string,
): Promise<VoiceClonePreviewResponse> => {
const searchParams = new URLSearchParams()
if (text) searchParams.set("text", text)
const qs = searchParams.toString()
const response = await apiClient.get<VoiceClonePreviewResponse>(
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
)
return response.data
}
-2
View File
@@ -14,7 +14,6 @@ export type {
VoiceCloneStatusResponse, VoiceCloneStatusResponse,
CreateVoiceCloneRequestFull, CreateVoiceCloneRequestFull,
VoiceCloneListParams, VoiceCloneListParams,
VoiceClonePreviewResponse,
} from "./types" } from "./types"
// 工具函数 // 工具函数
@@ -30,5 +29,4 @@ export {
updateVoiceClone, updateVoiceClone,
getVoiceCloneStatus, getVoiceCloneStatus,
retryVoiceClone, retryVoiceClone,
getVoiceClonePreview,
} from "./clones" } from "./clones"
+4 -30
View File
@@ -22,13 +22,10 @@ export interface VoiceClone {
updated_at: string updated_at: string
} }
/** 创建克隆请求(前端简化版audio_url 与 asset_id 二选一 */ /** 创建克隆请求(前端简化版) */
export interface CreateVoiceCloneRequest { export interface CreateVoiceCloneRequest {
name: string name: string
/** 录音/文件上传后的音频 URL(与 asset_id 二选一) */ audio_url: string
audio_url?: string
/** 从配音素材选择时直接传素材 ID,后端用素材 OSS 路径克隆(与 audio_url 二选一) */
asset_id?: string
description?: string description?: string
} }
@@ -75,13 +72,11 @@ export interface VoiceCloneStatusResponse {
retry_count: number retry_count: number
} }
/** 后端创建克隆请求(完整版source_audio_url 与 asset_id 二选一 */ /** 后端创建克隆请求(完整版) */
export interface CreateVoiceCloneRequestFull { export interface CreateVoiceCloneRequestFull {
name: string name: string
description?: string description?: string
source_audio_url?: string source_audio_url: string
/** 从配音素材选择克隆时传素材 ID */
asset_id?: string
voice_model?: string voice_model?: string
language?: string language?: string
gender?: string gender?: string
@@ -95,24 +90,3 @@ export interface VoiceCloneListParams {
skip?: number skip?: number
limit?: number limit?: number
} }
/** 克隆音色试听响应 */
export interface VoiceClonePreviewResponse {
clone_id: string
/** 音色克隆档案 ID */
voice_id: string
/** CosyVoice 音色 ID */
audio_url: string
/** 试听音频 URL */
text: string
/** 试听文本 */
duration: number
/** 音频时长(秒) */
file_size: number
/** 文件大小(字节) */
}
@@ -59,23 +59,8 @@ const AssetCard: React.FC<AssetCardProps> = ({
> >
{/* 缩略图 */} {/* 缩略图 */}
<div className="as-card-thumb"> <div className="as-card-thumb">
{asset.thumbnail_url && !asset.thumbnail_url.match(/\.(mp4|mov|avi|webm|mkv)(\?|$)/i) ? ( {asset.thumbnail_url ? (
<img <img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
src={asset.thumbnail_url}
alt={asset.name}
loading="lazy"
onError={(e) => {
// 图片加载失败时降级显示类型图标
const parent = (e.target as HTMLElement).parentElement
if (parent) {
;(e.target as HTMLElement).style.display = "none"
const icon = document.createElement("span")
icon.className = "as-card-thumb-icon"
icon.textContent = MATERIAL_TYPE_ICONS[asset.type] || "🎬"
parent.appendChild(icon)
}
}}
/>
) : ( ) : (
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span> <span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
)} )}

Some files were not shown because too many files have changed in this diff Show More