Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 862340ace5 |
@@ -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"
|
|
||||||
+280
-529
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Regular → Executable
+1
-6
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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 1(MVP,2天):**
|
|
||||||
1. 数据模型 + 迁移
|
|
||||||
2. API 层支持 is_preview 参数
|
|
||||||
3. 渲染分辨率降级(480p)
|
|
||||||
4. 不计入配额
|
|
||||||
5. 基础测试
|
|
||||||
|
|
||||||
**Phase 2(优化,1-2天):**
|
|
||||||
1. 时长限制(前15秒)
|
|
||||||
2. 效果层降级(跳高级效果)
|
|
||||||
3. 预览任务低优先级队列
|
|
||||||
4. 预览转正式生成功能
|
|
||||||
|
|
||||||
## 与前端对齐点
|
|
||||||
|
|
||||||
1. 预览生成的触发时机(第3步自动生成?用户点击才生成?)
|
|
||||||
2. 预览时长是固定15秒还是完整但低清?
|
|
||||||
3. 是否需要「预览转正式生成」功能
|
|
||||||
4. 预览视频的展示形态(和正式视频一样还是有特殊UI)
|
|
||||||
|
|
||||||
## 风险与注意事项
|
|
||||||
|
|
||||||
1. **数据混淆**:确保统计、计费、列表展示时正确区分预览和正式任务
|
|
||||||
2. **存储成本**:预览视频也占 OSS 空间,可设置自动清理(7天后自动删除)
|
|
||||||
3. **用户预期**:要明确告诉用户这是预览,效果和正式生成一致但清晰度低
|
|
||||||
4. **并发压力**:如果用户频繁生成预览,可能增加系统负载,需要限流
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> 本内容由 Coze AI 生成,请遵循相关法律法规及《人工智能生成合成内容标识办法》使用与传播。
|
|
||||||
@@ -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 个 GenerationTask(is_preview=true)
|
|
||||||
↓
|
|
||||||
N 个 Celery 任务并行执行(走现有生成链路,参数降级)
|
|
||||||
↓
|
|
||||||
N 个结果汇聚,前端左侧列表展示
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 详细设计
|
|
||||||
|
|
||||||
### 4.1 数据模型变更
|
|
||||||
|
|
||||||
#### 4.1.1 GenerationTask 新增字段
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 现有字段保留,新增:
|
|
||||||
is_preview: bool = False
|
|
||||||
"""是否为预览生成"""
|
|
||||||
|
|
||||||
preview_batch_id: str = ""
|
|
||||||
"""预览批次 ID(同批次的 N 个预览共享一个 batch)"""
|
|
||||||
|
|
||||||
variant_seed: int = 0
|
|
||||||
"""变体种子,用于控制随机化行为(素材选择、排序、转场等)"""
|
|
||||||
|
|
||||||
variant_params: dict = field(default_factory=dict)
|
|
||||||
"""变体参数快照(记录本次使用了哪些素材、标题、配音等,可追溯)
|
|
||||||
{
|
|
||||||
"asset_ids": [...], # 实际选用的素材子集
|
|
||||||
"title_id": "", # 选用的标题
|
|
||||||
"voice_id": "", # 选用的配音
|
|
||||||
"transition_style": "", # 转场风格
|
|
||||||
"bgm_track": "", # BGM 音轨
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.1.2 GeneratedVideo 新增字段
|
|
||||||
|
|
||||||
```python
|
|
||||||
is_preview: bool = False
|
|
||||||
"""是否为预览视频"""
|
|
||||||
|
|
||||||
preview_batch_id: str = ""
|
|
||||||
"""所属预览批次"""
|
|
||||||
|
|
||||||
variant_index: int = 0
|
|
||||||
"""在批次中的序号(0, 1, 2...)"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.1.3 迁移方案
|
|
||||||
|
|
||||||
alembic 新增 migration,两个表各加 4 个字段,默认值为空/false,无数据回填成本。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 变体引擎(Variant Engine)
|
|
||||||
|
|
||||||
**核心组件**:根据 count 和 seed,生成 N 组互不相同的生成参数。
|
|
||||||
|
|
||||||
#### 4.2.1 变纬度设计
|
|
||||||
|
|
||||||
| 维度 | 策略 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| **素材子集选择** | 从素材池中随机选 M 个(M=min(素材数, 模板clip数*2)) | 版本差异最大的来源 |
|
|
||||||
| **素材排序** | 随机打乱顺序 | 影响叙事节奏 |
|
|
||||||
| **标题选择** | 从 title_ids 中随机选 1 个 | 影响文案内容 |
|
|
||||||
| **配音选择** | 从 voice_ids 中随机选 1 个(如有多个) | 影响听觉体验 |
|
|
||||||
| **转场风格** | 从预设转场池中随机选 1 种 | 影响视觉过渡 |
|
|
||||||
| **BGM 选择** | 从 bgm 列表中随机选 1 首(如有配置) | 影响氛围 |
|
|
||||||
|
|
||||||
#### 4.2.2 去重机制
|
|
||||||
|
|
||||||
- 同一批次内,变体参数必须两两不同(至少素材组合或排序不同)
|
|
||||||
- 使用 `variant_seed` 保证可复现(相同 seed → 相同变体)
|
|
||||||
- 如果素材数量不足导致无法生成 N 个不同版本,按实际能生成的数量返回
|
|
||||||
|
|
||||||
#### 4.2.3 接口设计
|
|
||||||
|
|
||||||
```python
|
|
||||||
def generate_variants(
|
|
||||||
count: int,
|
|
||||||
seed: int,
|
|
||||||
asset_pool: list[str], # 可用素材 ID 列表
|
|
||||||
title_pool: list[str] = [], # 可用标题 ID 列表
|
|
||||||
voice_pool: list[str] = [], # 可用配音 ID 列表
|
|
||||||
template_id: str = "",
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
|
||||||
生成 count 组变体参数。
|
|
||||||
|
|
||||||
每组参数包含:asset_ids(选用的素材+排序)、title_id、voice_id、
|
|
||||||
transition_style 等,确保两两不同。
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.3 API 层设计
|
|
||||||
|
|
||||||
#### 4.3.1 预览生成接口
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/templates/{template_id}/generate-preview
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"asset_library_id": "lib_xxx",
|
|
||||||
"asset_ids": ["asset_1", "asset_2", ...],
|
|
||||||
"title_ids": ["title_1", "title_2"],
|
|
||||||
"voice_ids": ["voice_1", "voice_2"],
|
|
||||||
"bgm_config": {},
|
|
||||||
"count": 3,
|
|
||||||
"seed": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
|
||||||
|------|------|------|------|------|
|
|
||||||
| template_id | path | ✅ | - | 模板 ID |
|
|
||||||
| asset_library_id | body | ✅ | - | 素材库 ID |
|
|
||||||
| asset_ids | body | ✅ | - | 素材池(从中选子集/排序) |
|
|
||||||
| title_ids | body | - | [] | 标题池(可选,不传则不用标题) |
|
|
||||||
| voice_ids | body | - | [] | 配音池(可选) |
|
|
||||||
| bgm_config | body | - | {} | BGM 配置 |
|
|
||||||
| count | body | - | 3 | 生成几个预览版本(1~10) |
|
|
||||||
| seed | body | - | 0 | 随机种子,0 表示随机 |
|
|
||||||
|
|
||||||
**响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"preview_batch_id": "pb_xxx",
|
|
||||||
"count": 3,
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"task_id": "gen_xxx_0",
|
|
||||||
"variant_index": 0,
|
|
||||||
"status": "processing"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"task_id": "gen_xxx_1",
|
|
||||||
"variant_index": 1,
|
|
||||||
"status": "processing"
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3.2 预览批次查询接口
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/preview-batches/{batch_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
返回批次内所有预览任务的状态、结果(已完成的带 video_url)。
|
|
||||||
|
|
||||||
**响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"preview_batch_id": "pb_xxx",
|
|
||||||
"count": 3,
|
|
||||||
"completed_count": 2,
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"task_id": "gen_xxx_0",
|
|
||||||
"variant_index": 0,
|
|
||||||
"status": "completed",
|
|
||||||
"video_url": "https://oss.xxx/preview/xxx.mp4",
|
|
||||||
"duration": 15.5,
|
|
||||||
"thumbnail_url": "https://oss.xxx/preview/xxx.jpg"
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3.3 预览转正式生成
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/preview-batches/{batch_id}/tasks/{task_id}/promote
|
|
||||||
```
|
|
||||||
|
|
||||||
将某个预览版本升级为正式生成(复用素材缓存,重新全分辨率渲染)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.4 渲染参数降级
|
|
||||||
|
|
||||||
预览模式下自动调整以下参数:
|
|
||||||
|
|
||||||
| 参数 | 正式生成 | 预览生成 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| 分辨率 | 720p (1280x720) | 480p (854x480) |
|
|
||||||
| 码率 | 4 Mbps | 1.5 Mbps |
|
|
||||||
| 帧率 | 30 fps | 24 fps |
|
|
||||||
| 时长 | 完整时长 | 前 15 秒(或第一段完整clip) |
|
|
||||||
| 转场效果 | 完整转场 | 仅淡入淡出 |
|
|
||||||
| 高级特效 | 全部启用 | 跳过粒子/光效等 |
|
|
||||||
| 字幕 | 完整渲染 | 正常渲染 |
|
|
||||||
| 配音 | 完整混音 | 正常混音 |
|
|
||||||
| 输出质量 | high | medium |
|
|
||||||
|
|
||||||
**实现位置**:`_render_video` 函数入口处,根据 `is_preview` 标记调整渲染配置。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.5 任务调度
|
|
||||||
|
|
||||||
- **并行执行**:N 个预览任务并行提交到 Celery,不排队等待
|
|
||||||
- **低优先级**:预览任务走独立队列(`preview_queue`),不抢占正式生成资源
|
|
||||||
- **超时控制**:预览任务超时时间 5 分钟(正式 30 分钟)
|
|
||||||
- **自动清理**:预览视频 7 天后自动从 OSS 删除,任务记录标记为 archived
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 前端对接要点
|
|
||||||
|
|
||||||
### 5.1 交互流程
|
|
||||||
|
|
||||||
```
|
|
||||||
第2步选素材 → 第3步点击"生成预览"
|
|
||||||
→ 显示 loading + 进度
|
|
||||||
→ 预览陆续完成,左侧列表逐张出现
|
|
||||||
→ 用户点击左侧不同版本,右侧预览区切换
|
|
||||||
→ 用户选中满意版本 → 点击"正式生成"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 需要对齐的接口
|
|
||||||
|
|
||||||
1. **预览创建**:`POST /templates/{id}/generate-preview`
|
|
||||||
2. **批次状态轮询**:`GET /preview-batches/{id}`(建议 2s 轮询,或走 SSE)
|
|
||||||
3. **预览转正式**:`POST /preview-batches/{id}/tasks/{task_id}/promote`
|
|
||||||
|
|
||||||
### 5.3 数据格式对齐
|
|
||||||
|
|
||||||
预览视频条目结构:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "gen_xxx",
|
|
||||||
"variant_index": 0,
|
|
||||||
"status": "completed",
|
|
||||||
"video_url": "https://...",
|
|
||||||
"duration": 15.5,
|
|
||||||
"file_size": 2850000,
|
|
||||||
"thumbnail_url": "https://...",
|
|
||||||
"is_preview": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 配额与计费
|
|
||||||
|
|
||||||
- 预览生成**不计入**用户配额
|
|
||||||
- 同一模板 + 同一素材池,每天最多生成 3 次多版本预览(防滥用)
|
|
||||||
- 单个预览批次最多 10 个版本
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 实施步骤
|
|
||||||
|
|
||||||
### Phase 1:单版本预览(MVP,2 天)
|
|
||||||
1. 数据模型 + 迁移(is_preview 字段)
|
|
||||||
2. API 层支持 is_preview 参数
|
|
||||||
3. 渲染分辨率降级(480p)
|
|
||||||
4. 不计入配额
|
|
||||||
5. 基础测试
|
|
||||||
|
|
||||||
### Phase 2:多版本预览(3 天)
|
|
||||||
1. 变体引擎实现(素材随机选择 + 排序 + 去重)
|
|
||||||
2. preview_batch 批次管理
|
|
||||||
3. 批量创建 N 个预览任务
|
|
||||||
4. 批次查询接口
|
|
||||||
5. 前端联调
|
|
||||||
|
|
||||||
### Phase 3:预览转正式 + 优化(2 天)
|
|
||||||
1. 预览转正式生成接口(promote)
|
|
||||||
2. 素材下载缓存复用
|
|
||||||
3. 独立预览队列(低优先级)
|
|
||||||
4. 自动清理机制
|
|
||||||
5. 完整测试 + 压测
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 风险与注意事项
|
|
||||||
|
|
||||||
| 风险 | 影响 | 应对 |
|
|
||||||
|------|------|------|
|
|
||||||
| 并发预览任务过多打满 worker | 正式生成被阻塞 | 独立预览队列 + 限流 |
|
|
||||||
| 变体生成的视频差异不够大 | 用户觉得"都一样" | 优先素材子集+排序差异,保证视觉差异 |
|
|
||||||
| 预览视频占用 OSS 存储 | 存储成本上升 | 7 天自动清理 + 低码率 |
|
|
||||||
| N 个版本同时下载重复素材 | 带宽浪费 | 批次内共享一次下载(Phase 3 优化) |
|
|
||||||
| 用户预期管理 | 以为预览就是最终效果 | 明确标注"预览版",说明分辨率差异 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 开发量估算
|
|
||||||
|
|
||||||
| 阶段 | 后端 | 前端 | 合计 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| Phase 1 单版本预览 | 2 天 | 1 天 | 3 天 |
|
|
||||||
| Phase 2 多版本预览 | 3 天 | 2 天 | 5 天 |
|
|
||||||
| Phase 3 转正式+优化 | 2 天 | 1 天 | 3 天 |
|
|
||||||
| **总计** | **7 天** | **4 天** | **~7 天(并行)** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 与 v1 方案的差异总结
|
|
||||||
|
|
||||||
1. **新增多版本能力**:从"生成1个预览"升级为"生成N个不重复预览"
|
|
||||||
2. **新增变体引擎**:负责素材选择/排序/配音/标题的随机化
|
|
||||||
3. **新增批次概念**:preview_batch 管理一组预览任务
|
|
||||||
4. **新增 promote 接口**:预览转正式生成
|
|
||||||
5. **独立队列**:预览不抢占正式生成资源
|
|
||||||
6. **开发量**:从 2-3 天增加到约 7 天(后端)
|
|
||||||
@@ -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")
|
|
||||||
@@ -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
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -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.config:generation_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。
|
|
||||||
# 无源 plan(source_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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
# 视频理解最多 30s(poll_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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 声明为 async,SQLAlchemy 实现为同步,与 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-op;UoW/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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
|
|||||||
Executable → Regular
+16
-129
@@ -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}")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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="参与评分的候选素材总数")
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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="音频时长(秒)")
|
|
||||||
|
|||||||
@@ -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_id(duplicated=true 时返回)")
|
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||||
url: str = Field(default="", description="Public URL of uploaded file")
|
|
||||||
|
|
||||||
|
|
||||||
class UploadAssetResponse(BaseModel):
|
class UploadAssetResponse(BaseModel):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Executable → Regular
+1
-24
@@ -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 @@
|
|||||||
"""素材片段级使用记录追踪与受控复用.
|
|
||||||
|
|
||||||
在素材 metadata(assets.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
|
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Generated
+26
-24
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
esbuild: set this to true or false
|
|
||||||
@@ -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,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 的素材时为 true,ingest_job_id 为空 */
|
|
||||||
duplicated?: boolean
|
|
||||||
/** duplicated 为 true 时返回已存在素材的 id */
|
|
||||||
asset_id?: string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 ?? []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = "/"
|
||||||
|
|||||||
@@ -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}`)
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user