Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e3f7175d8 | |||
| 4750e67c55 | |||
| aa36e7ae62 |
@@ -1 +0,0 @@
|
||||
CI re-trigger after runner add-host/DNS fix. This file is harmless and not referenced.
|
||||
@@ -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"
|
||||
@@ -1,105 +0,0 @@
|
||||
name: CI Base Image Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements-dev.txt'
|
||||
- 'infra/docker/ci.Dockerfile'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "触发原因"
|
||||
required: false
|
||||
default: "手动触发 - ci-base 镜像重建"
|
||||
|
||||
concurrency:
|
||||
group: ci-base-image-build
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-ci-base:
|
||||
name: Build CI Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 60
|
||||
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 Gitea Registry
|
||||
shell: sh
|
||||
env:
|
||||
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 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 CI base image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
|
||||
VERSION_TAG="deps-$(date +%Y%m%d-%H%M)-${GITHUB_SHA::8}"
|
||||
|
||||
echo "=== Building CI base image (tags: latest, ${VERSION_TAG}) ==="
|
||||
docker build --progress=plain \
|
||||
-f infra/docker/ci.Dockerfile \
|
||||
-t "${IMAGE}:latest" \
|
||||
-t "${IMAGE}:${VERSION_TAG}" \
|
||||
.
|
||||
echo "✅ Image built successfully"
|
||||
|
||||
echo "=== Pushing ${VERSION_TAG} ==="
|
||||
docker push "${IMAGE}:${VERSION_TAG}"
|
||||
echo "=== Pushing latest ==="
|
||||
docker push "${IMAGE}:latest"
|
||||
echo "✅ Pushed to Gitea Registry"
|
||||
|
||||
- name: Verify image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base:latest"
|
||||
echo "=== Verifying pinned deps in fresh image ==="
|
||||
docker run --rm "${IMAGE}" /opt/xiaoxia-ci-venv/bin/python -c \
|
||||
"import httpcore, h2, numpy, httpx; print('VERSIONS:', httpcore.__version__, h2.__version__, numpy.__version__, httpx.__version__)"
|
||||
|
||||
- name: Notify result
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
shell: sh
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
NOTIFY_MODE=success JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
|
||||
else
|
||||
NOTIFY_MODE=failure JOB_NAME="CI Base Image Build" python3 scripts/ci_notify.py
|
||||
fi
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
IMAGE="git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas/ci-base"
|
||||
docker rmi "${IMAGE}:latest" 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
@@ -1,51 +0,0 @@
|
||||
name: CI Canary Check
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
canary:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Canary (runner -> docker -> network -> gitea)
|
||||
run: |
|
||||
set -e
|
||||
echo "== runner/container basic =="
|
||||
date; hostname; whoami
|
||||
echo "== gitea api reachability =="
|
||||
code=$(curl -s -o /tmp/v.json -w '%{http_code}' -m 15 "$GITHUB_API_URL/version")
|
||||
echo "gitea api http_code=$code"
|
||||
[ "$code" = "200" ] || { echo "::error::Gitea API unreachable, http_code=$code"; exit 1; }
|
||||
cat /tmp/v.json; echo
|
||||
echo "== external egress =="
|
||||
ext=$(curl -s -o /dev/null -w '%{http_code}' -m 15 https://www.baidu.com || echo 000)
|
||||
echo "external http_code=$ext"
|
||||
echo "== gitea domain resolves NOT to loopback =="
|
||||
set -o pipefail
|
||||
ip=$(getent hosts git.xiaoxiajianji.com | awk '{print $1}' | head -1)
|
||||
echo "git.xiaoxiajianji.com -> $ip"
|
||||
if [ -z "$ip" ]; then
|
||||
echo "::error::DNS resolution failed, git.xiaoxiajianji.com unresolvable"; exit 1
|
||||
fi
|
||||
if [ "$ip" = "127.0.0.1" ] || [ "$ip" = "::1" ]; then
|
||||
echo "::error::Gitea domain resolves to loopback inside job container (hosts/DNS leak)"; exit 1
|
||||
fi
|
||||
echo "CANARY OK"
|
||||
- name: Notify failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
||||
run: |
|
||||
set +e
|
||||
if [ -n "$CI_NOTIFY_WEBHOOK" ]; then
|
||||
MSG="🚨 CI 金丝雀失败:runner->docker->网络->Gitea 链路异常,时间 $(date '+%Y-%m-%d %H:%M:%S'),请立即检查构建服务器"
|
||||
python3 - "$CI_NOTIFY_WEBHOOK" "$MSG" <<'PY'
|
||||
import json,sys,urllib.request
|
||||
hook,msg=sys.argv[1],sys.argv[2]
|
||||
data=json.dumps({"msg_type":"text","content":{"text":msg}}).encode()
|
||||
urllib.request.urlopen(urllib.request.Request(hook,data=data,headers={"Content-Type":"application/json"}),timeout=10)
|
||||
PY
|
||||
fi
|
||||
exit 0
|
||||
+290
-590
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,13 @@ name: CI Trigger Monitor
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *' # 每10分钟检查一次(与pr-auto-scan同步降频)
|
||||
- cron: '*/5 * * * *' # 每5分钟检查一次
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stale_threshold:
|
||||
description: 'CI未触发告警阈值(分钟)'
|
||||
required: false
|
||||
default: '10'
|
||||
default: '5'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
report: ${{ steps.report.outputs.report }}
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
runs-on: ci-l2
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
report: ${{ steps.e2e.outputs.report }}
|
||||
report: ${{ steps.smoke.outputs.report }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Playwright Base Image Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "触发原因"
|
||||
required: false
|
||||
default: "构建 playwright 基础镜像"
|
||||
|
||||
jobs:
|
||||
build-playwright:
|
||||
name: Build Playwright Base Image
|
||||
runs-on: runtime-builder
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Docker login to Gitea Registry
|
||||
shell: sh
|
||||
env:
|
||||
GITEA_REGISTRY_USER: xiaoxia
|
||||
GITEA_REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
for i in 1 2 3; do
|
||||
echo "=== Docker login attempt $i/3 ==="
|
||||
if printf '%s' "${GITEA_REGISTRY_TOKEN}" | docker login git.xiaoxiajianji.com -u "${GITEA_REGISTRY_USER}" --password-stdin; then
|
||||
echo "Docker login successful"
|
||||
break
|
||||
fi
|
||||
echo "Docker login failed (attempt $i/3), retrying in 5s..."
|
||||
sleep 5
|
||||
[ $i -eq 3 ] && exit 1
|
||||
done
|
||||
|
||||
- name: Pull, retag and push Playwright image
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
OFFICIAL_IMAGE="mcr.microsoft.com/playwright:v1.45.0-jammy"
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy"
|
||||
|
||||
echo "=== Pulling official Playwright image ==="
|
||||
docker pull "${OFFICIAL_IMAGE}"
|
||||
|
||||
echo "=== Tagging ==="
|
||||
docker tag "${OFFICIAL_IMAGE}" "${GITEA_IMAGE}"
|
||||
|
||||
echo "=== Pushing to Gitea Registry ==="
|
||||
docker push "${GITEA_IMAGE}"
|
||||
|
||||
echo "Done: ${GITEA_IMAGE}"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
docker rmi "mcr.microsoft.com/playwright:v1.45.0-jammy" 2>/dev/null || true
|
||||
docker rmi "git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy" 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
@@ -3,7 +3,7 @@ name: PR Auto Scan
|
||||
# 作为短作业模式的兜底,防止事件驱动遗漏
|
||||
on:
|
||||
schedule:
|
||||
# - cron: "*/15 * * * *" # DISABLED: temporarily to stop failure spam (2026-09-02) # 每10分钟扫描一次(脚本自带240s墙钟上限,降频减负)
|
||||
- cron: "*/5 * * * *" # 每5分钟扫描一次
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
Regular → Executable
+2
-8
@@ -8,17 +8,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
concurrency:
|
||||
group: pr-automation-${{ gitea.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
auto-approve:
|
||||
name: Auto Approve on CI Green
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft
|
||||
timeout-minutes: 10 # 等待CI全绿+审批,需要充足时间
|
||||
timeout-minutes: 3 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
@@ -61,8 +56,7 @@ jobs:
|
||||
name: Auto Merge on CI Green + Approved
|
||||
runs-on: ci-check
|
||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||
needs: [auto-approve] # 修复竞态:必须等审批完成后再尝试合并
|
||||
timeout-minutes: 15 # 等待审批+CI就绪+合并,需要充足时间
|
||||
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
shell: sh
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||
run: |
|
||||
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_port="${PREVIEW_SSH_PORT:-22222}"
|
||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||
|
||||
@@ -95,10 +95,13 @@ jobs:
|
||||
set -eu
|
||||
cd apps/web
|
||||
|
||||
# Config npm mirror for speed
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# Install dependencies with retry
|
||||
for i in 1 2 3; do
|
||||
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break
|
||||
echo "npm install failed, retry $i/3..."
|
||||
npm ci --no-audit --no-fund && break
|
||||
echo "npm ci failed, retry $i/3..."
|
||||
[ $i -eq 3 ] && exit 1
|
||||
rm -rf node_modules
|
||||
sleep 5
|
||||
@@ -106,12 +109,12 @@ jobs:
|
||||
|
||||
# TypeScript check
|
||||
echo "=== TypeScript check ==="
|
||||
./node_modules/.bin/tsc --noEmit
|
||||
npx --no-install tsc --noEmit
|
||||
|
||||
# Vite build
|
||||
echo "=== Vite build ==="
|
||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
||||
./node_modules/.bin/vite build
|
||||
npx --no-install vite build
|
||||
|
||||
echo "=== Build completed ==="
|
||||
ls -la dist/
|
||||
|
||||
@@ -7,25 +7,35 @@ on:
|
||||
- main
|
||||
paths:
|
||||
- 'requirements-base.txt'
|
||||
- 'requirements.txt'
|
||||
- 'requirements-worker.txt'
|
||||
- 'infra/docker/worker-base.Dockerfile'
|
||||
workflow_dispatch:
|
||||
- 'infra/docker/worker-base-builder.Dockerfile'
|
||||
- 'infra/docker/worker-base-runtime.Dockerfile'
|
||||
workflow_dispatch: # 支持手动触发
|
||||
|
||||
jobs:
|
||||
build-worker-base:
|
||||
name: Build Worker Base Image
|
||||
name: Build Worker Base Images
|
||||
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:
|
||||
- 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
|
||||
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
|
||||
@@ -38,8 +48,7 @@ jobs:
|
||||
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
|
||||
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
|
||||
@@ -47,40 +56,48 @@ jobs:
|
||||
sleep 5
|
||||
done
|
||||
|
||||
- name: Build and push Worker base image
|
||||
- name: Setup buildx builder
|
||||
shell: sh
|
||||
run: |
|
||||
set -eu
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
|
||||
GITEA_IMAGE="git.xiaoxiajianji.com/xiaoxia-saas/saas-worker-base:latest"
|
||||
|
||||
echo "=== Building Worker base image ==="
|
||||
|
||||
# 使用普通 docker build(单平台不需要 buildx)
|
||||
docker build \
|
||||
-f infra/docker/worker-base.Dockerfile \
|
||||
-t "${ACR_IMAGE}" \
|
||||
.
|
||||
BUILDER_NAME="ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}"
|
||||
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||
echo "Created $BUILDER_NAME"
|
||||
else
|
||||
docker buildx use "$BUILDER_NAME"
|
||||
echo "Using existing $BUILDER_NAME"
|
||||
fi
|
||||
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 "✅ Image built successfully"
|
||||
echo "✅ ${{ matrix.name }} base image built and pushed"
|
||||
|
||||
# 推送到 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
|
||||
- name: Cleanup buildx builder
|
||||
if: always()
|
||||
shell: sh
|
||||
run: |
|
||||
ACR_IMAGE="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/saas-worker-base:latest"
|
||||
docker rmi "${ACR_IMAGE}" 2>/dev/null || true
|
||||
docker image prune -f 2>/dev/null || true
|
||||
echo "Cleanup done"
|
||||
docker buildx rm "ci-builder-${GITHUB_RUN_ID}-${{ matrix.name }}" 2>/dev/null || true
|
||||
docker buildx prune -f 2>/dev/null || true
|
||||
echo "Builder cleanup done"
|
||||
|
||||
@@ -24,11 +24,6 @@ ruff_cache/
|
||||
.env.production
|
||||
.env.staging
|
||||
!.env.example
|
||||
# 配置模板不受忽略规则限制
|
||||
!deploy/configs/.env.staging
|
||||
!deploy/configs/.env.production
|
||||
# 渲染后的 env 文件包含真实密钥,绝不能提交
|
||||
.env.rendered
|
||||
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
@@ -59,4 +54,3 @@ frontend-v21-ui-prototype-final.html
|
||||
!.vscode/settings.json
|
||||
.vscode/extensions.json
|
||||
.coverage
|
||||
.env.current
|
||||
|
||||
@@ -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
|
||||
@@ -1,26 +0,0 @@
|
||||
"""add sort_order to template_categories
|
||||
|
||||
Revision ID: 061_sort_order
|
||||
Revises: 060_migrate_segments
|
||||
Create Date: 2026-09-02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "061_sort_order"
|
||||
down_revision = "060_migrate_segments"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"template_categories",
|
||||
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("template_categories", "sort_order")
|
||||
@@ -1,28 +0,0 @@
|
||||
"""re-add edit_plan_id to generation_tasks (align staging with production)
|
||||
|
||||
Revision ID: 062_edit_plan_id
|
||||
Revises: 061_sort_order
|
||||
Create Date: 2026-09-02
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "062_edit_plan_id"
|
||||
down_revision = "061_sort_order"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("edit_plan_id", sa.String(36), nullable=True),
|
||||
)
|
||||
op.create_index("ix_generation_tasks_edit_plan_id_2", "generation_tasks", ["edit_plan_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_generation_tasks_edit_plan_id_2", table_name="generation_tasks")
|
||||
op.drop_column("generation_tasks", "edit_plan_id")
|
||||
@@ -1,46 +0,0 @@
|
||||
"""add video_fingerprint_chunks table for per-chunk fingerprint storage
|
||||
|
||||
Revision ID: 063_fingerprint_chunks
|
||||
Revises: 062_edit_plan_id
|
||||
Create Date: 2026-09-03
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "063_fingerprint_chunks"
|
||||
down_revision = "062_edit_plan_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"video_fingerprint_chunks",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("video_id", sa.String(36), nullable=False),
|
||||
sa.Column("project_id", sa.String(36), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("start_time_ms", sa.Integer, nullable=False),
|
||||
sa.Column("end_time_ms", sa.Integer, nullable=False),
|
||||
sa.Column("phash_binary", sa.String(16), nullable=False),
|
||||
sa.Column("color_histogram", sa.JSON, nullable=False),
|
||||
sa.Column("frame_count", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_vfc_video_id", "video_fingerprint_chunks", ["video_id"])
|
||||
op.create_index("ix_vfc_project_id", "video_fingerprint_chunks", ["project_id"])
|
||||
op.create_index("ix_vfc_user_id", "video_fingerprint_chunks", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_vfc_user_id", table_name="video_fingerprint_chunks")
|
||||
op.drop_index("ix_vfc_project_id", table_name="video_fingerprint_chunks")
|
||||
op.drop_index("ix_vfc_video_id", table_name="video_fingerprint_chunks")
|
||||
op.drop_table("video_fingerprint_chunks")
|
||||
@@ -1,25 +0,0 @@
|
||||
"""add match_count and visual_similarity to generated_videos
|
||||
|
||||
Revision ID: 064_match_count_visual_sim
|
||||
Revises: 063_fingerprint_chunks
|
||||
Create Date: 2026-09-03
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "064_match_count_visual_sim"
|
||||
down_revision = "063_fingerprint_chunks"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("generated_videos", sa.Column("match_count", sa.Integer(), nullable=True, server_default="0"))
|
||||
op.add_column("generated_videos", sa.Column("visual_similarity", sa.Float(), nullable=True, server_default="0.0"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("generated_videos", "visual_similarity")
|
||||
op.drop_column("generated_videos", "match_count")
|
||||
@@ -1,25 +0,0 @@
|
||||
"""add visual_similarity and match_count to duplication_records
|
||||
|
||||
Revision ID: 065_dup_record_sim_match
|
||||
Revises: 064_match_count_visual_sim
|
||||
Create Date: 2026-09-04
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "065_dup_record_sim_match"
|
||||
down_revision = "064_match_count_visual_sim"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("duplication_records", sa.Column("visual_similarity", sa.Float(), nullable=True))
|
||||
op.add_column("duplication_records", sa.Column("match_count", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("duplication_records", "match_count")
|
||||
op.drop_column("duplication_records", "visual_similarity")
|
||||
@@ -1,34 +0,0 @@
|
||||
"""add client_upload_id to assets and asset_id to ingest_jobs
|
||||
|
||||
Issue #1714:上传 complete 幂等 + worker 转码回写关联。
|
||||
- assets.client_upload_id:客户端幂等 token(complete 去重)
|
||||
- ingest_jobs.asset_id:complete 阶段创建的占位 asset id(worker 回写关联,
|
||||
防止 HEVC 转码改写 storage_key 后找不到占位而兜底新建 READY 记录)
|
||||
|
||||
Revision ID: 066_upload_idempotency
|
||||
Revises: 065_dup_record_sim_match
|
||||
Create Date: 2026-09-05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "066_upload_idempotency"
|
||||
down_revision = "065_dup_record_sim_match"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("assets", sa.Column("client_upload_id", sa.String(64), nullable=True))
|
||||
op.create_index("ix_assets_client_upload_id", "assets", ["client_upload_id"])
|
||||
op.add_column("ingest_jobs", sa.Column("asset_id", sa.String(36), nullable=False, server_default=""))
|
||||
op.create_index("ix_ingest_jobs_asset_id", "ingest_jobs", ["asset_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ingest_jobs_asset_id", table_name="ingest_jobs")
|
||||
op.drop_column("ingest_jobs", "asset_id")
|
||||
op.drop_index("ix_assets_client_upload_id", table_name="assets")
|
||||
op.drop_column("assets", "client_upload_id")
|
||||
@@ -1,35 +0,0 @@
|
||||
"""add celery_task_id to generation_tasks and ingest_jobs
|
||||
|
||||
Issue #1714:孤儿恢复/超时清理撤销队列消息。
|
||||
- generation_tasks.celery_task_id:入队时记录的 Celery 消息 ID,清理时 revoke
|
||||
- ingest_jobs.celery_task_id:同上(素材转码任务)
|
||||
|
||||
Revision ID: 067_celery_task_id
|
||||
Revises: 066_upload_idempotency
|
||||
Create Date: 2026-09-05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "067_celery_task_id"
|
||||
down_revision = "066_upload_idempotency"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"generation_tasks",
|
||||
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
|
||||
)
|
||||
op.add_column(
|
||||
"ingest_jobs",
|
||||
sa.Column("celery_task_id", sa.String(64), nullable=False, server_default=""),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("ingest_jobs", "celery_task_id")
|
||||
op.drop_column("generation_tasks", "celery_task_id")
|
||||
@@ -1,26 +0,0 @@
|
||||
"""add profile_completed to users
|
||||
|
||||
Issue #1718:微信新用户首次登录需设置昵称(PATCH /auth/me)。
|
||||
- users.profile_completed:资料是否已完善;存量行默认 True(不触发引导),
|
||||
微信新建用户在应用层置 False。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "068_user_profile_completed"
|
||||
down_revision = "067_celery_task_id"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("profile_completed", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "profile_completed")
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Projects is_default + partial unique index for idempotent default project (Issue #1775)
|
||||
|
||||
Revision ID: 069_project_is_default
|
||||
Revises: 068_user_profile_completed
|
||||
Create Date: 2026-09-08
|
||||
|
||||
背景:
|
||||
小程序端 getOrCreateDefaultProject 在重试/并发/前端重复调用下,
|
||||
仅靠应用层"先查再插"不保证幂等,会给同一用户重复创建默认项目。
|
||||
|
||||
改动:
|
||||
1. projects 表新增 is_default 布尔列(默认 false)
|
||||
2. 部分唯一索引 uq_projects_owner_default:(owner_user_id) WHERE is_default = true
|
||||
—— 保证每个用户至多一个默认项目
|
||||
3. 存量数据回填:把名为"默认项目"的存量项目按创建时间最早者标记为 is_default=true
|
||||
(只标记不删除;存量重复项目的清理另行确认后单独执行)
|
||||
|
||||
注意:部分唯一索引依赖 PostgreSQL,不支持 downgrade 到其他方言。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "069_project_is_default"
|
||||
down_revision = "068_user_profile_completed"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. 新增 is_default 列
|
||||
op.add_column(
|
||||
"projects",
|
||||
sa.Column(
|
||||
"is_default",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
# 2. 存量回填:每个拥有"默认项目"的用户,只把最早创建的那一个标记为默认。
|
||||
# 用 ROW_NUMBER() 取每组第一条;非"默认项目"命名的项目不标记(保守,不动用户自建项目)。
|
||||
op.execute("""
|
||||
UPDATE projects p
|
||||
SET is_default = true
|
||||
WHERE p.id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY owner_user_id
|
||||
ORDER BY created_at ASC, id ASC
|
||||
) AS rn
|
||||
FROM projects
|
||||
WHERE name = '默认项目'
|
||||
) t
|
||||
WHERE t.rn = 1
|
||||
)
|
||||
""")
|
||||
|
||||
# 3. 部分唯一索引:每用户至多一个默认项目(只约束 is_default = true 的行)
|
||||
op.execute("""
|
||||
CREATE UNIQUE INDEX uq_projects_owner_default
|
||||
ON projects (owner_user_id)
|
||||
WHERE is_default = true
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS uq_projects_owner_default")
|
||||
op.drop_column("projects", "is_default")
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Add scripts table for oral broadcast script library (Issue #1795)
|
||||
|
||||
Revision ID: 070_add_scripts
|
||||
Revises: 069_project_is_default
|
||||
Create Date: 2026-09-08
|
||||
|
||||
新建 scripts 表,支持口播文案 CRUD + 分段存储。
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "070_add_scripts"
|
||||
down_revision = "069_project_is_default"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"scripts",
|
||||
sa.Column("id", sa.String(36), nullable=False),
|
||||
sa.Column("user_id", sa.String(36), nullable=False),
|
||||
sa.Column("title", sa.String(255), nullable=False),
|
||||
sa.Column("content", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("segments", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("tags", 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(),
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_scripts_user_id", "scripts", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_scripts_user_id", table_name="scripts")
|
||||
op.drop_table("scripts")
|
||||
@@ -1,47 +0,0 @@
|
||||
"""add lipsync jobs table
|
||||
|
||||
Revision ID: 071_add_lipsync_jobs
|
||||
Revises: 070_add_scripts
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "071_add_lipsync_jobs"
|
||||
down_revision = "070_add_scripts"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"lipsync_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("video_url", sa.Text(), nullable=False),
|
||||
sa.Column("audio_url", sa.Text(), nullable=False),
|
||||
sa.Column("enable_video_loop", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("mediakit_task_id", sa.String(200), nullable=False, server_default="", index=True),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("error_code", sa.String(100), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
# 复合索引:用户 + 状态(列表查询常用)
|
||||
op.create_index("ix_lipsync_jobs_user_status", "lipsync_jobs", ["user_id", "status"])
|
||||
# 项目 + 用户(项目维度查询)
|
||||
op.create_index("ix_lipsync_jobs_project_user", "lipsync_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_lipsync_jobs_project_user", table_name="lipsync_jobs")
|
||||
op.drop_index("ix_lipsync_jobs_user_status", table_name="lipsync_jobs")
|
||||
op.drop_table("lipsync_jobs")
|
||||
@@ -1,48 +0,0 @@
|
||||
"""add ai avatar render jobs table
|
||||
|
||||
Revision ID: 072_add_ai_avatar_render
|
||||
Revises: 071_add_lipsync_jobs
|
||||
Create Date: 2026-09-09
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "072_add_ai_avatar_render"
|
||||
down_revision = "071_add_lipsync_jobs"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ai_avatar_render_jobs",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("user_id", sa.String(36), nullable=False, index=True),
|
||||
sa.Column("project_id", sa.String(36), nullable=False, server_default=""),
|
||||
sa.Column("lipsync_job_id", sa.String(36), nullable=False),
|
||||
sa.Column("script_id", sa.String(36), nullable=False),
|
||||
sa.Column("b_roll_segments", sa.JSON(), nullable=False, server_default="[]"),
|
||||
sa.Column("title_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("cover_config", sa.JSON(), nullable=False, server_default="{}"),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True),
|
||||
sa.Column("progress", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("output_video_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_cover_url", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("output_duration", sa.Float(), nullable=False, server_default=sa.text("0.0")),
|
||||
sa.Column("error_message", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("submitted_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("started_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_ai_avatar_render_user_status", "ai_avatar_render_jobs", ["user_id", "status"])
|
||||
op.create_index("ix_ai_avatar_render_project_user", "ai_avatar_render_jobs", ["project_id", "user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_ai_avatar_render_project_user", table_name="ai_avatar_render_jobs")
|
||||
op.drop_index("ix_ai_avatar_render_user_status", table_name="ai_avatar_render_jobs")
|
||||
op.drop_table("ai_avatar_render_jobs")
|
||||
@@ -1,24 +1,17 @@
|
||||
from app.api.routes.ai import router as ai_router
|
||||
from app.api.routes.ai_avatar_render import router as ai_avatar_render_router
|
||||
from app.api.routes.asset_diagnosis import router as asset_diagnosis_router
|
||||
from app.api.routes.asset_libraries import router as asset_libraries_router
|
||||
from app.api.routes.assets import router as assets_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.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.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_variant_plans import router as generation_variant_plans_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.internal_render import router as internal_render_router
|
||||
from app.api.routes.lipsync import router as lipsync_router
|
||||
from app.api.routes.projects import router as projects_router
|
||||
from app.api.routes.scripts import router as scripts_router
|
||||
from app.api.routes.share import router as share_router
|
||||
from app.api.routes.subscription import router as subscription_router
|
||||
from app.api.routes.tags import router as tags_router
|
||||
@@ -41,11 +34,6 @@ api_router.include_router(
|
||||
auth_router,
|
||||
tags=["Auth"],
|
||||
)
|
||||
api_router.include_router(
|
||||
lipsync_router,
|
||||
prefix="/lipsync",
|
||||
tags=["Lipsync"],
|
||||
)
|
||||
api_router.include_router(
|
||||
projects_router,
|
||||
prefix="/projects",
|
||||
@@ -56,10 +44,6 @@ api_router.include_router(
|
||||
prefix="/tags",
|
||||
tags=["Tag"],
|
||||
)
|
||||
api_router.include_router(
|
||||
cover_templates_router,
|
||||
tags=["CoverTemplate"],
|
||||
)
|
||||
api_router.include_router(
|
||||
task_center_router,
|
||||
tags=["TaskCenter"],
|
||||
@@ -103,21 +87,6 @@ api_router.include_router(
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_preview_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_variant_plans_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
generation_cover_router,
|
||||
prefix="/generation",
|
||||
tags=["Generation"],
|
||||
)
|
||||
api_router.include_router(
|
||||
titles_router,
|
||||
prefix="/titles",
|
||||
@@ -179,13 +148,3 @@ api_router.include_router(
|
||||
internal_render_router,
|
||||
tags=["Internal"],
|
||||
)
|
||||
api_router.include_router(
|
||||
scripts_router,
|
||||
prefix="/scripts",
|
||||
tags=["ScriptLibrary"],
|
||||
)
|
||||
api_router.include_router(
|
||||
ai_avatar_render_router,
|
||||
prefix="/ai-avatar/render",
|
||||
tags=["AI Avatar Render"],
|
||||
)
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
"""AI数字人渲染合成 API 路由 — #1798.
|
||||
|
||||
接口:
|
||||
POST /api/v1/ai-avatar/render 提交渲染任务
|
||||
GET /api/v1/ai-avatar/render/jobs 任务列表
|
||||
GET /api/v1/ai-avatar/render/{job_id} 任务详情
|
||||
POST /api/v1/ai-avatar/render/{job_id}/cancel 取消任务
|
||||
POST /api/v1/ai-avatar/render/{job_id}/retry 重试失败任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.ai_avatar_render import (
|
||||
AiAvatarRenderJobResponse,
|
||||
CreateAiAvatarRenderRequest,
|
||||
)
|
||||
from app.services.ai_avatar_render_service import (
|
||||
AiAvatarRenderError,
|
||||
AiAvatarRenderService,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> AiAvatarRenderService:
|
||||
return AiAvatarRenderService(db)
|
||||
|
||||
|
||||
# ── POST / — 提交渲染任务 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("", response_model=AiAvatarRenderJobResponse, status_code=201)
|
||||
def create_render_job(
|
||||
body: CreateAiAvatarRenderRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""提交 AI 数字人渲染任务.
|
||||
|
||||
将对口型视频 + B-roll 素材 + 标题叠加 + 封面提取合成最终输出视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_render_job(
|
||||
user_id=current_user.id,
|
||||
lipsync_job_id=body.lipsync_job_id,
|
||||
script_id=body.script_id,
|
||||
b_roll_segments=[s.model_dump() for s in body.b_roll_segments],
|
||||
title_config=body.title_config,
|
||||
cover_config=body.cover_config,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except AiAvatarRenderError as exc:
|
||||
status_map = {
|
||||
"LipsyncJobNotFound": 404,
|
||||
"LipsyncJobNotCompleted": 400,
|
||||
"LipsyncJobNoOutput": 400,
|
||||
"ScriptNotFound": 404,
|
||||
}
|
||||
raise HTTPException(
|
||||
status_code=status_map.get(exc.code, 400),
|
||||
detail={"code": exc.code, "message": str(exc)},
|
||||
) from exc
|
||||
|
||||
# 异步触发渲染
|
||||
try:
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,渲染任务已创建但未触发执行: %s", job.id)
|
||||
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=dict)
|
||||
def list_render_jobs(
|
||||
project_id: str = Query("", description="项目 ID 过滤"),
|
||||
status: str = Query("", description="状态过滤"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取 AI 数字人渲染任务列表."""
|
||||
items, total = svc.list_render_jobs(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"items": [AiAvatarRenderJobResponse.model_validate(j) for j in items],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /{job_id} — 任务详情 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=AiAvatarRenderJobResponse)
|
||||
def get_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""获取渲染任务详情."""
|
||||
job = svc.get_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /{job_id}/cancel — 取消任务 ─────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/cancel", response_model=AiAvatarRenderJobResponse)
|
||||
def cancel_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = svc.cancel_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending 可取消",
|
||||
)
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /{job_id}/retry — 重试失败任务 ──────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/{job_id}/retry", response_model=AiAvatarRenderJobResponse)
|
||||
def retry_render_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: AiAvatarRenderService = Depends(_get_service),
|
||||
):
|
||||
"""重试失败的渲染任务."""
|
||||
job = svc.retry_render_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="渲染任务不存在")
|
||||
if job.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"仅 failed 状态的任务可重试,当前状态: {job.status}",
|
||||
)
|
||||
|
||||
# 重新触发渲染
|
||||
try:
|
||||
from app.tasks.ai_avatar_render import execute_ai_avatar_render
|
||||
|
||||
execute_ai_avatar_render.delay(job.id)
|
||||
except Exception:
|
||||
logger.warning("Celery 任务提交失败,重试任务已重置但未触发执行: %s", job.id)
|
||||
|
||||
return job
|
||||
@@ -20,7 +20,7 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListAssetLibrariesUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibraryKind
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
|
||||
from ._helpers import check_project_access
|
||||
|
||||
@@ -120,11 +120,30 @@ def ensure_default_library(
|
||||
|
||||
kind = AssetLibraryKind(request.kind)
|
||||
|
||||
# Issue #1775: 幂等获取/创建——依赖唯一约束 uq_asset_libraries_project_kind,
|
||||
# 并发创建冲突时回滚重查返回已有记录,不再依赖应用层"先查后插",也不会 500。
|
||||
# 查找该项目下同 kind 的素材库,返回第一个
|
||||
existing = asset_library_repository.find_by_project(request.project_id)
|
||||
for lib in existing:
|
||||
if lib.kind == kind:
|
||||
return _to_asset_library_response(lib)
|
||||
|
||||
# 不存在 → 自动创建
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
default_name = _DEFAULT_LIBRARY_NAMES.get(request.kind, f"{request.kind}素材库")
|
||||
library = asset_library_repository.get_or_create_default_library(request.project_id, kind, name=default_name)
|
||||
return _to_asset_library_response(library)
|
||||
library = AssetLibrary(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=request.project_id,
|
||||
name=default_name,
|
||||
kind=kind,
|
||||
asset_count=0,
|
||||
total_size=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created = asset_library_repository.create(library)
|
||||
return _to_asset_library_response(created)
|
||||
|
||||
|
||||
@router.delete("/{library_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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.auth import AuthenticatedUser, get_current_user
|
||||
@@ -14,45 +14,28 @@ from app.schemas.asset import (
|
||||
AssetResponse,
|
||||
BatchClassifyRequest,
|
||||
BatchDeleteRequest,
|
||||
BatchGetRequest,
|
||||
BatchMarkRequest,
|
||||
BatchOperationResponse,
|
||||
BatchTagRequest,
|
||||
CreateAssetRequest,
|
||||
ListAssetsResponse,
|
||||
SmartMatchItem,
|
||||
SmartMatchRequest,
|
||||
SmartMatchResponse,
|
||||
UpdateAssetRequest,
|
||||
UpdateAssetReviewRequest,
|
||||
)
|
||||
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 packages.domain.smart_match import smart_select_assets
|
||||
from packages.application import (
|
||||
CreateAssetCommand,
|
||||
CreateAssetUseCase,
|
||||
)
|
||||
from packages.domain import AssetStatus, ClassificationStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
# 生成签名文件 URL(用于视频播放 / 文件下载)
|
||||
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)
|
||||
file_url = None
|
||||
|
||||
# 缩略图:存储的是 storage_key,需要生成签名 URL 供前端使用
|
||||
# 不再降级使用视频文件 URL(浏览器 <img> 无法渲染 .mp4,会显示黑屏)
|
||||
thumbnail_url = None
|
||||
if item.thumbnail_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
|
||||
# 缩略图:优先用已有 thumbnail_url,否则对视频素材复用文件签名 URL
|
||||
thumbnail_url = item.thumbnail_url
|
||||
if not thumbnail_url and item.mime_type and item.mime_type.startswith("video") and file_url:
|
||||
thumbnail_url = file_url
|
||||
|
||||
return AssetResponse(
|
||||
id=item.id,
|
||||
@@ -97,7 +74,6 @@ def _to_asset_response(item, storage_service=None) -> AssetResponse:
|
||||
created_at=format_utc_datetime(item.created_at),
|
||||
uploaded_by_user_id=item.uploaded_by_user_id,
|
||||
tag_ids=getattr(item, "tag_ids", []),
|
||||
**_asset_availability_fields(item),
|
||||
)
|
||||
|
||||
|
||||
@@ -290,7 +266,7 @@ def list_assets(
|
||||
else:
|
||||
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
|
||||
# 跨项目分页:逐项目累积直到凑够一页
|
||||
paged_items = []
|
||||
paged_items: list = []
|
||||
offset = skip
|
||||
remaining = limit
|
||||
for pid in project_ids:
|
||||
@@ -390,18 +366,6 @@ def update_asset_review_status(
|
||||
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)
|
||||
def batch_delete_assets(
|
||||
request: BatchDeleteRequest,
|
||||
@@ -555,119 +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)
|
||||
|
||||
# ── 过滤前置:余量 + 高频使用,过滤在评分/截取 limit 之前完成 ──────────
|
||||
# 旧实现先 smart_select_assets(limit=N) 再对这 N 条做过滤,过滤后不回补,
|
||||
# 当排名靠前的素材恰好都被排除时返回空 items(前端回退全选,smart-match 名存实亡)。
|
||||
# 现在先过滤全量候选,每级过滤后为空/不足则回退上一级,最后才评分截取。
|
||||
|
||||
# 1) 余量过滤:usable=False(零重复可切区间耗尽且历史区间均达复用上限)的素材排除
|
||||
usable_assets = []
|
||||
exhausted_assets = []
|
||||
for a in filtered_assets:
|
||||
try:
|
||||
avail = compute_asset_availability(a)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"smart-match 余量计算失败,按可用处理: asset_id=%s",
|
||||
getattr(a, "id", "?"),
|
||||
exc_info=True,
|
||||
)
|
||||
avail = None
|
||||
if avail is not None and not avail["usable"]:
|
||||
exhausted_assets.append(a)
|
||||
else:
|
||||
usable_assets.append(a)
|
||||
|
||||
if exhausted_assets:
|
||||
logger.info(
|
||||
"smart-match 余量过滤: 候选 %d,可切区间耗尽 %d",
|
||||
len(filtered_assets), len(exhausted_assets),
|
||||
)
|
||||
|
||||
# 回退策略:余量过滤后为空(全部耗尽)时,保留全部候选,不返回空结果。
|
||||
# 宁可让用户在已耗尽素材上复用,也比 smart-match 空结果回退全选更可控
|
||||
# (全选同样会选到这些素材,且不经过评分排序)。
|
||||
pool = usable_assets if usable_assets else filtered_assets
|
||||
|
||||
# 2) 高频使用排除:同一素材在最近 5 个视频中出现超过 3 次则排除
|
||||
MAX_RECENT_USE_COUNT = 3
|
||||
high_freq_assets = set()
|
||||
if pool:
|
||||
asset_ids = [getattr(a, "id", "") for a in pool if getattr(a, "id", "")]
|
||||
if asset_ids:
|
||||
try:
|
||||
use_counts = get_asset_recent_use_counts(
|
||||
db=asset_repository.session,
|
||||
asset_ids=asset_ids,
|
||||
recent_video_count=5,
|
||||
)
|
||||
for a in pool:
|
||||
aid = getattr(a, "id", "")
|
||||
count = use_counts.get(aid, 0)
|
||||
if count > MAX_RECENT_USE_COUNT:
|
||||
high_freq_assets.add(aid)
|
||||
logger.info(
|
||||
"smart-match 排除高频使用素材: asset_id=%s use_count=%d limit=%d",
|
||||
aid, count, MAX_RECENT_USE_COUNT,
|
||||
)
|
||||
# 回退策略:排除后剩余素材不足(为空或不够 limit)时,
|
||||
# 不再全部排除,保留全部可用素材
|
||||
if high_freq_assets:
|
||||
remaining_count = len(pool) - len(high_freq_assets)
|
||||
enough = request.limit is None or remaining_count >= request.limit
|
||||
if remaining_count > 0 and enough:
|
||||
pool = [a for a in pool if getattr(a, "id", "") not in high_freq_assets]
|
||||
else:
|
||||
logger.info(
|
||||
"smart-match 高频排除后素材不足(%d<%s),保留全部 %d 条",
|
||||
remaining_count,
|
||||
request.limit if request.limit is not None else "不限",
|
||||
len(pool),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("smart-match 高频使用查询失败,跳过排除", exc_info=True)
|
||||
|
||||
# 3) 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
||||
results = smart_select_assets(pool, limit=request.limit, kind=None)
|
||||
|
||||
# 扁平结构: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 results
|
||||
]
|
||||
|
||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
def get_asset(
|
||||
asset_id: str,
|
||||
@@ -764,12 +615,43 @@ def untag_asset(
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset() -> None:
|
||||
"""
|
||||
已废弃接口。
|
||||
所有素材上传统一走 uploadAssetDirect → completeDirectUpload → ingest-jobs 流程。
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=410,
|
||||
detail="此接口已废弃。请使用 uploadAssetDirect 接口上传素材,Worker 会自动处理(视频转码、图片/音频元数据提取)并创建 Asset 记录。",
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
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),
|
||||
) -> AssetResponse:
|
||||
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,5 +1,4 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
Canonical authentication API routes.
|
||||
|
||||
The route layer is intentionally thin: repository construction lives in
|
||||
@@ -14,9 +13,9 @@ import jwt
|
||||
from app.auth import AuthenticatedUser, blacklist_token, get_current_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_auth_email_service, get_auth_session_store, get_user_repository
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from packages.adapters.redis import NoopSessionStore
|
||||
from packages.adapters.smtp import NoopEmailService
|
||||
@@ -85,23 +84,6 @@ class CurrentUserResponse(BaseModel):
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
"""用户资料负载(PATCH /me、绑定/解绑接口复用;字段与 GET /auth/me 一致,前端 normalizeUser 直接消费)"""
|
||||
|
||||
user_id: str
|
||||
email: str
|
||||
username: str
|
||||
display_name: str
|
||||
email_verified: bool
|
||||
phone: str = ""
|
||||
phone_verified: bool = False
|
||||
binding_complete: bool = False
|
||||
wechat_bound: bool = False
|
||||
profile_completed: bool = True
|
||||
|
||||
|
||||
class PasswordResetRequestModel(BaseModel):
|
||||
@@ -290,52 +272,9 @@ async def get_current_user_info(
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
"""更新个人资料请求(当前仅支持昵称)"""
|
||||
|
||||
display_name: str
|
||||
|
||||
@field_validator("display_name")
|
||||
@classmethod
|
||||
def _validate_display_name(cls, v: str) -> str:
|
||||
name = (v or "").strip()
|
||||
if not name:
|
||||
raise ValueError("昵称不能为空白")
|
||||
if len(name) > 20:
|
||||
raise ValueError("昵称长度需在 1-20 个字符之间")
|
||||
return name
|
||||
|
||||
|
||||
class UpdateProfileResponse(BaseModel):
|
||||
"""更新资料响应:前端 normalizeUser(response.user) 直接消费"""
|
||||
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UpdateProfileResponse)
|
||||
async def update_current_user_profile(
|
||||
request: UpdateProfileRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> UpdateProfileResponse:
|
||||
"""更新当前登录用户昵称(微信新用户首次设置昵称后置 profile_completed=True)。"""
|
||||
user = current_user.user
|
||||
user.display_name = request.display_name # 已 strip(validator)
|
||||
if not user.profile_completed:
|
||||
user.profile_completed = True
|
||||
user_repository.save(user)
|
||||
|
||||
logger.info("[资料更新] 用户 %s 更新昵称,profile_completed=%s", user.id, user.profile_completed)
|
||||
# 重新读取,确保返回的是持久化后的最新状态
|
||||
fresh = user_repository.find_by_id(user.id) or user
|
||||
return UpdateProfileResponse(user=_user_profile(fresh))
|
||||
|
||||
|
||||
class _NoopSessionStore(NoopSessionStore):
|
||||
pass
|
||||
|
||||
@@ -487,7 +426,6 @@ async def get_wechat_auth_url() -> WechatAuthUrlResponse:
|
||||
@router.post("/wechat/callback", response_model=WechatLoginResponse)
|
||||
async def wechat_callback(
|
||||
request: WechatCallbackRequest,
|
||||
http_request: Request,
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatLoginResponse:
|
||||
"""微信登录回调处理"""
|
||||
@@ -495,30 +433,11 @@ async def wechat_callback(
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncRequest as SyncRequest
|
||||
from packages.application.auth.wechat_sync_use_case import WechatSyncUseCase
|
||||
|
||||
# 回调可观测性:记录 UA(区分微信内置浏览器 MicroMessenger)与 state,
|
||||
# 便于排查"停留 open.weixin.qq.com / 回调失败"类问题(#1718)
|
||||
user_agent = http_request.headers.get("User-Agent", "")
|
||||
is_wechat_browser = "MicroMessenger" in user_agent
|
||||
logger.info(
|
||||
"[微信回调] 收到回调: state=%s code_len=%d UA=%r 微信内置浏览器=%s",
|
||||
(request.state or "")[:8],
|
||||
len(request.code or ""),
|
||||
user_agent[:200],
|
||||
is_wechat_browser,
|
||||
)
|
||||
|
||||
# 1. 用 code 换微信用户信息
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
# state 校验失败 / 微信 errcode 等错误原文已在 service 内 log,这里带上 UA 上下文
|
||||
logger.warning("[微信回调] 处理失败: err=%s 微信内置浏览器=%s", err, is_wechat_browser)
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
logger.info(
|
||||
"[微信回调] state 校验通过,微信用户信息获取成功: openid=%s unionid=%s",
|
||||
wechat_user.openid[:8] if wechat_user.openid else "",
|
||||
bool(wechat_user.unionid),
|
||||
)
|
||||
|
||||
# 2. 同步登录/注册(复用 wechat-sync 逻辑)
|
||||
use_case = WechatSyncUseCase(user_repository=user_repository)
|
||||
@@ -537,7 +456,7 @@ async def wechat_callback(
|
||||
user = user_repository.find_by_id(response.user_id)
|
||||
binding_complete = False
|
||||
if user:
|
||||
binding_complete = bool(
|
||||
binding_complete = (
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
|
||||
@@ -553,109 +472,6 @@ async def wechat_callback(
|
||||
)
|
||||
|
||||
|
||||
# ==================== 微信账号绑定/解绑(已登录用户) ====================
|
||||
|
||||
|
||||
class WechatBindUrlResponse(BaseModel):
|
||||
auth_url: str
|
||||
state: str
|
||||
|
||||
|
||||
class WechatBindCompleteRequest(BaseModel):
|
||||
code: str
|
||||
state: str = ""
|
||||
|
||||
|
||||
class WechatBindCompleteResponse(BaseModel):
|
||||
success: bool
|
||||
user: UserProfileResponse
|
||||
|
||||
|
||||
class WechatUnbindResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
def _user_profile(user) -> UserProfileResponse:
|
||||
binding_complete = bool(
|
||||
user.phone_verified and user.email_verified and user.email and "@wechat.local" not in user.email
|
||||
)
|
||||
return UserProfileResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
email_verified=user.email_verified,
|
||||
phone=user.phone or "",
|
||||
phone_verified=user.phone_verified,
|
||||
binding_complete=binding_complete,
|
||||
wechat_bound=bool(user.wechat_openid),
|
||||
profile_completed=user.profile_completed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wechat/bind/url", response_model=WechatBindUrlResponse)
|
||||
async def get_wechat_bind_url(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> WechatBindUrlResponse:
|
||||
"""获取微信绑定授权链接(已登录用户场景)。state 经 Redis 存储做 CSRF 校验。"""
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
auth_url, state = oauth_service.generate_auth_url()
|
||||
logger.info("[微信绑定] 用户 %s 请求绑定授权链接", current_user.user.id)
|
||||
return WechatBindUrlResponse(auth_url=auth_url, state=state)
|
||||
|
||||
|
||||
@router.post("/wechat/bind", response_model=WechatBindCompleteResponse)
|
||||
async def wechat_bind(
|
||||
request: WechatBindCompleteRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatBindCompleteResponse:
|
||||
"""微信绑定完成:扫码回调后用 code 换 openid,绑定到当前登录账号(不创建新用户)。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatBindRequest, WechatBindUseCase
|
||||
from packages.application.auth.wechat_oauth_service import get_wechat_oauth_service
|
||||
|
||||
oauth_service = get_wechat_oauth_service()
|
||||
wechat_user, err = oauth_service.handle_callback(request.code, request.state)
|
||||
if err:
|
||||
logger.warning("[微信绑定] 用户 %s 换取微信信息失败: %s", current_user.user.id, err)
|
||||
raise HTTPException(status_code=400, detail=err)
|
||||
|
||||
use_case = WechatBindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.bind(
|
||||
WechatBindRequest(
|
||||
user_id=current_user.user.id,
|
||||
openid=wechat_user.openid,
|
||||
unionid=wechat_user.unionid or "",
|
||||
)
|
||||
)
|
||||
if error:
|
||||
logger.warning("[微信绑定] 用户 %s 绑定失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信绑定] 用户 %s 绑定成功 openid=%s", current_user.user.id, wechat_user.openid[:8])
|
||||
return WechatBindCompleteResponse(success=True, user=_user_profile(result.user))
|
||||
|
||||
|
||||
@router.delete("/wechat/bind", response_model=WechatUnbindResponse)
|
||||
async def wechat_unbind(
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
) -> WechatUnbindResponse:
|
||||
"""解绑微信:需账号仍有其他登录方式(密码/手机/真实邮箱),否则拒绝。"""
|
||||
from packages.application.auth.wechat_bind_use_case import WechatUnbindUseCase
|
||||
|
||||
use_case = WechatUnbindUseCase(user_repository=user_repository)
|
||||
result, error, http_status = use_case.unbind(current_user.user.id)
|
||||
if error:
|
||||
logger.warning("[微信解绑] 用户 %s 解绑失败: %s", current_user.user.id, error)
|
||||
raise HTTPException(status_code=http_status, detail=error)
|
||||
|
||||
logger.info("[微信解绑] 用户 %s 解绑成功", current_user.user.id)
|
||||
return WechatUnbindResponse(success=True)
|
||||
|
||||
|
||||
# ==================== 验证码 & 绑定 ====================
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes._helpers import require_project_and_library
|
||||
from app.api.routes.upload import _persist_celery_task_id
|
||||
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
|
||||
@@ -382,8 +381,7 @@ async def complete_chunked_upload(
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
)
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
_persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", ""))
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
# Update metadata status
|
||||
meta["status"] = "completed"
|
||||
|
||||
@@ -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)
|
||||
@@ -7,7 +7,6 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
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_duplication_repository
|
||||
from app.schemas.duplication import (
|
||||
@@ -77,8 +76,6 @@ def _to_record_response(record: DuplicationRecord) -> DuplicationRecordResponse:
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
visual_similarity=getattr(record, "visual_similarity", None),
|
||||
match_count=getattr(record, "match_count", None),
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
)
|
||||
@@ -93,8 +90,6 @@ def _to_detail_response(record: DuplicationRecord) -> DuplicationDetailResponse:
|
||||
status=record.status,
|
||||
duplicate_rate=record.duplicate_rate,
|
||||
duplicate_count=record.duplicate_count,
|
||||
visual_similarity=getattr(record, "visual_similarity", None),
|
||||
match_count=getattr(record, "match_count", None),
|
||||
created_at=record.created_at.isoformat(),
|
||||
updated_at=record.updated_at.isoformat(),
|
||||
segments=[
|
||||
@@ -197,8 +192,6 @@ async def upload_for_duplication(
|
||||
authenticated_user.user.id,
|
||||
)
|
||||
|
||||
celery_app.send_task("worker.process_duplication_check", args=[record.id])
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=record.id,
|
||||
status=record.status,
|
||||
@@ -303,8 +296,6 @@ def retry_duplication(
|
||||
detail=f"查重记录 {record_id} 不存在",
|
||||
)
|
||||
|
||||
celery_app.send_task("worker.process_duplication_check", args=[updated.id])
|
||||
|
||||
return DuplicationUploadResponse(
|
||||
id=updated.id,
|
||||
status=updated.status,
|
||||
|
||||
@@ -1,879 +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 _select_best_frame_from_snapshots(
|
||||
snapshots: list[dict], plan_id: str
|
||||
) -> str:
|
||||
"""从 MediaKit 抽帧结果中,通过质量评分选出最佳帧。
|
||||
|
||||
降级策略:cv2 不可用或评分失败时,返回第一帧。
|
||||
|
||||
Args:
|
||||
snapshots: MediaKit 返回的帧列表 [{"image_url": str, ...}, ...]
|
||||
plan_id: 计划 ID(日志用)
|
||||
|
||||
Returns:
|
||||
最佳帧的 image_url,或空字符串
|
||||
"""
|
||||
if not snapshots:
|
||||
return ""
|
||||
if len(snapshots) == 1:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
try:
|
||||
import tempfile
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.shared.cover_frame_scorer import score_frames
|
||||
|
||||
scored_candidates = []
|
||||
for snap in snapshots:
|
||||
url = snap.get("image_url") or snap.get("url") or ""
|
||||
if not url:
|
||||
continue
|
||||
# 下载帧到临时文件进行评分
|
||||
try:
|
||||
resp = httpx.get(url, timeout=15, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp:
|
||||
tmp.write(resp.content)
|
||||
tmp_path = tmp.name
|
||||
scored_candidates.append({"image_path": tmp_path, "url": url})
|
||||
except Exception:
|
||||
# 下载失败的帧跳过,给默认低分
|
||||
scored_candidates.append({"image_path": None, "url": url, "score": 0.0})
|
||||
|
||||
if not scored_candidates:
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
scored = score_frames(scored_candidates)
|
||||
best = scored[0] if scored else None
|
||||
best_url = best.get("url", "") if best else ""
|
||||
best_score = best.get("score", 0.0) if best else 0.0
|
||||
logger.info(
|
||||
"[封面生成] 帧质量评分完成: plan_id=%s candidates=%d best_score=%.1f",
|
||||
plan_id,
|
||||
len(scored_candidates),
|
||||
best_score,
|
||||
)
|
||||
|
||||
# 清理临时文件
|
||||
for c in scored_candidates:
|
||||
path = c.get("image_path")
|
||||
if path:
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
Path(path).unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return best_url
|
||||
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[封面生成] 帧质量评分失败,使用第一帧: plan_id=%s",
|
||||
plan_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return snapshots[0].get("image_url") or snapshots[0].get("url") or ""
|
||||
|
||||
|
||||
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", ""): # type: ignore[arg-type]
|
||||
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", ""): # type: ignore[arg-type]
|
||||
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=5, # 抽 5 帧,通过质量评分选最佳
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=5,
|
||||
max_retries=0,
|
||||
)
|
||||
if snapshots:
|
||||
raw = _select_best_frame_from_snapshots(snapshots, plan_id)
|
||||
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=5, # 抽 5 帧,通过质量评分选最佳
|
||||
poll_interval=2.0,
|
||||
max_poll_attempts=5,
|
||||
max_retries=0,
|
||||
)
|
||||
if snapshots:
|
||||
raw = _select_best_frame_from_snapshots(snapshots, plan_id)
|
||||
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: dict[str, object] = { # type: ignore[no-redef]
|
||||
"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,658 +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,
|
||||
build_rate_limit_detail,
|
||||
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 (
|
||||
BatchPreviewGenerationTaskResponse,
|
||||
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", ""): # type: ignore[arg-type]
|
||||
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()
|
||||
|
||||
title_cfg = getattr(task, "title_config", None)
|
||||
title_cfg = title_cfg if isinstance(title_cfg, dict) else {}
|
||||
extra_meta = getattr(task, "extra_meta", None)
|
||||
extra_meta = extra_meta if isinstance(extra_meta, dict) else {}
|
||||
voice_library_id = getattr(task, "voice_library_id", "") or ""
|
||||
if not isinstance(voice_library_id, str):
|
||||
voice_library_id = str(voice_library_id) if voice_library_id else ""
|
||||
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)),
|
||||
variant_index=int(extra_meta.get("variant_index", 0) or 0),
|
||||
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 "",
|
||||
title_text=str(title_cfg.get("text", "") or ""),
|
||||
voice_library_id=voice_library_id,
|
||||
created_at=task.created_at,
|
||||
started_at=started_at,
|
||||
finished_at=completed_at,
|
||||
generate_duration=generate_duration,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_preview_edit_plan_id(
|
||||
*,
|
||||
request: CreatePreviewGenerationTaskRequest,
|
||||
task,
|
||||
db: Session,
|
||||
user_id: str,
|
||||
) -> str:
|
||||
"""确定任务关联的编辑计划ID:优先前端传入,否则按 template_id+user 兜底查找。"""
|
||||
if task.source_edit_plan_id:
|
||||
return task.source_edit_plan_id
|
||||
if not request.template_id:
|
||||
return ""
|
||||
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:
|
||||
logger.info(
|
||||
"[预览生成] 自动关联编辑计划: task_id=%s plan_id=%s",
|
||||
task.id,
|
||||
_p.id,
|
||||
)
|
||||
return _p.id
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[预览生成] 查找关联编辑计划失败(不影响主流程): task_id=%s",
|
||||
task.id,
|
||||
exc_info=True,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
|
||||
"""从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。"""
|
||||
if not values:
|
||||
return fallback
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
return values[index] if index < len(values) else fallback
|
||||
|
||||
|
||||
@router.post("/preview", response_model=BatchPreviewGenerationTaskResponse, 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),
|
||||
) -> BatchPreviewGenerationTaskResponse:
|
||||
"""创建预览生成任务(支持批量)。
|
||||
|
||||
preview_count=1 时行为与旧版完全一致(创建 1 个任务);
|
||||
preview_count=N 时一次创建 N 个独立变体任务:
|
||||
- 每个变体克隆独立编辑计划(独立 clips、独立随机素材起点),N 个预览内容互不相同
|
||||
- 每个变体拥有独立 task_id / 状态 / 预览视频 URL,前端按 task_id 分别轮询
|
||||
- 标题样式(font/color/position 等)全局共用;标题文字/配音/封面可按变体独立
|
||||
(titles[] / voice_library_ids[] / cover_urls[],长度1=共用,长度N=独立)
|
||||
|
||||
Returns:
|
||||
201 + 变体任务数组 {items: [...], total: N}
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
count = max(1, request.preview_count)
|
||||
logger.info(
|
||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
||||
user_id,
|
||||
request.template_id,
|
||||
len(request.asset_ids),
|
||||
count,
|
||||
)
|
||||
|
||||
# 预检查队列限流(按变体总数计)
|
||||
try:
|
||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
||||
global_pending = generation_task_repository.count_pending_total()
|
||||
if user_pending + count > USER_PENDING_LIMIT:
|
||||
raise UserPendingLimitExceeded(
|
||||
user_id=user_id, pending_count=user_pending + count, limit=USER_PENDING_LIMIT
|
||||
)
|
||||
if global_pending + count > GLOBAL_PENDING_LIMIT:
|
||||
raise GlobalQueueFull(pending_count=global_pending + count, limit=GLOBAL_PENDING_LIMIT)
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||||
) 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,
|
||||
)
|
||||
|
||||
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
||||
base_title_config = request.title_config or {}
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
|
||||
# ── 预创建第一个任务,仅用于解析源编辑计划(不落库为最终任务)──
|
||||
# 先创建一个临时任务拿到 task 对象上下文,实际 N 个任务在循环中统一创建;
|
||||
# 为保持与旧版一致的源 plan 解析逻辑,先创建任务0、解析源 plan,
|
||||
# 再预克隆 N 个变体 plan,最后重建任务关联。
|
||||
# 简化实现:直接创建全部任务,plan 关联在创建后、入队前完成。
|
||||
|
||||
created_tasks: list = []
|
||||
variant_plan_ids: list[str] = [] # 每个变体最终关联的 plan_id(按变体顺序)
|
||||
|
||||
try:
|
||||
for variant_index in range(count):
|
||||
# 变体独立标题文字:titles[] 覆盖 title_config.text
|
||||
variant_title_text = _variant_value(request.titles, variant_index, "")
|
||||
variant_title_config = dict(base_title_config)
|
||||
if variant_title_text.strip():
|
||||
variant_title_config["text"] = variant_title_text.strip()
|
||||
|
||||
# 变体独立配音
|
||||
variant_voice_library_id = _variant_value(
|
||||
request.voice_library_ids, variant_index, request.voice_library_id
|
||||
)
|
||||
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id="",
|
||||
asset_library_id="",
|
||||
strategy_id=strategy_id,
|
||||
voice_library_id=variant_voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=list(request.asset_ids),
|
||||
title_ids=list(request.title_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=variant_title_config,
|
||||
output_width=output_width,
|
||||
output_height=output_height,
|
||||
)
|
||||
)
|
||||
task.extra_meta["variant_index"] = variant_index
|
||||
|
||||
# 解析源编辑计划(前端传入或按模板兜底查找)
|
||||
source_plan_id = _resolve_preview_edit_plan_id(request=request, task=task, db=db, user_id=user_id)
|
||||
task.source_edit_plan_id = source_plan_id
|
||||
generation_task_repository.update(task)
|
||||
created_tasks.append(task)
|
||||
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
|
||||
|
||||
# ── 独立变体 plan(#1743)──
|
||||
# count=1:克隆源 plan(预览不污染源 plan,仅起点重算),行为与旧版一致;
|
||||
# count>1:变体 0 保留源 plan,变体 1..N-1 用 reselect_plan_for_variant 完整
|
||||
# 重跑单视频选片(素材洗牌+镜头洗牌+起点随机+跨变体避让+批次 20% 重叠重选),
|
||||
# 所见即所得——预览变体差异即正式成片差异。
|
||||
source_plan_id = created_tasks[0].source_edit_plan_id if created_tasks else ""
|
||||
|
||||
# #1749:各变体配音解析(严格守卫已在 schema;此处取每变体 voice 查时长)+ 时长分配
|
||||
def _preview_voice_durations() -> list[float]:
|
||||
try:
|
||||
from packages.domain.variant_voice_resolver import resolve_variant_voice_ids
|
||||
|
||||
voices = resolve_variant_voice_ids(
|
||||
count=count,
|
||||
voice_library_id=request.voice_library_id,
|
||||
voice_library_ids=request.voice_library_ids or None,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("[预览生成] 配音解析失败(按无配音处理)", exc_info=True)
|
||||
return [0.0] * count
|
||||
try:
|
||||
from app.api.routes.generation_tasks import _query_voice_durations
|
||||
|
||||
return _query_voice_durations(db, voices)
|
||||
except Exception:
|
||||
return [0.0] * count
|
||||
|
||||
voice_durations = _preview_voice_durations()
|
||||
|
||||
if source_plan_id and count == 1:
|
||||
# 单预览:克隆一份(原逻辑)+ 配音时长分配
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
variant_plan = _plan_svc.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix="预览变体",
|
||||
)
|
||||
if voice_durations and voice_durations[0] > 0:
|
||||
try:
|
||||
_plan_svc.apply_voice_duration_to_plan(variant_plan.id, voice_durations[0])
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", variant_plan.id)
|
||||
variant_plan_ids.append(variant_plan.id)
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 克隆预览 plan 异常: %s", e, exc_info=True)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览计划创建失败")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from e
|
||||
elif source_plan_id and count > 1:
|
||||
try:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
# #1749:变体 0 也 clone(不污染源 plan)+ 配音分配;变体 1..N-1 独立选片
|
||||
_plan0 = _plan_svc.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix="预览变体1",
|
||||
)
|
||||
if voice_durations and voice_durations[0] > 0:
|
||||
try:
|
||||
_plan_svc.apply_voice_duration_to_plan(_plan0.id, voice_durations[0])
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 变体0 配音分配失败(不阻断): plan=%s", _plan0.id)
|
||||
variant_plan_ids.append(_plan0.id)
|
||||
batch_asset_pool = list(dict.fromkeys(request.asset_ids or []))
|
||||
for variant_index in range(1, count):
|
||||
last_err: Exception | None = None
|
||||
variant_plan = None
|
||||
for _attempt in range(2): # 1 次重试,抗 DB 瞬时抖动
|
||||
try:
|
||||
variant_plan = _plan_svc.reselect_plan_for_variant(
|
||||
source_plan_id,
|
||||
batch_asset_pool,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"预览变体{variant_index + 1}",
|
||||
voice_duration=(
|
||||
voice_durations[variant_index] if variant_index < len(voice_durations) else 0.0
|
||||
),
|
||||
)
|
||||
break
|
||||
except ValueError as ve:
|
||||
logger.warning("[预览生成] 变体独立选片失败(素材不足): %s", ve)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体选片失败")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"批量预览第 {variant_index + 1} 个视频无法独立选片:{ve}。"
|
||||
"请增加素材库中的视频素材后重试。",
|
||||
) from ve
|
||||
except Exception as reselection_err: # noqa: PERF203
|
||||
last_err = reselection_err
|
||||
logger.warning(
|
||||
"[预览生成] 变体独立选片失败(尝试%d/2): variant=%d error=%s",
|
||||
_attempt + 1,
|
||||
variant_index,
|
||||
reselection_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if variant_plan is None:
|
||||
logger.error(
|
||||
"[预览生成] 变体独立选片重试仍失败: variant=%d source=%s",
|
||||
variant_index,
|
||||
source_plan_id,
|
||||
exc_info=last_err,
|
||||
)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from last_err
|
||||
variant_plan_ids.append(variant_plan.id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[预览生成] 变体 plan 生成异常: %s", e, exc_info=True)
|
||||
for t in created_tasks:
|
||||
_mark_task_failed(generation_task_repository, t, "预览变体计划创建失败")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建预览任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from e
|
||||
|
||||
# 关联变体 plan 并回写标题配置
|
||||
for variant_index, task in enumerate(created_tasks):
|
||||
if variant_plan_ids:
|
||||
task.source_edit_plan_id = variant_plan_ids[variant_index]
|
||||
generation_task_repository.update(task)
|
||||
# 回写变体标题到 plan config(worker 渲染时从 plan 读取 title 配置)
|
||||
if task.source_edit_plan_id and (task.title_config or {}).get("text", "").strip():
|
||||
try:
|
||||
from app.api.routes.generation_tasks import _writeback_edit_plan_config
|
||||
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=task.source_edit_plan_id,
|
||||
task_id=task.id,
|
||||
title_config=task.title_config,
|
||||
db=db,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"[预览生成] 回写标题配置失败(不影响主流程): task_id=%s",
|
||||
task.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# ── 入队 ──
|
||||
responses: list[PreviewGenerationTaskResponse] = []
|
||||
rate_limit_exc: Exception | None = None # 记录首个限流异常,全部失败时返回结构化提示
|
||||
for variant_index, task in enumerate(created_tasks):
|
||||
try:
|
||||
enqueued = safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
user_id=user_id,
|
||||
log_prefix=f"[预览生成][变体{variant_index + 1}]",
|
||||
log_task_status=True,
|
||||
)
|
||||
if not enqueued:
|
||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
||||
except UserPendingLimitExceeded as e:
|
||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
except GlobalQueueFull as e:
|
||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
||||
rate_limit_exc = rate_limit_exc or e
|
||||
except Exception:
|
||||
logger.exception("[预览生成] 入队异常: task_id=%s", task.id)
|
||||
_mark_task_failed(generation_task_repository, task, "任务入队异常")
|
||||
# enqueue 会原地更新 task 状态/进度,直接用 task 构造响应
|
||||
responses.append(_to_preview_response(task))
|
||||
|
||||
# 队列满/限流时若全部失败,返回结构化错误码(前端区分"排队"与"创建失败")
|
||||
if all(r.status == "failed" for r in responses) and rate_limit_exc is not None:
|
||||
if isinstance(rate_limit_exc, UserPendingLimitExceeded):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="user"),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(rate_limit_exc, generation_task_repository, scope="global"),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"[预览生成] 创建完成: %d 个变体任务, task_ids=%s",
|
||||
len(responses),
|
||||
[r.task_id for r in responses],
|
||||
)
|
||||
return BatchPreviewGenerationTaskResponse(items=responses, total=len(responses))
|
||||
|
||||
|
||||
@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 random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
@@ -10,13 +11,11 @@ from app.core.task_enqueue import (
|
||||
USER_PENDING_LIMIT,
|
||||
GlobalQueueFull,
|
||||
UserPendingLimitExceeded,
|
||||
build_rate_limit_detail,
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_db_session,
|
||||
get_generated_video_repository,
|
||||
get_generation_task_repository,
|
||||
get_project_repository,
|
||||
@@ -27,13 +26,11 @@ from app.schemas.generated_video import (
|
||||
)
|
||||
from app.schemas.generation_task import (
|
||||
BatchGenerationTaskResponse,
|
||||
ConfirmGenerationRequest,
|
||||
CreateGenerationTaskRequest,
|
||||
GenerationTaskResponse,
|
||||
ListGenerationTasksResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.application import (
|
||||
CreateGenerationTaskCommand,
|
||||
@@ -41,46 +38,12 @@ from packages.application import (
|
||||
GetGenerationTaskUseCase,
|
||||
ListGeneratedVideosByTaskUseCase,
|
||||
)
|
||||
from packages.domain.smart_match import smart_select_assets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _variant_value(values: list[str], index: int, fallback: str = "") -> str:
|
||||
"""从变体数组中取值:长度1=共用,长度>N=按索引,空数组=回退 fallback。"""
|
||||
if not values:
|
||||
return fallback
|
||||
if len(values) == 1:
|
||||
return values[0]
|
||||
return values[index] if index < len(values) else fallback
|
||||
|
||||
|
||||
def _query_voice_durations(db: Session, voice_ids: list[str]) -> list[float]:
|
||||
"""批量查询配音素材时长(秒),#1749 配音时长分配用。
|
||||
|
||||
逐项 try/float 硬化:MagicMock/异常/缺失 → 0.0(无配音不分配,不阻断)。
|
||||
"""
|
||||
ids = [v for v in dict.fromkeys(voice_ids or []) if v]
|
||||
if not ids:
|
||||
return []
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
rows = db.query(AssetModel.id, AssetModel.duration).filter(AssetModel.id.in_(ids)).all()
|
||||
dur_map: dict[str, float] = {}
|
||||
for row in rows:
|
||||
try:
|
||||
dur_map[row[0]] = float(row[1] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
dur_map[row[0]] = 0.0
|
||||
return [dur_map.get(v, 0.0) for v in ids]
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 配音时长查询失败(按无配音处理,不阻断)", exc_info=True)
|
||||
return [0.0 for _ in ids]
|
||||
|
||||
|
||||
def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
return GenerationTaskResponse(
|
||||
id=task.id,
|
||||
@@ -98,12 +61,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
title_config=getattr(task, "title_config", {}) or {},
|
||||
logs=getattr(task, "logs", "[]"),
|
||||
status=task.status,
|
||||
progress=task.progress,
|
||||
@@ -126,9 +83,6 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener
|
||||
height=item.height,
|
||||
fps=item.fps,
|
||||
download_url=download_url,
|
||||
duplicate_rate=getattr(item, "duplicate_rate", None),
|
||||
visual_similarity=getattr(item, "visual_similarity", None),
|
||||
match_count=getattr(item, "match_count", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -147,16 +101,13 @@ def _select_assets_from_library(
|
||||
assets: list,
|
||||
mode: str,
|
||||
count: int,
|
||||
rng=None,
|
||||
) -> list[str]:
|
||||
"""根据选取模式从素材库中选取 ready 状态的视频素材 ID。
|
||||
|
||||
Args:
|
||||
assets: 素材库中所有素材(Asset 实体列表)
|
||||
mode: 选取模式 — all=全部, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 smart 模式有效)
|
||||
rng: 可选随机源(smart 模式排序噪声用),生产环境不传则内部随机;
|
||||
测试可注入固定种子或零噪声随机源获得确定性结果。
|
||||
mode: 选取模式 — all=全部, random=随机, smart=智能匹配(多维度评分+多样性)
|
||||
count: 选取数量,0 表示全部(仅 random/smart 模式有效)
|
||||
|
||||
Returns:
|
||||
选中的素材 ID 列表
|
||||
@@ -166,81 +117,31 @@ def _select_assets_from_library(
|
||||
if not ready_video_assets:
|
||||
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":
|
||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
||||
# 排序注入随机噪声(#1743):同分素材每次选出不同组合,从素材组合层面降重
|
||||
limit = count if count > 0 else None
|
||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video", rng=rng)
|
||||
return [r.asset.id for r in results]
|
||||
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
||||
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
||||
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
||||
scored_assets = sorted(
|
||||
ready_video_assets,
|
||||
key=lambda a: (
|
||||
-(a.quality_score if a.quality_score is not None else 0.0),
|
||||
-(getattr(a, "duration", 0.0) or 0.0),
|
||||
),
|
||||
)
|
||||
if count > 0:
|
||||
scored_assets = scored_assets[:count]
|
||||
return [a.id for a in scored_assets]
|
||||
|
||||
# 默认 all 模式:返回全部 ready 视频素材
|
||||
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(
|
||||
request: CreateGenerationTaskRequest,
|
||||
project_repository: Any,
|
||||
@@ -286,7 +187,6 @@ def create_generation_task(
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> BatchGenerationTaskResponse:
|
||||
logger.info(
|
||||
"[生成任务] 接收请求: user_id=%s, template_id=%s, asset_count=%d, mode=%s, count=%d",
|
||||
@@ -327,8 +227,8 @@ def create_generation_task(
|
||||
mode=request.asset_select_mode,
|
||||
count=request.asset_select_count,
|
||||
)
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("smart",):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 smart 模式时,也自动选取
|
||||
elif project_id and not resolved_asset_ids and request.asset_select_mode in ("random", "smart"):
|
||||
# 项目级模式:未指定 asset_ids 且选择了 random/smart 模式时,也自动选取
|
||||
assets = asset_repository.find_by_project(project_id)
|
||||
if assets:
|
||||
resolved_asset_ids = _select_assets_from_library(
|
||||
@@ -342,92 +242,9 @@ def create_generation_task(
|
||||
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)
|
||||
count = request.count
|
||||
created_tasks: list = []
|
||||
created_tasks = []
|
||||
failed_tasks = []
|
||||
user_id = authenticated_user.user.id
|
||||
# 同批次任务共享 batch_id,用于视频查重时批次内比对
|
||||
@@ -446,234 +263,28 @@ def create_generation_task(
|
||||
except UserPendingLimitExceeded as e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="user"),
|
||||
detail=f"您的待处理任务过多(当前 {e.pending_count - count}/{e.limit},本次提交 {count} 个),请等待完成后再提交",
|
||||
) from e
|
||||
except GlobalQueueFull as e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from e
|
||||
|
||||
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
|
||||
effective_strategy_id = request.strategy_id
|
||||
if effective_strategy_id in ("pip", "voice_pip"):
|
||||
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
|
||||
effective_strategy_id = "one_take"
|
||||
|
||||
# 批量生成(count>1):每个变体必须走与单视频完全相同的独立选片流程(#1743/#1749)。
|
||||
# - 变体 0:clone 源 plan(不污染源 plan),变体 1..N-1 用 reselect_plan_for_variant
|
||||
# 完整重跑选片(素材级去重:fresh 优先 → 受控复用 overlap≤20% → 短素材禁复用);
|
||||
# - #1749:前端可回传 variant-plans 接口预生成的 plan_id(variant_plan_ids),直接复用;
|
||||
# 回传 plan 仍按各变体配音幂等重分配段长(防 variant-plans 阶段未带配音/占位时长);
|
||||
# - 配音时长:独立配音各自时长、统一配音同值,逐变体 apply_voice_duration_to_plan,
|
||||
# 成片总时长=配音时长(素材短→末帧冻结,禁慢放/禁截配音);
|
||||
# - count>1 但没有源 plan 时,不允许 N 个任务兜底共用同一 plan,直接 4xx 中断。
|
||||
# 在创建任何任务【之前】预生成/校验全部变体 plan:失败直接中断(此时无脏数据)。
|
||||
variant_plan_ids: list[str] = []
|
||||
if count > 1:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
_plan_svc = EditPlanService(db)
|
||||
|
||||
# 解析每变体配音(严格守卫:独立配音长度/缺值 → 400,禁静默 fallback)
|
||||
try:
|
||||
variant_voices = resolve_variant_voice_ids(
|
||||
count=count,
|
||||
voice_library_id=request.voice_library_id,
|
||||
voice_library_ids=request.voice_library_ids or None,
|
||||
)
|
||||
except VariantVoiceError as ve:
|
||||
raise HTTPException(status_code=400, detail=str(ve)) from ve
|
||||
# 各变体配音时长(查询硬化:异常 → 0.0 不阻断)
|
||||
voice_durations = _query_voice_durations(db, variant_voices)
|
||||
|
||||
# 解析批量源 plan:优先前端传入;否则按 template_id + user 查最新(与单任务兜底同源)
|
||||
batch_source_plan_id = request.source_edit_plan_id
|
||||
if not batch_source_plan_id and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
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 _latest:
|
||||
batch_source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 批量源 plan 解析失败", exc_info=True)
|
||||
|
||||
if not batch_source_plan_id and not request.variant_plan_ids:
|
||||
# 无任何可用源 plan:批量变体无从选片,明确报错,严禁静默共用/同源
|
||||
logger.error("[生成任务] 批量 count=%d 但无可编辑计划(无 source_edit_plan_id/template plan)", count)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="批量生成需要先完成预览生成(缺少剪辑计划)。请先生成预览后再批量创建。",
|
||||
)
|
||||
|
||||
# 批次素材池:请求显式素材 + 库自动匹配素材(resolved_asset_ids)
|
||||
batch_asset_pool = list(dict.fromkeys(resolved_asset_ids or []))
|
||||
|
||||
if request.variant_plan_ids:
|
||||
# ① 前端回传 variant-plans 预生成结果:直接复用(轻量选片接口已建好 plan)
|
||||
if len(request.variant_plan_ids) != count:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"variant_plan_ids 数量({len(request.variant_plan_ids)})与视频数量({count})不一致",
|
||||
)
|
||||
# 校验归属权
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
for _pid in request.variant_plan_ids:
|
||||
_pm = db.query(EditPlanModel).filter(EditPlanModel.id == _pid).first()
|
||||
if _pm is None:
|
||||
raise HTTPException(status_code=400, detail=f"剪辑计划不存在: {_pid}")
|
||||
if _pm.created_by_user_id and _pm.created_by_user_id != user_id:
|
||||
raise HTTPException(status_code=403, detail=f"无权使用剪辑计划: {_pid}")
|
||||
variant_plan_ids = list(request.variant_plan_ids)
|
||||
else:
|
||||
# ② 服务端选片:变体 0 clone 源 plan(不污染源 plan)
|
||||
try:
|
||||
_plan0 = _plan_svc.clone_plan_for_variant(
|
||||
batch_source_plan_id,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix="批量1",
|
||||
)
|
||||
except Exception as clone_err:
|
||||
logger.error("[生成任务] 变体0 clone 失败: %s", clone_err, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500, detail="创建批量任务失败:无法生成独立剪辑计划,请重试"
|
||||
) from clone_err
|
||||
variant_plan_ids.append(_plan0.id)
|
||||
|
||||
# 变体 1..N-1 独立选片
|
||||
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.reselect_plan_for_variant(
|
||||
batch_source_plan_id,
|
||||
batch_asset_pool,
|
||||
created_by_user_id=user_id,
|
||||
name_suffix=f"批量{task_index + 1}",
|
||||
voice_duration=voice_durations[task_index] if task_index < len(voice_durations) else 0.0,
|
||||
)
|
||||
break
|
||||
except ValueError as ve:
|
||||
# 素材不足等可预期错误:不重试,直接中断并给出明确提示
|
||||
logger.warning("[生成任务] 变体独立选片失败(素材不足): %s", ve)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"批量生成第 {task_index + 1} 个视频无法独立选片:{ve}。"
|
||||
"请增加素材库中的视频素材后重试。",
|
||||
) from ve
|
||||
except Exception as reselection_err: # noqa: PERF203
|
||||
last_err = reselection_err
|
||||
logger.warning(
|
||||
"[生成任务] 变体独立选片失败(尝试%d/2): source=%s error=%s",
|
||||
_attempt + 1,
|
||||
batch_source_plan_id,
|
||||
reselection_err,
|
||||
exc_info=True,
|
||||
)
|
||||
if variant is None:
|
||||
logger.error(
|
||||
"[生成任务] 变体独立选片重试仍失败,中断批量创建: source=%s",
|
||||
batch_source_plan_id,
|
||||
exc_info=last_err,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建批量任务失败:无法生成独立剪辑计划,请重试",
|
||||
) from last_err
|
||||
variant_plan_ids.append(variant.id)
|
||||
|
||||
# ③ 配音时长分配(回传 plan / clone 变体0 均需幂等分配;reselect 已在选片时分配)
|
||||
for _vi, _pid in enumerate(variant_plan_ids):
|
||||
_vd = voice_durations[_vi] if _vi < len(voice_durations) else 0.0
|
||||
if _vd > 0:
|
||||
try:
|
||||
_plan_svc.apply_voice_duration_to_plan(_pid, _vd)
|
||||
except Exception:
|
||||
logger.exception("[生成任务] 变体%d 配音时长分配失败(不阻断): plan=%s", _vi, _pid)
|
||||
|
||||
# N=1 正式生成:渲染侧全局慢放兜底已删除(#1749),enqueue 前也必须按配音分配段长
|
||||
if count == 1 and not request.is_preview:
|
||||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
try:
|
||||
_voices = resolve_variant_voice_ids(
|
||||
count=1,
|
||||
voice_library_id=request.voice_library_id,
|
||||
voice_library_ids=request.voice_library_ids or None,
|
||||
)
|
||||
_single_vd: list[float] = _query_voice_durations(db, _voices)
|
||||
_single_dur = _single_vd[0] if _single_vd else 0.0
|
||||
_single_plan = request.source_edit_plan_id
|
||||
if not _single_plan and request.template_id:
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
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 _latest:
|
||||
_single_plan = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[生成任务] 单任务源 plan 解析失败", exc_info=True)
|
||||
if _single_dur > 0 and _single_plan:
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
try:
|
||||
EditPlanService(db).apply_voice_duration_to_plan(_single_plan, _single_dur)
|
||||
except Exception:
|
||||
logger.exception("[生成任务] N=1 配音时长分配失败(不阻断): plan=%s", _single_plan)
|
||||
except VariantVoiceError as ve:
|
||||
raise HTTPException(status_code=400, detail=str(ve)) from ve
|
||||
except Exception:
|
||||
logger.exception("[生成任务] N=1 配音分配兜底异常(不阻断)")
|
||||
|
||||
try:
|
||||
for task_index in range(count):
|
||||
# #1749:count>1 时每个变体(含变体0)都关联各自独立 plan(clone/reselect/variant-plans)。
|
||||
if count > 1 and variant_plan_ids:
|
||||
effective_plan_id = variant_plan_ids[task_index]
|
||||
else:
|
||||
effective_plan_id = request.source_edit_plan_id
|
||||
|
||||
# 变体级独立配置:titles[]/voice_library_ids[]/cover_urls[]
|
||||
# 长度1=所有变体共用,长度=count=每个变体独立,空数组=回退单值字段
|
||||
variant_title_text = _variant_value(request.titles, task_index, "")
|
||||
variant_title_config = dict(request.title_config or {})
|
||||
if variant_title_text.strip():
|
||||
variant_title_config["text"] = variant_title_text.strip()
|
||||
variant_voice_library_id = _variant_value(request.voice_library_ids, task_index, request.voice_library_id)
|
||||
variant_cover_url = _variant_value(request.cover_urls, task_index, request.cover_url)
|
||||
|
||||
for _ in range(count):
|
||||
task = use_case.execute(
|
||||
CreateGenerationTaskCommand(
|
||||
project_id=project_id,
|
||||
asset_library_id=asset_library_id,
|
||||
strategy_id=effective_strategy_id,
|
||||
voice_library_id=variant_voice_library_id,
|
||||
strategy_id=request.strategy_id,
|
||||
voice_library_id=request.voice_library_id,
|
||||
template_id=request.template_id,
|
||||
asset_ids=resolved_asset_ids,
|
||||
title_ids=request.title_ids,
|
||||
voice_ids=request.voice_ids,
|
||||
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,
|
||||
batch_id=batch_id,
|
||||
video_title=request.video_title,
|
||||
@@ -681,72 +292,9 @@ def create_generation_task(
|
||||
bgm_config=request.bgm_config,
|
||||
auto_retry_enabled=request.auto_retry_enabled,
|
||||
auto_retry_max=request.auto_retry_max,
|
||||
is_preview=request.is_preview,
|
||||
source_task_id=request.source_task_id,
|
||||
output_width=request.output_width,
|
||||
output_height=request.output_height,
|
||||
cover_url=variant_cover_url,
|
||||
title_config=variant_title_config,
|
||||
)
|
||||
)
|
||||
# 变体序号写入 extra_meta(响应/排查时可辨识)
|
||||
task.extra_meta["variant_index"] = task_index
|
||||
try:
|
||||
# 兜底关联编辑计划:前端未传 source_edit_plan_id 时,
|
||||
# 通过 template_id + user_id 在 DB 层直接查找最新的 plan。
|
||||
# 必须在 enqueue 之前执行,避免 worker 读取时 source_edit_plan_id 为空(竞态条件)。
|
||||
# #1743:批量(count>1)场景严禁兜底共用——变体 plan 已在上方预生成,
|
||||
# 走到这里还缺 plan 说明预生成漏配,直接报错中断,不允许 N 任务关联同一 plan。
|
||||
if not task.source_edit_plan_id and count > 1:
|
||||
logger.error(
|
||||
"[生成任务] 批量任务缺少独立 plan(禁止共用兜底): task_index=%d task_id=%s",
|
||||
task_index,
|
||||
task.id,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="创建批量任务失败:变体剪辑计划缺失,请重新预览后再批量生成。",
|
||||
)
|
||||
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。
|
||||
# 批量场景下每个变体关联独立 plan,需各自回写自己的变体标题配置。
|
||||
_effective_plan_id = task.source_edit_plan_id
|
||||
if _effective_plan_id:
|
||||
_writeback_edit_plan_config(
|
||||
plan_id=_effective_plan_id,
|
||||
task_id=task.id,
|
||||
title_config=variant_title_config,
|
||||
db=db,
|
||||
)
|
||||
|
||||
if safe_enqueue_generation_task(
|
||||
task,
|
||||
generation_task_repository,
|
||||
@@ -763,7 +311,7 @@ def create_generation_task(
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from _e
|
||||
break
|
||||
except GlobalQueueFull as _e:
|
||||
@@ -771,7 +319,7 @@ def create_generation_task(
|
||||
if not created_tasks:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from _e
|
||||
break
|
||||
except HTTPException:
|
||||
@@ -784,135 +332,6 @@ def create_generation_task(
|
||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
|
||||
def confirm_generation(
|
||||
task_id: str,
|
||||
request: ConfirmGenerationRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
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,
|
||||
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 as _e:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
) from None
|
||||
except GlobalQueueFull as _e:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
) from None
|
||||
|
||||
return BatchGenerationTaskResponse(
|
||||
items=[_to_generation_task_response(new_task)],
|
||||
total=1,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||
def list_generation_tasks(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
@@ -986,24 +405,12 @@ def retry_generation_task(
|
||||
if user_pending >= USER_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(
|
||||
UserPendingLimitExceeded(
|
||||
user_id=user_id,
|
||||
pending_count=user_pending,
|
||||
limit=USER_PENDING_LIMIT,
|
||||
),
|
||||
generation_task_repository,
|
||||
scope="user",
|
||||
),
|
||||
detail=f"您的待处理任务过多(当前 {user_pending}/{USER_PENDING_LIMIT}),请等待完成后再提交",
|
||||
)
|
||||
if global_pending >= GLOBAL_PENDING_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(
|
||||
GlobalQueueFull(pending_count=global_pending, limit=GLOBAL_PENDING_LIMIT),
|
||||
generation_task_repository,
|
||||
scope="global",
|
||||
),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
)
|
||||
|
||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
||||
@@ -1016,16 +423,12 @@ def retry_generation_task(
|
||||
template_id=task.template_id,
|
||||
asset_ids=task.asset_ids,
|
||||
title_ids=task.title_ids,
|
||||
voice_ids=task.voice_ids,
|
||||
created_by_user_id=user_id,
|
||||
source_edit_plan_id=task.source_edit_plan_id or "",
|
||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||
video_title=getattr(task, "video_title", ""),
|
||||
resolution=getattr(task, "resolution", ""),
|
||||
is_preview=getattr(task, "is_preview", False),
|
||||
source_task_id=getattr(task, "source_task_id", ""),
|
||||
output_width=getattr(task, "output_width", 1280),
|
||||
output_height=getattr(task, "output_height", 720),
|
||||
cover_url=getattr(task, "cover_url", ""),
|
||||
)
|
||||
)
|
||||
try:
|
||||
@@ -1037,15 +440,15 @@ def retry_generation_task(
|
||||
log_task_status=True,
|
||||
):
|
||||
logger.warning("[生成任务] 重试入队失败: task_id=%s", retried.id)
|
||||
except UserPendingLimitExceeded as _e:
|
||||
except UserPendingLimitExceeded:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="user"),
|
||||
detail="您的待处理任务过多,请等待完成后再提交",
|
||||
) from None
|
||||
except GlobalQueueFull as _e:
|
||||
except GlobalQueueFull:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=build_rate_limit_detail(_e, generation_task_repository, scope="global"),
|
||||
detail="系统繁忙,请稍后再试",
|
||||
) from None
|
||||
return _to_generation_task_response(retried)
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"""轻量选片接口 POST /generation/variant-plans(#1749)。
|
||||
|
||||
与正式生成共用同一套选片函数(EditPlanService.ensure_variant_plans →
|
||||
clone_plan_for_variant / reselect_plan_for_variant → variant_plan_selector),
|
||||
但**不建任务、不入队、不渲染**:
|
||||
|
||||
- 仅为 N 个变体创建/选好 EditPlan + clips,返回 plan_id 与片段列表;
|
||||
- 前端确认后调正式生成接口回传 variant_plan_ids,直接复用这些 plan,
|
||||
不再重复选片(回传后仍按各变体配音幂等重分配段长);
|
||||
- 配音守卫:voice_library_ids 长度/缺值 → 400(variant_voice_resolver),
|
||||
禁静默 fallback;
|
||||
- 素材不足等选片失败 → 400(与正式生成同口径);除此之外不报错打断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VariantPlanRequest(BaseModel):
|
||||
"""轻量选片请求体(与前端 variantPlans.ts 契约一致)。"""
|
||||
|
||||
template_id: str = Field(default="", description="模板 ID(无 source_edit_plan_id 时用于查找骨架 plan)")
|
||||
asset_ids: list[str] = Field(default_factory=list, description="批次素材池")
|
||||
count: int = Field(default=1, ge=1, le=50, description="变体数量")
|
||||
source_edit_plan_id: str = Field(default="", description="源剪辑计划 ID(优先)")
|
||||
# 配音(可选;传独立配音时严格守卫)
|
||||
voice_library_id: str = Field(default="", description="统一配音 ID")
|
||||
voice_library_ids: list[str] = Field(default_factory=list, description="独立配音 ID 列表(长度须=count)")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate(self) -> "VariantPlanRequest":
|
||||
if not self.template_id.strip() and not self.source_edit_plan_id.strip():
|
||||
raise ValueError("template_id 与 source_edit_plan_id 至少需要提供一个")
|
||||
try:
|
||||
resolve_variant_voice_ids(
|
||||
count=self.count,
|
||||
voice_library_id=self.voice_library_id,
|
||||
voice_library_ids=self.voice_library_ids or None,
|
||||
)
|
||||
except VariantVoiceError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
return self
|
||||
|
||||
|
||||
class VariantPlanItem(BaseModel):
|
||||
variant_index: int
|
||||
plan_id: str
|
||||
clips: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class VariantPlanResponse(BaseModel):
|
||||
items: list[VariantPlanItem]
|
||||
total: int
|
||||
|
||||
|
||||
@router.post("/variant-plans", response_model=VariantPlanResponse)
|
||||
def create_variant_plans(
|
||||
request: VariantPlanRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
db: Session = Depends(get_db_session),
|
||||
) -> VariantPlanResponse:
|
||||
"""轻量选片:为 N 个变体创建独立 EditPlan + clips,不建任务/不渲染。
|
||||
|
||||
Returns:
|
||||
200 + {items: [{variant_index, plan_id, clips}], total}
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 配音严格守卫(schema 已校验,此处复用解析取每变体配音)
|
||||
try:
|
||||
voices = resolve_variant_voice_ids(
|
||||
count=request.count,
|
||||
voice_library_id=request.voice_library_id,
|
||||
voice_library_ids=request.voice_library_ids or None,
|
||||
)
|
||||
except VariantVoiceError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
# 解析源 plan:显式传入优先;否则按 template_id + user 查最新
|
||||
source_plan_id = request.source_edit_plan_id.strip()
|
||||
if not source_plan_id and request.template_id.strip():
|
||||
try:
|
||||
from packages.adapters.sqlalchemy_impl.models import EditPlanModel
|
||||
|
||||
_latest = (
|
||||
db.query(EditPlanModel)
|
||||
.filter(
|
||||
EditPlanModel.template_id == request.template_id.strip(),
|
||||
EditPlanModel.created_by_user_id == user_id,
|
||||
)
|
||||
.order_by(EditPlanModel.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if _latest:
|
||||
source_plan_id = _latest.id
|
||||
except Exception:
|
||||
logger.warning("[variant-plans] 源 plan 解析失败", exc_info=True)
|
||||
|
||||
if not source_plan_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="缺少剪辑计划:请先完成一次预览生成(或传入 source_edit_plan_id)后再试。",
|
||||
)
|
||||
|
||||
# 配音时长(硬化:异常 → 0.0 不阻断选片)
|
||||
try:
|
||||
from app.api.routes.generation_tasks import _query_voice_durations
|
||||
|
||||
voice_durations = _query_voice_durations(db, voices)
|
||||
except Exception:
|
||||
logger.warning("[variant-plans] 配音时长查询失败(按占位段长选片)", exc_info=True)
|
||||
voice_durations = [0.0] * request.count
|
||||
|
||||
from app.services.edit_plan_service import EditPlanService
|
||||
|
||||
svc = EditPlanService(db)
|
||||
try:
|
||||
plan_ids = svc.ensure_variant_plans(
|
||||
source_plan_id,
|
||||
request.count,
|
||||
list(dict.fromkeys(request.asset_ids or [])),
|
||||
created_by_user_id=user_id,
|
||||
voice_durations=voice_durations,
|
||||
)
|
||||
except ValueError as ve:
|
||||
# 素材池为空/时长全未知等可预期错误 → 400(与正式生成同口径)
|
||||
logger.warning("[variant-plans] 选片失败: %s", ve)
|
||||
raise HTTPException(status_code=400, detail=f"变体选片失败:{ve}。请增加素材后重试。") from ve
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("[variant-plans] 选片异常: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="选片失败,请稍后重试") from e
|
||||
|
||||
# 组装 clips 响应
|
||||
items: list[VariantPlanItem] = []
|
||||
for idx, pid in enumerate(plan_ids):
|
||||
clips = svc.list_clips(pid)
|
||||
clip_dicts = [
|
||||
{
|
||||
"id": c.id,
|
||||
"order": c.order,
|
||||
"asset_id": c.asset_id,
|
||||
"start_time": float(c.start_time or 0.0),
|
||||
"duration": float(c.duration or 0.0),
|
||||
"clip_type": c.clip_type,
|
||||
"transition_effect": c.transition_effect,
|
||||
"transition_duration": float(c.transition_duration or 0.0),
|
||||
"playback_speed": float(c.playback_speed or 1.0),
|
||||
"text_content": c.text_content or "",
|
||||
"status": c.status or "ready",
|
||||
}
|
||||
for c in clips
|
||||
]
|
||||
items.append(VariantPlanItem(variant_index=idx, plan_id=pid, clips=clip_dicts))
|
||||
|
||||
logger.info(
|
||||
"[variant-plans] 轻量选片完成: user=%s source=%s count=%d plans=%d",
|
||||
user_id,
|
||||
source_plan_id,
|
||||
request.count,
|
||||
len(plan_ids),
|
||||
)
|
||||
return VariantPlanResponse(items=items, total=len(items))
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import psycopg
|
||||
import psycopg2
|
||||
import redis
|
||||
from app.config import settings
|
||||
from fastapi import APIRouter, status
|
||||
@@ -49,7 +49,7 @@ async def _check_database() -> dict:
|
||||
"message": "Using in-memory database",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
@@ -124,7 +124,7 @@ async def _check_migrations() -> dict:
|
||||
"message": "Using in-memory database, no migrations needed",
|
||||
}
|
||||
try:
|
||||
conn = psycopg.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
conn = psycopg2.connect(settings.DATABASE_URL, connect_timeout=3)
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Any
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_ingest_job_repository
|
||||
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
|
||||
|
||||
@@ -17,7 +17,7 @@ def get_ingest_job(
|
||||
) -> IngestJobResponse:
|
||||
job = ingest_job_repository.get(job_id)
|
||||
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(
|
||||
id=job.id,
|
||||
project_id=job.project_id,
|
||||
@@ -43,13 +43,7 @@ def submit_ingest_job(
|
||||
)
|
||||
)
|
||||
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
if getattr(celery_result, "id", ""):
|
||||
try:
|
||||
job.celery_task_id = celery_result.id
|
||||
ingest_job_repository.update(job)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
|
||||
return IngestJobResponse(
|
||||
id=job.id,
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
"""对口型 API 路由 — #1796 MediaKit 对口型.
|
||||
|
||||
接口:
|
||||
POST /api/v1/lipsync/jobs 提交对口型任务
|
||||
GET /api/v1/lipsync/jobs 任务列表
|
||||
GET /api/v1/lipsync/jobs/{id} 任务详情
|
||||
POST /api/v1/lipsync/jobs/{id}/refresh 刷新任务状态
|
||||
POST /api/v1/lipsync/jobs/{id}/cancel 取消任务
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.lipsync import CreateLipsyncJobRequest, LipsyncJobResponse
|
||||
from app.services.lipsync_service import LipsyncService
|
||||
from app.services.mediakit_client import MediaKitError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(db: Session = Depends(get_db_session)) -> LipsyncService:
|
||||
return LipsyncService(db)
|
||||
|
||||
|
||||
# ── POST /jobs — 提交对口型任务 ───────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs", response_model=LipsyncJobResponse, status_code=201)
|
||||
def create_lipsync_job(
|
||||
body: CreateLipsyncJobRequest,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""提交对口型任务.
|
||||
|
||||
输入人物视频 + 驱动音频,异步生成口型对齐视频。
|
||||
"""
|
||||
try:
|
||||
job = svc.create_job(
|
||||
user_id=current_user.id,
|
||||
video_url=body.video_url,
|
||||
audio_url=body.audio_url,
|
||||
enable_video_loop=body.enable_video_loop,
|
||||
project_id=body.project_id,
|
||||
)
|
||||
except MediaKitError as exc:
|
||||
# 创建失败(job 已记录 error),返回 502
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"code": exc.code,
|
||||
"message": str(exc),
|
||||
"request_id": exc.request_id,
|
||||
},
|
||||
) from exc
|
||||
|
||||
return job
|
||||
|
||||
|
||||
# ── GET /jobs — 任务列表 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs", response_model=dict)
|
||||
def list_lipsync_jobs(
|
||||
project_id: str = Query("", description="项目 ID 过滤"),
|
||||
status: str = Query("", description="状态过滤"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务列表."""
|
||||
items, total = svc.list_jobs(
|
||||
user_id=current_user.id,
|
||||
project_id=project_id,
|
||||
status=status,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
return {
|
||||
"items": [LipsyncJobResponse.model_validate(j) for j in items],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /jobs/{job_id} — 任务详情 ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}", response_model=LipsyncJobResponse)
|
||||
def get_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""获取对口型任务详情."""
|
||||
job = svc.get_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /jobs/{job_id}/refresh — 刷新状态 ───────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/refresh", response_model=LipsyncJobResponse)
|
||||
def refresh_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""从 MediaKit 拉取最新状态并更新."""
|
||||
job = svc.refresh_job_status(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return job
|
||||
|
||||
|
||||
# ── POST /jobs/{job_id}/cancel — 取消任务 ────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/jobs/{job_id}/cancel", response_model=LipsyncJobResponse)
|
||||
def cancel_lipsync_job(
|
||||
job_id: str,
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: LipsyncService = Depends(_get_service),
|
||||
):
|
||||
"""取消对口型任务(仅 pending/submitted 状态可取消)."""
|
||||
job = svc.cancel_job(job_id, current_user.id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if job.status != "cancelled":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"任务状态 {job.status} 不可取消,仅 pending/submitted 可取消",
|
||||
)
|
||||
return job
|
||||
@@ -1,14 +1,13 @@
|
||||
from typing import Any
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_asset_library_repository, get_project_repository
|
||||
from app.dependencies import get_project_repository
|
||||
from app.schemas.project import (
|
||||
CreateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
ProjectResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from packages.application import (
|
||||
CreateProjectCommand,
|
||||
@@ -17,20 +16,10 @@ from packages.application import (
|
||||
GetProjectUseCase,
|
||||
ListProjectsUseCase,
|
||||
)
|
||||
from packages.domain import AssetLibraryKind
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DefaultContextResponse(BaseModel):
|
||||
"""幂等默认上下文响应(Issue #1775):默认项目 + 各类型默认素材库 ID。"""
|
||||
|
||||
project_id: str
|
||||
image_library_id: str
|
||||
video_library_id: str
|
||||
voice_library_id: str
|
||||
|
||||
|
||||
def _to_project_response(item) -> ProjectResponse:
|
||||
return ProjectResponse(
|
||||
id=item.id,
|
||||
@@ -83,35 +72,6 @@ def create_project(
|
||||
return _to_project_response(project)
|
||||
|
||||
|
||||
@router.post("/ensure-default", response_model=DefaultContextResponse)
|
||||
def ensure_default_project_and_libraries(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
) -> DefaultContextResponse:
|
||||
"""幂等获取/创建当前用户的默认项目和三类默认素材库(Issue #1775)。
|
||||
|
||||
- 同一用户永远只有一个默认项目(部分唯一索引 uq_projects_owner_default)
|
||||
- 同一项目同 kind 永远只有一个默认素材库(唯一约束 uq_asset_libraries_project_kind)
|
||||
- 并发调用/失败重试:唯一约束冲突时返回已存在记录,不报 500
|
||||
- 项目和素材库的创建各自在仓储事务内幂等,冲突回滚后重查返回同一条
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
project = project_repository.get_or_create_default_project(user_id)
|
||||
|
||||
libraries = {}
|
||||
for kind in (AssetLibraryKind.VIDEO, AssetLibraryKind.VOICE, AssetLibraryKind.IMAGE):
|
||||
library = asset_library_repository.get_or_create_default_library(project.id, kind)
|
||||
libraries[kind] = library.id
|
||||
|
||||
return DefaultContextResponse(
|
||||
project_id=project.id,
|
||||
image_library_id=libraries[AssetLibraryKind.IMAGE],
|
||||
video_library_id=libraries[AssetLibraryKind.VIDEO],
|
||||
voice_library_id=libraries[AssetLibraryKind.VOICE],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_project(
|
||||
project_id: str,
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Script (口播文案库) CRUD routes — Issue #1795."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.dependencies import get_db_session
|
||||
from app.schemas.script import (
|
||||
CreateScriptRequest,
|
||||
ScriptListResponse,
|
||||
ScriptResponse,
|
||||
ScriptSegment,
|
||||
UpdateScriptRequest,
|
||||
)
|
||||
from app.services.script_service import ScriptNotFoundError, ScriptService
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_service(session: Session = Depends(get_db_session)) -> ScriptService:
|
||||
return ScriptService(session)
|
||||
|
||||
|
||||
def _to_response(script) -> ScriptResponse:
|
||||
segments = script.segments or []
|
||||
return ScriptResponse(
|
||||
id=script.id,
|
||||
user_id=script.user_id,
|
||||
title=script.title,
|
||||
content=script.content,
|
||||
segments=[
|
||||
ScriptSegment(text=s.get("text", ""), duration=s.get("duration")) if isinstance(s, dict) else s
|
||||
for s in segments
|
||||
],
|
||||
tags=script.tags or [],
|
||||
created_at=script.created_at,
|
||||
updated_at=script.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=ScriptListResponse)
|
||||
def list_scripts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
tag: Optional[str] = Query(None, description="按标签筛选"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptListResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
items, total = svc.list_scripts(user_id, skip=skip, limit=limit, tag=tag)
|
||||
return ScriptListResponse(
|
||||
items=[_to_response(i) for i in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ScriptResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_script(
|
||||
request: CreateScriptRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
script = svc.create_script(
|
||||
user_id=user_id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
segments=[s.model_dump() for s in request.segments],
|
||||
tags=request.tags,
|
||||
)
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.get("/{script_id}", response_model=ScriptResponse)
|
||||
def get_script(
|
||||
script_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
script = svc.get_script(script_id, user_id)
|
||||
except ScriptNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.put("/{script_id}", response_model=ScriptResponse)
|
||||
def update_script(
|
||||
script_id: str,
|
||||
request: UpdateScriptRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> ScriptResponse:
|
||||
user_id = authenticated_user.user.id
|
||||
try:
|
||||
script = svc.update_script(
|
||||
script_id=script_id,
|
||||
user_id=user_id,
|
||||
title=request.title,
|
||||
content=request.content,
|
||||
segments=[s.model_dump() for s in request.segments] if request.segments is not None else None,
|
||||
tags=request.tags,
|
||||
)
|
||||
except ScriptNotFoundError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found") from exc
|
||||
return _to_response(script)
|
||||
|
||||
|
||||
@router.delete("/{script_id}", status_code=status.HTTP_204_NO_CONTENT, response_model=None, response_class=Response)
|
||||
def delete_script(
|
||||
script_id: str,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
svc: ScriptService = Depends(_get_service),
|
||||
) -> Response:
|
||||
user_id = authenticated_user.user.id
|
||||
deleted = svc.delete_script(script_id, user_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Script not found")
|
||||
return
|
||||
@@ -375,13 +375,7 @@ def retry_project_task(
|
||||
storage_key=job.storage_key,
|
||||
)
|
||||
)
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[retried.id])
|
||||
if getattr(celery_result, "id", ""):
|
||||
try:
|
||||
retried.celery_task_id = celery_result.id
|
||||
ingest_job_repository.update(retried)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
celery_app.send_task("worker.ingest_asset", args=[retried.id])
|
||||
return ProjectTaskResponse(
|
||||
id=f"ingest:{retried.id}",
|
||||
task_type="ingest",
|
||||
|
||||
@@ -106,10 +106,6 @@ def list_templates(
|
||||
tag: str | None = Query(None, description="按标签筛选"),
|
||||
keyword: str | None = Query(None, description="按名称关键词搜索"),
|
||||
mode: str | None = Query(None, description="按剪辑模式筛选"),
|
||||
valid_only: bool = Query(
|
||||
False,
|
||||
description="仅返回已配置片段的模板(剪辑页传 true;模板编辑器不传,可查看全部模板含草稿)",
|
||||
),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
template_repository: SQLAlchemyTemplateRepository = Depends(_get_template_repository),
|
||||
) -> ListTemplatesResponse:
|
||||
@@ -120,7 +116,6 @@ def list_templates(
|
||||
tag=tag,
|
||||
keyword=keyword,
|
||||
mode=mode,
|
||||
valid_only=valid_only,
|
||||
)
|
||||
use_case = ListTemplatesUseCase(template_repository)
|
||||
templates = use_case.execute(user_id, skip=skip, limit=limit, filter=tpl_filter)
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
"""模板编辑器 API 路由包.
|
||||
|
||||
模块拆分:
|
||||
将原来 2560 行的 templates_editor.py 巨无霸拆分为 12 个模块:
|
||||
- schemas.py: 所有 Pydantic model
|
||||
- dependencies.py: 依赖注入
|
||||
- _utils.py: 工具函数
|
||||
- _fallback.py: 自动兜底逻辑
|
||||
- draft.py: 草稿管理(详情/更新/发布/版本/回滚)
|
||||
- clips.py: 片段管理(CRUD/分割/合并/重排/批量删除/从素材创建)
|
||||
- adjustments.py: 片段调整(速度/音量/裁剪/批量调速)
|
||||
- bgm.py: BGM 管理
|
||||
- effects.py: 转场 + 滤镜
|
||||
- export.py: 导出配置
|
||||
- cover.py: 封面管理 + AI 生成封面
|
||||
- subtitles.py: 字幕管理
|
||||
- ai_features.py: AI 推荐
|
||||
- generation.py: 生成(触发/进度/记录)
|
||||
- timeline.py: 时间线
|
||||
|
||||
挂载路径: /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 .bgm import router as bgm_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 .draft import router as draft_router
|
||||
from .effects import router as effects_router
|
||||
from .export import router as export_router
|
||||
from .generation import router as generation_router
|
||||
from .subtitles import router as subtitles_router
|
||||
from .timeline import router as timeline_router
|
||||
|
||||
@@ -46,8 +51,10 @@ _sub_routers = [
|
||||
bgm_router,
|
||||
effects_router,
|
||||
export_router,
|
||||
cover_router,
|
||||
subtitles_router,
|
||||
ai_features_router,
|
||||
generation_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"])
|
||||
|
||||
|
||||
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)
|
||||
def editor_ai_recommend(
|
||||
template_id: str,
|
||||
@@ -134,16 +46,12 @@ def editor_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(
|
||||
plan_id=plan_id,
|
||||
template_id=plan.template_id,
|
||||
asset_ids=body.asset_ids,
|
||||
editing_mode=body.editing_mode,
|
||||
target_duration=body.target_duration,
|
||||
asset_analyses=asset_analyses,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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_draft_plan_id: 根据 template_id 获取或创建草稿,返回 plan_id
|
||||
- _check_queue_limits: 生成队列限流检查
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,6 +11,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
|
||||
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.services.edit_plan_service import EditPlanService
|
||||
from app.services.edit_template_service import EditTemplateService
|
||||
@@ -41,33 +43,29 @@ def get_draft_plan_id(
|
||||
这是模板编辑器路由的核心依赖——所有编辑器端点都先经过这里,
|
||||
确保 template_id → plan_id 的映射始终存在。
|
||||
|
||||
模板读取遵循单一数据源、显式判定(不使用异常降级):
|
||||
- 用户自建模板在旧表 ``templates``(归属 user_id,is_active=True);
|
||||
- 全局模板在新表 ``edit_templates``(无 user_id,全局可读)。
|
||||
模板不存在、已删除或不归属于当前用户时,一律返回 404。
|
||||
兼容策略:优先从新模板系统(edit_templates 表)查找,
|
||||
若不存在则回退到旧模板系统(templates 表),确保用户自建模板可用。
|
||||
"""
|
||||
tpl_svc, plan_svc = services
|
||||
user_id = str(current_user.user.id)
|
||||
|
||||
# 0. 门禁:校验模板存在且可访问(即使草稿已缓存命中也要校验,
|
||||
# 避免模板被删除/无权访问后仍可通过既有草稿 plan 继续操作)。
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get_active(template_id, user_id)
|
||||
is_global_template = tpl_svc.get_template(template_id) is not None
|
||||
if old_template is None and not is_global_template:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 1. 草稿已存在 → 直接返回
|
||||
draft = tpl_svc.get_template_draft(template_id)
|
||||
if draft is not None:
|
||||
return draft.id
|
||||
|
||||
# 2. 全局模板(新系统)→ 用新服务创建草稿
|
||||
if is_global_template:
|
||||
# 2. 新系统有模板 → 用新服务创建草稿
|
||||
if tpl_svc.get_template(template_id) is not None:
|
||||
draft = tpl_svc.create_template_draft(template_id, user_id=user_id)
|
||||
return draft.id
|
||||
|
||||
# 3. 旧模板(templates 表)→ 基于旧模板创建草稿计划
|
||||
# 3. 回退到旧模板系统(templates 表)
|
||||
old_repo = SQLAlchemyTemplateRepository(db)
|
||||
old_template = old_repo.get(template_id, user_id=user_id)
|
||||
if old_template is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="模板不存在")
|
||||
|
||||
# 4. 基于旧模板创建草稿计划
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
from packages.domain.edit_template import EditTemplate, EditTemplateStatus
|
||||
@@ -115,3 +113,29 @@ def get_draft_plan_id(
|
||||
user_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 .schemas import (
|
||||
EditorClipBatchUpdateRequest,
|
||||
EditorClipBatchUpdateResponse,
|
||||
EditorDraftResponse,
|
||||
EditorPublishResponse,
|
||||
EditorRollbackRequest,
|
||||
@@ -128,7 +126,11 @@ def list_template_versions(
|
||||
clip_count=len(v.clip_configs),
|
||||
change_note=v.change_note,
|
||||
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
|
||||
]
|
||||
@@ -150,7 +152,7 @@ def rollback_template(
|
||||
try:
|
||||
tpl = tpl_svc.rollback_to_version(template_id, request.version)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
clip_configs = tpl_svc.list_clip_configs(template_id)
|
||||
return EditorRollbackResponse(
|
||||
@@ -160,35 +162,3 @@ def rollback_template(
|
||||
new_version=tpl.version,
|
||||
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))
|
||||
|
||||
@@ -41,17 +41,17 @@ def list_editor_transition_presets(
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> TransitionPresetListResponse:
|
||||
"""获取转场预设列表"""
|
||||
from packages.domain.transition_presets import TRANSITION_PRESET_LIBRARY
|
||||
from packages.domain.transition_presets import TRANSITION_PRESETS
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"category": p.category,
|
||||
"duration": p.default_duration,
|
||||
"description": p.description,
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"category": p.get("category", "通用"),
|
||||
"duration": p.get("default_duration", 0.5),
|
||||
"description": p.get("description", ""),
|
||||
}
|
||||
for p in TRANSITION_PRESET_LIBRARY
|
||||
for p in TRANSITION_PRESETS
|
||||
]
|
||||
return TransitionPresetListResponse(items=items, total=len(items))
|
||||
|
||||
@@ -123,17 +123,17 @@ def list_editor_filter_presets(
|
||||
_: AuthenticatedUser = Depends(get_current_user),
|
||||
) -> FilterPresetListResponse:
|
||||
"""获取滤镜预设列表"""
|
||||
from packages.domain.filter_presets import FILTER_PRESET_LIBRARY
|
||||
from packages.domain.filter_presets import FILTER_PRESETS
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"category": p.category,
|
||||
"thumbnail": p.lut_url,
|
||||
"description": p.description,
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"category": p.get("category", "通用"),
|
||||
"thumbnail": p.get("thumbnail", ""),
|
||||
"description": p.get("description", ""),
|
||||
}
|
||||
for p in FILTER_PRESET_LIBRARY
|
||||
for p in FILTER_PRESETS
|
||||
]
|
||||
return FilterPresetListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
+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
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.schemas.generation_task import GenerationTaskResponse
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
_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"}
|
||||
|
||||
|
||||
# ── 生成状态相关 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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 推荐 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -22,8 +67,12 @@ class AIRecommendRequest(BaseModel):
|
||||
"""AI 推荐片段方案请求体"""
|
||||
|
||||
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")
|
||||
target_duration: float = Field(default=30.0, ge=1.0, le=600.0, description="目标时长(秒)")
|
||||
editing_mode: str = Field(
|
||||
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):
|
||||
@@ -50,6 +99,31 @@ class AIRecommendResponse(BaseModel):
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -165,20 +239,10 @@ class ClipBatchDeleteResponse(BaseModel):
|
||||
class ClipsFromAssetsRequest(BaseModel):
|
||||
"""从素材批量创建片段请求"""
|
||||
|
||||
asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾")
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
required_clips_count: Optional[int] = Field(
|
||||
default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量"
|
||||
asset_ids: List[str] = Field(
|
||||
..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾"
|
||||
)
|
||||
|
||||
@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()]
|
||||
clip_type: str = Field(default="main", description="片段类型,默认 main")
|
||||
|
||||
|
||||
class ClipsFromAssetsResponse(BaseModel):
|
||||
@@ -186,16 +250,50 @@ class ClipsFromAssetsResponse(BaseModel):
|
||||
|
||||
success: bool = True
|
||||
created_count: int
|
||||
plan_id: str = ""
|
||||
message: str = ""
|
||||
clip_ids: List[str] = Field(default_factory=list, description="创建的片段ID列表")
|
||||
duplicate_warning: Optional[str] = Field(default=None, description="查重率超标警告")
|
||||
exhaustion_warning: Optional[str] = Field(default=None, description="素材耗尽警告")
|
||||
|
||||
|
||||
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
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="抽帧时间点(秒)")
|
||||
|
||||
|
||||
# ── 导出配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -401,28 +499,17 @@ class EditorUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class EditorClipResponse(BaseModel):
|
||||
"""片段响应 — 与数据库 edit_plan_clips 表字段对齐"""
|
||||
"""片段响应"""
|
||||
|
||||
id: str
|
||||
plan_id: str
|
||||
clip_type: str
|
||||
order: int
|
||||
duration: float
|
||||
start_time: float = 0.0
|
||||
text_content: str = ""
|
||||
transition_effect: str = "cut"
|
||||
transition_duration: float = 0.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)
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
|
||||
class EditorClipListResponse(BaseModel):
|
||||
@@ -454,28 +541,6 @@ class EditorClipUpdateRequest(BaseModel):
|
||||
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):
|
||||
"""发布草稿响应"""
|
||||
|
||||
|
||||
+56
-268
@@ -2,44 +2,36 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
get_user_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
get_voice_library_repository,
|
||||
)
|
||||
from app.schemas.tts import (
|
||||
ListTTSJobResponse,
|
||||
SaveToLibraryRequest,
|
||||
SaveToLibraryResponse,
|
||||
TTSJobResponse,
|
||||
TTSPreviewRequest,
|
||||
TTSPreviewResponse,
|
||||
TTSStatusResponse,
|
||||
TTSSynthesizeRequest,
|
||||
TTSSynthesizeResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, WebSocket, WebSocketDisconnect, status
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.tts_job_repository import (
|
||||
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.use_cases import (
|
||||
CreateTTSJobUseCase,
|
||||
@@ -50,12 +42,13 @@ from packages.application.tts_job.use_cases import (
|
||||
TTSJobNotFoundError,
|
||||
)
|
||||
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.ports.asset_library_repository import AssetLibraryRepository
|
||||
from packages.ports.asset_repository import AssetRepository
|
||||
from packages.ports.project_repository import ProjectRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
from packages.ports.user_repository import UserRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -139,47 +132,27 @@ def synthesize(
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 解析 voice_id:前端可能传克隆音色 profile UUID(而非 CosyVoice voice_id),
|
||||
# 与 /tts/preview 保持一致:命中 profile → 校验归属 → 取 CosyVoice voice_id
|
||||
actual_voice_id = request.voice_id
|
||||
voice_clone_profile_id = request.voice_clone_profile_id
|
||||
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:
|
||||
# 校验 voice_clone_profile_id 归属(防止越权使用他人克隆音色)
|
||||
if request.voice_clone_profile_id:
|
||||
profile = voice_clone_repo.get(request.voice_clone_profile_id)
|
||||
if profile is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Voice clone profile not found",
|
||||
)
|
||||
|
||||
if resolved_profile is not None:
|
||||
if resolved_profile.user_id != user_id:
|
||||
if profile.user_id != user_id:
|
||||
raise HTTPException(
|
||||
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)
|
||||
job = use_case.execute(
|
||||
user_id=user_id,
|
||||
input_text=request.text,
|
||||
voice_id=actual_voice_id,
|
||||
voice_id=request.voice_id,
|
||||
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_,
|
||||
)
|
||||
|
||||
@@ -309,62 +282,6 @@ def delete_tts_job(
|
||||
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(
|
||||
"/jobs/{job_id}/save-to-library",
|
||||
response_model=SaveToLibraryResponse,
|
||||
@@ -375,17 +292,13 @@ def save_tts_job_to_library(
|
||||
request: SaveToLibraryRequest = SaveToLibraryRequest(),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
tts_repository: SQLAlchemyTTSJobRepository = Depends(_get_repository),
|
||||
asset_repository: AssetRepository = Depends(get_asset_repository),
|
||||
asset_library_repository: AssetLibraryRepository = Depends(get_asset_library_repository),
|
||||
project_repository: ProjectRepository = Depends(get_project_repository),
|
||||
storage_service: SharedStorageService = Depends(get_storage_service),
|
||||
voice_library_repository: SQLAlchemyVoiceLibraryRepository = Depends(get_voice_library_repository),
|
||||
user_repository: UserRepository = Depends(get_user_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
) -> SaveToLibraryResponse:
|
||||
"""将已完成的 TTS 合成结果保存到配音素材库(assets 表新素材体系)。
|
||||
"""将已完成的 TTS 合成结果保存到配音库。
|
||||
|
||||
流程:把 TTS 输出音频转存到用户素材 OSS 路径 → 创建 file_type=audio、
|
||||
status=ready 的 asset(挂用户 voice 素材库)→ 返回前端可用结构。
|
||||
配额策略与素材上传一致(上传/ingest 链路无额外配额拦截)。
|
||||
自动携带音色名、时长、语速等元信息。
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
@@ -403,185 +316,60 @@ def save_tts_job_to_library(
|
||||
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]}"
|
||||
|
||||
# 找到(或自动创建)用户 voice 素材库
|
||||
library = _find_or_create_voice_library(
|
||||
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
|
||||
audio_duration: float | None = None
|
||||
file_size = 0
|
||||
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)
|
||||
|
||||
# 从音频文件提取时长(ffprobe),作为 job.duration 的兜底
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", str(tmp_path),
|
||||
],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
fmt = json.loads(proc.stdout).get("format", {})
|
||||
dur = float(fmt.get("duration", 0))
|
||||
if dur > 0:
|
||||
audio_duration = dur
|
||||
except Exception:
|
||||
logger.warning("ffprobe 提取时长失败: job_id=%s", job.id, exc_info=True)
|
||||
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] = {
|
||||
# 构建元信息
|
||||
metadata_ = {
|
||||
"source": "tts_job",
|
||||
"tts_job_id": job.id,
|
||||
"format": job.format,
|
||||
"sample_rate": job.sample_rate,
|
||||
"voice_id": job.voice_id,
|
||||
"voice_name": job.voice_model or "",
|
||||
}
|
||||
if job.metadata:
|
||||
# 保留原始 job 的有用元信息
|
||||
for key in ("speed", "language"):
|
||||
if key in job.metadata:
|
||||
metadata_[key] = job.metadata[key]
|
||||
|
||||
asset = Asset.create(
|
||||
project_id=library.project_id,
|
||||
library_id=library.id,
|
||||
name=name,
|
||||
storage_key=storage_key,
|
||||
mime_type=content_type,
|
||||
metadata=metadata_,
|
||||
file_size=file_size,
|
||||
duration=job.duration or audio_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
|
||||
# 获取用户套餐(用于配额检查)
|
||||
user = user_repository.find_by_id(user_id)
|
||||
plan_name = getattr(user, "subscription_plan", "free") if user else "free"
|
||||
|
||||
return SaveToLibraryResponse(
|
||||
id=asset.id,
|
||||
name=asset.name,
|
||||
audio_url=sign_url(storage_key),
|
||||
duration=asset.duration or 0.0,
|
||||
# 构建命令并执行
|
||||
command = CreateVoiceLibraryCommand(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
text=job.input_text,
|
||||
voice_provider="cosyvoice",
|
||||
voice_id=job.voice_id,
|
||||
voice_name=job.voice_model or "",
|
||||
audio_url=job.output_audio_url,
|
||||
duration=job.duration,
|
||||
file_size=job.file_size,
|
||||
status="completed",
|
||||
project_id=job.project_id or "",
|
||||
tags=[],
|
||||
metadata_=metadata_,
|
||||
)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
use_case = CreateVoiceLibraryUseCase(voice_library_repository)
|
||||
try:
|
||||
result = cosyvoice_service.synthesize_speech(
|
||||
text=request.text,
|
||||
voice_id=actual_voice_id,
|
||||
speed=request.speed,
|
||||
)
|
||||
except CosyVoiceError as e:
|
||||
item = use_case.execute(command, plan_name=plan_name or "free")
|
||||
except QuotaExceededError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"TTS 合成失败: {e}",
|
||||
) from e
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
) from e
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"配音库配额已满({exc.used}/{exc.limit}),请升级套餐",
|
||||
) from exc
|
||||
|
||||
return TTSPreviewResponse(
|
||||
audio_url=result.audio_url,
|
||||
duration=result.duration if result.duration and result.duration > 0 else None,
|
||||
return SaveToLibraryResponse(
|
||||
id=item.id,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from app.schemas.upload import (
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from packages.application import SubmitIngestJobCommand, SubmitIngestJobUseCase
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,199 +80,12 @@ def _validate_mime_type(content_type: str | None) -> str:
|
||||
return base_type
|
||||
|
||||
|
||||
def _infer_mime_type_from_storage_key(storage_key: str) -> str:
|
||||
"""从 storage_key 推断 MIME 类型(与 worker 端保持一致)。"""
|
||||
lower_filename = storage_key.rsplit("/", 1)[-1].lower()
|
||||
_MIME_MAP = {
|
||||
".mov": "video/quicktime",
|
||||
".mp4": "video/mp4",
|
||||
".avi": "video/x-msvideo",
|
||||
".mkv": "video/x-matroska",
|
||||
".webm": "video/webm",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".bmp": "image/bmp",
|
||||
".svg": "image/svg+xml",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".mp3": "audio/mpeg",
|
||||
".wav": "audio/wav",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
".m4a": "audio/x-m4a",
|
||||
}
|
||||
for ext, mime in _MIME_MAP.items():
|
||||
if lower_filename.endswith(ext):
|
||||
return mime
|
||||
return "video/mp4" # default
|
||||
|
||||
|
||||
# 兜底去重:无 file_hash / client_upload_id 且大小已知时,同库同名同大小近期活动记录视为重复
|
||||
FALLBACK_DEDUP_WINDOW_MINUTES = 30
|
||||
|
||||
|
||||
def _find_duplicate_asset(
|
||||
asset_repository: Any,
|
||||
*,
|
||||
library_id: str,
|
||||
file_hash: str,
|
||||
client_upload_id: str,
|
||||
filename: str,
|
||||
file_size: int = 0,
|
||||
) -> Any:
|
||||
"""complete/上传幂等去重,按优先级查找已存在的素材。
|
||||
|
||||
1. client_upload_id(客户端幂等 token,同一次上传的重试保持一致)
|
||||
2. file_hash(内容哈希,不同上传只要内容相同即去重)
|
||||
3. 兜底(严格模式,宁可漏判不可误杀):file_hash 与 client_upload_id
|
||||
均缺失、且 file_size > 0 时,同库 + 同文件名 + **同大小** 且 30 分钟内
|
||||
仍处 uploading/processing 的记录才判重。
|
||||
- file_hash 非空时跳过兜底(hash 已代表内容;同名但内容全新的视频
|
||||
如 iPhone 的 IMG_xxxx.MOV 绝不能被同名占位误杀)
|
||||
- file_size=0(未知)时不允许仅凭同名 + processing 判重,直接放行
|
||||
|
||||
全部为鸭子类型调用:旧仓储无对应方法时静默跳过,不破坏既有实现。
|
||||
"""
|
||||
if client_upload_id:
|
||||
find = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
|
||||
if callable(find):
|
||||
existing = find(library_id=library_id, client_upload_id=client_upload_id)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材幂等命中(client_upload_id): library=%s token=%s asset=%s",
|
||||
library_id,
|
||||
client_upload_id,
|
||||
getattr(existing, "id", "?"),
|
||||
)
|
||||
return existing
|
||||
if file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(file_hash): library=%s hash=%s asset=%s",
|
||||
library_id,
|
||||
file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return existing
|
||||
# 同名兜底去重(最后防线,严格模式):
|
||||
# - 仅当 file_hash / client_upload_id 均缺失时启用(hash 能代表内容时不靠同名猜)
|
||||
# - file_size 必须 > 0 且与记录大小严格一致;大小未知(0)直接放行
|
||||
# - 只命中近期 UPLOADING/PROCESSING 活动记录(READY 历史素材不拦)
|
||||
if filename and not file_hash and not client_upload_id and file_size and file_size > 0:
|
||||
find_recent = getattr(asset_repository, "find_recent_active_by_library_and_name", None)
|
||||
if callable(find_recent):
|
||||
existing = find_recent(
|
||||
library_id=library_id,
|
||||
name=filename,
|
||||
within_minutes=FALLBACK_DEDUP_WINDOW_MINUTES,
|
||||
file_size=file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材幂等兜底命中(近期同名同大小活动记录): library=%s name=%s asset=%s status=%s size=%s",
|
||||
library_id,
|
||||
filename,
|
||||
getattr(existing, "id", "?"),
|
||||
getattr(existing, "status", None),
|
||||
file_size,
|
||||
)
|
||||
return existing
|
||||
elif filename and not file_hash and not client_upload_id and not file_size:
|
||||
logger.debug(
|
||||
"同名兜底去重跳过(file_size 未知,宁可放行不可误杀): library=%s name=%s",
|
||||
library_id,
|
||||
filename,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _create_pending_asset(
|
||||
asset_repository,
|
||||
project_id,
|
||||
library_id,
|
||||
storage_key,
|
||||
filename,
|
||||
mime_type,
|
||||
user_id,
|
||||
file_hash="",
|
||||
client_upload_id="",
|
||||
file_size: int = 0,
|
||||
):
|
||||
"""立即创建或复用一条 PROCESSING 状态的 Asset 记录。
|
||||
|
||||
find-or-create:prepare 阶段已按 file_hash/client_upload_id 预建的占位记录
|
||||
会被 find_by_library_and_file_hash/find_by_library_and_client_upload_id 命中,
|
||||
直接复用并补齐字段(避免 pre-create + complete 重复建两条)。
|
||||
|
||||
Issue #1776: 素材库计数由 asset_repository.create() 自动维护。
|
||||
"""
|
||||
# 1. 按 client_upload_id / file_hash 查找现有记录
|
||||
existing = None
|
||||
if client_upload_id:
|
||||
find_by_cuid = getattr(asset_repository, "find_by_library_and_client_upload_id", None)
|
||||
if callable(find_by_cuid):
|
||||
existing = find_by_cuid(library_id=library_id, client_upload_id=client_upload_id)
|
||||
if existing is None and file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(library_id=library_id, file_hash=file_hash)
|
||||
if existing is not None:
|
||||
# 补齐字段(幂等:避免重复建记录,前端已拿到 asset_id)
|
||||
changed = False
|
||||
if file_hash and not existing.file_hash:
|
||||
existing.file_hash = file_hash
|
||||
changed = True
|
||||
if client_upload_id and not existing.client_upload_id:
|
||||
existing.client_upload_id = client_upload_id
|
||||
changed = True
|
||||
if file_size and not existing.file_size:
|
||||
existing.file_size = file_size
|
||||
changed = True
|
||||
if existing.status not in (AssetStatus.PROCESSING, AssetStatus.UPLOADING):
|
||||
existing.status = AssetStatus.PROCESSING
|
||||
changed = True
|
||||
if changed:
|
||||
try:
|
||||
asset_repository.update(existing)
|
||||
except Exception: # noqa: BLE001 — 字段补齐失败不阻塞主流程
|
||||
pass
|
||||
return existing
|
||||
|
||||
asset = Asset.create(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
name=filename,
|
||||
storage_key=storage_key,
|
||||
mime_type=mime_type,
|
||||
status=AssetStatus.PROCESSING,
|
||||
uploaded_by_user_id=user_id,
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
file_size=file_size,
|
||||
)
|
||||
return asset_repository.create(asset)
|
||||
|
||||
|
||||
def _persist_celery_task_id(repo: Any, job: Any, celery_task_id: str) -> None:
|
||||
"""记录 celery 消息 ID 到任务行,供孤儿清理时 revoke/清除队列消息(#1714)。"""
|
||||
if not celery_task_id:
|
||||
return
|
||||
try:
|
||||
job.celery_task_id = celery_task_id
|
||||
repo.update(job)
|
||||
except Exception: # noqa: BLE001 — 记录失败不影响主流程(执行前状态守卫兜底)
|
||||
pass
|
||||
|
||||
|
||||
def _submit_ingest_job(
|
||||
project_id: str,
|
||||
library_id: str,
|
||||
storage_key: str,
|
||||
ingest_job_repository: Any,
|
||||
file_hash: str = "",
|
||||
asset_id: str = "",
|
||||
) -> Any:
|
||||
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
||||
job = use_case.execute(
|
||||
@@ -282,11 +94,9 @@ def _submit_ingest_job(
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
file_hash=file_hash,
|
||||
asset_id=asset_id,
|
||||
)
|
||||
)
|
||||
celery_result = celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
_persist_celery_task_id(ingest_job_repository, job, getattr(celery_result, "id", ""))
|
||||
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
||||
return job
|
||||
|
||||
|
||||
@@ -296,15 +106,9 @@ async def prepare_direct_upload(
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadPrepareResponse:
|
||||
"""创建浏览器直传 OSS 的短期表单签名,并在签名前按 file_hash/client_upload_id 去重。
|
||||
|
||||
命中去重:直接返回 duplicated=True + skip_transfer=True(前端跳过 OSS 直传),
|
||||
未命中:正常签名 OSS 并立即预建一条 PROCESSING 状态的 asset 记录占住
|
||||
file_hash 闸门,响应带 asset_id 供前端/后续 complete 关联。
|
||||
"""
|
||||
"""创建浏览器直传 OSS 的短期表单签名。"""
|
||||
settings = get_settings()
|
||||
max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024
|
||||
if request.file_size > max_size_bytes:
|
||||
@@ -323,39 +127,8 @@ async def prepare_direct_upload(
|
||||
asset_library_repository,
|
||||
)
|
||||
|
||||
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
||||
|
||||
# ── prepare 阶段去重:OSS 签名之前先查已存在素材 ──
|
||||
if request.file_hash or request.client_upload_id:
|
||||
existing = _find_duplicate_asset(
|
||||
asset_repository,
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
filename=request.filename,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"prepare 命中去重: library=%s hash=%s cuid=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
request.client_upload_id,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url="",
|
||||
method="",
|
||||
storage_key=existing.storage_key,
|
||||
expires_at="",
|
||||
fields={},
|
||||
max_size_bytes=0,
|
||||
duplicated=True,
|
||||
skip_transfer=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
try:
|
||||
payload = storage_service.create_direct_upload_post(
|
||||
@@ -374,28 +147,6 @@ async def prepare_direct_upload(
|
||||
detail=f"Failed to prepare upload: {type(error).__name__}",
|
||||
) from error
|
||||
|
||||
# ── 预建 asset 占位:占住 file_hash/client_upload_id 闸门,避免并发重复上传 ──
|
||||
pending_asset_id = ""
|
||||
if request.file_hash or request.client_upload_id:
|
||||
try:
|
||||
pending = _create_pending_asset(
|
||||
asset_repository=asset_repository,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=storage_key,
|
||||
filename=safe_filename,
|
||||
mime_type=validated_content_type,
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
pending_asset_id = pending.id
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
except Exception as error:
|
||||
# 预建失败不阻塞签名:complete 仍可按 OSS 文件 + hash 兜底去重
|
||||
logger.warning("预建 asset 占位失败,降级走 old flow: %s", error)
|
||||
|
||||
return DirectUploadPrepareResponse(
|
||||
upload_url=str(payload["url"]),
|
||||
method=str(payload["method"]),
|
||||
@@ -403,9 +154,6 @@ async def prepare_direct_upload(
|
||||
expires_at=str(payload["expires_at"]),
|
||||
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
|
||||
max_size_bytes=max_size_bytes,
|
||||
duplicated=False,
|
||||
skip_transfer=False,
|
||||
asset_id=pending_asset_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -419,7 +167,7 @@ async def complete_direct_upload(
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
storage_service: OSSStorageService = Depends(get_storage_service),
|
||||
) -> DirectUploadCompleteResponse:
|
||||
"""确认浏览器直传完成并创建导入任务(幂等:重复 complete 返回同一素材)。"""
|
||||
"""确认浏览器直传完成并创建导入任务。"""
|
||||
require_project_and_library(
|
||||
request.project_id,
|
||||
request.library_id,
|
||||
@@ -429,29 +177,6 @@ async def complete_direct_upload(
|
||||
normalized_key = storage_service._normalize_storage_key(request.storage_key)
|
||||
if not normalized_key.startswith("uploads/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
|
||||
|
||||
filename = normalized_key.rsplit("/", 1)[-1]
|
||||
|
||||
# ── 幂等去重(放在 OSS 检查之前):complete 超时后前端重试时,
|
||||
# 第一次 complete 可能已建好占位记录,此时即使 OSS 检查失败也必须返回
|
||||
# 已存在记录,绝不能再建第二条。─
|
||||
existing = _find_duplicate_asset(
|
||||
asset_repository,
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
filename=filename,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
if existing is not None:
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
url=storage_service.get_url(existing.storage_key),
|
||||
)
|
||||
|
||||
try:
|
||||
file_exists = storage_service.file_exists(normalized_key)
|
||||
except Exception as error:
|
||||
@@ -463,21 +188,25 @@ async def complete_direct_upload(
|
||||
if not file_exists:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
||||
|
||||
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
|
||||
mime_type = _infer_mime_type_from_storage_key(normalized_key)
|
||||
pending_asset = _create_pending_asset(
|
||||
asset_repository=asset_repository,
|
||||
project_id=request.project_id,
|
||||
library_id=request.library_id,
|
||||
storage_key=normalized_key,
|
||||
filename=filename,
|
||||
mime_type=mime_type,
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=request.file_hash,
|
||||
client_upload_id=request.client_upload_id,
|
||||
file_size=request.file_size,
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
# ── 素材去重检测:同素材库 + 同 file_hash 视为重复 ──
|
||||
if request.file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=request.library_id,
|
||||
file_hash=request.file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中: library=%s hash=%s existing_asset=%s",
|
||||
request.library_id,
|
||||
request.file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_id="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=request.project_id,
|
||||
@@ -485,14 +214,8 @@ async def complete_direct_upload(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=request.file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
return DirectUploadCompleteResponse(
|
||||
storage_key=normalized_key,
|
||||
ingest_job_id=job.id,
|
||||
asset_id=pending_asset.id,
|
||||
url=storage_service.get_url(normalized_key),
|
||||
)
|
||||
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -505,8 +228,7 @@ async def upload_asset(
|
||||
project_id: str = Form(..., min_length=1, description="项目 ID"),
|
||||
library_id: str = Form(..., min_length=1, description="素材库 ID"),
|
||||
file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"),
|
||||
file_hash: str = Form(default="", description="文件哈希,用于去重检测"),
|
||||
client_upload_id: str = Form(default="", description="客户端幂等 token(同一次上传的重试保持一致)"),
|
||||
file_hash: str = Form(default="", description="文件 MD5 哈希,用于去重检测"),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
||||
project_repository: Any = Depends(get_project_repository),
|
||||
@@ -517,31 +239,32 @@ async def upload_asset(
|
||||
"""上传素材文件并触发导入流水线。"""
|
||||
require_project_and_library(project_id, library_id, project_repository, asset_library_repository)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型(先验证,再幂等去重,避免非法类型绕过)
|
||||
# ── 素材去重检测:上传前检查同素材库 + 同 file_hash ──
|
||||
if file_hash:
|
||||
existing = asset_repository.find_by_library_and_file_hash(
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
)
|
||||
if existing is not None:
|
||||
logger.info(
|
||||
"素材去重命中(multipart): library=%s hash=%s existing_asset=%s",
|
||||
library_id,
|
||||
file_hash,
|
||||
existing.id,
|
||||
)
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
# P2-5: 服务端验证 MIME 类型
|
||||
validated_content_type = _validate_mime_type(file.content_type)
|
||||
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
|
||||
|
||||
# ── 幂等去重:client_upload_id → file_hash → 近期活动同名记录兜底 ──
|
||||
# 放在 OSS 上传之前:重复提交直接返回,不占 OSS 流量、不建新记录。
|
||||
existing = _find_duplicate_asset(
|
||||
asset_repository,
|
||||
library_id=library_id,
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
filename=safe_filename,
|
||||
file_size=0,
|
||||
)
|
||||
if existing is not None:
|
||||
return UploadAssetResponse(
|
||||
storage_key=existing.storage_key,
|
||||
ingest_job_id="",
|
||||
url="",
|
||||
duplicated=True,
|
||||
asset_id=existing.id,
|
||||
)
|
||||
|
||||
file_id = uuid4().hex[:8]
|
||||
safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown"
|
||||
storage_key = f"uploads/{file_id}/{safe_filename}"
|
||||
|
||||
try:
|
||||
@@ -560,32 +283,16 @@ async def upload_asset(
|
||||
detail=f"Failed to upload file: {type(error).__name__}",
|
||||
) from error
|
||||
|
||||
# 立即创建 Asset 记录(PROCESSING 状态),使前端刷新后即可看到新素材
|
||||
pending_asset = _create_pending_asset(
|
||||
asset_repository=asset_repository,
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
filename=safe_filename,
|
||||
mime_type=validated_content_type,
|
||||
user_id=authenticated_user.user.id,
|
||||
file_hash=file_hash,
|
||||
client_upload_id=client_upload_id,
|
||||
)
|
||||
# Issue #1776: 计数由 asset_repository.create() 自动维护
|
||||
|
||||
job = _submit_ingest_job(
|
||||
project_id=project_id,
|
||||
library_id=library_id,
|
||||
storage_key=storage_key,
|
||||
ingest_job_repository=ingest_job_repository,
|
||||
file_hash=file_hash,
|
||||
asset_id=pending_asset.id,
|
||||
)
|
||||
|
||||
return UploadAssetResponse(
|
||||
storage_key=storage_key,
|
||||
ingest_job_id=job.id,
|
||||
asset_id=pending_asset.id,
|
||||
url=file_url,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,6 @@ from app.schemas.video_center import (
|
||||
VideoItemResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from packages.application import (
|
||||
GetGeneratedVideoUseCase,
|
||||
@@ -53,9 +52,6 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
|
||||
generation_params=item.generation_params,
|
||||
download_url=download_url,
|
||||
generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "",
|
||||
duplicate_rate=getattr(item, "duplicate_rate", None),
|
||||
visual_similarity=getattr(item, "visual_similarity", None),
|
||||
match_count=getattr(item, "match_count", None),
|
||||
)
|
||||
|
||||
|
||||
@@ -240,74 +236,3 @@ def get_batch_download_status(
|
||||
status=api_status,
|
||||
download_url=download_url,
|
||||
)
|
||||
|
||||
|
||||
# ── 重新计算查重率 ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class RecomputeDedupRequest(BaseModel):
|
||||
"""重新计算查重率请求。"""
|
||||
|
||||
video_ids: list[str] | None = Field(
|
||||
None,
|
||||
description="指定视频 ID 列表。为空则对当前用户所有缺少查重数据的视频重新计算。",
|
||||
)
|
||||
force: bool = Field(
|
||||
False,
|
||||
description="强制重算:即使视频已有查重数据也重新入队(#1702 查重算法升级后用于存量视频重算)。",
|
||||
)
|
||||
|
||||
|
||||
class RecomputeDedupResponse(BaseModel):
|
||||
"""重新计算查重率响应。"""
|
||||
|
||||
enqueued: int = Field(..., description="已入队的任务数量")
|
||||
total_scanned: int = Field(..., description="扫描的视频总数")
|
||||
skipped: int = Field(..., description="已有查重数据跳过的数量")
|
||||
message: str = ""
|
||||
|
||||
|
||||
@router.post("/videos/recompute-dedup", response_model=RecomputeDedupResponse)
|
||||
def recompute_dedup(
|
||||
request: RecomputeDedupRequest = RecomputeDedupRequest(),
|
||||
repo=Depends(get_generated_video_repository),
|
||||
current_user: AuthenticatedUser = Depends(get_current_user),
|
||||
):
|
||||
"""重新计算视频的查重率/视觉相似度。
|
||||
|
||||
对于已存在但缺少 duplicate_rate / video_fingerprint 的视频,
|
||||
触发异步 Celery 任务重新下载并计算指纹 + 查重率。
|
||||
|
||||
不传 video_ids 时,对当前用户所有视频进行检查。
|
||||
"""
|
||||
user_id = current_user.user.id
|
||||
|
||||
# 获取目标视频列表
|
||||
if request.video_ids:
|
||||
all_videos = repo.get_by_ids(request.video_ids)
|
||||
# 安全校验:只处理当前用户的视频
|
||||
target_videos = [v for v in all_videos if v.user_id == user_id]
|
||||
else:
|
||||
target_videos = repo.list_by_user(user_id)
|
||||
|
||||
total_scanned = len(target_videos)
|
||||
enqueued = 0
|
||||
skipped = 0
|
||||
|
||||
for video in target_videos:
|
||||
# 已有完整查重数据的跳过(force=True 时强制重算,#1702 算法升级后存量视频需要重算指纹/分片)
|
||||
if not request.force and video.duplicate_rate is not None and video.video_fingerprint:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# 触发异步查重任务
|
||||
celery_app.send_task("worker.check_duplicate", args=[video.id])
|
||||
enqueued += 1
|
||||
logger.info("Enqueued re-dedup for video %s (user=%s, force=%s)", video.id, user_id, request.force)
|
||||
|
||||
return RecomputeDedupResponse(
|
||||
enqueued=enqueued,
|
||||
total_scanned=total_scanned,
|
||||
skipped=skipped,
|
||||
message=f"已入队 {enqueued} 个查重任务" if enqueued > 0 else "所有视频查重数据已完整",
|
||||
)
|
||||
|
||||
@@ -7,17 +7,10 @@ from typing import Optional
|
||||
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.celery_app import celery_app
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_repository,
|
||||
get_cosyvoice_service,
|
||||
get_project_repository,
|
||||
get_voice_clone_profile_repository,
|
||||
)
|
||||
from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repository
|
||||
from app.schemas.voice_clone import (
|
||||
CreateVoiceCloneRequest,
|
||||
ListVoiceCloneResponse,
|
||||
VoiceClonePreviewResponse,
|
||||
VoiceCloneProfileResponse,
|
||||
VoiceCloneStatusResponse,
|
||||
)
|
||||
@@ -26,7 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
||||
SQLAlchemyVoiceCloneProfileRepository,
|
||||
)
|
||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
||||
from packages.application.cosyvoice_service import CosyVoiceService
|
||||
from packages.application.voice_clone.use_cases import (
|
||||
DeleteVoiceCloneUseCase,
|
||||
GetVoiceCloneStatusUseCase,
|
||||
@@ -38,21 +31,11 @@ from packages.application.voice_clone.use_cases import (
|
||||
from packages.application.voice_clone.workflow import (
|
||||
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__)
|
||||
|
||||
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:
|
||||
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
||||
@@ -92,68 +75,23 @@ def create_voice_clone(
|
||||
request: CreateVoiceCloneRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
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:
|
||||
"""创建音色克隆任务。
|
||||
|
||||
创建 VoiceCloneProfile → 提交 CosyVoice 克隆任务 → 触发 Celery 异步轮询。
|
||||
参考音频两种来源(二选一):
|
||||
- source_audio_url:前端直传后的音频 URL(兼容旧流程)
|
||||
- asset_id:配音素材库中的音频素材,服务端用其 OSS storage_key 生成
|
||||
预签名下载 URL(不依赖前端签名,避免签名过期导致克隆失败)
|
||||
如果有参考音频,状态会变为 processing;否则保持 pending。
|
||||
如果有 source_audio_url,状态会变为 processing;否则保持 pending。
|
||||
"""
|
||||
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(
|
||||
user_id=user_id,
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
source_audio_url=source_audio_url,
|
||||
source_audio_url=request.source_audio_url,
|
||||
voice_model=request.voice_model,
|
||||
language=request.language,
|
||||
gender=request.gender,
|
||||
max_retries=request.max_retries,
|
||||
metadata=clone_metadata,
|
||||
metadata=request.metadata_,
|
||||
)
|
||||
|
||||
# 如果 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}")
|
||||
|
||||
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
+18
-389
@@ -5,27 +5,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from app.api.routes._helpers import get_user_plan
|
||||
from app.auth import AuthenticatedUser, get_current_user
|
||||
from app.core.storage import get_storage_service
|
||||
from app.dependencies import (
|
||||
get_asset_library_repository,
|
||||
get_asset_repository,
|
||||
get_audio_url_signer,
|
||||
get_cosyvoice_service,
|
||||
get_db_session,
|
||||
get_project_repository,
|
||||
get_user_repository,
|
||||
)
|
||||
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository
|
||||
from app.schemas.voice import (
|
||||
PresetVoiceItemResponse,
|
||||
PresetVoiceListResponse,
|
||||
@@ -38,7 +22,7 @@ from app.schemas.voice_library import (
|
||||
UpdateVoiceLibraryRequest,
|
||||
VoiceLibraryItemResponse,
|
||||
)
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
||||
@@ -54,15 +38,10 @@ from packages.application.voice_library.use_cases import (
|
||||
QuotaExceededError,
|
||||
UpdateVoiceLibraryUseCase,
|
||||
)
|
||||
from packages.domain import Asset, AssetStatus
|
||||
from packages.domain.classification import AssetLibraryKind, ClassificationStatus
|
||||
from packages.domain.entities import AssetLibrary
|
||||
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
|
||||
from packages.ports.user_repository import UserRepository
|
||||
from packages.shared.storage import SharedStorageService
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
|
||||
# key: voice_id, value: (audio_url, timestamp)
|
||||
@@ -72,65 +51,6 @@ PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
||||
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:
|
||||
return SQLAlchemyVoiceLibraryRepository(session)
|
||||
|
||||
@@ -198,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:
|
||||
"""将预置音色转换为统一响应格式。
|
||||
|
||||
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]
|
||||
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
||||
"""将预置音色转换为统一响应格式。"""
|
||||
return UnifiedVoiceItemResponse(
|
||||
id=preset.voice_id,
|
||||
type="preset",
|
||||
@@ -217,40 +129,11 @@ def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None =
|
||||
language=preset.language,
|
||||
voice_id=preset.voice_id,
|
||||
voice_provider="cosyvoice",
|
||||
preview_url=preview_url,
|
||||
preview_url=preset.preview_url,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 统一配音列表(预置 + 克隆)====================
|
||||
|
||||
|
||||
@@ -267,7 +150,6 @@ def list_voices_unified(
|
||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> UnifiedVoiceListResponse:
|
||||
"""获取配音列表(预置音色 + 用户克隆音色)。
|
||||
|
||||
@@ -283,30 +165,19 @@ def list_voices_unified(
|
||||
has_preset = type is None or type == "preset"
|
||||
has_clone = type is None or type == "clone"
|
||||
|
||||
# 获取预置音色(动态生成 preview_url)
|
||||
# 获取预置音色
|
||||
if has_preset:
|
||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
||||
preset_items = [_preset_to_unified_response(p, preview_url_map) for p in PRESET_VOICES]
|
||||
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
|
||||
preset_count = len(preset_items)
|
||||
|
||||
# 获取克隆音色(从 voice_clone_profile 读取,ready 状态的克隆音色)
|
||||
# 获取克隆音色
|
||||
if has_clone:
|
||||
# status_filter 映射:不传则默认只返回 ready 状态(可用的克隆音色)
|
||||
# 前端可以传 status=all 获取所有状态,或传具体状态过滤
|
||||
filter_status = None
|
||||
if status_filter and status_filter != "all":
|
||||
filter_status = status_filter
|
||||
elif not status_filter:
|
||||
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]
|
||||
use_case = ListVoiceLibraryUseCase(voice_repository)
|
||||
clone_items_raw, clone_count = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
||||
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
|
||||
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
|
||||
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
|
||||
|
||||
# 组装结果
|
||||
if type == "preset":
|
||||
@@ -333,15 +204,11 @@ def list_voices_unified(
|
||||
|
||||
|
||||
@router.get("/presets", response_model=PresetVoiceListResponse)
|
||||
def list_preset_voices(
|
||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
||||
) -> PresetVoiceListResponse:
|
||||
def list_preset_voices() -> PresetVoiceListResponse:
|
||||
"""获取预置音色列表。
|
||||
|
||||
不需要认证,返回所有系统预置的 CosyVoice 音色。
|
||||
preview_url 通过 CosyVoice 动态生成,不依赖硬编码的过期 URL。
|
||||
"""
|
||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
||||
items = [
|
||||
PresetVoiceItemResponse(
|
||||
voice_id=p.voice_id,
|
||||
@@ -349,7 +216,7 @@ def list_preset_voices(
|
||||
description=p.description,
|
||||
gender=p.gender,
|
||||
language=p.language,
|
||||
preview_url=preview_url_map.get(p.voice_id, p.preview_url),
|
||||
preview_url=p.preview_url,
|
||||
tags=p.tags or [],
|
||||
)
|
||||
for p in PRESET_VOICES
|
||||
@@ -369,6 +236,8 @@ def get_preset_voice_preview(
|
||||
- 相同 voice_id 重复调用直接返回缓存的音频URL
|
||||
- 可传入自定义 text 参数试听不同文本
|
||||
"""
|
||||
import time
|
||||
|
||||
preset = get_preset_voice_by_id(voice_id)
|
||||
if preset is None:
|
||||
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
|
||||
@@ -525,243 +394,3 @@ def delete_voice(
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice not found")
|
||||
return
|
||||
|
||||
|
||||
# ── 提取视频配音 ─────────────────────────────────────────────────────
|
||||
|
||||
# 支持的视频格式
|
||||
EXTRACT_VIDEO_MIMES = frozenset({"video/mp4", "video/quicktime", "video/webm", "video/x-msvideo"})
|
||||
MAX_EXTRACT_SIZE = 500 * 1024 * 1024 # 500MB
|
||||
|
||||
|
||||
@router.post(
|
||||
"/extract-voice",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def extract_voice_from_video(
|
||||
file: UploadFile = File(...),
|
||||
project_id: str = Form(...),
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
project_repository=Depends(get_project_repository),
|
||||
asset_library_repository=Depends(get_asset_library_repository),
|
||||
asset_repository=Depends(get_asset_repository),
|
||||
storage_service: SharedStorageService = Depends(get_storage_service),
|
||||
sign_url=Depends(get_audio_url_signer),
|
||||
):
|
||||
"""从上传的视频中提取人声配音。
|
||||
|
||||
流程:
|
||||
1. 接收视频文件(mp4/mov/webm)
|
||||
2. ffmpeg 提取音频 + 降噪 + 编码为 mp3
|
||||
3. 上传到 OSS,创建 Asset 记录到配音素材库
|
||||
4. 返回素材信息(时长、文件大小、URL)
|
||||
"""
|
||||
user_id = authenticated_user.user.id
|
||||
|
||||
# 校验文件类型
|
||||
content_type = file.content_type or ""
|
||||
if content_type and content_type not in EXTRACT_VIDEO_MIMES:
|
||||
# 兜底:按扩展名判断
|
||||
ext = (file.filename or "").rsplit(".", 1)[-1].lower()
|
||||
ext_to_mime = {"mp4": "video/mp4", "mov": "video/quicktime", "webm": "video/webm", "avi": "video/x-msvideo"}
|
||||
if ext not in ext_to_mime:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="仅支持 mp4/mov/webm/avi 格式的视频文件",
|
||||
)
|
||||
content_type = ext_to_mime[ext]
|
||||
|
||||
# 找到(或自动创建)用户 voice 素材库(复用 TTS 的逻辑)
|
||||
library = _find_or_create_voice_library_for_extract(
|
||||
user_id=user_id,
|
||||
project_repository=project_repository,
|
||||
asset_library_repository=asset_library_repository,
|
||||
)
|
||||
|
||||
tmp_dir = None
|
||||
try:
|
||||
tmp_dir = Path(tempfile.mkdtemp(prefix="voice_extract_"))
|
||||
video_path = tmp_dir / f"input_{uuid4().hex[:8]}_{file.filename or 'video.mp4'}"
|
||||
audio_path = tmp_dir / f"output_{uuid4().hex[:8]}.mp3"
|
||||
|
||||
# 保存上传的视频到临时文件
|
||||
with open(video_path, "wb") as f:
|
||||
total = 0
|
||||
while chunk := file.file.read(1024 * 1024): # 1MB chunks
|
||||
total += len(chunk)
|
||||
if total > MAX_EXTRACT_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="视频文件过大,最大支持 500MB",
|
||||
)
|
||||
f.write(chunk)
|
||||
|
||||
if video_path.stat().st_size == 0:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="视频文件为空")
|
||||
|
||||
# ffmpeg: 提取音频 + 降噪 + 编码 mp3
|
||||
# 滤镜链:highpass(去低频噪声) → afftdn(FFT降噪) → lowpass(去高频噪声)
|
||||
ffmpeg_cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(video_path),
|
||||
"-vn", # 不要视频
|
||||
"-af",
|
||||
"highpass=f=80,afftdn=nf=-25:tn=1,lowpass=f=8000",
|
||||
"-acodec",
|
||||
"libmp3lame",
|
||||
"-ab",
|
||||
"192k",
|
||||
"-ar",
|
||||
"44100",
|
||||
"-ac",
|
||||
"1", # 单声道(人声足够)
|
||||
str(audio_path),
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
ffmpeg_cmd,
|
||||
capture_output=True,
|
||||
timeout=300, # 5 分钟超时
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr_text = result.stderr.decode("utf-8", errors="replace")[-500:]
|
||||
logger.error("ffmpeg 提取配音失败: %s", stderr_text)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="视频音频提取失败,可能该视频没有音轨或格式不支持",
|
||||
)
|
||||
|
||||
if not audio_path.exists() or audio_path.stat().st_size == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="音频提取结果为空",
|
||||
)
|
||||
|
||||
# 获取音频时长
|
||||
duration = _get_audio_duration(audio_path)
|
||||
file_size = audio_path.stat().st_size
|
||||
|
||||
# 上传到 OSS
|
||||
audio_ext = "mp3"
|
||||
storage_key = f"uploads/voice/extracted/{uuid4().hex}.{audio_ext}"
|
||||
storage_service.upload_file(audio_path, storage_key, content_type="audio/mpeg")
|
||||
|
||||
# 创建 Asset 记录
|
||||
original_name = (file.filename or "video").rsplit(".", 1)[0]
|
||||
asset_name = f"{original_name}-配音"
|
||||
|
||||
asset = Asset.create(
|
||||
project_id=library.project_id,
|
||||
library_id=library.id,
|
||||
name=asset_name,
|
||||
storage_key=storage_key,
|
||||
mime_type="audio/mpeg",
|
||||
metadata={
|
||||
"source": "video_extract",
|
||||
"original_video": file.filename or "unknown",
|
||||
},
|
||||
file_size=file_size,
|
||||
duration=duration,
|
||||
status=AssetStatus.READY,
|
||||
classification_status=ClassificationStatus.PENDING,
|
||||
uploaded_by_user_id=user_id,
|
||||
)
|
||||
asset = asset_repository.create(asset)
|
||||
|
||||
return {
|
||||
"id": asset.id,
|
||||
"name": asset.name,
|
||||
"audio_url": sign_url(storage_key),
|
||||
"duration": duration,
|
||||
"file_size": file_size,
|
||||
"status": "completed",
|
||||
"source": "video_extract",
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||
detail="视频处理超时,请尝试较短的视频",
|
||||
) from None
|
||||
except Exception as e:
|
||||
logger.exception("提取视频配音失败: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="提取配音失败,请稍后重试",
|
||||
) from e
|
||||
finally:
|
||||
# 清理临时文件
|
||||
if tmp_dir and Path(tmp_dir).exists():
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _find_or_create_voice_library_for_extract(*, user_id, project_repository, asset_library_repository):
|
||||
"""为用户找到或创建 voice 素材库(与 TTS 保存逻辑一致)。"""
|
||||
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
|
||||
|
||||
# 自动创建
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
project = projects[0]
|
||||
library = AssetLibrary.create(
|
||||
project_id=project.id,
|
||||
name="配音素材库",
|
||||
kind=AssetLibraryKind.VOICE,
|
||||
)
|
||||
try:
|
||||
return asset_library_repository.create(library)
|
||||
except IntegrityError:
|
||||
session = getattr(asset_library_repository, "session", None)
|
||||
if session is not None:
|
||||
try:
|
||||
session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
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
|
||||
|
||||
|
||||
def _get_audio_duration(audio_path: Path) -> float:
|
||||
"""用 ffprobe 获取音频时长(秒)。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"quiet",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(audio_path),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return float(result.stdout.strip())
|
||||
except (ValueError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
return 0.0
|
||||
|
||||
@@ -5,11 +5,3 @@ settings = get_settings()
|
||||
celery_app = Celery("xiaoxia-saas-api")
|
||||
celery_app.conf.broker_url = settings.CELERY_BROKER_URL
|
||||
celery_app.conf.result_backend = settings.CELERY_RESULT_BACKEND
|
||||
|
||||
# #1714 队列隔离:视频生成走 generation 队列,素材转码走 transcode 队列
|
||||
try:
|
||||
from packages.shared.celery_queues import apply_queue_settings
|
||||
|
||||
apply_queue_settings(celery_app)
|
||||
except Exception: # noqa: BLE001 — 队列配置失败不阻断 API 启动
|
||||
pass
|
||||
|
||||
@@ -8,145 +8,27 @@ logger = logging.getLogger(__name__)
|
||||
# ── 限流阈值常量(全系统统一管理,不要在业务代码里硬编码) ──
|
||||
USER_PENDING_LIMIT = 3 # 单用户 pending 上限
|
||||
GLOBAL_PENDING_LIMIT = 20 # 全局 pending 上限
|
||||
WORKER_CONCURRENCY = 4 # worker 渲染并发数(infra/docker/compose.yml WORKER_CONCURRENCY 默认值)
|
||||
|
||||
# 限流错误码:前端据此区分"排队等待"与"创建失败"
|
||||
ERROR_CODE_USER_QUEUE_FULL = "USER_QUEUE_FULL" # 429:用户自己的任务排队中
|
||||
ERROR_CODE_SYSTEM_QUEUE_FULL = "SYSTEM_QUEUE_FULL" # 503:系统整体繁忙
|
||||
|
||||
|
||||
class UserPendingLimitExceeded(Exception):
|
||||
"""用户 pending 任务数超限,返回 429。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
pending_count: int,
|
||||
limit: int,
|
||||
*,
|
||||
running_count: int = 0,
|
||||
requested_count: int = 1,
|
||||
queue_ahead: int = 0,
|
||||
estimated_wait_seconds: int = 0,
|
||||
):
|
||||
def __init__(self, user_id: str, pending_count: int, limit: int):
|
||||
self.user_id = user_id
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
# 排队上下文(用于 429 结构化提示,前端展示"排队中"而非"创建失败")
|
||||
self.running_count = running_count
|
||||
self.requested_count = requested_count
|
||||
self.queue_ahead = queue_ahead
|
||||
self.estimated_wait_seconds = estimated_wait_seconds
|
||||
super().__init__(f"用户 {user_id} pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
class GlobalQueueFull(Exception):
|
||||
"""全局限流,返回 503。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pending_count: int,
|
||||
limit: int,
|
||||
*,
|
||||
running_count: int = 0,
|
||||
queue_ahead: int = 0,
|
||||
estimated_wait_seconds: int = 0,
|
||||
):
|
||||
def __init__(self, pending_count: int, limit: int):
|
||||
self.pending_count = pending_count
|
||||
self.limit = limit
|
||||
self.running_count = running_count
|
||||
self.queue_ahead = queue_ahead
|
||||
self.estimated_wait_seconds = estimated_wait_seconds
|
||||
super().__init__(f"系统 pending 任务数 {pending_count} 超过上限 {limit}")
|
||||
|
||||
|
||||
def _estimate_wait_seconds(queue_ahead: int, generation_task_repository: Any) -> int:
|
||||
"""根据排队任务数 + worker 并发数 + 历史平均任务耗时估算等待秒数。
|
||||
|
||||
估算公式:ceil(排队任务数 / 并发数) × 平均单任务耗时。
|
||||
拿不到历史数据时仓储层返回默认 120 秒。
|
||||
"""
|
||||
import math
|
||||
|
||||
if queue_ahead <= 0:
|
||||
return 0
|
||||
try:
|
||||
estimator = getattr(generation_task_repository, "estimate_avg_duration_seconds", None)
|
||||
avg_seconds = estimator() if estimator is not None else 120.0
|
||||
except Exception:
|
||||
avg_seconds = 120.0
|
||||
return int(math.ceil(queue_ahead / WORKER_CONCURRENCY) * avg_seconds)
|
||||
|
||||
|
||||
def build_rate_limit_detail(
|
||||
exc: Exception,
|
||||
generation_task_repository: Any,
|
||||
*,
|
||||
scope: str = "user",
|
||||
) -> dict:
|
||||
"""构造结构化限流响应体(HTTPException 的 detail)。
|
||||
|
||||
前端按 detail.code 判断场景:
|
||||
- USER_QUEUE_FULL (429):用户自己的任务在排队,应提示"等待/继续排队",不是创建失败
|
||||
- SYSTEM_QUEUE_FULL (503):系统繁忙,稍后重试
|
||||
|
||||
detail 字段:
|
||||
- code: 错误码
|
||||
- message: 可读中文提示(可直接展示)
|
||||
- queued_count: 当前排队(pending)任务数
|
||||
- running_count: 当前渲染中(running)任务数
|
||||
- queue_ahead: 前方排队任务数(预计等待批次依据)
|
||||
- estimated_wait_seconds: 预计等待秒数
|
||||
- limit: 对应限流上限
|
||||
"""
|
||||
if scope == "user" and isinstance(exc, UserPendingLimitExceeded):
|
||||
running = exc.running_count
|
||||
if not running:
|
||||
try:
|
||||
counter = getattr(generation_task_repository, "count_running_by_user", None)
|
||||
running = counter(exc.user_id) if counter is not None else 0
|
||||
except Exception:
|
||||
running = 0
|
||||
queue_ahead = exc.queue_ahead or max(exc.pending_count, 0)
|
||||
wait = exc.estimated_wait_seconds or _estimate_wait_seconds(queue_ahead, generation_task_repository)
|
||||
wait_minutes = max(1, round(wait / 60))
|
||||
message = (
|
||||
f"您有 {exc.pending_count} 个任务正在排队、{running} 个正在渲染,"
|
||||
f"同一时间最多提交 {exc.limit} 个任务。请等待约 {wait_minutes} 分钟后再提交"
|
||||
)
|
||||
return {
|
||||
"code": ERROR_CODE_USER_QUEUE_FULL,
|
||||
"message": message,
|
||||
"queued_count": exc.pending_count,
|
||||
"running_count": running,
|
||||
"queue_ahead": queue_ahead,
|
||||
"estimated_wait_seconds": wait,
|
||||
"limit": exc.limit,
|
||||
}
|
||||
|
||||
# 全局繁忙
|
||||
pending = getattr(exc, "pending_count", 0)
|
||||
running = getattr(exc, "running_count", 0)
|
||||
if not running:
|
||||
try:
|
||||
counter = getattr(generation_task_repository, "count_running_total", None)
|
||||
running = counter() if counter is not None else 0
|
||||
except Exception:
|
||||
running = 0
|
||||
queue_ahead = getattr(exc, "queue_ahead", 0) or pending
|
||||
wait = getattr(exc, "estimated_wait_seconds", 0) or _estimate_wait_seconds(queue_ahead, generation_task_repository)
|
||||
wait_minutes = max(1, round(wait / 60))
|
||||
return {
|
||||
"code": ERROR_CODE_SYSTEM_QUEUE_FULL,
|
||||
"message": f"系统繁忙:当前 {pending} 个任务排队中、{running} 个渲染中,预计等待约 {wait_minutes} 分钟,请稍后再试",
|
||||
"queued_count": pending,
|
||||
"running_count": running,
|
||||
"queue_ahead": queue_ahead,
|
||||
"estimated_wait_seconds": wait,
|
||||
"limit": getattr(exc, "limit", GLOBAL_PENDING_LIMIT),
|
||||
}
|
||||
|
||||
|
||||
def check_queue_limits(
|
||||
user_id: str,
|
||||
generation_task_repository: Any,
|
||||
@@ -279,17 +161,7 @@ def safe_enqueue_generation_task(
|
||||
|
||||
# ── 发送 Celery 任务 ──
|
||||
try:
|
||||
celery_result = celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
# 记录 celery 消息 ID:孤儿清理/超时作废时据此 revoke + 清除队列消息(#1714)
|
||||
celery_task_id = getattr(celery_result, "id", "")
|
||||
if celery_task_id:
|
||||
try:
|
||||
task.celery_task_id = celery_task_id
|
||||
generation_task_repository.update(task)
|
||||
except Exception as persist_err: # noqa: BLE001
|
||||
logger.warning(
|
||||
"%s 持久化 celery_task_id 失败(不影响主流程): task_id=%s err=%s", log_prefix, task.id, persist_err
|
||||
)
|
||||
celery_app.send_task("worker.generate_video", args=[task.id])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"%s 入队失败,标记为失败: task_id=%s error=%s",
|
||||
|
||||
@@ -22,9 +22,6 @@ from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRe
|
||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||
SQLAlchemyClassificationJobRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.cover_template_repository import (
|
||||
SQLAlchemyCoverTemplateRepository,
|
||||
)
|
||||
from packages.adapters.sqlalchemy_impl.duplication_repository import (
|
||||
SQLAlchemyDuplicationRecordRepository,
|
||||
)
|
||||
@@ -131,13 +128,6 @@ def get_project_repository(
|
||||
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(
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> TagRepository:
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""AI数字人渲染合成管线 API Schema — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class BRollSegment(BaseModel):
|
||||
"""B-roll 片段配置."""
|
||||
|
||||
script_segment_index: int = Field(..., ge=0, description="对应文案片段索引")
|
||||
asset_url: str = Field(..., description="B-roll 素材 URL")
|
||||
mode: str = Field(..., description="插入模式: fullscreen 或 pip")
|
||||
start_time: float = Field(..., ge=0.0, description="在对口型视频中的起始时间(秒)")
|
||||
end_time: float = Field(..., ge=0.0, description="在对口型视频中的结束时间(秒)")
|
||||
pip_position: Optional[str] = Field("bottom_right", description="pip 模式位置")
|
||||
pip_scale: Optional[float] = Field(0.3, ge=0.05, le=1.0, description="pip 模式缩放比例")
|
||||
|
||||
@field_validator("mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
v = v.strip().lower()
|
||||
if v not in ("fullscreen", "pip"):
|
||||
raise ValueError("mode 必须为 fullscreen 或 pip")
|
||||
return v
|
||||
|
||||
@field_validator("asset_url")
|
||||
@classmethod
|
||||
def validate_asset_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("asset_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("asset_url 必须是 HTTP/HTTPS URL")
|
||||
return v
|
||||
|
||||
@field_validator("end_time")
|
||||
@classmethod
|
||||
def validate_end_time(cls, v: float, info: Any) -> float:
|
||||
start = info.data.get("start_time", 0.0)
|
||||
if v <= start:
|
||||
raise ValueError("end_time 必须大于 start_time")
|
||||
return v
|
||||
|
||||
|
||||
class CreateAiAvatarRenderRequest(BaseModel):
|
||||
"""创建渲染任务请求."""
|
||||
|
||||
lipsync_job_id: str = Field(..., description="对口型任务 ID")
|
||||
script_id: str = Field(..., description="文案 ID")
|
||||
b_roll_segments: list[BRollSegment] = Field(default_factory=list, description="B-roll 片段列表")
|
||||
title_config: dict[str, Any] = Field(default_factory=dict, description="标题配置")
|
||||
cover_config: dict[str, Any] = Field(default_factory=dict, description="封面配置")
|
||||
project_id: str = Field("", description="项目 ID")
|
||||
|
||||
@field_validator("lipsync_job_id")
|
||||
@classmethod
|
||||
def validate_lipsync_job_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("lipsync_job_id 不能为空")
|
||||
return v
|
||||
|
||||
@field_validator("script_id")
|
||||
@classmethod
|
||||
def validate_script_id(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("script_id 不能为空")
|
||||
return v
|
||||
|
||||
|
||||
class AiAvatarRenderJobResponse(BaseModel):
|
||||
"""渲染任务响应."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
lipsync_job_id: str
|
||||
script_id: str
|
||||
b_roll_segments: list[dict[str, Any]]
|
||||
title_config: dict[str, Any]
|
||||
cover_config: dict[str, Any]
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
started_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class AiAvatarRenderProgressResponse(BaseModel):
|
||||
"""渲染进度响应."""
|
||||
|
||||
status: str
|
||||
progress: int
|
||||
output_video_url: str
|
||||
output_cover_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
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)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
@@ -53,25 +53,11 @@ class AssetResponse(BaseModel):
|
||||
created_at: str
|
||||
uploaded_by_user_id: str
|
||||
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
|
||||
|
||||
|
||||
class BatchGetRequest(BaseModel):
|
||||
"""批量获取素材详情请求。"""
|
||||
|
||||
ids: list[str] = Field(..., min_length=1, max_length=MAX_BATCH_SIZE, description="素材 ID 列表")
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
"""批量删除请求(软删除)。"""
|
||||
|
||||
@@ -115,33 +101,3 @@ class ListAssetsResponse(BaseModel):
|
||||
total: int = Field(default=0, ge=0)
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=100, ge=1)
|
||||
|
||||
|
||||
class SmartMatchRequest(BaseModel):
|
||||
"""智能选素材请求。"""
|
||||
|
||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
||||
limit: int | None = Field(default=None, ge=1, le=200, description="最大返回数量,不传则返回全部匹配素材")
|
||||
kind: str | None = Field(
|
||||
default=None,
|
||||
pattern="^(video|image|audio)$",
|
||||
description="按文件类型过滤,不传则返回所有类型",
|
||||
)
|
||||
|
||||
|
||||
class SmartMatchItem(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)
|
||||
@@ -28,9 +28,6 @@ class DuplicationRecordResponse(BaseModel):
|
||||
status: str = "pending"
|
||||
duplicate_rate: float | None = None
|
||||
duplicate_count: int = 0
|
||||
# #1661 视觉相似度(归一化 0~1)/ 匹配视频数
|
||||
visual_similarity: float | None = None
|
||||
match_count: int | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
@@ -25,10 +25,6 @@ class GeneratedVideoResponse(BaseModel):
|
||||
review_status: str = "pending_review"
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
# #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数
|
||||
duplicate_rate: float | None = None
|
||||
visual_similarity: float | None = None
|
||||
match_count: int | None = None
|
||||
|
||||
|
||||
class GeneratedVideoDownloadUrlResponse(BaseModel):
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
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):
|
||||
"""创建生成任务请求。
|
||||
|
||||
@@ -25,29 +15,14 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
asset_library_id: str = ""
|
||||
strategy_id: str = ""
|
||||
voice_library_id: str = ""
|
||||
# ── 多变体独立配音(批量生成)──
|
||||
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立配音;空数组 = 回退 voice_library_id
|
||||
voice_library_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="各变体独立配音素材库ID数组:长度1=共用,长度=count=独立。为空时回退 voice_library_id",
|
||||
)
|
||||
created_by_user_id: str = ""
|
||||
# ── 模板模式新增字段 ──
|
||||
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)
|
||||
# ── 来源剪辑计划 ──
|
||||
source_edit_plan_id: str = ""
|
||||
# ── variant-plans 轻量选片回传(#1749):正式生成直接复用,不再重选 ──
|
||||
variant_plan_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="POST /generation/variant-plans 返回的各变体 plan_id(长度须=count);为空则走服务端选片",
|
||||
)
|
||||
# ── 标题配置(结构化)──
|
||||
title_config: dict | None = Field(
|
||||
default=None,
|
||||
description="标题样式对象,包含 text/font/font_size/font_color/position/bold/stroke/shadow 等。为空时不影响现有行为。",
|
||||
)
|
||||
# ── 视频标题 ──
|
||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
||||
# ── 批量生成 ──
|
||||
@@ -55,9 +30,11 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
# ── 素材库自动匹配 ──
|
||||
asset_select_mode: str = Field(
|
||||
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(
|
||||
default=False,
|
||||
@@ -79,53 +56,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
default_factory=dict,
|
||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||
)
|
||||
# ── 预览 / 确认生成 ──
|
||||
is_preview: bool = Field(default=False, description="是否为预览任务")
|
||||
source_task_id: str = Field(default="", description="来源预览任务 ID(确认生成时传入)")
|
||||
output_width: int = Field(default=1280, description="输出视频宽度")
|
||||
output_height: int = Field(default=720, description="输出视频高度")
|
||||
cover_url: str = Field(default="", description="封面图片 URL")
|
||||
# ── 多变体独立封面(批量生成)──
|
||||
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立封面;空数组 = 回退 cover_url
|
||||
cover_urls: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="各变体独立封面URL数组:长度1=共用,长度=count=独立。为空时回退 cover_url",
|
||||
)
|
||||
# ── 多变体独立标题文字(批量生成)──
|
||||
# 长度 1 = 所有变体共用;长度 = count = 每个变体独立标题文字;空数组 = 使用 title_config.text
|
||||
titles: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="各变体独立标题文字数组:长度1=共用,长度=count=独立。为空时使用 title_config.text",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_variant_arrays(self) -> "CreateGenerationTaskRequest":
|
||||
"""变体数组字段长度校验 + #1749 配音严格守卫。
|
||||
|
||||
- cover_urls/titles:空(回退单值)、长度 1(共用)或长度 = count(独立);
|
||||
- voice_library_ids:独立配音长度必须恰好 = count 且逐项非空,禁止静默 fallback
|
||||
(长度 1 的"共用"场景请用 voice_library_id 单值字段);
|
||||
- variant_plan_ids:非空时长度必须 = count。
|
||||
"""
|
||||
for name in ("cover_urls", "titles"):
|
||||
arr = getattr(self, name)
|
||||
if arr and len(arr) != 1 and len(arr) != self.count:
|
||||
raise ValueError(f"{name} 长度必须为 1(共用)或 {self.count}(与 count 一致),当前为 {len(arr)}")
|
||||
|
||||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
try:
|
||||
resolve_variant_voice_ids(
|
||||
count=self.count,
|
||||
voice_library_id=self.voice_library_id,
|
||||
voice_library_ids=self.voice_library_ids or None,
|
||||
)
|
||||
except VariantVoiceError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
if self.variant_plan_ids and len(self.variant_plan_ids) != self.count:
|
||||
raise ValueError(f"variant_plan_ids 长度({len(self.variant_plan_ids)})必须与 count({self.count})一致")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||
@@ -134,9 +64,9 @@ class CreateGenerationTaskRequest(BaseModel):
|
||||
if not has_project and not has_template:
|
||||
raise ValueError("project_id 或 template_id 至少需要提供一个")
|
||||
has_library = bool(self.asset_library_id.strip())
|
||||
has_assets = bool(self.asset_ids or self.title_ids)
|
||||
has_assets = bool(self.asset_ids or self.title_ids or self.voice_ids)
|
||||
if not has_library and not has_assets:
|
||||
raise ValueError("asset_library_id 或 asset_ids/title_ids 至少需要提供一个")
|
||||
raise ValueError("asset_library_id 或 asset_ids/title_ids/voice_ids 至少需要提供一个")
|
||||
return self
|
||||
|
||||
|
||||
@@ -156,12 +86,6 @@ class GenerationTaskResponse(BaseModel):
|
||||
video_title: str = ""
|
||||
resolution: str = ""
|
||||
bgm_config: dict = Field(default_factory=dict)
|
||||
is_preview: bool = False
|
||||
source_task_id: str = ""
|
||||
output_width: int = 1280
|
||||
output_height: int = 720
|
||||
cover_url: str = ""
|
||||
title_config: dict = Field(default_factory=dict)
|
||||
status: str
|
||||
progress: float
|
||||
result_count: int
|
||||
@@ -198,129 +122,3 @@ class ListGenerationTasksResponse(BaseModel):
|
||||
"""用户级生成任务列表响应(跨 project)。"""
|
||||
|
||||
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_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。N个变体时样式全局共用",
|
||||
)
|
||||
# ── 多变体独立配置(preview_count > 1)──
|
||||
# 长度 1 = 所有变体共用;长度 = preview_count = 每个变体独立;空数组 = 回退单值字段
|
||||
titles: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="各变体独立标题文字数组:长度1=共用,长度=preview_count=独立。为空时使用 title_config.text",
|
||||
)
|
||||
voice_library_ids: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="各变体独立配音素材库ID数组:长度1=共用,长度=preview_count=独立。为空时回退 voice_library_id",
|
||||
)
|
||||
cover_urls: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="各变体独立封面URL数组:长度1=共用,长度=preview_count=独立(预览阶段通常为空)",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_variant_arrays(self) -> "CreatePreviewGenerationTaskRequest":
|
||||
"""变体数组字段长度校验 + #1749 配音严格守卫。"""
|
||||
for name in ("titles", "cover_urls"):
|
||||
arr = getattr(self, name)
|
||||
if arr and len(arr) != 1 and len(arr) != self.preview_count:
|
||||
raise ValueError(
|
||||
f"{name} 长度必须为 1(共用)或 {self.preview_count}(与 preview_count 一致),当前为 {len(arr)}"
|
||||
)
|
||||
|
||||
from packages.domain.variant_voice_resolver import VariantVoiceError, resolve_variant_voice_ids
|
||||
|
||||
try:
|
||||
resolve_variant_voice_ids(
|
||||
count=self.preview_count,
|
||||
voice_library_id=self.voice_library_id,
|
||||
voice_library_ids=self.voice_library_ids or None,
|
||||
)
|
||||
except VariantVoiceError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
return self
|
||||
|
||||
@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:
|
||||
raise ValueError("asset_ids/title_ids 至少需要提供一个")
|
||||
return self
|
||||
|
||||
|
||||
class PreviewGenerationTaskResponse(BaseModel):
|
||||
"""单个预览变体任务响应。
|
||||
|
||||
包含任务状态、进度、分辨率、生成结果 URL 等关键字段。
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
status: str
|
||||
progress: float
|
||||
is_preview: bool = True
|
||||
variant_index: int = 0
|
||||
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 = ""
|
||||
title_text: str = ""
|
||||
voice_library_id: str = ""
|
||||
created_at: datetime | None = None
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
generate_duration: float = 0.0
|
||||
|
||||
|
||||
class BatchPreviewGenerationTaskResponse(BaseModel):
|
||||
"""批量预览任务响应:preview_count=N 时返回 N 个独立变体任务。
|
||||
|
||||
- items: 变体任务数组,按 variant_index 顺序排列,每个含独立 task_id/状态/预览视频URL
|
||||
- total: 变体总数(= preview_count)
|
||||
- 前端按 items[i].task_id 分别轮询 GET /preview/{task_id} 获取进度与结果
|
||||
"""
|
||||
|
||||
items: list[PreviewGenerationTaskResponse]
|
||||
total: int
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""对口型 API Schema 定义 — #1796."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class LipsyncJobResponse(BaseModel):
|
||||
"""对口型任务响应."""
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
video_url: str
|
||||
audio_url: str
|
||||
enable_video_loop: bool
|
||||
mediakit_task_id: str
|
||||
status: str
|
||||
output_video_url: str
|
||||
output_duration: float
|
||||
error_message: str
|
||||
error_code: str
|
||||
submitted_at: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CreateLipsyncJobRequest(BaseModel):
|
||||
"""创建对口型任务请求."""
|
||||
|
||||
video_url: str = Field(..., description="人物视频 URL(MP4,≤30min,单人真人)")
|
||||
audio_url: str = Field(..., description="驱动音频 URL(mp3/aac/wav/m4a/flac)")
|
||||
enable_video_loop: bool = Field(False, description="音频长于视频时是否循环画面")
|
||||
project_id: str = Field("", description="项目 ID(可选)")
|
||||
|
||||
@field_validator("video_url")
|
||||
@classmethod
|
||||
def validate_video_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("video_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("video_url 必须是 HTTP/HTTPS URL")
|
||||
# 仅支持 MP4
|
||||
lower = v.lower().split("?")[0]
|
||||
if not lower.endswith(".mp4"):
|
||||
raise ValueError("video_url 仅支持 MP4 格式")
|
||||
return v
|
||||
|
||||
@field_validator("audio_url")
|
||||
@classmethod
|
||||
def validate_audio_url(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("audio_url 不能为空")
|
||||
if not v.startswith(("http://", "https://")):
|
||||
raise ValueError("audio_url 必须是 HTTP/HTTPS URL")
|
||||
# 支持的音频格式
|
||||
lower = v.lower().split("?")[0]
|
||||
allowed_exts = (".mp3", ".aac", ".wav", ".m4a", ".flac")
|
||||
if not any(lower.endswith(ext) for ext in allowed_exts):
|
||||
raise ValueError(f"audio_url 格式不支持,仅支持: {', '.join(allowed_exts)}")
|
||||
return v
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Script (口播文案库) Pydantic schemas — Issue #1795."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScriptSegment(BaseModel):
|
||||
"""单段文案."""
|
||||
|
||||
text: str
|
||||
duration: Optional[float] = None
|
||||
|
||||
|
||||
class ScriptResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ScriptListResponse(BaseModel):
|
||||
items: list[ScriptResponse]
|
||||
total: int = 0
|
||||
|
||||
|
||||
class CreateScriptRequest(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
content: str = ""
|
||||
segments: List[ScriptSegment] = Field(default_factory=list)
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UpdateScriptRequest(BaseModel):
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
content: Optional[str] = None
|
||||
segments: Optional[List[ScriptSegment]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
@@ -101,19 +101,3 @@ class SaveToLibraryResponse(BaseModel):
|
||||
voice_id: str
|
||||
voice_name: 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="音频时长(秒)")
|
||||
|
||||
@@ -16,7 +16,6 @@ class DirectUploadPrepareRequest(BaseModel):
|
||||
content_type: str = Field(default="application/octet-stream", min_length=1, max_length=100)
|
||||
file_size: int = Field(..., gt=0)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
|
||||
|
||||
|
||||
class DirectUploadPrepareResponse(BaseModel):
|
||||
@@ -26,31 +25,25 @@ class DirectUploadPrepareResponse(BaseModel):
|
||||
expires_at: str
|
||||
fields: dict[str, str]
|
||||
max_size_bytes: int
|
||||
duplicated: bool = False
|
||||
skip_transfer: bool = False
|
||||
asset_id: str = ""
|
||||
|
||||
|
||||
class DirectUploadCompleteRequest(BaseModel):
|
||||
project_id: str = Field(..., min_length=1)
|
||||
library_id: str = Field(..., min_length=1)
|
||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||
file_hash: str = Field(default="", max_length=64, description="文件哈希,用于去重检测")
|
||||
client_upload_id: str = Field(default="", max_length=64, description="客户端幂等 token(同一次上传的重试保持一致)")
|
||||
file_size: int = Field(default=0, ge=0, description="文件大小(字节),用于无 hash 时的兜底去重")
|
||||
file_hash: str = Field(default="", max_length=64, description="文件 MD5 哈希,用于去重检测")
|
||||
|
||||
|
||||
class DirectUploadCompleteResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材/重复 complete(命中幂等去重)")
|
||||
asset_id: str = Field(default="", description="素材 asset_id(重复 complete 时返回已存在记录)")
|
||||
url: str = Field(default="", description="Public URL of uploaded file")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
|
||||
class UploadAssetResponse(BaseModel):
|
||||
storage_key: str
|
||||
ingest_job_id: str
|
||||
url: str = Field(..., description="Public URL of uploaded file")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材/重复提交(命中幂等去重)")
|
||||
asset_id: str = Field(default="", description="素材 asset_id(重复提交时返回已存在记录)")
|
||||
duplicated: bool = Field(default=False, description="是否为重复素材(命中去重)")
|
||||
asset_id: str = Field(default="", description="重复素材的 asset_id(duplicated=true 时返回)")
|
||||
|
||||
@@ -22,10 +22,6 @@ class VideoItemResponse(BaseModel):
|
||||
generation_params: dict = Field(default_factory=dict)
|
||||
download_url: str | None = None
|
||||
generated_at: str = ""
|
||||
# #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数
|
||||
duplicate_rate: float | None = None
|
||||
visual_similarity: float | None = None
|
||||
match_count: int | None = None
|
||||
|
||||
|
||||
class ListVideosResponse(BaseModel):
|
||||
|
||||
Executable → Regular
+1
-24
@@ -13,8 +13,7 @@ class CreateVoiceCloneRequest(BaseModel):
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=100, description="音色名称")
|
||||
description: str = Field("", description="音色描述")
|
||||
source_audio_url: str = Field("", description="参考音频 URL(与 asset_id 二选一)")
|
||||
asset_id: str = Field("", description="参考音频素材 ID(配音素材库中的音频 asset,与 source_audio_url 二选一)")
|
||||
source_audio_url: str = Field("", description="参考音频 URL")
|
||||
voice_model: str = Field("", description="语音模型名称")
|
||||
language: str = Field("zh-CN", description="语言")
|
||||
gender: str = Field("unknown", description="性别")
|
||||
@@ -64,25 +63,3 @@ class ListVoiceCloneResponse(BaseModel):
|
||||
|
||||
items: List[VoiceCloneProfileResponse]
|
||||
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,377 +0,0 @@
|
||||
"""AI数字人渲染合成 Service — #1798.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消渲染任务
|
||||
- 调用 Celery 异步任务执行渲染
|
||||
- B-roll 合成 + 标题叠加 + 封面提取
|
||||
- 用户隔离
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import (
|
||||
AiAvatarRenderJob,
|
||||
LipsyncJobModel,
|
||||
ScriptModel,
|
||||
)
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_cover_extract_command,
|
||||
build_title_drawtext_filter,
|
||||
)
|
||||
from packages.shared.storage import get_shared_storage_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiAvatarRenderError(Exception):
|
||||
"""渲染服务异常."""
|
||||
|
||||
def __init__(self, message: str, code: str = "RenderError"):
|
||||
self.code = code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AiAvatarRenderService:
|
||||
"""AI数字人渲染合成 Service."""
|
||||
|
||||
def __init__(self, db: Session):
|
||||
self.db = db
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_render_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
lipsync_job_id: str,
|
||||
script_id: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
title_config: dict[str, Any],
|
||||
cover_config: dict[str, Any],
|
||||
project_id: str = "",
|
||||
) -> AiAvatarRenderJob:
|
||||
"""创建渲染任务.
|
||||
|
||||
Raises:
|
||||
AiAvatarRenderError: 校验失败
|
||||
"""
|
||||
# 1. 验证对口型任务
|
||||
lipsync_job = (
|
||||
self.db.query(LipsyncJobModel)
|
||||
.filter(
|
||||
LipsyncJobModel.id == lipsync_job_id,
|
||||
LipsyncJobModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if lipsync_job is None:
|
||||
raise AiAvatarRenderError("对口型任务不存在", code="LipsyncJobNotFound")
|
||||
if lipsync_job.status != "completed":
|
||||
raise AiAvatarRenderError(
|
||||
f"对口型任务状态为 {lipsync_job.status},仅 completed 状态可渲染",
|
||||
code="LipsyncJobNotCompleted",
|
||||
)
|
||||
if not lipsync_job.output_video_url:
|
||||
raise AiAvatarRenderError("对口型任务输出视频 URL 为空", code="LipsyncJobNoOutput")
|
||||
|
||||
# 2. 验证文案归属
|
||||
script = (
|
||||
self.db.query(ScriptModel)
|
||||
.filter(
|
||||
ScriptModel.id == script_id,
|
||||
ScriptModel.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if script is None:
|
||||
raise AiAvatarRenderError("文案不存在或无权访问", code="ScriptNotFound")
|
||||
|
||||
# 3. 创建渲染任务
|
||||
job_id = str(uuid.uuid4())
|
||||
job = AiAvatarRenderJob(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
lipsync_job_id=lipsync_job_id,
|
||||
script_id=script_id,
|
||||
b_roll_segments=[s if isinstance(s, dict) else s.model_dump() for s in b_roll_segments],
|
||||
title_config=title_config,
|
||||
cover_config=cover_config,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""获取渲染任务详情(用户隔离)."""
|
||||
return (
|
||||
self.db.query(AiAvatarRenderJob)
|
||||
.filter(
|
||||
AiAvatarRenderJob.id == job_id,
|
||||
AiAvatarRenderJob.user_id == user_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_render_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[AiAvatarRenderJob], int]:
|
||||
"""获取渲染任务列表(分页 + 用户隔离)."""
|
||||
query = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(AiAvatarRenderJob.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(AiAvatarRenderJob.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(AiAvatarRenderJob.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""取消渲染任务(仅 pending 状态可取消)."""
|
||||
job = self.get_render_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 重试任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def retry_render_job(self, job_id: str, user_id: str) -> Optional[AiAvatarRenderJob]:
|
||||
"""重试失败的渲染任务."""
|
||||
job = self.get_render_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
if job.status != "failed":
|
||||
return None
|
||||
job.status = "pending"
|
||||
job.progress = 0
|
||||
job.error_message = ""
|
||||
job.output_video_url = ""
|
||||
job.output_cover_url = ""
|
||||
job.output_duration = 0.0
|
||||
job.started_at = None
|
||||
job.completed_at = None
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 执行渲染(Celery 异步调用) ──────────────────────────────────────
|
||||
|
||||
def execute_render(self, job_id: str) -> None:
|
||||
"""执行渲染管线.
|
||||
|
||||
由 Celery 异步任务调用,流程:
|
||||
1. 下载对口型输出视频 (20%)
|
||||
2. 构建 FFmpeg 滤镜链 (40%)
|
||||
3. 执行 FFmpeg 渲染 (80%)
|
||||
4. 提取封面 (90%)
|
||||
5. 上传到 OSS (95%)
|
||||
6. 更新任务状态 (100%)
|
||||
"""
|
||||
job = self.db.query(AiAvatarRenderJob).filter(AiAvatarRenderJob.id == job_id).first()
|
||||
if job is None:
|
||||
logger.error("渲染任务不存在: %s", job_id)
|
||||
return
|
||||
|
||||
if job.status == "cancelled":
|
||||
logger.info("渲染任务已取消: %s", job_id)
|
||||
return
|
||||
|
||||
try:
|
||||
# 更新状态为 processing
|
||||
job.status = "processing"
|
||||
job.started_at = datetime.now(timezone.utc)
|
||||
job.progress = 5
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
|
||||
# 获取对口型任务信息
|
||||
lipsync_job = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.id == job.lipsync_job_id).first()
|
||||
if lipsync_job is None:
|
||||
raise AiAvatarRenderError("关联的对口型任务不存在", code="LipsyncJobNotFound")
|
||||
|
||||
# 1. 下载对口型输出视频 (20%)
|
||||
input_video_path = self._download_video(lipsync_job.output_video_url)
|
||||
job.progress = 20
|
||||
self.db.commit()
|
||||
|
||||
# 2. 构建 FFmpeg 滤镜链 (40%)
|
||||
from packages.domain.video_filter_builder import build_broll_overlay_filter
|
||||
|
||||
filter_complex = build_broll_overlay_filter(
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
video_duration=lipsync_job.output_duration,
|
||||
)
|
||||
|
||||
# 标题叠加
|
||||
title_filter = build_title_drawtext_filter(job.title_config)
|
||||
if title_filter:
|
||||
if filter_complex:
|
||||
filter_complex += f"[vout]{title_filter}[vout_titled];"
|
||||
else:
|
||||
filter_complex = f"[0:v]{title_filter}[vout_titled];"
|
||||
|
||||
# 清理末尾分号
|
||||
if filter_complex.endswith(";"):
|
||||
filter_complex = filter_complex[:-1]
|
||||
|
||||
# 最终输出标签
|
||||
final_label = "vout_titled" if title_filter else ("vout" if filter_complex else None)
|
||||
|
||||
job.progress = 40
|
||||
self.db.commit()
|
||||
|
||||
# 3. 执行 FFmpeg 渲染 (80%)
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output_video_path = os.path.join(tmpdir, "output.mp4")
|
||||
|
||||
cmd = self._build_ffmpeg_command(
|
||||
input_video=input_video_path,
|
||||
b_roll_segments=job.b_roll_segments,
|
||||
filter_complex=filter_complex,
|
||||
final_label=final_label,
|
||||
output_path=output_video_path,
|
||||
)
|
||||
|
||||
exit_code = os.system(cmd)
|
||||
if exit_code != 0:
|
||||
raise AiAvatarRenderError(f"FFmpeg 渲染失败,退出码: {exit_code}", code="FFmpegFailed")
|
||||
|
||||
job.progress = 80
|
||||
self.db.commit()
|
||||
|
||||
# 4. 提取封面 (90%)
|
||||
cover_path = ""
|
||||
if job.cover_config:
|
||||
cover_path = os.path.join(tmpdir, "cover.jpg")
|
||||
cover_cmd = build_cover_extract_command(job.cover_config, cover_path)
|
||||
cover_cmd = cover_cmd.replace("INPUT_VIDEO", output_video_path)
|
||||
cover_exit = os.system(cover_cmd)
|
||||
if cover_exit != 0:
|
||||
logger.warning("封面提取失败,跳过: %s", cover_cmd)
|
||||
cover_path = ""
|
||||
|
||||
job.progress = 90
|
||||
self.db.commit()
|
||||
|
||||
# 5. 上传到 OSS (95%)
|
||||
output_video_url = self._upload_to_oss(output_video_path, f"ai-avatar/{job_id}/output.mp4")
|
||||
job.output_video_url = output_video_url
|
||||
|
||||
if cover_path:
|
||||
output_cover_url = self._upload_to_oss(cover_path, f"ai-avatar/{job_id}/cover.jpg")
|
||||
job.output_cover_url = output_cover_url
|
||||
|
||||
# 获取输出视频时长
|
||||
job.output_duration = lipsync_job.output_duration
|
||||
job.progress = 95
|
||||
self.db.commit()
|
||||
|
||||
# 6. 完成
|
||||
job.status = "completed"
|
||||
job.progress = 100
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.info("渲染任务完成: %s", job_id)
|
||||
|
||||
except AiAvatarRenderError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.error("渲染任务失败 [%s]: %s", job_id, exc)
|
||||
except Exception as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = f"渲染异常: {str(exc)}"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
logger.exception("渲染任务异常 [%s]", job_id)
|
||||
|
||||
def _download_video(self, url: str) -> str:
|
||||
"""下载视频到临时文件."""
|
||||
import httpx
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
||||
try:
|
||||
with httpx.Client(timeout=120) as client:
|
||||
resp = client.get(url)
|
||||
resp.raise_for_status()
|
||||
tmp.write(resp.content)
|
||||
return tmp.name
|
||||
except Exception:
|
||||
if os.path.exists(tmp.name):
|
||||
os.unlink(tmp.name)
|
||||
raise
|
||||
|
||||
def _build_ffmpeg_command(
|
||||
self,
|
||||
*,
|
||||
input_video: str,
|
||||
b_roll_segments: list[dict[str, Any]],
|
||||
filter_complex: str,
|
||||
final_label: Optional[str],
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""构建 FFmpeg 命令."""
|
||||
# 输入文件
|
||||
inputs = f"-i {input_video}"
|
||||
for seg in b_roll_segments:
|
||||
asset_url = seg.get("asset_url", "")
|
||||
if asset_url:
|
||||
inputs += f" -i {asset_url}"
|
||||
|
||||
# 滤镜
|
||||
if filter_complex and final_label:
|
||||
filter_arg = f'-filter_complex "{filter_complex}" -map "[{final_label}]"'
|
||||
elif filter_complex:
|
||||
filter_arg = f'-filter_complex "{filter_complex}"'
|
||||
else:
|
||||
filter_arg = ""
|
||||
|
||||
return f"ffmpeg {inputs} {filter_arg} -c:v libx264 -preset fast -crf 23 -y {output_path}"
|
||||
|
||||
def _upload_to_oss(self, local_path: str, oss_key: str) -> str:
|
||||
"""上传文件到 OSS,返回 URL.
|
||||
|
||||
使用 SharedStorageService 统一存储服务。
|
||||
"""
|
||||
storage = get_shared_storage_service()
|
||||
url = storage.upload_file_smart(local_path, oss_key)
|
||||
if url is None:
|
||||
raise AiAvatarRenderError(
|
||||
f"上传文件到 OSS 失败: {oss_key}",
|
||||
code="OSSUploadFailed",
|
||||
)
|
||||
logger.info("上传文件到 OSS 成功: %s -> %s", local_path, url)
|
||||
return url
|
||||
@@ -1,494 +0,0 @@
|
||||
"""素材片段级使用记录追踪与受控复用.
|
||||
|
||||
在素材 metadata(assets.classification_result JSON)中持久化已使用的片段时间区间,
|
||||
供 from-assets 创建片段时避开历史区间,实现跨任务/跨调用的片段去重;
|
||||
素材可用区间耗尽后进入受控复用:允许有限次数(MAX_RANGE_USE_COUNT)复用最久未用
|
||||
的历史区间,配合调用方的成片复用占比控制(MAX_REUSE_RATIO = 10%),把任意两条
|
||||
成片的画面重复率控制在阈值内。
|
||||
|
||||
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 = 2
|
||||
"""单条历史区间最多被使用次数(含首次),达到后不再参与复用。"""
|
||||
|
||||
REUSE_RATIO_LIMIT = 0.10
|
||||
"""单条成片中,单个素材的复用片段累计时长 / 该素材在成片中的总时长上限(10%)。
|
||||
超过则该素材不再分配新片段(调用方在轮询分配时跳过)。"""
|
||||
|
||||
SEGMENT_EDGE_GAP = 1.5
|
||||
"""冲突判定边缘间隙(秒):历史区间按 [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: 累计复用时长}``,回调成功返回复用区间时
|
||||
会把本次片段时长累加进去,供调用方统计成片复用占比(10% 阈值)。
|
||||
assigned_tracker: 可选的 ``{asset_id: 已分配片段总时长}``,配合 ratio_limit
|
||||
在复用前预判:若复用本片段后占比 (reused + clip_duration) /
|
||||
(assigned + clip_duration) 超过 ratio_limit,则拒绝复用、返回 None
|
||||
(保证成片复用占比不超阈值)。
|
||||
ratio_limit: 单条成片复用时长占比上限,默认 10%。
|
||||
|
||||
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
|
||||
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 packages.adapters.sqlalchemy_impl import (
|
||||
@@ -377,618 +371,6 @@ class EditPlanService:
|
||||
logger.info("删除所有片段: plan_id=%s count=%d", plan_id, 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", ""),
|
||||
text_content=clip_item.get("text_content", ""),
|
||||
start_time=clip_item.get("start_time", 0.0),
|
||||
duration=clip_item.get("duration", 0.0),
|
||||
transition_effect=clip_item.get("transition_effect", "cut"),
|
||||
transition_duration=clip_item.get("transition_duration", 0.0),
|
||||
playback_speed=clip_item.get("playback_speed", 1.0),
|
||||
config=clip_item.get("config") or None,
|
||||
)
|
||||
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 reselect_plan_for_variant(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
candidate_asset_ids: list[str],
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
name_suffix: str = "变体",
|
||||
voice_duration: float = 0.0,
|
||||
rng=None,
|
||||
) -> EditPlan:
|
||||
"""为批量变体生成独立 plan:完整重跑单视频选片流程(#1743)。
|
||||
|
||||
与 clone_plan_for_variant(只重算起点、素材/顺序不变)不同,本方法:
|
||||
- 源 plan 片段骨架(clip_type/order/duration/文案/转场)保留;
|
||||
- 素材池 shuffle 随机分配 + main 片段顺序洗牌;
|
||||
- 起点走场景镜头洗牌/随机起点/历史区间避让(与单视频同一入口);
|
||||
- 批次内同素材区间重叠 >20% 自动重选起点;
|
||||
- 新片段区间 record_used_segments 写回素材 metadata(跨变体/跨任务避让)。
|
||||
|
||||
Args:
|
||||
source_plan_id: 源 plan(任务 0 / 预览源)。
|
||||
candidate_asset_ids: 素材池(源 plan 素材 ∪ 批次素材)。
|
||||
created_by_user_id: 新 plan 归属用户。
|
||||
name_suffix: plan 名后缀。
|
||||
rng: 可选随机数(测试注入种子)。
|
||||
|
||||
Raises:
|
||||
ValueError: 源 plan 不存在/无片段、素材池为空或时长全未知。
|
||||
"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
from packages.domain.plan_generator_utils import extract_scene_points_from_metadata
|
||||
from packages.domain.variant_plan_selector import reselect_clips_for_variant
|
||||
|
||||
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}")
|
||||
|
||||
source_clips_data: list[dict[str, Any]] = [
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"asset_id": c.asset_id,
|
||||
"start_time": float(c.start_time or 0.0),
|
||||
"duration": float(c.duration or 0.0),
|
||||
"clip_type": c.clip_type,
|
||||
"playback_speed": float(c.playback_speed or 1.0),
|
||||
"transition_effect": c.transition_effect,
|
||||
"transition_duration": float(c.transition_duration or 0.0),
|
||||
"text_content": c.text_content or "",
|
||||
"config": c.config or {},
|
||||
}
|
||||
for i, c in enumerate(clips)
|
||||
]
|
||||
|
||||
db = self._clip_repo.session
|
||||
|
||||
# #1749:配音时长 → 每段目标段长(片段数=模板片段数定死;素材不足由渲染末帧冻结铺满)
|
||||
target_durations: list[float] | None = None
|
||||
try:
|
||||
voice = float(voice_duration or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
voice = 0.0
|
||||
if voice > 0 and source_clips_data:
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations
|
||||
|
||||
_effects: list[str | None] = [c.get("transition_effect") for c in source_clips_data]
|
||||
_tdurs: list[float] = [float(c.get("transition_duration") or 0.0) for c in source_clips_data]
|
||||
target_durations = plan_clip_durations(
|
||||
len(source_clips_data),
|
||||
voice,
|
||||
transition_effects=_effects,
|
||||
transition_durations=_tdurs,
|
||||
)
|
||||
if target_durations:
|
||||
for _c, _d in zip(source_clips_data, target_durations, strict=False):
|
||||
_c["duration"] = _d
|
||||
|
||||
# 素材池 = 源 plan 素材 ∪ 调用方传入素材(去重保序)
|
||||
pool_ids: list[str] = []
|
||||
seen = set()
|
||||
for aid in [c.asset_id for c in clips if c.asset_id] + list(candidate_asset_ids or []):
|
||||
if aid and aid not in seen:
|
||||
seen.add(aid)
|
||||
pool_ids.append(aid)
|
||||
|
||||
# 时长 + 场景点
|
||||
durations: dict[str, float] = {}
|
||||
scene_points: dict[str, list[float]] = {}
|
||||
if pool_ids:
|
||||
for m in db.query(AssetModel).filter(AssetModel.id.in_(pool_ids)).all():
|
||||
durations[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
|
||||
pts = extract_scene_points_from_metadata(getattr(m, "metadata", None))
|
||||
if pts:
|
||||
scene_points[m.id] = pts
|
||||
|
||||
historical = get_used_segments(db, pool_ids)
|
||||
|
||||
# 创建新 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 ""),
|
||||
)
|
||||
|
||||
# 批次内区间:以源 plan(变体 0)片段为初始避让对象
|
||||
batch_segments: dict[str, list[tuple[float, float]]] = {}
|
||||
for c in clips:
|
||||
if c.asset_id and float(c.duration or 0) > 0:
|
||||
st = float(c.start_time or 0.0)
|
||||
batch_segments.setdefault(c.asset_id, []).append((st, st + float(c.duration)))
|
||||
|
||||
clips_data = reselect_clips_for_variant(
|
||||
source_clips_data,
|
||||
pool_ids,
|
||||
asset_durations=durations,
|
||||
asset_scene_points=scene_points,
|
||||
historical_used_segments=historical,
|
||||
batch_segments=batch_segments,
|
||||
target_durations=target_durations,
|
||||
rng=rng,
|
||||
)
|
||||
|
||||
# 片段区间写回素材 metadata(与落库同事务;replace_all_clips_transactional 内 commit)
|
||||
for item in clips_data:
|
||||
aid = item.get("asset_id", "")
|
||||
if aid:
|
||||
st = float(item.get("start_time", 0.0))
|
||||
record_used_segments(db, aid, st, st + float(item.get("duration", 0.0)), new_plan.id)
|
||||
|
||||
self.replace_all_clips_transactional(new_plan.id, clips_data)
|
||||
logger.info(
|
||||
"变体独立选片完成: source=%s new=%s clips=%d assets=%d",
|
||||
source_plan_id,
|
||||
new_plan.id,
|
||||
len(clips_data),
|
||||
len(pool_ids),
|
||||
)
|
||||
return new_plan
|
||||
|
||||
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
|
||||
|
||||
# ── #1749 配音时长分配 / 素材时长查询 / 批量变体 plan 确保 ──────────────
|
||||
|
||||
def get_asset_durations(self, asset_ids: list[str]) -> dict[str, float]:
|
||||
"""批量查询素材时长(秒),O(N) 单查;缺失/异常返回 0.0。"""
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
ids = [a for a in dict.fromkeys(asset_ids or []) if a]
|
||||
if not ids:
|
||||
return {}
|
||||
db = self._clip_repo.session
|
||||
out: dict[str, float] = {}
|
||||
for m in db.query(AssetModel).filter(AssetModel.id.in_(ids)).all():
|
||||
try:
|
||||
out[m.id] = float(getattr(m, "duration", 0.0) or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
out[m.id] = 0.0
|
||||
return out
|
||||
|
||||
def apply_voice_duration_to_plan(self, plan_id: str, voice_duration: float) -> Optional[EditPlan]:
|
||||
"""把配音时长分配到 plan 的每段(#1749)。
|
||||
|
||||
- 片段数保持不变(= 模板片段数,定死);
|
||||
- 每段 duration 按 voice_duration_planner 分配(含转场重叠扣减);
|
||||
- 素材短于段长 → start_time 钳制为 0(末帧冻结由渲染侧 tpad/apad 铺满);
|
||||
- plan.total_duration 回写为成片净时长(≈ 配音时长);
|
||||
- 幂等:配音时长相同则分配结果不变,可重复调用。
|
||||
|
||||
无配音(<=0)或无片段时直接返回 None,不报错。
|
||||
"""
|
||||
try:
|
||||
voice = float(voice_duration or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if voice <= 0:
|
||||
return None
|
||||
|
||||
plan = self.get_plan(plan_id)
|
||||
if plan is None:
|
||||
return None
|
||||
|
||||
clips: List[EditPlanClip] = []
|
||||
skip, page = 0, 500
|
||||
while True:
|
||||
batch = self._clip_repo.list_by_plan(plan_id, skip=skip, limit=page)
|
||||
if not batch:
|
||||
break
|
||||
clips.extend(batch)
|
||||
if len(batch) < page:
|
||||
break
|
||||
skip += page
|
||||
if not clips:
|
||||
return None
|
||||
|
||||
clips.sort(key=lambda c: (c.order if c.order is not None else 0))
|
||||
|
||||
from packages.domain.voice_duration_planner import plan_clip_durations, total_output_duration
|
||||
|
||||
# #1764:从 plan config 读取节奏模板
|
||||
rhythm_template = None
|
||||
if plan and hasattr(plan, "config") and plan.config:
|
||||
rhythm_template = plan.config.get("rhythm_template")
|
||||
|
||||
# #1768:先获取素材时长,传入 plan_clip_durations 用于最大片段钳制
|
||||
asset_ids = [c.asset_id for c in clips if c.asset_id]
|
||||
durations = self.get_asset_durations(asset_ids)
|
||||
asset_durations_for_plan = [durations.get(c.asset_id, 0.0) for c in clips]
|
||||
|
||||
target = plan_clip_durations(
|
||||
len(clips),
|
||||
voice,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
rhythm_template=rhythm_template,
|
||||
asset_durations=asset_durations_for_plan,
|
||||
)
|
||||
if not target:
|
||||
return None
|
||||
|
||||
clips_data: list[dict] = []
|
||||
for i, c in enumerate(clips):
|
||||
dur = float(target[i])
|
||||
total = durations.get(c.asset_id, 0.0)
|
||||
start = float(c.start_time or 0.0)
|
||||
if c.asset_id and total > 0:
|
||||
# 素材短于段长:起点钳 0,段长超出部分渲染侧末帧冻结
|
||||
max_start = max(0.0, total - min(dur, total))
|
||||
start = min(start, max_start)
|
||||
clips_data.append(
|
||||
{
|
||||
"order": c.order if c.order is not None else i,
|
||||
"asset_id": c.asset_id or "",
|
||||
"start_time": round(start, 3),
|
||||
"duration": dur,
|
||||
"clip_type": c.clip_type,
|
||||
"playback_speed": float(c.playback_speed or 1.0),
|
||||
"transition_effect": c.transition_effect,
|
||||
"transition_duration": float(c.transition_duration or 0.0),
|
||||
"text_content": c.text_content or "",
|
||||
"config": c.config or {},
|
||||
}
|
||||
)
|
||||
|
||||
self.replace_all_clips_transactional(plan_id, clips_data)
|
||||
|
||||
net = total_output_duration(
|
||||
target,
|
||||
transition_effects=[c.transition_effect for c in clips],
|
||||
transition_durations=[float(c.transition_duration or 0.0) for c in clips],
|
||||
)
|
||||
try:
|
||||
plan.total_duration = net
|
||||
db = self._clip_repo.session
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("回写 plan.total_duration 失败(不阻断): plan_id=%s", plan_id)
|
||||
|
||||
logger.info(
|
||||
"配音时长分配完成: plan=%s clips=%d voice=%.2fs 成片净时长=%.2fs",
|
||||
plan_id,
|
||||
len(clips),
|
||||
voice,
|
||||
net,
|
||||
)
|
||||
return plan
|
||||
|
||||
def ensure_variant_plans(
|
||||
self,
|
||||
source_plan_id: str,
|
||||
count: int,
|
||||
candidate_asset_ids: list[str],
|
||||
*,
|
||||
created_by_user_id: str = "",
|
||||
voice_durations: Optional[list[float]] = None,
|
||||
rng=None,
|
||||
) -> list[str]:
|
||||
"""确保批量 N 个变体各自拥有独立 plan(#1749 批量正式生成/预览共用)。
|
||||
|
||||
- 变体 0:clone 源 plan(不污染源 plan,片段独立可改),并按配音分配段长;
|
||||
- 变体 1..N-1:reselect_plan_for_variant 完整重跑选片(素材级去重);
|
||||
- voice_durations:每个变体的配音时长(独立配音各自时长;统一配音同值);
|
||||
缺省/为 0 时不分配(段长保持骨架/模板值)。
|
||||
|
||||
Returns:
|
||||
plan_id 列表,长度 == count,index 即 variant_index。
|
||||
"""
|
||||
import random as _random
|
||||
|
||||
rng = rng or _random.Random()
|
||||
plan_ids: list[str] = []
|
||||
|
||||
# 变体 0:clone(片段结构同源 plan,起点重算),不污染源 plan
|
||||
plan0 = self.clone_plan_for_variant(
|
||||
source_plan_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
name_suffix="变体1",
|
||||
)
|
||||
v0_voice = 0.0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
try:
|
||||
v0_voice = float(voice_durations[0] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
v0_voice = 0.0
|
||||
if v0_voice > 0:
|
||||
try:
|
||||
self.apply_voice_duration_to_plan(plan0.id, v0_voice)
|
||||
except Exception:
|
||||
logger.exception("变体0 配音分配失败(不阻断): plan=%s", plan0.id)
|
||||
plan_ids.append(plan0.id)
|
||||
|
||||
# 变体 1..N-1:独立选片
|
||||
for i in range(1, count):
|
||||
voice = 0.0
|
||||
if voice_durations and i < len(voice_durations):
|
||||
try:
|
||||
voice = float(voice_durations[i] or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
voice = 0.0
|
||||
variant = self.reselect_plan_for_variant(
|
||||
source_plan_id,
|
||||
candidate_asset_ids,
|
||||
created_by_user_id=created_by_user_id,
|
||||
name_suffix=f"变体{i + 1}",
|
||||
voice_duration=voice,
|
||||
rng=rng,
|
||||
)
|
||||
plan_ids.append(variant.id)
|
||||
|
||||
# #1764:为每个变体生成独立节奏模板(让批量视频片段时长分布不同)
|
||||
from packages.domain.voice_duration_planner import RHYTHM_TEMPLATES, adapt_template_length
|
||||
|
||||
clip_count = 0
|
||||
if voice_durations and len(voice_durations) > 0:
|
||||
# 从源 plan 获取片段数
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and hasattr(source_plan, "clips"):
|
||||
clip_count = len(list(source_plan.clips)) if source_plan.clips else 0
|
||||
|
||||
rhythm_templates_for_variants = []
|
||||
if clip_count > 0:
|
||||
for idx in range(len(plan_ids)):
|
||||
# 每个变体用不同的 seed 选择节奏模板
|
||||
variant_seed = rng.randint(0, 999999)
|
||||
template = adapt_template_length(RHYTHM_TEMPLATES[variant_seed % len(RHYTHM_TEMPLATES)], clip_count)
|
||||
rhythm_templates_for_variants.append(template)
|
||||
logger.info("变体 %d 节奏模板: plan=%s template=%s", idx, plan_ids[idx], template)
|
||||
|
||||
# #1767:BGM 池差异化分配(让批量变体使用不同 BGM / 段落 / 音量)
|
||||
from packages.domain.bgm_pool import allocate_bgm_pool_for_variants
|
||||
|
||||
source_bgm_config = {}
|
||||
source_plan = self.get_plan(source_plan_id)
|
||||
if source_plan and source_plan.config:
|
||||
source_bgm_config = source_plan.config.get("bgm", {}) or {}
|
||||
|
||||
variant_seeds_for_bgm = [rng.randint(0, 999999) for _ in plan_ids]
|
||||
bgm_pool_assignments = allocate_bgm_pool_for_variants(source_bgm_config, variant_seeds_for_bgm)
|
||||
|
||||
# 为每个变体生成独立视觉扰动参数(让批量视频画面本身更不同)
|
||||
from packages.domain.variant_plan_selector import generate_visual_perturbation
|
||||
|
||||
for idx, pid in enumerate(plan_ids):
|
||||
try:
|
||||
perturbation = generate_visual_perturbation(rng)
|
||||
# 变体 0 不做 hflip(保持预览 plan 原始画面方向)
|
||||
if idx == 0:
|
||||
perturbation["hflip"] = False
|
||||
config_update = {"visual_perturbation": perturbation}
|
||||
# #1764:写入节奏模板
|
||||
if idx < len(rhythm_templates_for_variants):
|
||||
config_update["rhythm_template"] = rhythm_templates_for_variants[idx]
|
||||
# #1765:写入像素级扰动滤镜
|
||||
from packages.domain.variant_plan_selector import generate_pixel_perturbation
|
||||
|
||||
pixel_pert = generate_pixel_perturbation(rng)
|
||||
config_update["pixel_perturbation"] = pixel_pert
|
||||
# #1767:写入 BGM 池分配(覆盖 bgm 配置中的 preset_id / audio_offset / volume_adjust_db)
|
||||
if idx < len(bgm_pool_assignments):
|
||||
existing_bgm = dict((source_plan.config or {}).get("bgm", {}) or {})
|
||||
existing_bgm.update(bgm_pool_assignments[idx])
|
||||
config_update["bgm"] = existing_bgm
|
||||
self.update_plan_config(pid, config_update)
|
||||
logger.info(
|
||||
"变体 %d 视觉扰动+像素扰动+BGM池: plan=%s vis=%s pix=%s bgm=%s",
|
||||
idx,
|
||||
pid,
|
||||
perturbation,
|
||||
pixel_pert,
|
||||
bgm_pool_assignments[idx] if idx < len(bgm_pool_assignments) else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("变体 %d 视觉扰动生成失败(不阻断): plan=%s", idx, pid)
|
||||
|
||||
# 标记所有变体 plan 的 clips 为 ready(已分配素材+起点,语义上就是 ready)
|
||||
for pid in plan_ids:
|
||||
try:
|
||||
self.mark_clips_ready(pid)
|
||||
except Exception:
|
||||
logger.exception("标记 clips ready 失败(不阻断): plan=%s", pid)
|
||||
|
||||
return plan_ids
|
||||
|
||||
# ── 片段分割与合并 ──────────────────────────────────────────────────────
|
||||
|
||||
def split_clip(self, clip_id: str, split_time: float) -> Dict[str, Any]:
|
||||
@@ -1189,10 +571,6 @@ class EditPlanService:
|
||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||
"""检查是否可以触发渲染
|
||||
|
||||
包含最后一道防线的自动修复:
|
||||
- 如果 clips 存在但都没有 asset_id,且 config.asset_ids 非空,
|
||||
直接在内部执行素材分配,不再依赖前置 fallback 链路。
|
||||
|
||||
Returns:
|
||||
tuple: (can_generate, reason)
|
||||
"""
|
||||
@@ -1207,69 +585,10 @@ class EditPlanService:
|
||||
if not clips:
|
||||
return False, "请先添加片段后再生成视频"
|
||||
|
||||
# 检查是否至少有一个片段分配了素材
|
||||
has_asset = any(c.asset_id for c in clips)
|
||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
||||
logger.info(
|
||||
"can_generate 诊断: plan=%s status=%s total_clips=%d clips_with_asset=%d config_asset_ids_count=%d",
|
||||
plan_id,
|
||||
plan.status,
|
||||
len(clips),
|
||||
clips_with_asset_count,
|
||||
config_asset_ids_count,
|
||||
)
|
||||
if not has_asset:
|
||||
# ── 最后防线:自动从 config.asset_ids 分配素材 ──
|
||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
||||
if config_asset_ids:
|
||||
logger.warning(
|
||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材,从 config.asset_ids(%d个) 自动分配",
|
||||
plan_id,
|
||||
len(clips),
|
||||
len(config_asset_ids),
|
||||
)
|
||||
clips_without_asset = [c for c in clips if not c.asset_id]
|
||||
assigned_count = 0
|
||||
for i, clip in enumerate(clips_without_asset):
|
||||
asset_idx = i % len(config_asset_ids)
|
||||
try:
|
||||
self.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||
assigned_count += 1
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"can_generate 最后防线: plan=%s clip=%s 分配素材 %s 失败: %s",
|
||||
plan_id,
|
||||
clip.id,
|
||||
config_asset_ids[asset_idx],
|
||||
exc,
|
||||
)
|
||||
logger.info(
|
||||
"can_generate 最后防线: plan=%s 已为 %d/%d 个片段分配素材",
|
||||
plan_id,
|
||||
assigned_count,
|
||||
len(clips_without_asset),
|
||||
)
|
||||
# 重新加载 clips 验证分配结果
|
||||
clips = self._clip_repo.list_by_plan(plan_id)
|
||||
if not any(c.asset_id for c in clips):
|
||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
||||
else:
|
||||
logger.warning(
|
||||
"can_generate 失败: plan=%s clips=%d 均无素材,且 config.asset_ids 为空,无法自动修复",
|
||||
plan_id,
|
||||
len(clips),
|
||||
)
|
||||
return False, "没有可渲染的就绪片段,请确保已选择素材"
|
||||
|
||||
return True, ""
|
||||
|
||||
def mark_clips_ready(self, plan_id: str) -> int:
|
||||
"""将已分配素材的 pending 片段标记为 ready
|
||||
|
||||
只标记同时满足以下条件的片段:
|
||||
- status == PENDING
|
||||
- asset_id 非空(已分配素材)
|
||||
"""将所有 pending 状态的片段标记为 ready
|
||||
|
||||
Returns:
|
||||
int: 标记的片段数量
|
||||
@@ -1280,16 +599,10 @@ class EditPlanService:
|
||||
)
|
||||
count = 0
|
||||
for clip in clips:
|
||||
if clip.asset_id:
|
||||
clip.mark_ready()
|
||||
self._clip_repo.update(clip)
|
||||
count += 1
|
||||
logger.info(
|
||||
"标记片段就绪: plan_id=%s marked=%d total_pending=%d",
|
||||
plan_id,
|
||||
count,
|
||||
len(clips),
|
||||
)
|
||||
clip.mark_ready()
|
||||
self._clip_repo.update(clip)
|
||||
count += 1
|
||||
logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
|
||||
return count
|
||||
|
||||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||||
|
||||
@@ -34,17 +34,6 @@ from packages.domain.template_clip_converter import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateNotFoundError(Exception):
|
||||
"""模板不存在、已删除或当前用户无权访问.
|
||||
|
||||
与"模板存在但无片段配置"区分:路由层应映射为 HTTP 404。
|
||||
"""
|
||||
|
||||
def __init__(self, template_id: str) -> None:
|
||||
self.template_id = template_id
|
||||
super().__init__(f"模板不存在: {template_id}")
|
||||
|
||||
|
||||
class EditTemplateService:
|
||||
"""模板管理服务
|
||||
|
||||
@@ -228,14 +217,7 @@ class EditTemplateService:
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""列出模板的片段配置
|
||||
|
||||
注意:本方法要求模板存在于新表 ``edit_templates``(全局模板库),
|
||||
主要服务于新模板系统的写入/发布路径。用户自建模板存放在旧表
|
||||
``templates``,不在 ``edit_templates`` 中,读取其片段配置请改用
|
||||
:meth:`list_clip_configs_for_editor`,后者直接读取片段配置主表
|
||||
``template_clip_configs``,不依赖新模板主表、也不靠异常降级。
|
||||
"""
|
||||
"""列出模板的片段配置"""
|
||||
# 确保模板存在
|
||||
self.get_template_or_raise(template_id)
|
||||
return self._clip_config_repo.list_by_template(
|
||||
@@ -245,52 +227,6 @@ class EditTemplateService:
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
def list_clip_configs_for_editor(
|
||||
self,
|
||||
template_id: str,
|
||||
user_id: str,
|
||||
*,
|
||||
clip_type: Optional[ClipType] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
) -> List[TemplateClipConfig]:
|
||||
"""编辑器读取模板片段配置的单一数据源入口.
|
||||
|
||||
片段配置主表是 ``template_clip_configs``(直接读取,不抛异常、不降级)。
|
||||
模板主表按双表现状显式判定,不使用 try/except 控制流:
|
||||
|
||||
1. 用户自建模板在旧表 ``templates``(归属 user_id)→ 校验归属与未删除后直接读;
|
||||
2. 全局模板在新表 ``edit_templates``(无 user_id,全局可读)→ 直接读;
|
||||
3. 两者都没有 → 模板不存在/无权限,抛 :class:`TemplateNotFoundError`。
|
||||
|
||||
Args:
|
||||
template_id: 模板 ID
|
||||
user_id: 当前登录用户 ID(用于旧表模板归属校验)
|
||||
|
||||
Raises:
|
||||
TemplateNotFoundError: 模板不存在、已删除或不归属于当前用户。
|
||||
"""
|
||||
# 1) 用户自建模板(旧表 templates,归属 user_id)
|
||||
if self._clip_config_repo.template_owned_by(template_id, user_id):
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 2) 全局模板(新表 edit_templates,无 user_id,全局可读)
|
||||
if self._template_repo.get(template_id) is not None:
|
||||
return self._clip_config_repo.list_by_template(
|
||||
template_id,
|
||||
clip_type=clip_type,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# 3) 两表都没有:不存在 / 已删除 / 无权限
|
||||
raise TemplateNotFoundError(template_id)
|
||||
|
||||
def get_clip_config(self, config_id: str) -> Optional[TemplateClipConfig]:
|
||||
"""获取片段配置详情"""
|
||||
return self._clip_config_repo.get(config_id)
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
"""对口型 Service — #1796 MediaKit 对口型业务逻辑.
|
||||
|
||||
职责:
|
||||
- 创建/查询/取消对口型任务
|
||||
- 调用 MediaKit 客户端提交异步任务
|
||||
- 轮询更新任务状态
|
||||
- 用户隔离(每个用户只能操作自己的任务)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from app.services.mediakit_client import (
|
||||
STATUS_COMPLETED,
|
||||
STATUS_FAILED,
|
||||
STATUS_RUNNING,
|
||||
MediaKitClient,
|
||||
MediaKitError,
|
||||
get_mediakit_client,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import LipsyncJobModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LipsyncService:
|
||||
"""对口型任务 Service."""
|
||||
|
||||
def __init__(self, db: Session, client: Optional[MediaKitClient] = None):
|
||||
self.db = db
|
||||
self.client = client or get_mediakit_client()
|
||||
|
||||
# ── 创建任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def create_job(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
project_id: str = "",
|
||||
) -> LipsyncJobModel:
|
||||
"""创建对口型任务并提交到 MediaKit.
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
# 1. 创建数据库记录
|
||||
job_id = str(uuid.uuid4())
|
||||
job = LipsyncJobModel(
|
||||
id=job_id,
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
status="pending",
|
||||
)
|
||||
self.db.add(job)
|
||||
self.db.flush()
|
||||
|
||||
# 2. 提交到 MediaKit
|
||||
try:
|
||||
result = self.client.submit_lipsync(
|
||||
video_url=video_url,
|
||||
audio_url=audio_url,
|
||||
enable_video_loop=enable_video_loop,
|
||||
client_token=job_id, # 幂等控制
|
||||
)
|
||||
job.mediakit_task_id = result["task_id"]
|
||||
job.status = "submitted"
|
||||
job.submitted_at = datetime.now(timezone.utc)
|
||||
except MediaKitError as exc:
|
||||
job.status = "failed"
|
||||
job.error_message = str(exc)
|
||||
job.error_code = exc.code
|
||||
logger.error("提交对口型任务失败: %s", exc)
|
||||
raise
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 查询任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""获取任务详情(用户隔离)."""
|
||||
return (
|
||||
self.db.query(LipsyncJobModel)
|
||||
.filter(LipsyncJobModel.id == job_id, LipsyncJobModel.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
project_id: str = "",
|
||||
status: str = "",
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
) -> tuple[list[LipsyncJobModel], int]:
|
||||
"""获取任务列表(分页 + 用户隔离)."""
|
||||
query = self.db.query(LipsyncJobModel).filter(LipsyncJobModel.user_id == user_id)
|
||||
if project_id:
|
||||
query = query.filter(LipsyncJobModel.project_id == project_id)
|
||||
if status:
|
||||
query = query.filter(LipsyncJobModel.status == status)
|
||||
|
||||
total = query.count()
|
||||
items = query.order_by(LipsyncJobModel.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── 更新任务状态(轮询) ──────────────────────────────────────────────
|
||||
|
||||
def refresh_job_status(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""从 MediaKit 拉取最新状态并更新本地记录.
|
||||
|
||||
Returns:
|
||||
更新后的 Job,或 None(任务不存在/不属于该用户)
|
||||
"""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
# 终态不需要再轮询
|
||||
if job.status in (STATUS_COMPLETED, "failed"):
|
||||
return job
|
||||
|
||||
# 未提交的任务不轮询
|
||||
if not job.mediakit_task_id:
|
||||
return job
|
||||
|
||||
try:
|
||||
status_data = self.client.get_task_status(job.mediakit_task_id)
|
||||
except MediaKitError as exc:
|
||||
logger.error("轮询对口型任务状态失败 [%s]: %s", job_id, exc)
|
||||
return job
|
||||
|
||||
mk_status = status_data.get("status", STATUS_RUNNING)
|
||||
|
||||
if mk_status == STATUS_COMPLETED:
|
||||
result = status_data.get("result", {})
|
||||
job.status = STATUS_COMPLETED
|
||||
job.output_video_url = result.get("video_url", "")
|
||||
job.output_duration = result.get("duration", 0.0)
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
elif mk_status == STATUS_FAILED:
|
||||
error = status_data.get("error", {})
|
||||
job.status = "failed"
|
||||
job.error_message = error.get("message", "任务执行失败")
|
||||
job.error_code = error.get("code", "TaskFailed")
|
||||
job.completed_at = datetime.now(timezone.utc)
|
||||
# running 状态只更新时间戳
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
return job
|
||||
|
||||
# ── 取消任务 ──────────────────────────────────────────────────────────
|
||||
|
||||
def cancel_job(self, job_id: str, user_id: str) -> Optional[LipsyncJobModel]:
|
||||
"""取消任务(仅 pending/submitted 状态可取消)."""
|
||||
job = self.get_job(job_id, user_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if job.status in ("pending", "submitted"):
|
||||
job.status = "cancelled"
|
||||
job.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(job)
|
||||
|
||||
return job
|
||||
@@ -1,243 +0,0 @@
|
||||
"""MediaKit 客户端 — 封装火山引擎 AI MediaKit 对口型 API.
|
||||
|
||||
接口文档:https://docs.volcengine.com/docs/6448/2656064
|
||||
|
||||
异步任务流程:
|
||||
1. POST /api/v1/tools/lip-sync 提交对口型任务 → 返回 task_id
|
||||
2. GET /api/v1/tasks/{task_id} 轮询任务状态 → running/completed/failed
|
||||
3. completed 时 result.video_url 为口型对齐视频(临时链接 24h 有效)
|
||||
|
||||
设计原则:
|
||||
- API Key 从配置读取(settings.mediakit_api_key)
|
||||
- 未配置 API Key 时所有方法返回降级响应,不阻塞主流程
|
||||
- HTTP 超时/网络异常统一包装为 MediaKitError
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.config import get_api_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── 任务状态常量 ──────────────────────────────────────────────────────────
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_COMPLETED = "completed"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
|
||||
class MediaKitError(Exception):
|
||||
"""MediaKit API 调用异常."""
|
||||
|
||||
def __init__(self, message: str, code: str = "", request_id: str = ""):
|
||||
self.code = code
|
||||
self.request_id = request_id
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class MediaKitClient:
|
||||
"""火山引擎 AI MediaKit 对口型 API 客户端.
|
||||
|
||||
用法:
|
||||
client = get_mediakit_client()
|
||||
result = client.submit_lipsync(video_url="...", audio_url="...")
|
||||
task_id = result["task_id"]
|
||||
|
||||
status = client.get_task_status(task_id)
|
||||
# {"status": "completed", "result": {"video_url": "...", "duration": 60.5}}
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
settings = get_api_settings()
|
||||
self._api_key = settings.mediakit_api_key
|
||||
self._base_url = settings.mediakit_base_url.rstrip("/")
|
||||
self._timeout = settings.mediakit_timeout
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""是否已配置 API Key(未配置时自动降级)."""
|
||||
return bool(self._api_key)
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# ── 提交对口型任务 ────────────────────────────────────────────────────
|
||||
|
||||
def submit_lipsync(
|
||||
self,
|
||||
*,
|
||||
video_url: str,
|
||||
audio_url: str,
|
||||
enable_video_loop: bool = False,
|
||||
callback_url: Optional[str] = None,
|
||||
callback_args: Optional[str] = None,
|
||||
client_token: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""提交视频口型对齐任务.
|
||||
|
||||
Args:
|
||||
video_url: 人物视频 URL(MP4,≤30min,单人真人)
|
||||
audio_url: 驱动音频 URL(mp3/aac/wav/m4a/flac)
|
||||
enable_video_loop: 音频长于视频时是否循环画面
|
||||
callback_url: 任务完成回调 URL
|
||||
callback_args: 回调时原样返回的自定义参数
|
||||
client_token: 幂等控制 token
|
||||
|
||||
Returns:
|
||||
{"success": True, "task_id": "...", "request_id": "..."}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"video_url": video_url,
|
||||
"audio_url": audio_url,
|
||||
}
|
||||
if enable_video_loop:
|
||||
payload["enable_video_loop"] = True
|
||||
if callback_url:
|
||||
payload["callback_url"] = callback_url
|
||||
if callback_args:
|
||||
payload["callback_args"] = callback_args[:512] # API 限制 512 字节
|
||||
if client_token:
|
||||
payload["client_token"] = client_token[:64] # API 限制 64 字符
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.post(
|
||||
f"{self._base_url}/tools/lip-sync",
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "提交任务失败"),
|
||||
code=error.get("code", "SubmitFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": data["task_id"],
|
||||
"request_id": data.get("request_id", ""),
|
||||
}
|
||||
|
||||
# ── 查询任务状态 ──────────────────────────────────────────────────────
|
||||
|
||||
def get_task_status(self, task_id: str) -> dict[str, Any]:
|
||||
"""查询异步任务状态和结果.
|
||||
|
||||
Args:
|
||||
task_id: 提交任务时返回的任务 ID
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": True,
|
||||
"task_id": "...",
|
||||
"status": "running" | "completed" | "failed",
|
||||
"result": {"video_url": "...", "duration": 60.5} | None,
|
||||
"error": {"code": "...", "message": "..."} | None,
|
||||
"created_at": 1777291767,
|
||||
"finished_at": 1777291851 | None,
|
||||
"expires_at": 1777464650 | None,
|
||||
}
|
||||
|
||||
Raises:
|
||||
MediaKitError: API 调用失败
|
||||
"""
|
||||
if not self.is_available:
|
||||
raise MediaKitError("MediaKit API Key 未配置", code="NotConfigured")
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout) as client:
|
||||
resp = client.get(
|
||||
f"{self._base_url}/tasks/{task_id}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.TimeoutException as exc:
|
||||
raise MediaKitError(f"MediaKit API 超时 ({self._timeout}s)", code="Timeout") from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
body = exc.response.text[:500]
|
||||
raise MediaKitError(
|
||||
f"MediaKit API HTTP {exc.response.status_code}: {body}",
|
||||
code="HttpError",
|
||||
) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise MediaKitError(f"MediaKit API 网络错误: {exc}", code="NetworkError") from exc
|
||||
except Exception as exc:
|
||||
raise MediaKitError(f"MediaKit API 未知错误: {exc}", code="UnknownError") from exc
|
||||
|
||||
if not data.get("success"):
|
||||
error = data.get("error", {})
|
||||
raise MediaKitError(
|
||||
error.get("message", "查询任务失败"),
|
||||
code=error.get("code", "QueryFailed"),
|
||||
request_id=data.get("request_id", ""),
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"task_id": data.get("task_id", task_id),
|
||||
"status": data.get("status", STATUS_RUNNING),
|
||||
"result": data.get("result"),
|
||||
"created_at": data.get("created_at"),
|
||||
"finished_at": data.get("finished_at"),
|
||||
"expires_at": data.get("expires_at"),
|
||||
}
|
||||
|
||||
# 失败时提取错误信息
|
||||
if data.get("status") == STATUS_FAILED:
|
||||
error_obj = data.get("error", {})
|
||||
result["error"] = {
|
||||
"code": error_obj.get("code", "TaskFailed"),
|
||||
"message": error_obj.get("message", "任务执行失败"),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── 单例 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_client: Optional[MediaKitClient] = None
|
||||
|
||||
|
||||
def get_mediakit_client() -> MediaKitClient:
|
||||
"""获取 MediaKit 客户端单例."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = MediaKitClient()
|
||||
return _client
|
||||
|
||||
|
||||
def reset_mediakit_client() -> None:
|
||||
"""重置客户端(测试用)."""
|
||||
global _client
|
||||
_client = None
|
||||
@@ -13,7 +13,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -30,11 +29,9 @@ from packages.domain.editing_mode import EditingMode
|
||||
from packages.domain.plan_generator_utils import (
|
||||
create_clips_from_configs,
|
||||
distribute_assets,
|
||||
extract_scene_points_from_metadata,
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -52,10 +49,9 @@ class PlanGeneratorService:
|
||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||
"""
|
||||
|
||||
def __init__(self, db: Session, asset_repo=None) -> None:
|
||||
def __init__(self, db: Session) -> None:
|
||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||
self._asset_repo = asset_repo
|
||||
|
||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -68,7 +64,6 @@ class PlanGeneratorService:
|
||||
project_id: str = "",
|
||||
created_by_user_id: str = "",
|
||||
name: str = "",
|
||||
random_preview: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""基于模板+素材生成剪辑计划
|
||||
|
||||
@@ -79,7 +74,6 @@ class PlanGeneratorService:
|
||||
project_id: 所属项目 ID
|
||||
created_by_user_id: 创建者用户 ID
|
||||
name: 计划名称(为空则自动取模板名)
|
||||
random_preview: 是否启用随机预览模式(随机选素材+随机截取片段)
|
||||
|
||||
Returns:
|
||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||
@@ -121,18 +115,7 @@ class PlanGeneratorService:
|
||||
|
||||
# 4. 按 editing_mode 分配素材
|
||||
if asset_ids:
|
||||
# 获取素材时长信息,用于随机起始时间
|
||||
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,
|
||||
user_id=created_by_user_id,
|
||||
)
|
||||
self._distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
# 5. 持久化所有 clips 并计算总时长
|
||||
created_clips: List[EditPlanClip] = []
|
||||
@@ -216,97 +199,9 @@ class PlanGeneratorService:
|
||||
clips: List[EditPlanClip],
|
||||
asset_ids: List[str],
|
||||
editing_mode: str,
|
||||
*,
|
||||
random_selection: bool = False,
|
||||
asset_durations: dict[str, float] | None = None,
|
||||
user_id: str = "",
|
||||
) -> None:
|
||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||
|
||||
先用 smart_match 评分对素材排序(高分优先),再委托给
|
||||
plan_generator_utils.distribute_assets 纯函数完成分配。
|
||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||
"""
|
||||
# 预览随机模式:素材顺序已 shuffle,纯随机起点即可,不读 DB 评分/缓存
|
||||
asset_scene_points: dict[str, list[float]] = {}
|
||||
if not random_selection:
|
||||
# 正式生成:smart_match 评分排序(高分优先)+ 场景切换点缓存
|
||||
if self._asset_repo:
|
||||
asset_ids = self._sort_assets_by_smart_score(asset_ids)
|
||||
# 读取素材 metadata 中的场景切换点缓存(后台 SceneChange 检测写入):
|
||||
# 有缓存的素材片段起点从随机镜头段选取,无缓存走随机起点兜底
|
||||
asset_scene_points = self._fetch_asset_scene_points(asset_ids)
|
||||
|
||||
# 正式生成也随机重排片段顺序(降重,默认开启无开关)
|
||||
# smart_match 决定选哪些素材,shuffle 只改变分配到 clips 的顺序
|
||||
asset_ids = list(asset_ids) # 复制避免修改调用方原列表
|
||||
random.shuffle(asset_ids)
|
||||
|
||||
# 查询已有视频的已用区间(跨视频避让)
|
||||
external_used_segments = None
|
||||
if user_id and self._clip_repo:
|
||||
try:
|
||||
external_used_segments = self._clip_repo.list_used_segments_by_user(user_id, limit_recent=50)
|
||||
except Exception:
|
||||
logger.warning("跨视频避让查询失败,回退到纯随机", exc_info=True)
|
||||
|
||||
distribute_assets(
|
||||
clips,
|
||||
asset_ids,
|
||||
editing_mode,
|
||||
random_selection=random_selection,
|
||||
asset_durations=asset_durations,
|
||||
asset_scene_points=asset_scene_points,
|
||||
external_used_segments=external_used_segments,
|
||||
)
|
||||
|
||||
def _fetch_asset_scene_points(self, asset_ids: List[str]) -> dict[str, list[float]]:
|
||||
"""从素材 metadata 读取场景切换点缓存(无缓存的素材不包含在结果中)。"""
|
||||
points_map: dict[str, list[float]] = {}
|
||||
if not self._asset_repo:
|
||||
return points_map
|
||||
for asset_id in asset_ids:
|
||||
asset = self._asset_repo.get(asset_id)
|
||||
if asset:
|
||||
points = extract_scene_points_from_metadata(getattr(asset, "metadata", None))
|
||||
if points:
|
||||
points_map[asset_id] = points
|
||||
return points_map
|
||||
|
||||
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
|
||||
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
|
||||
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
|
||||
排序时给每个素材的得分注入 0~SCORE_RANDOM_NOISE_MAX 的随机噪声,
|
||||
使得分接近的素材排名每次浮动,避免一键生成反复选出相同素材组合,
|
||||
从素材组合层面降低成片查重率。分差大于噪声上限时排名保持稳定。
|
||||
"""
|
||||
scored: list[tuple[str, float]] = []
|
||||
for asset_id in asset_ids:
|
||||
asset = self._asset_repo.get(asset_id)
|
||||
if asset:
|
||||
score, _ = score_asset(asset)
|
||||
scored.append((asset_id, score))
|
||||
else:
|
||||
scored.append((asset_id, 0.0))
|
||||
# 评分 + 随机噪声后按降序排列
|
||||
scored.sort(
|
||||
key=lambda x: x[1] + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX),
|
||||
reverse=True,
|
||||
)
|
||||
return [aid for aid, _ in scored]
|
||||
|
||||
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
|
||||
distribute_assets(clips, asset_ids, editing_mode)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"""ScriptService — Issue #1795 口播文案库 CRUD.
|
||||
|
||||
纯 Service 层封装,routes 直接调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import ScriptModel
|
||||
|
||||
|
||||
class ScriptNotFoundError(Exception):
|
||||
"""文案不存在或不属于当前用户."""
|
||||
|
||||
|
||||
class ScriptService:
|
||||
"""口播文案 CRUD."""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
# ── list ──────────────────────────────────────────────────────────────
|
||||
|
||||
def list_scripts(
|
||||
self,
|
||||
user_id: str,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
tag: Optional[str] = None,
|
||||
) -> tuple[list[ScriptModel], int]:
|
||||
"""返回 (items, total)."""
|
||||
q = self.db.query(ScriptModel).filter(ScriptModel.user_id == user_id)
|
||||
if tag:
|
||||
# JSON 数组包含查询
|
||||
q = q.filter(ScriptModel.tags.contains([tag]))
|
||||
total = q.count()
|
||||
items = q.order_by(ScriptModel.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return items, total
|
||||
|
||||
# ── create ────────────────────────────────────────────────────────────
|
||||
|
||||
def create_script(
|
||||
self,
|
||||
user_id: str,
|
||||
title: str,
|
||||
content: str = "",
|
||||
segments: list | None = None,
|
||||
tags: list | None = None,
|
||||
) -> ScriptModel:
|
||||
script = ScriptModel(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
segments=segments if segments is not None else [],
|
||||
tags=tags if tags is not None else [],
|
||||
)
|
||||
self.db.add(script)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
# ── get ───────────────────────────────────────────────────────────────
|
||||
|
||||
def get_script(self, script_id: str, user_id: str) -> ScriptModel:
|
||||
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
|
||||
if script is None:
|
||||
raise ScriptNotFoundError(f"Script {script_id} not found")
|
||||
return script
|
||||
|
||||
# ── update ────────────────────────────────────────────────────────────
|
||||
|
||||
def update_script(
|
||||
self,
|
||||
script_id: str,
|
||||
user_id: str,
|
||||
title: Optional[str] = None,
|
||||
content: Optional[str] = None,
|
||||
segments: Optional[list] = None,
|
||||
tags: Optional[list] = None,
|
||||
) -> ScriptModel:
|
||||
script = self.get_script(script_id, user_id)
|
||||
if title is not None:
|
||||
script.title = title
|
||||
if content is not None:
|
||||
script.content = content
|
||||
if segments is not None:
|
||||
script.segments = segments
|
||||
if tags is not None:
|
||||
script.tags = tags
|
||||
script.updated_at = datetime.now(timezone.utc)
|
||||
self.db.commit()
|
||||
self.db.refresh(script)
|
||||
return script
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────
|
||||
|
||||
def delete_script(self, script_id: str, user_id: str) -> bool:
|
||||
script = self.db.query(ScriptModel).filter(ScriptModel.id == script_id, ScriptModel.user_id == user_id).first()
|
||||
if script is None:
|
||||
return False
|
||||
self.db.delete(script)
|
||||
self.db.commit()
|
||||
return True
|
||||
+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)
|
||||
@@ -39,9 +39,6 @@ from packages.domain.video_filter_builder import (
|
||||
)
|
||||
from packages.domain.video_filter_builder import build_concat_filter as _build_concat_filter_func
|
||||
from packages.domain.video_filter_builder import build_filter_complex as _build_filter_complex
|
||||
from packages.domain.video_filter_builder import (
|
||||
build_title_drawtext_filter,
|
||||
)
|
||||
from packages.domain.video_filter_builder import build_xfade_filter as _build_xfade_filter_func
|
||||
from packages.domain.video_filter_builder import chain_filters as _chain_filters_func
|
||||
from packages.domain.video_filter_builder import has_audio as _has_audio_func
|
||||
@@ -251,27 +248,6 @@ class VideoComposeService:
|
||||
transitions=[c.transition_effect for c in ready_clips],
|
||||
)
|
||||
|
||||
# ── #1789 标题 drawtext 滤镜叠加 ──
|
||||
# 从 plan.config 读取 title_config,生成 drawtext 滤镜链入 filter_complex
|
||||
title_cfg = (plan.config or {}).get("title", {}) or {}
|
||||
if not isinstance(title_cfg, dict):
|
||||
title_cfg = {}
|
||||
# 同时兼容 plan.config["title_config"](API 回写路径)
|
||||
if not title_cfg.get("text") and not title_cfg.get("content"):
|
||||
title_cfg_alt = (plan.config or {}).get("title_config", {}) or {}
|
||||
if isinstance(title_cfg_alt, dict) and (title_cfg_alt.get("text") or title_cfg_alt.get("content")):
|
||||
title_cfg = title_cfg_alt
|
||||
drawtext_filter = build_title_drawtext_filter(title_cfg, output_width, output_height)
|
||||
if drawtext_filter:
|
||||
# 将最终输出标签从 [outv] 改为 [composed],再链入 drawtext → [outv]
|
||||
filter_complex = filter_complex.replace("[outv]", "[composed]")
|
||||
filter_complex += f";[composed]{drawtext_filter}[outv]"
|
||||
logger.info(
|
||||
"[#1789] 标题 drawtext 滤镜已注入: plan_id=%s text=%s",
|
||||
plan_id,
|
||||
(title_cfg.get("text") or title_cfg.get("content") or "")[:30],
|
||||
)
|
||||
|
||||
# 构建完整命令
|
||||
command: list[str] = ["ffmpeg", "-y"]
|
||||
|
||||
@@ -398,7 +374,7 @@ class VideoComposeService:
|
||||
EditPlanStatus.EDITING,
|
||||
EditPlanStatus.RENDERING,
|
||||
),
|
||||
"rendered_url": plan.config.get("rendered_storage_key", "") or plan.config.get("rendered_url", ""),
|
||||
"rendered_url": plan.config.get("rendered_url", ""),
|
||||
}
|
||||
|
||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Celery 异步任务模块."""
|
||||
@@ -1,48 +0,0 @@
|
||||
"""AI数字人渲染 Celery 异步任务 — #1798."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.core.celery_app import celery_app
|
||||
from app.dependencies import get_db_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery_app.task(bind=True, name="ai_avatar_render.execute", max_retries=2)
|
||||
def execute_ai_avatar_render(self, job_id: str) -> dict:
|
||||
"""执行 AI 数字人渲染管线.
|
||||
|
||||
进度更新:
|
||||
- 0%: 任务开始
|
||||
- 20%: 下载对口型视频完成
|
||||
- 40%: 滤镜链构建完成
|
||||
- 80%: FFmpeg 渲染完成
|
||||
- 95%: 上传 OSS 完成
|
||||
- 100%: 任务完成
|
||||
"""
|
||||
logger.info("开始执行渲染任务: %s", job_id)
|
||||
self.update_state(state="PROCESSING", meta={"progress": 0, "job_id": job_id})
|
||||
|
||||
try:
|
||||
# 获取数据库 session
|
||||
db_gen = get_db_session()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
from app.services.ai_avatar_render_service import AiAvatarRenderService
|
||||
|
||||
service = AiAvatarRenderService(db)
|
||||
service.execute_render(job_id)
|
||||
finally:
|
||||
try:
|
||||
next(db_gen)
|
||||
except StopIteration:
|
||||
pass
|
||||
|
||||
return {"status": "completed", "job_id": job_id}
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("渲染任务执行异常 [%s]: %s", job_id, exc)
|
||||
self.update_state(state="FAILED", meta={"progress": 0, "error": str(exc)})
|
||||
raise
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""存量指纹重建脚本 — 为已有视频生成 video_fingerprint_chunks 分片数据。
|
||||
|
||||
功能:
|
||||
- 查询 generated_videos 中 video_fingerprint IS NOT NULL 但尚无分片数据的视频
|
||||
- 从 OSS 下载视频 → 用新的分片算法重新计算指纹 → 写入分片表
|
||||
- 支持 --dry-run(只打印不写入)和 --batch-size(默认 50)
|
||||
- 幂等:已存在分片数据的视频跳过
|
||||
|
||||
用法:
|
||||
# 预览(不写入)
|
||||
python rebuild_fingerprint_chunks.py --dry-run
|
||||
|
||||
# 执行重建
|
||||
python rebuild_fingerprint_chunks.py --batch-size 50
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# 确保可以 import worker_app 和 packages
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "worker"))
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("rebuild_fingerprint_chunks")
|
||||
|
||||
|
||||
def find_videos_needing_rebuild(session, batch_size: int) -> list[dict]:
|
||||
"""查询需要重建分片指纹的视频。"""
|
||||
from sqlalchemy import and_
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel, VideoFingerprintChunkModel
|
||||
|
||||
# 有 video_fingerprint 的视频
|
||||
has_fingerprint = GeneratedVideoModel.video_fingerprint.isnot(None)
|
||||
has_fingerprint = and_(has_fingerprint, GeneratedVideoModel.video_fingerprint != "")
|
||||
|
||||
# 排除已有分片数据的视频
|
||||
subq = session.query(VideoFingerprintChunkModel.video_id).distinct().subquery()
|
||||
no_chunks = ~GeneratedVideoModel.id.in_(subq)
|
||||
|
||||
videos = (
|
||||
session.query(GeneratedVideoModel)
|
||||
.filter(and_(has_fingerprint, no_chunks))
|
||||
.order_by(GeneratedVideoModel.generated_at.desc())
|
||||
.limit(batch_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": v.id,
|
||||
"project_id": v.project_id,
|
||||
"user_id": v.user_id or "",
|
||||
"duration": v.duration,
|
||||
}
|
||||
for v in videos
|
||||
]
|
||||
|
||||
|
||||
def rebuild_one(video_info: dict, dry_run: bool = False) -> int:
|
||||
"""重建单个视频的分片数据。返回写入的 chunk 数量。"""
|
||||
from video_processing.dedup import VideoDeduplicator, _save_fingerprint_chunks
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import VideoFingerprintChunkModel
|
||||
from packages.shared.storage import get_storage_service
|
||||
|
||||
video_id = video_info["id"]
|
||||
project_id = video_info["project_id"]
|
||||
user_id = video_info["user_id"]
|
||||
|
||||
if dry_run:
|
||||
logger.info("[DRY-RUN] Would rebuild video %s (project=%s)", video_id, project_id)
|
||||
return 0
|
||||
|
||||
session = SessionLocal()
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
|
||||
try:
|
||||
# 再次检查幂等性
|
||||
existing_count = (
|
||||
session.query(VideoFingerprintChunkModel).filter(VideoFingerprintChunkModel.video_id == video_id).count()
|
||||
)
|
||||
if existing_count > 0:
|
||||
logger.info("Video %s already has %d chunks, skipping", video_id, existing_count)
|
||||
return 0
|
||||
|
||||
# 下载视频
|
||||
storage_service = get_storage_service()
|
||||
local_path = os.path.join(temp_dir, f"{video_id}.mp4")
|
||||
storage_key = f"projects/{project_id}/generated/{video_id}/{video_id}.mp4"
|
||||
storage_service.download_file(storage_key, local_path)
|
||||
|
||||
# 重新计算指纹
|
||||
deduplicator = VideoDeduplicator()
|
||||
fingerprint = deduplicator.compute_fingerprint(local_path)
|
||||
|
||||
# 写入分片表
|
||||
_save_fingerprint_chunks(fingerprint, video_id, project_id, user_id, session)
|
||||
session.commit()
|
||||
|
||||
chunk_count = len(fingerprint.chunks)
|
||||
logger.info("Rebuilt %d chunks for video %s", chunk_count, video_id)
|
||||
return chunk_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to rebuild video %s: %s", video_id, e)
|
||||
session.rollback()
|
||||
return -1
|
||||
finally:
|
||||
session.close()
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="存量指纹重建脚本")
|
||||
parser.add_argument("--dry-run", action="store_true", help="只打印不写入")
|
||||
parser.add_argument("--batch-size", type=int, default=50, help="每批处理数量(默认 50)")
|
||||
parser.add_argument("--total-limit", type=int, default=0, help="总处理数量限制(0=不限制)")
|
||||
args = parser.parse_args()
|
||||
|
||||
from worker_app.db import SessionLocal
|
||||
|
||||
session = SessionLocal()
|
||||
|
||||
try:
|
||||
videos = find_videos_needing_rebuild(session, args.batch_size)
|
||||
logger.info("Found %d videos needing rebuild", len(videos))
|
||||
|
||||
if args.dry_run:
|
||||
for v in videos:
|
||||
logger.info("[DRY-RUN] Video %s | project=%s | duration=%.1fs", v["id"], v["project_id"], v["duration"])
|
||||
return
|
||||
|
||||
total_chunks = 0
|
||||
processed = 0
|
||||
failed = 0
|
||||
|
||||
for v in videos:
|
||||
if args.total_limit > 0 and processed >= args.total_limit:
|
||||
break
|
||||
|
||||
result = rebuild_one(v, dry_run=False)
|
||||
if result < 0:
|
||||
failed += 1
|
||||
else:
|
||||
total_chunks += result
|
||||
processed += 1
|
||||
|
||||
logger.info(
|
||||
"Rebuild complete: processed=%d, chunks=%d, failed=%d",
|
||||
processed,
|
||||
total_chunks,
|
||||
failed,
|
||||
)
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user