Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b233f8e539 | |||
| 78257fae96 |
@@ -1,161 +0,0 @@
|
|||||||
name: ACR Cleanup
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 19 * * *' # UTC 19:00 = 北京时间凌晨3:00
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
pr_sha:
|
|
||||||
description: "PR commit SHA(仅清理指定PR镜像,留空则全量清理)"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
dry_run:
|
|
||||||
description: "预览模式(dry-run),不实际删除"
|
|
||||||
required: false
|
|
||||||
default: "true"
|
|
||||||
pull_request_target:
|
|
||||||
types: [closed]
|
|
||||||
branches: [develop, main]
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: acr-cleanup-${{ gitea.ref }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cleanup:
|
|
||||||
name: ACR Image Cleanup
|
|
||||||
runs-on: ci-l2
|
|
||||||
timeout-minutes: 20
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
env:
|
|
||||||
ACR_REGISTRY: xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com
|
|
||||||
ACR_NAMESPACE: xiaoxiakeji
|
|
||||||
ACR_SERVICE: registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa
|
|
||||||
GITEA_URL: https://git.xiaoxiajianji.com
|
|
||||||
GITEA_REPO: xiaoxia/xiaoxia-saas
|
|
||||||
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
|
|
||||||
|
|
||||||
# ====== Cron模式:获取staging运行中镜像作为白名单 ======
|
|
||||||
- name: Get staging running images (whitelist)
|
|
||||||
id: protected_images
|
|
||||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
|
||||||
env:
|
|
||||||
STAGING_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
echo "获取staging服务器运行中镜像作为白名单..."
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa
|
|
||||||
chmod 600 ~/.ssh/id_rsa
|
|
||||||
|
|
||||||
staging_host="${STAGING_SSH_HOST:-47.98.113.167}"
|
|
||||||
staging_port="${STAGING_SSH_PORT:-22222}"
|
|
||||||
|
|
||||||
ssh-keyscan -p "$staging_port" -H "$staging_host" >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
# 获取所有运行容器的镜像,提取tag部分
|
|
||||||
IMAGES=$(ssh -p "$staging_port" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no \
|
|
||||||
"root@$staging_host" "docker ps --format '{{.Image}}' 2>/dev/null" 2>/dev/null | grep -v "^$" | sort -u)
|
|
||||||
|
|
||||||
PROTECTED_TAGS=""
|
|
||||||
if [ -n "$IMAGES" ]; then
|
|
||||||
while IFS= read -r img; do
|
|
||||||
# 从完整镜像名中提取tag(最后一个冒号后)
|
|
||||||
tag=$(echo "$img" | rev | cut -d: -f1 | rev)
|
|
||||||
if [ -n "$tag" ] && [ "$tag" != "latest" ] && [ ${#tag} -gt 5 ]; then
|
|
||||||
if [ -z "$PROTECTED_TAGS" ]; then
|
|
||||||
PROTECTED_TAGS="$tag"
|
|
||||||
else
|
|
||||||
PROTECTED_TAGS="$PROTECTED_TAGS,$tag"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done <<< "$IMAGES"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "staging运行中镜像tag: ${PROTECTED_TAGS:-(无)}"
|
|
||||||
echo "protected_tags=$PROTECTED_TAGS" >> $GITEA_OUTPUT
|
|
||||||
|
|
||||||
# ====== Docker登录 ======
|
|
||||||
- name: Docker login to ACR
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
printf '%s' "$ACR_PASSWORD" | docker login "$ACR_REGISTRY" -u "$ACR_USERNAME" --password-stdin
|
|
||||||
|
|
||||||
# ====== 模式1:PR关闭时清理 ======
|
|
||||||
- name: Cleanup PR images (PR closed)
|
|
||||||
if: gitea.event_name == 'pull_request_target'
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
PR_SHA: ${{ gitea.event.pull_request.head.sha }}
|
|
||||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
|
||||||
run: |
|
|
||||||
echo "============================================"
|
|
||||||
echo " PR #$PR_NUMBER 已关闭,清理对应镜像"
|
|
||||||
echo " Head SHA: ${PR_SHA::12}"
|
|
||||||
echo "============================================"
|
|
||||||
echo ""
|
|
||||||
python3 scripts/ci/acr_cleanup.py \
|
|
||||||
--pr-sha "$PR_SHA" \
|
|
||||||
--execute
|
|
||||||
|
|
||||||
# ====== 模式2:Cron全量清理 ======
|
|
||||||
- name: Full cleanup (cron / manual)
|
|
||||||
if: gitea.event_name != 'pull_request_target' && !gitea.event.inputs.pr_sha
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
PROTECTED_TAGS: ${{ steps.protected_images.outputs.protected_tags }}
|
|
||||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
|
||||||
run: |
|
|
||||||
echo "============================================"
|
|
||||||
echo " ACR 全量清理(${{ gitea.event_name }})"
|
|
||||||
echo "============================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 决定是否dry-run
|
|
||||||
DRY_RUN_FLAG=""
|
|
||||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
|
||||||
DRY_RUN_FLAG="--dry-run"
|
|
||||||
echo "模式: 预览模式 (dry-run)"
|
|
||||||
else
|
|
||||||
echo "模式: 执行模式"
|
|
||||||
fi
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
python3 scripts/ci/acr_cleanup.py \
|
|
||||||
--keep 20 \
|
|
||||||
--protected-tags "$PROTECTED_TAGS" \
|
|
||||||
$DRY_RUN_FLAG
|
|
||||||
|
|
||||||
# ====== 模式3:手动指定PR SHA清理 ======
|
|
||||||
- name: Cleanup specific PR image (manual)
|
|
||||||
if: gitea.event_name == 'workflow_dispatch' && gitea.event.inputs.pr_sha
|
|
||||||
env:
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
PR_SHA: ${{ gitea.event.inputs.pr_sha }}
|
|
||||||
DRY_RUN_INPUT: ${{ gitea.event.inputs.dry_run }}
|
|
||||||
run: |
|
|
||||||
echo "手动清理PR镜像: ${PR_SHA::12}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
DRY_RUN_FLAG=""
|
|
||||||
if [ "$DRY_RUN_INPUT" = "true" ]; then
|
|
||||||
DRY_RUN_FLAG="--dry-run"
|
|
||||||
fi
|
|
||||||
|
|
||||||
python3 scripts/ci/acr_cleanup.py \
|
|
||||||
--pr-sha "$PR_SHA" \
|
|
||||||
$DRY_RUN_FLAG
|
|
||||||
@@ -8,7 +8,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-health-report:
|
ci-health-report:
|
||||||
name: CI健康度每日巡检
|
name: CI健康度每日巡检
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
+157
-375
@@ -21,8 +21,20 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-pipeline-${{ gitea.ref }}
|
group: ci-pipeline-${{ gitea.event_name }}-${{ gitea.ref }}
|
||||||
cancel-in-progress: true
|
# PR事件取消进行中的旧run,push事件不取消(确保完整CI跑完)
|
||||||
|
cancel-in-progress: ${{ gitea.event_name == 'pull_request' }}
|
||||||
|
env:
|
||||||
|
CI_PG_HOST: host.docker.internal
|
||||||
|
CI_LOCAL_PG_PORT: "5432"
|
||||||
|
CI_PG_USER: postgres
|
||||||
|
CI_PG_PASSWORD: postgres
|
||||||
|
CI_PG_DB: xiaoxia_saas
|
||||||
|
CI_SHARED_PG_PORT: "5433"
|
||||||
|
CI_SHARED_PG_USER: postgres
|
||||||
|
CI_SHARED_PG_PASSWORD: ci_pg_2026!
|
||||||
|
CI_DEFAULT_DB: xiaoxia_saas
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-frontend-only:
|
check-frontend-only:
|
||||||
name: Check if frontend-only change
|
name: Check if frontend-only change
|
||||||
@@ -37,7 +49,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Check changed files
|
- name: Check changed files
|
||||||
id: check
|
id: check
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -90,7 +102,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -180,7 +192,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -250,7 +262,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||||
USE_IN_MEMORY_DB: 'false'
|
USE_IN_MEMORY_DB: 'false'
|
||||||
CI_USE_SHARED_PG: 'true'
|
CI_USE_SHARED_PG: 'true'
|
||||||
steps:
|
steps:
|
||||||
@@ -259,7 +271,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -334,13 +346,14 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: placeholder
|
OSS_ACCESS_KEY_SECRET: placeholder
|
||||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||||
|
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -398,20 +411,21 @@ jobs:
|
|||||||
- validate-type-check
|
- validate-type-check
|
||||||
- validate-migration
|
- validate-migration
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: postgresql+psycopg://postgres:postgres@host.docker.internal:5432/xiaoxia_saas
|
DATABASE_URL: postgresql+psycopg://${{ env.CI_PG_USER }}:${{ env.CI_PG_PASSWORD }}@${{ env.CI_PG_HOST }}:${{ env.CI_LOCAL_PG_PORT }}/${{ env.CI_PG_DB }}
|
||||||
USE_IN_MEMORY_DB: 'false'
|
USE_IN_MEMORY_DB: 'false'
|
||||||
CI_USE_SHARED_PG: 'true'
|
CI_USE_SHARED_PG: 'true'
|
||||||
OSS_ACCESS_KEY_ID: placeholder
|
OSS_ACCESS_KEY_ID: placeholder
|
||||||
OSS_ACCESS_KEY_SECRET: placeholder
|
OSS_ACCESS_KEY_SECRET: placeholder
|
||||||
OSS_BUCKET_NAME: xiaoxia-autocut
|
OSS_BUCKET_NAME: xiaoxia-autocut
|
||||||
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
OSS_ENDPOINT: oss-cn-hangzhou.aliyuncs.com
|
||||||
|
JWT_SECRET_KEY: test-jwt-secret-for-ci-only-2026
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -457,15 +471,13 @@ jobs:
|
|||||||
name: Frontend Lint
|
name: Frontend Lint
|
||||||
runs-on: ci-l2
|
runs-on: ci-l2
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
needs: check-frontend-only
|
|
||||||
if: needs.check-frontend-only.outputs.skip_frontend != 'true'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -527,7 +539,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -577,12 +589,7 @@ jobs:
|
|||||||
name: PR Build ${{ matrix.service_display }} Image
|
name: PR Build ${{ matrix.service_display }} Image
|
||||||
runs-on: runtime-builder
|
runs-on: runtime-builder
|
||||||
timeout-minutes: ${{ matrix.timeout }}
|
timeout-minutes: ${{ matrix.timeout }}
|
||||||
needs: check-frontend-only
|
if: github.event_name == 'pull_request'
|
||||||
if: |
|
|
||||||
github.event_name == 'pull_request' && (
|
|
||||||
(matrix.service == 'web' && needs.check-frontend-only.outputs.skip_frontend != 'true') ||
|
|
||||||
(matrix.service != 'web' && needs.check-frontend-only.outputs.skip_backend != 'true')
|
|
||||||
)
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -611,7 +618,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -633,60 +640,83 @@ jobs:
|
|||||||
echo "Docker login failed ($i/3), retrying in 5s..."
|
echo "Docker login failed ($i/3), retrying in 5s..."
|
||||||
sleep 5
|
sleep 5
|
||||||
done
|
done
|
||||||
- name: Pre-build worker base images (fallback if not exist)
|
- name: Pre-build worker base images (3-level cache)
|
||||||
if: matrix.service == 'worker'
|
if: matrix.service == 'worker'
|
||||||
id: prebuild
|
id: prebuild
|
||||||
shell: sh
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
GITEA_REGISTRY="git.xiaoxiajianji.com/xiaoxia-saas"
|
||||||
BASE_BUILDER="${REGISTRY}/worker-base-builder:latest"
|
ACR_REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||||
BASE_RUNTIME="${REGISTRY}/worker-base-runtime:latest"
|
GITEA_BUILDER="${GITEA_REGISTRY}/worker-base-builder:latest"
|
||||||
|
GITEA_RUNTIME="${GITEA_REGISTRY}/worker-base-runtime:latest"
|
||||||
# 尝试拉取基础镜像
|
ACR_BUILDER="${ACR_REGISTRY}/worker-base-builder:latest"
|
||||||
echo "检查基础镜像..."
|
ACR_RUNTIME="${ACR_REGISTRY}/worker-base-runtime:latest"
|
||||||
if docker pull "$BASE_BUILDER" 2>/dev/null && docker pull "$BASE_RUNTIME" 2>/dev/null; then
|
|
||||||
echo "基础镜像已存在,使用远程镜像"
|
|
||||||
echo "fallback=false" >> $GITHUB_OUTPUT
|
|
||||||
else
|
|
||||||
echo "基础镜像不存在,本地构建(fallback模式)..."
|
|
||||||
|
|
||||||
# 尝试用buildx构建,失败则回退到普通docker build(DooD模式下buildx builder偶发崩溃)
|
|
||||||
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
|
||||||
BUILDX_AVAILABLE=true
|
|
||||||
if ! docker buildx create --use --name "$BUILDER_NAME" --driver docker-container > /dev/null 2>&1; then
|
|
||||||
BUILDX_AVAILABLE=false
|
|
||||||
fi
|
|
||||||
if [ "$BUILDX_AVAILABLE" = true ] && ! docker buildx inspect --bootstrap > /dev/null 2>&1; then
|
|
||||||
BUILDX_AVAILABLE=false
|
|
||||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
build_base() {
|
|
||||||
local df="$1"
|
|
||||||
local tag="$2"
|
|
||||||
local name="$3"
|
|
||||||
if [ "$BUILDX_AVAILABLE" = true ]; then
|
|
||||||
echo "构建 $name(buildx)..."
|
|
||||||
if docker buildx build --load -f "$df" -t "$tag" . > /dev/null 2>&1; then
|
|
||||||
echo "$name 构建成功"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "buildx失败,回退到普通docker build"
|
|
||||||
BUILDX_AVAILABLE=false
|
|
||||||
docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
echo "构建 $name(docker build)..."
|
|
||||||
docker build -f "$df" -t "$tag" .
|
|
||||||
}
|
|
||||||
|
|
||||||
build_base infra/docker/worker-base-builder.Dockerfile "$BASE_BUILDER" "worker-base-builder"
|
|
||||||
build_base infra/docker/worker-base-runtime.Dockerfile "$BASE_RUNTIME" "worker-base-runtime"
|
|
||||||
|
|
||||||
echo "fallback=true" >> $GITHUB_OUTPUT
|
|
||||||
echo "基础镜像本地构建完成"
|
|
||||||
fi
|
|
||||||
|
|
||||||
|
# L1: 本地daemon缓存(DooD模式8runner共享宿主机daemon)
|
||||||
|
echo "=== L1 本地缓存 ==="
|
||||||
|
if docker image inspect "$ACR_BUILDER" > /dev/null 2>&1 \
|
||||||
|
&& docker image inspect "$ACR_RUNTIME" > /dev/null 2>&1; then
|
||||||
|
echo "本地缓存命中"
|
||||||
|
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "本地无缓存"
|
||||||
|
|
||||||
|
# L2: Gitea registry缓存(内网快)
|
||||||
|
echo "=== L2 Registry拉取 ==="
|
||||||
|
if docker pull "$GITEA_BUILDER" 2>/dev/null && docker pull "$GITEA_RUNTIME" 2>/dev/null; then
|
||||||
|
echo "Registry拉取成功,重tag供Dockerfile使用"
|
||||||
|
docker tag "$GITEA_BUILDER" "$ACR_BUILDER"
|
||||||
|
docker tag "$GITEA_RUNTIME" "$ACR_RUNTIME"
|
||||||
|
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "Registry无缓存,需本地构建"
|
||||||
|
|
||||||
|
# L3: 本地构建
|
||||||
|
echo "=== L3 本地构建 ==="
|
||||||
|
BUILDER_NAME="ci-pr-builder-${GITHUB_RUN_ID:-local}"
|
||||||
|
if ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then
|
||||||
|
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||||
|
else
|
||||||
|
docker buildx use "$BUILDER_NAME"
|
||||||
|
fi
|
||||||
|
docker buildx inspect --bootstrap > /dev/null 2>&1
|
||||||
|
|
||||||
|
echo "构建 worker-base-builder..."
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if docker buildx build --load -f infra/docker/worker-base-builder.Dockerfile -t "$ACR_BUILDER" .; then
|
||||||
|
echo "worker-base-builder 构建成功"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "worker-base-builder 失败,重试 $attempt/3..."
|
||||||
|
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||||
|
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "构建 worker-base-runtime..."
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if docker buildx build --load -f infra/docker/worker-base-runtime.Dockerfile -t "$ACR_RUNTIME" .; then
|
||||||
|
echo "worker-base-runtime 构建成功"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "worker-base-runtime 失败,重试 $attempt/3..."
|
||||||
|
docker buildx rm "$BUILDER_NAME" 2>/dev/null || true
|
||||||
|
docker buildx create --use --name "$BUILDER_NAME" --driver docker-container
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
|
||||||
|
# 推送到Gitea registry供后续复用
|
||||||
|
echo "=== 推送缓存到Registry ==="
|
||||||
|
docker tag "$ACR_BUILDER" "$GITEA_BUILDER"
|
||||||
|
docker tag "$ACR_RUNTIME" "$GITEA_RUNTIME"
|
||||||
|
docker push "$GITEA_BUILDER" 2>/dev/null || echo "push builder失败(不影响)"
|
||||||
|
docker push "$GITEA_RUNTIME" 2>/dev/null || echo "push runtime失败(不影响)"
|
||||||
|
|
||||||
|
echo "has_local_base=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "基础镜像构建完成"
|
||||||
- name: Build PR image (verify only, no push)
|
- name: Build PR image (verify only, no push)
|
||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
@@ -700,15 +730,15 @@ jobs:
|
|||||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-staging.conf"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Worker fallback模式:基础镜像本地已构建,用普通docker build绕过buildx
|
# Worker有本地base镜像时:用BuildKit直接构建(快,无需起buildx容器)
|
||||||
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.fallback }}" = "true" ]; then
|
if [ "${{ matrix.service }}" = "worker" ] && [ "${{ steps.prebuild.outputs.has_local_base }}" = "true" ]; then
|
||||||
echo "Fallback模式:用普通docker build(基础镜像本地已构建)"
|
echo "本地base镜像已就绪,BuildKit快速构建"
|
||||||
BUILD_ARG_STR=""
|
BUILD_ARG_STR=""
|
||||||
for arg in $EXTRA_BUILD_ARGS; do
|
for arg in $EXTRA_BUILD_ARGS; do
|
||||||
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
BUILD_ARG_STR="$BUILD_ARG_STR --build-arg $arg"
|
||||||
done
|
done
|
||||||
docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
DOCKER_BUILDKIT=1 docker build -f ${{ matrix.dockerfile }} -t "${IMAGE_TAG}" $BUILD_ARG_STR .
|
||||||
echo "Fallback PR Build successful"
|
echo "快速构建成功"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -795,7 +825,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -927,7 +957,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -1079,14 +1109,32 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
- name: Run Playwright E2E on staging
|
- name: Run Playwright E2E on staging
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
bash scripts/ci/run_staging_tests.sh e2e
|
set -eu
|
||||||
|
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||||
|
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||||
|
CONTAINER_NAME="staging-e2e-${GITHUB_SHA::8}"
|
||||||
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
docker create --name "$CONTAINER_NAME" --ipc=host \
|
||||||
|
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||||
|
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||||
|
-e E2E_BROWSER_CHANNEL=chromium \
|
||||||
|
-e PLAYWRIGHT_HEADLESS=1 \
|
||||||
|
-w /workspace/apps/web \
|
||||||
|
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||||
|
sh -lc "npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts"
|
||||||
|
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||||
|
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||||
|
docker start -a "$CONTAINER_NAME"
|
||||||
|
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||||
|
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
exit $EXIT_CODE
|
||||||
|
|
||||||
- name: Job duration summary
|
- name: Job duration summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1126,14 +1174,30 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
- name: Run API integration tests on staging
|
- name: Run API integration tests on staging
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
bash scripts/ci/run_staging_tests.sh api
|
set -eu
|
||||||
|
# DooD模式下不能用-v挂载(宿主机路径与CI容器路径不一致)
|
||||||
|
# 改用 docker create + docker cp 方式把代码拷进容器
|
||||||
|
CONTAINER_NAME="staging-api-tests-${GITHUB_SHA::8}"
|
||||||
|
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
docker create --name "$CONTAINER_NAME" \
|
||||||
|
-e E2E_BASE_URL=https://staging.xiaoxiajianji.com \
|
||||||
|
-e E2E_API_BASE=https://staging-api.xiaoxiajianji.com/api/v1 \
|
||||||
|
-w /workspace/apps/web \
|
||||||
|
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||||
|
sh -lc 'npm ci && npx playwright test --reporter=line e2e/test_auth.spec.ts e2e/test_asset.spec.ts e2e/test_project.spec.ts'
|
||||||
|
docker cp apps "$CONTAINER_NAME:/workspace/"
|
||||||
|
docker cp package-lock.json "$CONTAINER_NAME:/workspace/" 2>/dev/null || true
|
||||||
|
docker start -a "$CONTAINER_NAME"
|
||||||
|
EXIT_CODE=$(docker wait "$CONTAINER_NAME")
|
||||||
|
docker rm "$CONTAINER_NAME" 2>/dev/null || true
|
||||||
|
exit $EXIT_CODE
|
||||||
|
|
||||||
- name: Job duration summary
|
- name: Job duration summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1166,12 +1230,7 @@ jobs:
|
|||||||
runs-on: runtime-builder
|
runs-on: runtime-builder
|
||||||
timeout-minutes: ${{ matrix.timeout }}
|
timeout-minutes: ${{ matrix.timeout }}
|
||||||
needs:
|
needs:
|
||||||
- validate-code-quality
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
- validate-type-check
|
|
||||||
- unit-tests
|
|
||||||
- frontend-lint
|
|
||||||
- frontend-unit-test
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
@@ -1200,7 +1259,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -1244,20 +1303,14 @@ jobs:
|
|||||||
docker buildx inspect --bootstrap
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
- name: Build and push production ${{ matrix.service_display }} image (with retry)
|
||||||
shell: bash
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
REGISTRY="xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji"
|
||||||
# 根据ref类型设置镜像标签:tag用版本号,分支用分支名+sha
|
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${GITHUB_REF_NAME}"
|
||||||
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
|
||||||
TAG_NAME="${GITHUB_REF_NAME}"
|
|
||||||
else
|
|
||||||
TAG_NAME="${GITHUB_REF_NAME}-${GITHUB_SHA::8}"
|
|
||||||
fi
|
|
||||||
IMAGE_TAG="${REGISTRY}/${{ matrix.image_name }}:${TAG_NAME}"
|
|
||||||
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
CACHE_REF="${REGISTRY}/${{ matrix.cache_name }}:main"
|
||||||
|
|
||||||
EXTRA_BUILD_ARGS="APP_VERSION=\"${TAG_NAME}\""
|
EXTRA_BUILD_ARGS="APP_VERSION=\"${GITHUB_REF_NAME}\""
|
||||||
if [ "${{ matrix.service }}" = "web" ]; then
|
if [ "${{ matrix.service }}" = "web" ]; then
|
||||||
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
|
EXTRA_BUILD_ARGS="$EXTRA_BUILD_ARGS NGINX_CONF=infra/docker/nginx-production.conf"
|
||||||
fi
|
fi
|
||||||
@@ -1324,7 +1377,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -1455,13 +1508,14 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
- name: Run production browser E2E
|
- name: Run production browser E2E
|
||||||
shell: bash
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
|
set -eu
|
||||||
docker run --rm --ipc=host \
|
docker run --rm --ipc=host \
|
||||||
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
-e E2E_BASE_URL=https://saas.xiaoxiajianji.com \
|
||||||
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
-e E2E_API_BASE=https://api.xiaoxiajianji.com/api/v1 \
|
||||||
@@ -1470,7 +1524,7 @@ jobs:
|
|||||||
-v "$PWD:/workspace" \
|
-v "$PWD:/workspace" \
|
||||||
-w /workspace/apps/web \
|
-w /workspace/apps/web \
|
||||||
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
git.xiaoxiajianji.com/xiaoxia/base/playwright:v1.45.0-jammy \
|
||||||
bash -c 'for i in 1 2 3; do npm ci --registry=https://registry.npmmirror.com && break; echo "npm ci attempt $i failed, retrying..."; sleep 15; done && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
sh -lc 'npm ci && npx playwright test --reporter=line --project=chromium e2e/auth.spec.ts e2e/auth-guard.spec.ts e2e/core-upload.spec.ts e2e/core-generation.spec.ts e2e/core-titles.spec.ts'
|
||||||
|
|
||||||
- name: Job duration summary
|
- name: Job duration summary
|
||||||
if: always()
|
if: always()
|
||||||
@@ -1514,7 +1568,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
||||||
- name: Record job start time
|
- name: Record job start time
|
||||||
shell: sh
|
shell: sh
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
run: bash scripts/ci/step_timer_start.sh
|
||||||
@@ -1554,276 +1608,4 @@ jobs:
|
|||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
[ ${{ job.status }} = "success" ] || STATUS="error"
|
||||||
START_TIME=""
|
START_TIME=""
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
||||||
|
|
||||||
canary-release:
|
|
||||||
name: Canary Release to Production
|
|
||||||
runs-on: runtime-builder
|
|
||||||
timeout-minutes: 120
|
|
||||||
concurrency:
|
|
||||||
group: canary-release-production
|
|
||||||
cancel-in-progress: false
|
|
||||||
if: github.event_name == 'push' && github.ref_name == 'main'
|
|
||||||
needs:
|
|
||||||
- build-production
|
|
||||||
- staging-api-tests
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
|
||||||
- name: Record job start time
|
|
||||||
shell: sh
|
|
||||||
run: bash scripts/ci/step_timer_start.sh
|
|
||||||
- name: Notify canary release start
|
|
||||||
continue-on-error: true
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
NOTIFY_MODE=start JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
|
||||||
- name: Install SSH client
|
|
||||||
shell: sh
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
apt-get update -qq && apt-get install -y -qq openssh-client curl >/dev/null 2>&1
|
|
||||||
echo "openssh-client installed"
|
|
||||||
- name: Run canary release
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
PRODUCTION_SSH_HOST: ${{ secrets.PRODUCTION_SSH_HOST }}
|
|
||||||
PRODUCTION_SSH_USER: ${{ secrets.PRODUCTION_SSH_USER }}
|
|
||||||
PRODUCTION_SSH_PORT: ${{ secrets.PRODUCTION_SSH_PORT }}
|
|
||||||
PRODUCTION_SSH_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
|
|
||||||
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
|
|
||||||
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
IMAGE_TAG="main-${GITHUB_SHA::8}"
|
|
||||||
export IMAGE_TAG
|
|
||||||
echo "Canary release version: $IMAGE_TAG"
|
|
||||||
bash scripts/ci/canary_release.sh
|
|
||||||
- name: Job duration summary
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
run: bash scripts/ci/step_timer_end.sh
|
|
||||||
- name: Notify on success
|
|
||||||
continue-on-error: true
|
|
||||||
if: success()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
NOTIFY_MODE=success JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
|
||||||
- name: Notify on failure
|
|
||||||
continue-on-error: true
|
|
||||||
if: failure()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
|
|
||||||
run: |
|
|
||||||
set +e
|
|
||||||
NOTIFY_MODE=failure JOB_NAME="Canary Release" python3 scripts/ci_notify.py
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ ${{ job.status }} = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
|
|
||||||
ci-gate:
|
|
||||||
name: CI Gate
|
|
||||||
runs-on: ci-l2
|
|
||||||
if: always() && github.event_name == 'pull_request'
|
|
||||||
needs:
|
|
||||||
- check-frontend-only
|
|
||||||
- validate-code-quality
|
|
||||||
- validate-type-check
|
|
||||||
- validate-migration
|
|
||||||
- unit-tests
|
|
||||||
- integration-tests
|
|
||||||
- frontend-lint
|
|
||||||
- frontend-unit-test
|
|
||||||
- build-pr
|
|
||||||
timeout-minutes: 3
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
run: |
|
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /tmp/_ci_checkout.sh \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" && bash /tmp/_ci_checkout.sh
|
|
||||||
|
|
||||||
- name: Evaluate CI Gate
|
|
||||||
id: gate
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
||||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
||||||
RESULT_CHECK_FRONTEND: ${{ needs.check-frontend-only.result }}
|
|
||||||
RESULT_CODE_QUALITY: ${{ needs.validate-code-quality.result }}
|
|
||||||
RESULT_TYPE_CHECK: ${{ needs.validate-type-check.result }}
|
|
||||||
RESULT_MIGRATION: ${{ needs.validate-migration.result }}
|
|
||||||
RESULT_UNIT_TESTS: ${{ needs.unit-tests.result }}
|
|
||||||
RESULT_INTEGRATION: ${{ needs.integration-tests.result }}
|
|
||||||
RESULT_FRONTEND_LINT: ${{ needs.frontend-lint.result }}
|
|
||||||
RESULT_FRONTEND_UNIT: ${{ needs.frontend-unit-test.result }}
|
|
||||||
RESULT_BUILD_PR: ${{ needs.build-pr.result }}
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
echo "=== CI Gate 评估 ==="
|
|
||||||
echo ""
|
|
||||||
echo "各job结果:"
|
|
||||||
echo " check-frontend-only: $RESULT_CHECK_FRONTEND"
|
|
||||||
echo " validate-code-quality: $RESULT_CODE_QUALITY"
|
|
||||||
echo " validate-type-check: $RESULT_TYPE_CHECK"
|
|
||||||
echo " validate-migration: $RESULT_MIGRATION"
|
|
||||||
echo " unit-tests: $RESULT_UNIT_TESTS"
|
|
||||||
echo " integration-tests: $RESULT_INTEGRATION"
|
|
||||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
|
||||||
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
|
||||||
echo " build-pr: $RESULT_BUILD_PR"
|
|
||||||
|
|
||||||
# 查询 AI Code Review 状态(跨workflow,读commit status)
|
|
||||||
AI_REVIEW_STATUS="pending"
|
|
||||||
AI_REVIEW_DESC=""
|
|
||||||
STATUS_JSON=$(curl -sfH "Authorization: token $GITHUB_TOKEN" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/commits/${PR_HEAD_SHA}/status" 2>/dev/null || true)
|
|
||||||
if [ -n "$STATUS_JSON" ]; then
|
|
||||||
AI_STATUS=$(echo "$STATUS_JSON" | python3 -c "
|
|
||||||
import json,sys
|
|
||||||
try:
|
|
||||||
data=json.load(sys.stdin)
|
|
||||||
for s in data.get('statuses',[]):
|
|
||||||
if 'AI Code Review' in s.get('context',''):
|
|
||||||
print(s['state']+'|'+s.get('description',''))
|
|
||||||
break
|
|
||||||
except: pass
|
|
||||||
" 2>/dev/null)
|
|
||||||
if [ -n "$AI_STATUS" ]; then
|
|
||||||
AI_REVIEW_STATUS="${AI_STATUS%%|*}"
|
|
||||||
AI_REVIEW_DESC="${AI_STATUS#*|}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
echo " ai-code-review: $AI_REVIEW_STATUS ($AI_REVIEW_DESC)"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 判断PR类型
|
|
||||||
SKIP_BACKEND="${{ needs.check-frontend-only.outputs.skip_backend }}"
|
|
||||||
SKIP_FRONTEND="${{ needs.check-frontend-only.outputs.skip_frontend }}"
|
|
||||||
echo "PR类型: skip_backend=$SKIP_BACKEND, skip_frontend=$SKIP_FRONTEND"
|
|
||||||
|
|
||||||
# 必填检查项(根据PR类型决定)
|
|
||||||
# 通用检查(所有PR都必须过)
|
|
||||||
REQUIRED_GENERAL=(
|
|
||||||
"validate-code-quality:$RESULT_CODE_QUALITY"
|
|
||||||
"validate-type-check:$RESULT_TYPE_CHECK"
|
|
||||||
"validate-migration:$RESULT_MIGRATION"
|
|
||||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
|
||||||
"build-pr:$RESULT_BUILD_PR"
|
|
||||||
"ai-code-review:$AI_REVIEW_STATUS"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 后端检查
|
|
||||||
REQUIRED_BACKEND=(
|
|
||||||
"unit-tests:$RESULT_UNIT_TESTS"
|
|
||||||
"integration-tests:$RESULT_INTEGRATION"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 前端检查
|
|
||||||
REQUIRED_FRONTEND=(
|
|
||||||
"frontend-unit-test:$RESULT_FRONTEND_UNIT"
|
|
||||||
)
|
|
||||||
|
|
||||||
ALL_PASSED=true
|
|
||||||
FAILED_ITEMS=()
|
|
||||||
|
|
||||||
check_job() {
|
|
||||||
local name=$1
|
|
||||||
local result=$2
|
|
||||||
if [ "$result" = "success" ]; then
|
|
||||||
echo " ✅ $name: success"
|
|
||||||
elif [ "$result" = "skipped" ]; then
|
|
||||||
echo " ⏭️ $name: skipped(跳过,不影响)"
|
|
||||||
else
|
|
||||||
echo " ❌ $name: $result"
|
|
||||||
ALL_PASSED=false
|
|
||||||
FAILED_ITEMS+=("$name=$result")
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== 通用检查(所有PR必填)==="
|
|
||||||
for item in "${REQUIRED_GENERAL[@]}"; do
|
|
||||||
name="${item%%:*}"
|
|
||||||
result="${item##*:}"
|
|
||||||
# AI Code Review pending时不阻塞(可能还在跑),等它跑完自然会重跑Gate
|
|
||||||
if [ "$name" = "ai-code-review" ] && [ "$result" = "pending" ]; then
|
|
||||||
echo " ⏳ $name: pending(审查中,暂不阻塞)"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
check_job "$name" "$result"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$SKIP_BACKEND" != "true" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "=== 后端检查 ==="
|
|
||||||
for item in "${REQUIRED_BACKEND[@]}"; do
|
|
||||||
name="${item%%:*}"
|
|
||||||
result="${item##*:}"
|
|
||||||
check_job "$name" "$result"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "=== 后端检查(纯前端PR,跳过)==="
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$SKIP_FRONTEND" != "true" ]; then
|
|
||||||
echo ""
|
|
||||||
echo "=== 前端检查 ==="
|
|
||||||
for item in "${REQUIRED_FRONTEND[@]}"; do
|
|
||||||
name="${item%%:*}"
|
|
||||||
result="${item##*:}"
|
|
||||||
check_job "$name" "$result"
|
|
||||||
done
|
|
||||||
else
|
|
||||||
echo ""
|
|
||||||
echo "=== 前端检查(纯后端PR,跳过)==="
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
if [ "$ALL_PASSED" = "true" ]; then
|
|
||||||
echo "✅ CI Gate: PASSED"
|
|
||||||
echo "gate_result=success" >> $GITHUB_OUTPUT
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "❌ CI Gate: FAILED"
|
|
||||||
echo "失败项: ${FAILED_ITEMS[*]}"
|
|
||||||
echo "gate_result=failure" >> $GITHUB_OUTPUT
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Report CI trace
|
|
||||||
if: always()
|
|
||||||
shell: sh
|
|
||||||
env:
|
|
||||||
AGENTLOOP_LICENSE_KEY: ${{ secrets.AGENTLOOP_LICENSE_KEY }}
|
|
||||||
run: |
|
|
||||||
STATUS="ok"
|
|
||||||
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
|
|
||||||
START_TIME=""
|
|
||||||
[ -f /tmp/ci_job_start_time ] && START_TIME=$(cat /tmp/ci_job_start_time)
|
|
||||||
python3 scripts/ci/ci_trace_report.py --service xiaoxia-saas-ci --status $STATUS --start-time "$START_TIME" || true
|
|
||||||
@@ -16,15 +16,15 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
monitor:
|
monitor:
|
||||||
name: Monitor CI Trigger Reliability
|
name: Monitor CI Trigger Reliability
|
||||||
runs-on: ci-l2
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
uses: actions/checkout@v3
|
||||||
env:
|
# 网络波动自动重试2次
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
retry:
|
||||||
run: |
|
max_attempts: 2
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
retry_on: error
|
||||||
|
|
||||||
- name: Check CI trigger status for all open PRs
|
- name: Check CI trigger status for all open PRs
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -15,18 +15,20 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
code-review:
|
code-review:
|
||||||
name: AI Code Review
|
name: AI Code Review
|
||||||
runs-on: ci-l2
|
runs-on: ubuntu-latest
|
||||||
# 跳过草稿 PR
|
# 跳过草稿 PR
|
||||||
if: ${{ !gitea.event.pull_request.draft }}
|
if: ${{ !gitea.event.pull_request.draft }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
# actions/checkout 由 runner 在宿主机层面处理,不受容器网络影响
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
uses: actions/checkout@v3
|
||||||
env:
|
with:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
fetch-depth: 0
|
||||||
run: |
|
# 网络波动自动重试2次
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" | bash
|
retry:
|
||||||
|
max_attempts: 2
|
||||||
|
retry_on: error
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -48,7 +50,6 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||||
REPO_NAME: ${{ gitea.repository }}
|
REPO_NAME: ${{ gitea.repository }}
|
||||||
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
PR_NUMBER: ${{ gitea.event.pull_request.number }}
|
||||||
PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }}
|
|
||||||
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
# LLM 提供商: coze (扣子原生Bot) / openai (OpenAI兼容)
|
||||||
LLM_PROVIDER: "coze"
|
LLM_PROVIDER: "coze"
|
||||||
# 扣子模式配置(默认国内站 api.coze.cn)
|
# 扣子模式配置(默认国内站 api.coze.cn)
|
||||||
@@ -61,9 +62,8 @@ jobs:
|
|||||||
LLM_TIMEOUT: "120"
|
LLM_TIMEOUT: "120"
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/ci_code_review.py
|
python3 scripts/ci_code_review.py
|
||||||
# 注意:脚本退出码决定job状态
|
# 审查脚本异常不影响 CI 通过
|
||||||
# - 有阻塞级问题 → exit 1 → job失败 → 门禁拦截
|
continue-on-error: true
|
||||||
# - 无阻塞级问题/LLM异常 → exit 0 → 通过(fail-open)
|
|
||||||
|
|
||||||
- name: Report CI trace
|
- name: Report CI trace
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
name: Daily Health Check
|
name: Daily Health Check
|
||||||
# 注意:使用 curl step_checkout.sh 方式以兼容 docker runner
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -13,7 +12,7 @@ jobs:
|
|||||||
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
# ── 1. 生产环境冒烟测试 ─────────────────────────────────────────────
|
||||||
production-smoke:
|
production-smoke:
|
||||||
name: Production Smoke Test
|
name: Production Smoke Test
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 8
|
timeout-minutes: 8
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.smoke.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
@@ -24,9 +23,47 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
set -eu
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
python3 - <<'PY'
|
||||||
| bash
|
import io, os, tarfile, time, urllib.request, urllib.error
|
||||||
|
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||||
|
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
|
archive = response.read()
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last_err = e
|
||||||
|
if e.code >= 500 and attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise last_err
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||||
|
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name
|
||||||
|
if name == root_prefix[:-1]:
|
||||||
|
continue
|
||||||
|
if name.startswith(root_prefix):
|
||||||
|
member.name = name[len(root_prefix):]
|
||||||
|
if member.name:
|
||||||
|
tar.extract(member, '.')
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Production health check & smoke test
|
- name: Production health check & smoke test
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -84,10 +121,10 @@ jobs:
|
|||||||
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
|
||||||
staging-api-tests:
|
staging-api-tests:
|
||||||
name: Staging API Integration Tests
|
name: Staging API Integration Tests
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.report.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -95,15 +132,50 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
set -eu
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
python3 - <<'PY'
|
||||||
| bash
|
import io, os, tarfile, time, urllib.request, urllib.error
|
||||||
|
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||||
|
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
|
archive = response.read()
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last_err = e
|
||||||
|
if e.code >= 500 and attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise last_err
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||||
|
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name
|
||||||
|
if name == root_prefix[:-1]:
|
||||||
|
continue
|
||||||
|
if name.startswith(root_prefix):
|
||||||
|
member.name = name[len(root_prefix):]
|
||||||
|
if member.name:
|
||||||
|
tar.extract(member, '.')
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Run API smoke test on staging
|
- name: Run API smoke test on staging
|
||||||
id: smoke
|
id: smoke
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
|
||||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
|
||||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
START_TIME=$(date +%s)
|
START_TIME=$(date +%s)
|
||||||
@@ -111,8 +183,8 @@ jobs:
|
|||||||
docker run --rm \
|
docker run --rm \
|
||||||
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
-e BASE_URL=https://staging-api.xiaoxiajianji.com \
|
||||||
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
-e WEB_URL=https://staging.xiaoxiajianji.com \
|
||||||
-e TEST_USER="$STAGING_TEST_USER" \
|
-e TEST_USER=18314979086@163.com \
|
||||||
-e TEST_PASSWORD="$STAGING_TEST_PASSWORD" \
|
-e TEST_PASSWORD=Ying1234 \
|
||||||
-e CLEANUP_ENABLED=1 \
|
-e CLEANUP_ENABLED=1 \
|
||||||
-e PERF_CHECK_ENABLED=1 \
|
-e PERF_CHECK_ENABLED=1 \
|
||||||
-e PERF_WARN_THRESHOLD_MS=500 \
|
-e PERF_WARN_THRESHOLD_MS=500 \
|
||||||
@@ -198,10 +270,10 @@ jobs:
|
|||||||
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
|
||||||
staging-e2e:
|
staging-e2e:
|
||||||
name: Staging Browser E2E
|
name: Staging Browser E2E
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.e2e.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -209,9 +281,47 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sH "Authorization: token $GITHUB_TOKEN" \
|
set -eu
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
python3 - <<'PY'
|
||||||
| bash
|
import io, os, tarfile, time, urllib.request, urllib.error
|
||||||
|
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
|
||||||
|
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
|
||||||
|
last_err = None
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=120) as response:
|
||||||
|
archive = response.read()
|
||||||
|
break
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
last_err = e
|
||||||
|
if e.code >= 500 and attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
last_err = e
|
||||||
|
if attempt < 4:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
else:
|
||||||
|
raise last_err
|
||||||
|
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
|
||||||
|
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name
|
||||||
|
if name == root_prefix[:-1]:
|
||||||
|
continue
|
||||||
|
if name.startswith(root_prefix):
|
||||||
|
member.name = name[len(root_prefix):]
|
||||||
|
if member.name:
|
||||||
|
tar.extract(member, '.')
|
||||||
|
PY
|
||||||
|
|
||||||
- name: Run Playwright E2E on staging
|
- name: Run Playwright E2E on staging
|
||||||
id: e2e
|
id: e2e
|
||||||
shell: sh
|
shell: sh
|
||||||
@@ -261,7 +371,7 @@ jobs:
|
|||||||
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
|
||||||
performance-check:
|
performance-check:
|
||||||
name: Performance Baseline Check
|
name: Performance Baseline Check
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 8
|
timeout-minutes: 8
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.report.outputs.report }}
|
report: ${{ steps.report.outputs.report }}
|
||||||
@@ -270,9 +380,6 @@ jobs:
|
|||||||
- name: Run performance baseline checks
|
- name: Run performance baseline checks
|
||||||
id: perf
|
id: perf
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
|
||||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
|
||||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
START_TIME=$(date +%s)
|
START_TIME=$(date +%s)
|
||||||
@@ -308,10 +415,9 @@ jobs:
|
|||||||
|
|
||||||
# 先登录获取 token
|
# 先登录获取 token
|
||||||
echo "--- 准备: 获取测试 Token ---"
|
echo "--- 准备: 获取测试 Token ---"
|
||||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
|
||||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$LOGIN_BODY" \
|
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||||
--max-time 10 2>&1)
|
--max-time 10 2>&1)
|
||||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||||
@@ -341,7 +447,7 @@ jobs:
|
|||||||
# 构建 curl 命令
|
# 构建 curl 命令
|
||||||
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||||
if [ "$method" = "POST" ]; then
|
if [ "$method" = "POST" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||||
fi
|
fi
|
||||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||||
@@ -389,9 +495,6 @@ jobs:
|
|||||||
- name: Generate performance report
|
- name: Generate performance report
|
||||||
id: report
|
id: report
|
||||||
shell: sh
|
shell: sh
|
||||||
env:
|
|
||||||
STAGING_TEST_USER: ${{ secrets.STAGING_TEST_USER }}
|
|
||||||
STAGING_TEST_PASSWORD: ${{ secrets.STAGING_TEST_PASSWORD }}
|
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
echo ""
|
echo ""
|
||||||
@@ -406,11 +509,10 @@ jobs:
|
|||||||
RESULTS=""
|
RESULTS=""
|
||||||
START_TIME=$(date +%s)
|
START_TIME=$(date +%s)
|
||||||
|
|
||||||
LOGIN_BODY="{\"email\":\"$STAGING_TEST_USER\",\"password\":\"$STAGING_TEST_PASSWORD\"}"
|
|
||||||
# 先登录获取 token
|
# 先登录获取 token
|
||||||
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
AUTH_RESP=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$LOGIN_BODY" \
|
-d '{"email":"18314979086@163.com","password":"Ying1234"}' \
|
||||||
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
"https://staging-api.xiaoxiajianji.com/api/v1/auth/login" \
|
||||||
--max-time 10 2>&1)
|
--max-time 10 2>&1)
|
||||||
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
AUTH_CODE=$(echo "$AUTH_RESP" | tail -1)
|
||||||
@@ -426,7 +528,7 @@ jobs:
|
|||||||
|
|
||||||
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
local CURL_ARGS="-s -o /dev/null -w '%{http_code} %{time_total}' --max-time 30"
|
||||||
if [ "$method" = "POST" ]; then
|
if [ "$method" = "POST" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d \"$LOGIN_BODY\""
|
CURL_ARGS="$CURL_ARGS -X POST -H 'Content-Type: application/json' -d '{\"email\":\"18314979086@163.com\",\"password\":\"Ying1234\"}'"
|
||||||
fi
|
fi
|
||||||
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
if [ -n "$TOKEN" ] && [ "$name" != "健康检查" ]; then
|
||||||
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
CURL_ARGS="$CURL_ARGS -H 'Authorization: Bearer $TOKEN'"
|
||||||
@@ -529,7 +631,7 @@ jobs:
|
|||||||
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
|
||||||
daily-report:
|
daily-report:
|
||||||
name: Daily Check Report
|
name: Daily Check Report
|
||||||
runs-on: ci-l2
|
runs-on: saas
|
||||||
timeout-minutes: 2
|
timeout-minutes: 2
|
||||||
if: always()
|
if: always()
|
||||||
needs:
|
needs:
|
||||||
|
|||||||
Regular → Executable
+1
-6
@@ -8,11 +8,6 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pr-automation-${{ gitea.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
auto-approve:
|
auto-approve:
|
||||||
name: Auto Approve on CI Green
|
name: Auto Approve on CI Green
|
||||||
@@ -61,7 +56,7 @@ jobs:
|
|||||||
name: Auto Merge on CI Green + Approved
|
name: Auto Merge on CI Green + Approved
|
||||||
runs-on: ci-check
|
runs-on: ci-check
|
||||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||||
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
||||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||||
|
|||||||
@@ -93,28 +93,44 @@ jobs:
|
|||||||
shell: sh
|
shell: sh
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
cd apps/web
|
NPM_CACHE_VOLUME="xiaoxia-npm-cache"
|
||||||
|
if ! docker volume inspect "$NPM_CACHE_VOLUME" >/dev/null 2>&1; then
|
||||||
|
docker volume create "$NPM_CACHE_VOLUME" >/dev/null
|
||||||
|
echo "Created npm cache volume: $NPM_CACHE_VOLUME"
|
||||||
|
fi
|
||||||
|
|
||||||
# Install dependencies with retry
|
docker run --rm \
|
||||||
for i in 1 2 3; do
|
-v "$PWD:/workspace" \
|
||||||
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break
|
-v "$NPM_CACHE_VOLUME:/workspace/apps/web/node_modules" \
|
||||||
echo "npm install failed, retry $i/3..."
|
-w /workspace/apps/web \
|
||||||
[ $i -eq 3 ] && exit 1
|
-e VITE_API_URL=https://staging-api.xiaoxiajianji.com \
|
||||||
rm -rf node_modules
|
docker.m.daocloud.io/library/node:20 \
|
||||||
sleep 5
|
sh -lc '
|
||||||
done
|
PACKAGE_LOCK_HASH=$(md5sum package-lock.json 2>/dev/null | cut -d" " -f1)
|
||||||
|
CACHE_HASH_FILE="node_modules/.package-lock-hash"
|
||||||
# TypeScript check
|
CACHE_VALID=false
|
||||||
echo "=== TypeScript check ==="
|
if [ -f "$CACHE_HASH_FILE" ] && [ "$(cat "$CACHE_HASH_FILE")" = "$PACKAGE_LOCK_HASH" ] && [ -x "node_modules/.bin/vite" ] && [ -x "node_modules/.bin/tsc" ]; then
|
||||||
./node_modules/.bin/tsc --noEmit
|
CACHE_VALID=true
|
||||||
|
echo "Cache hit: dependencies valid, skipping npm ci"
|
||||||
# Vite build
|
fi
|
||||||
echo "=== Vite build ==="
|
if [ "$CACHE_VALID" = "false" ]; then
|
||||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
echo "Cache miss or invalid: running npm ci..."
|
||||||
./node_modules/.bin/vite build
|
if ! npm ci; then
|
||||||
|
echo "npm ci failed, cleaning node_modules and retrying..."
|
||||||
echo "=== Build completed ==="
|
rm -rf node_modules
|
||||||
ls -la dist/
|
mkdir -p node_modules
|
||||||
|
npm ci
|
||||||
|
fi
|
||||||
|
echo "$PACKAGE_LOCK_HASH" > "$CACHE_HASH_FILE"
|
||||||
|
echo "Dependencies installed, cache updated"
|
||||||
|
fi
|
||||||
|
echo "Running TypeScript check..."
|
||||||
|
npx --no-install tsc
|
||||||
|
echo "Running Vite build..."
|
||||||
|
npx --no-install vite build
|
||||||
|
echo "Build completed successfully"
|
||||||
|
ls -la dist/
|
||||||
|
'
|
||||||
|
|
||||||
- name: Install SSH client and rsync
|
- name: Install SSH client and rsync
|
||||||
shell: sh
|
shell: sh
|
||||||
|
|||||||
@@ -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,64 +0,0 @@
|
|||||||
"""#642 - 生成任务新增 bgm_config 字段
|
|
||||||
|
|
||||||
Revision ID: 052_generation_task_bgm_config
|
|
||||||
Revises: 051_generation_task_resolution
|
|
||||||
Create Date: 2026-07-25
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. generation_tasks 表新增 bgm_config 字段(JSON类型),存储用户自定义BGM配置
|
|
||||||
2. 为空时使用默认空字典
|
|
||||||
|
|
||||||
背景:
|
|
||||||
#642 一键生成支持自定义BGM 功能在 SQLAlchemy 模型中加了 bgm_config 字段,
|
|
||||||
但遗漏了 alembic migration,导致 staging 环境数据库没有该列,
|
|
||||||
创建生成任务时直接 500。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import context, op
|
|
||||||
|
|
||||||
revision = "052_generation_task_bgm_config"
|
|
||||||
down_revision = "051_generation_task_resolution"
|
|
||||||
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 = 'bgm_config'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column(
|
|
||||||
"bgm_config",
|
|
||||||
sa.JSON,
|
|
||||||
nullable=False,
|
|
||||||
server_default=sa.text("'{}'::json"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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 = 'bgm_config'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.drop_column("generation_tasks", "bgm_config")
|
|
||||||
@@ -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,84 +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 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=json.dumps(config),
|
|
||||||
created_at=sa.func.now(),
|
|
||||||
updated_at=sa.func.now(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table("cover_templates")
|
|
||||||
@@ -5,11 +5,8 @@ from app.api.routes.assets import router as assets_router
|
|||||||
from app.api.routes.auth import router as auth_router
|
from app.api.routes.auth import router as auth_router
|
||||||
from app.api.routes.chunked_upload import router as chunked_upload_router
|
from app.api.routes.chunked_upload import router as chunked_upload_router
|
||||||
from app.api.routes.classification_jobs import router as classification_jobs_router
|
from app.api.routes.classification_jobs import router as classification_jobs_router
|
||||||
from app.api.routes.cover_templates import router as cover_templates_router
|
|
||||||
from app.api.routes.duplication import router as duplication_router
|
from app.api.routes.duplication import router as duplication_router
|
||||||
from app.api.routes.feature_flags import router as feature_flags_router
|
from app.api.routes.feature_flags import router as feature_flags_router
|
||||||
from app.api.routes.generation_cover import router as generation_cover_router
|
|
||||||
from app.api.routes.generation_preview import router as generation_preview_router
|
|
||||||
from app.api.routes.generation_tasks import router as generation_tasks_router
|
from app.api.routes.generation_tasks import router as generation_tasks_router
|
||||||
from app.api.routes.health import router as health_check_router
|
from app.api.routes.health import router as health_check_router
|
||||||
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
from app.api.routes.ingest_jobs import router as ingest_jobs_router
|
||||||
@@ -47,10 +44,6 @@ api_router.include_router(
|
|||||||
prefix="/tags",
|
prefix="/tags",
|
||||||
tags=["Tag"],
|
tags=["Tag"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
|
||||||
cover_templates_router,
|
|
||||||
tags=["CoverTemplate"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
task_center_router,
|
task_center_router,
|
||||||
tags=["TaskCenter"],
|
tags=["TaskCenter"],
|
||||||
@@ -94,16 +87,6 @@ api_router.include_router(
|
|||||||
prefix="/generation",
|
prefix="/generation",
|
||||||
tags=["Generation"],
|
tags=["Generation"],
|
||||||
)
|
)
|
||||||
api_router.include_router(
|
|
||||||
generation_preview_router,
|
|
||||||
prefix="/generation",
|
|
||||||
tags=["Generation"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
generation_cover_router,
|
|
||||||
prefix="/generation",
|
|
||||||
tags=["Generation"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
titles_router,
|
titles_router,
|
||||||
prefix="/titles",
|
prefix="/titles",
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ from app.schemas.asset import (
|
|||||||
BatchTagRequest,
|
BatchTagRequest,
|
||||||
CreateAssetRequest,
|
CreateAssetRequest,
|
||||||
ListAssetsResponse,
|
ListAssetsResponse,
|
||||||
SmartMatchItem,
|
|
||||||
SmartMatchRequest,
|
|
||||||
SmartMatchResponse,
|
|
||||||
UpdateAssetRequest,
|
UpdateAssetRequest,
|
||||||
UpdateAssetReviewRequest,
|
UpdateAssetReviewRequest,
|
||||||
)
|
)
|
||||||
@@ -33,7 +30,6 @@ from packages.application import (
|
|||||||
CreateAssetUseCase,
|
CreateAssetUseCase,
|
||||||
)
|
)
|
||||||
from packages.domain import AssetStatus, ClassificationStatus
|
from packages.domain import AssetStatus, ClassificationStatus
|
||||||
from packages.domain.smart_match import smart_select_assets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -523,51 +519,6 @@ def batch_mark_assets(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/smart-match", response_model=SmartMatchResponse)
|
|
||||||
def smart_match_assets(
|
|
||||||
request: SmartMatchRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
asset_repository: Any = Depends(get_asset_repository),
|
|
||||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> SmartMatchResponse:
|
|
||||||
"""智能选素材:根据素材库内容,按质量分+时长均衡+新鲜度+未使用偏好综合评分,返回 Top N 素材。"""
|
|
||||||
library = asset_library_repository.get(request.library_id)
|
|
||||||
if library is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
|
||||||
check_project_access(library.project_id, authenticated_user.user.id, project_repository)
|
|
||||||
|
|
||||||
# 获取素材库中所有 ready 素材(DB 层按 kind 过滤,避免加载不必要的数据到内存)
|
|
||||||
# kind → file_type 映射:schema 已校验只允许 video/image/audio,与 file_type 一致
|
|
||||||
if request.kind:
|
|
||||||
filtered_assets = asset_repository.find_by_library_and_file_type(
|
|
||||||
request.library_id, request.kind, status=["ready"], limit=10000
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
filtered_assets = asset_repository.find_by_library(
|
|
||||||
request.library_id, status=["ready"], limit=10000
|
|
||||||
)
|
|
||||||
total_candidates = len(filtered_assets)
|
|
||||||
|
|
||||||
# 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤)
|
|
||||||
results = smart_select_assets(
|
|
||||||
filtered_assets,
|
|
||||||
limit=request.limit,
|
|
||||||
kind=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
items = [
|
|
||||||
SmartMatchItem(
|
|
||||||
asset=_to_asset_response(r.asset),
|
|
||||||
score=r.score,
|
|
||||||
breakdown=r.breakdown,
|
|
||||||
)
|
|
||||||
for r in results
|
|
||||||
]
|
|
||||||
|
|
||||||
return SmartMatchResponse(items=items, total_candidates=total_candidates)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||||
def get_asset(
|
def get_asset(
|
||||||
asset_id: str,
|
asset_id: str,
|
||||||
@@ -671,28 +622,20 @@ def create_asset(
|
|||||||
asset_library_repository: Any = Depends(get_asset_library_repository),
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
||||||
project_repository: Any = Depends(get_project_repository),
|
project_repository: Any = Depends(get_project_repository),
|
||||||
) -> AssetResponse:
|
) -> AssetResponse:
|
||||||
# 先获取素材库,用于推导 project_id(前端可能不传)
|
project = project_repository.find_by_id(request.project_id)
|
||||||
library = asset_library_repository.get(request.library_id)
|
|
||||||
if library is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
|
||||||
|
|
||||||
# project_id 自动推导:优先用请求值,否则从 library 关联的项目获取
|
|
||||||
project_id = request.project_id or library.project_id
|
|
||||||
|
|
||||||
project = project_repository.find_by_id(project_id)
|
|
||||||
if project is None:
|
if project is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")
|
raise HTTPException(status_code=404, detail=f"Project {request.project_id} not found")
|
||||||
if not project.can_access(authenticated_user.user.id):
|
if not project.can_access(authenticated_user.user.id):
|
||||||
raise HTTPException(status_code=403, detail="Access denied to project")
|
raise HTTPException(status_code=403, detail="Access denied to project")
|
||||||
|
|
||||||
# 确保 library 和 project 归属一致
|
library = asset_library_repository.get(request.library_id)
|
||||||
if library.project_id != project_id:
|
if library is None or library.project_id != request.project_id:
|
||||||
raise HTTPException(status_code=400, detail="AssetLibrary does not belong to the specified project")
|
raise HTTPException(status_code=404, detail=f"AssetLibrary {request.library_id} not found")
|
||||||
|
|
||||||
use_case = CreateAssetUseCase(asset_repository)
|
use_case = CreateAssetUseCase(asset_repository)
|
||||||
item = use_case.execute(
|
item = use_case.execute(
|
||||||
CreateAssetCommand(
|
CreateAssetCommand(
|
||||||
project_id=project_id,
|
project_id=request.project_id,
|
||||||
library_id=request.library_id,
|
library_id=request.library_id,
|
||||||
name=request.name,
|
name=request.name,
|
||||||
storage_key=request.storage_key,
|
storage_key=request.storage_key,
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
"""封面模板 CRUD 路由。
|
|
||||||
|
|
||||||
API:
|
|
||||||
GET /api/v1/cover-templates - 列出当前用户可见的模板
|
|
||||||
POST /api/v1/cover-templates - 创建自定义模板
|
|
||||||
PUT /api/v1/cover-templates/{id} - 更新模板
|
|
||||||
DELETE /api/v1/cover-templates/{id} - 删除自定义模板(系统模板不可删)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.dependencies import get_cover_template_repository
|
|
||||||
from app.schemas.cover_template import (
|
|
||||||
CoverTemplateResponse,
|
|
||||||
CreateCoverTemplateRequest,
|
|
||||||
ListCoverTemplatesResponse,
|
|
||||||
UpdateCoverTemplateRequest,
|
|
||||||
)
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
||||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
|
||||||
|
|
||||||
from packages.domain.cover_template import CoverTemplate
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/cover-templates", tags=["CoverTemplate"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ListCoverTemplatesResponse)
|
|
||||||
def list_cover_templates(
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 100,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
repo: Any = Depends(get_cover_template_repository),
|
|
||||||
) -> ListCoverTemplatesResponse:
|
|
||||||
"""列出当前用户可见的封面模板(系统模板 + 用户自定义模板)。
|
|
||||||
|
|
||||||
当数据库表不存在时(迁移未执行),降级返回空列表而非 500。
|
|
||||||
"""
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
try:
|
|
||||||
items = repo.list_for_user(user_id, skip=skip, limit=limit)
|
|
||||||
total = repo.count_for_user(user_id)
|
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
|
||||||
logger.warning("cover_templates 表查询失败(可能未迁移),返回空列表: %s", exc)
|
|
||||||
return ListCoverTemplatesResponse(items=[], total=0)
|
|
||||||
return ListCoverTemplatesResponse(
|
|
||||||
items=[
|
|
||||||
CoverTemplateResponse(
|
|
||||||
id=t.id,
|
|
||||||
name=t.name,
|
|
||||||
thumbnail_url=t.thumbnail_url,
|
|
||||||
is_system=t.is_system,
|
|
||||||
created_at=t.created_at,
|
|
||||||
config=t.config or {},
|
|
||||||
)
|
|
||||||
for t in items
|
|
||||||
],
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=CoverTemplateResponse, status_code=201)
|
|
||||||
def create_cover_template(
|
|
||||||
request: CreateCoverTemplateRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
repo: Any = Depends(get_cover_template_repository),
|
|
||||||
) -> CoverTemplateResponse:
|
|
||||||
"""创建用户自定义封面模板。"""
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
config_dict = request.config.model_dump() if request.config else {}
|
|
||||||
template = CoverTemplate.create_user(
|
|
||||||
user_id=user_id,
|
|
||||||
name=request.name,
|
|
||||||
config=config_dict,
|
|
||||||
thumbnail_url=request.thumbnail_url,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
created = repo.create(template)
|
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
|
||||||
logger.warning("cover_templates 表不可用(可能未迁移): %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
|
||||||
return CoverTemplateResponse(
|
|
||||||
id=created.id,
|
|
||||||
name=created.name,
|
|
||||||
thumbnail_url=created.thumbnail_url,
|
|
||||||
is_system=created.is_system,
|
|
||||||
created_at=created.created_at,
|
|
||||||
config=created.config,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{template_id}", response_model=CoverTemplateResponse)
|
|
||||||
def update_cover_template(
|
|
||||||
template_id: str,
|
|
||||||
request: UpdateCoverTemplateRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
repo: Any = Depends(get_cover_template_repository),
|
|
||||||
) -> CoverTemplateResponse:
|
|
||||||
"""更新封面模板(仅允许更新自己的模板)。"""
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
try:
|
|
||||||
template = repo.get(template_id)
|
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
|
||||||
logger.warning("cover_templates 表不可用: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
|
||||||
if template is None:
|
|
||||||
raise HTTPException(status_code=404, detail="模板不存在")
|
|
||||||
if template.is_system:
|
|
||||||
raise HTTPException(status_code=403, detail="系统模板不可修改")
|
|
||||||
if template.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=403, detail="无权修改该模板")
|
|
||||||
|
|
||||||
if request.name is not None:
|
|
||||||
template.update(name=request.name)
|
|
||||||
if request.config is not None:
|
|
||||||
template.update(config=request.config.model_dump())
|
|
||||||
if request.thumbnail_url is not None:
|
|
||||||
template.update(thumbnail_url=request.thumbnail_url)
|
|
||||||
|
|
||||||
updated = repo.update(template)
|
|
||||||
return CoverTemplateResponse(
|
|
||||||
id=updated.id,
|
|
||||||
name=updated.name,
|
|
||||||
thumbnail_url=updated.thumbnail_url,
|
|
||||||
is_system=updated.is_system,
|
|
||||||
created_at=updated.created_at,
|
|
||||||
config=updated.config,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{template_id}", status_code=204, response_class=Response)
|
|
||||||
def delete_cover_template(
|
|
||||||
template_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
repo: Any = Depends(get_cover_template_repository),
|
|
||||||
) -> None:
|
|
||||||
"""删除用户自定义封面模板(系统模板不可删除)。"""
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
try:
|
|
||||||
template = repo.get(template_id)
|
|
||||||
except (OperationalError, ProgrammingError) as exc:
|
|
||||||
logger.warning("cover_templates 表不可用: %s", exc)
|
|
||||||
raise HTTPException(status_code=503, detail="封面模板服务暂不可用,请稍后重试") from None
|
|
||||||
if template is None:
|
|
||||||
raise HTTPException(status_code=404, detail="模板不存在")
|
|
||||||
if template.is_system:
|
|
||||||
raise HTTPException(status_code=403, detail="系统模板不可删除")
|
|
||||||
if template.user_id != user_id:
|
|
||||||
raise HTTPException(status_code=403, detail="无权删除该模板")
|
|
||||||
repo.delete(template_id)
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
"""封面生成路由 — Generation 模块.
|
|
||||||
|
|
||||||
端点:
|
|
||||||
- POST /generate-cover AI 生成封面(从预览视频中抽帧)
|
|
||||||
|
|
||||||
挂载路径: /api/v1/generation/generate-cover
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, List, Optional
|
|
||||||
|
|
||||||
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 .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 时有效",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateCoverResponse(BaseModel):
|
|
||||||
"""AI 封面生成响应体"""
|
|
||||||
|
|
||||||
plan_id: str = Field(..., description="剪辑计划 ID")
|
|
||||||
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
|
||||||
|
|
||||||
|
|
||||||
# ── Route ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
@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. 预览视频已渲染完成(通过 3 步查找获取 URL)
|
|
||||||
2. 用裸 URL 让 MediaKit 下载视频并抽帧
|
|
||||||
3. 帧图下载后上传到 OSS covers/ 路径
|
|
||||||
"""
|
|
||||||
_, plan_svc = services
|
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
|
|
||||||
# ── 3 步查找预览视频 URL ──────────────────────────────────────────
|
|
||||||
# 第一步:从 plan.config 读取
|
|
||||||
logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id)
|
|
||||||
rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "")
|
|
||||||
|
|
||||||
# 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物
|
|
||||||
if not rendered_storage_key:
|
|
||||||
generation_task_id = (plan.config or {}).get("generation_task_id", "")
|
|
||||||
logger.info(
|
|
||||||
"[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id
|
|
||||||
)
|
|
||||||
if generation_task_id:
|
|
||||||
try:
|
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
|
||||||
task = gen_task_repo.get(generation_task_id)
|
|
||||||
if task:
|
|
||||||
video_repo = get_generated_video_repository(db)
|
|
||||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
|
||||||
videos = use_case.execute(task.id)
|
|
||||||
if videos:
|
|
||||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
|
||||||
logger.info(
|
|
||||||
"[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s",
|
|
||||||
plan_id,
|
|
||||||
generation_task_id,
|
|
||||||
rendered_storage_key[:80],
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
plan_id,
|
|
||||||
generation_task_id,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s",
|
|
||||||
plan_id,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 第三步:按 user + template 查找最近的已完成预览任务(兜底)
|
|
||||||
if not rendered_storage_key:
|
|
||||||
try:
|
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
|
||||||
logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id)
|
|
||||||
preview_tasks = gen_task_repo.list_latest_completed_preview(
|
|
||||||
user_id=str(current_user.user.id),
|
|
||||||
template_id=template_id,
|
|
||||||
)
|
|
||||||
if preview_tasks:
|
|
||||||
completed_preview = preview_tasks[0]
|
|
||||||
video_repo = get_generated_video_repository(db)
|
|
||||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
|
||||||
videos = use_case.execute(completed_preview.id)
|
|
||||||
if videos:
|
|
||||||
rendered_storage_key = getattr(videos[0], "file_url", "") or ""
|
|
||||||
logger.info(
|
|
||||||
"封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s",
|
|
||||||
plan_id,
|
|
||||||
template_id,
|
|
||||||
completed_preview.id,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s",
|
|
||||||
plan_id,
|
|
||||||
template_id,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 仍然找不到才报 400
|
|
||||||
if not rendered_storage_key:
|
|
||||||
logger.error("[封面生成] ❌ 找不到预览视频: plan_id=%s", plan_id)
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="请先生成预览视频,再生成封面",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 回写到 plan.config
|
|
||||||
plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key})
|
|
||||||
|
|
||||||
# 使用裸 URL(rendered/* 已配置公开读)
|
|
||||||
primary_video_url = None
|
|
||||||
try:
|
|
||||||
if rendered_storage_key.startswith("http"):
|
|
||||||
primary_video_url = rendered_storage_key
|
|
||||||
else:
|
|
||||||
from packages.shared.storage import get_shared_storage_service
|
|
||||||
|
|
||||||
storage_svc = get_shared_storage_service()
|
|
||||||
primary_video_url = storage_svc.get_url(rendered_storage_key)
|
|
||||||
# 防御性规范化:合并路径中的双斜杠(// -> /),但保留协议头的 ://
|
|
||||||
# 历史数据中 project_id 为空时会产生 projects//tasks/ 路径,
|
|
||||||
# MediaKit 的 HTTP 客户端会规范化 URL 导致 404
|
|
||||||
if primary_video_url:
|
|
||||||
import re as _re
|
|
||||||
|
|
||||||
primary_video_url = _re.sub(r"(?<!:)//", "/", primary_video_url)
|
|
||||||
logger.info(
|
|
||||||
"获取预览视频URL用于封面生成: plan_id=%s url=%s",
|
|
||||||
plan_id,
|
|
||||||
primary_video_url[:80] if primary_video_url else "",
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"获取预览视频URL失败: {e}",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
# 优先使用渲染时预抽的封面候选帧(跳过 MediaKit,秒级返回)
|
|
||||||
cover_candidates = (plan.config or {}).get("cover_candidates", [])
|
|
||||||
if cover_candidates and body.cover_type in ("ai_frame", "ai_regenerate"):
|
|
||||||
logger.info(
|
|
||||||
"[封面生成] 使用预存封面候选帧: plan_id=%s count=%d",
|
|
||||||
plan_id,
|
|
||||||
len(cover_candidates),
|
|
||||||
)
|
|
||||||
first_frame = cover_candidates[0]
|
|
||||||
cover_data = {
|
|
||||||
"type": "ai_frame",
|
|
||||||
"image_url": first_frame.get("image_url", ""),
|
|
||||||
"frame_time": first_frame.get("frame_time", 0.0),
|
|
||||||
"confidence": 0.9,
|
|
||||||
}
|
|
||||||
if cover_data["image_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"]})
|
|
||||||
return GenerateCoverResponse(plan_id=plan_id, cover=cover_data)
|
|
||||||
|
|
||||||
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,366 +0,0 @@
|
|||||||
"""预览生成路由 — Phase 1:单版本预览接口(创建 + 查询)。
|
|
||||||
|
|
||||||
路径前缀:/api/v1/generation/preview(与 /generation/tasks 同体系)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.auth import AuthenticatedUser, get_current_user
|
|
||||||
from app.core.storage import get_storage_service
|
|
||||||
from app.core.task_enqueue import (
|
|
||||||
GLOBAL_PENDING_LIMIT,
|
|
||||||
USER_PENDING_LIMIT,
|
|
||||||
GlobalQueueFull,
|
|
||||||
UserPendingLimitExceeded,
|
|
||||||
safe_enqueue_generation_task,
|
|
||||||
)
|
|
||||||
from app.dependencies import (
|
|
||||||
get_asset_repository,
|
|
||||||
get_db_session,
|
|
||||||
get_generated_video_repository,
|
|
||||||
get_generation_task_repository,
|
|
||||||
)
|
|
||||||
from app.schemas.generation_task import (
|
|
||||||
CreatePreviewGenerationTaskRequest,
|
|
||||||
PreviewGenerationTaskResponse,
|
|
||||||
)
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.edit_template_repository import (
|
|
||||||
SQLAlchemyEditTemplateRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.template_repository import (
|
|
||||||
SQLAlchemyTemplateRepository,
|
|
||||||
)
|
|
||||||
from packages.application import (
|
|
||||||
CreateGenerationTaskCommand,
|
|
||||||
CreateGenerationTaskUseCase,
|
|
||||||
GetGenerationTaskUseCase,
|
|
||||||
ListGeneratedVideosByTaskUseCase,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
# 模板 mode → 视频比例映射
|
|
||||||
_TEMPLATE_MODE_TO_RATIO = {
|
|
||||||
"pip": "9:16",
|
|
||||||
"standard": "16:9",
|
|
||||||
"square": "1:1",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _infer_video_ratio_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
|
||||||
"""从模板 mode 推断视频比例,前端未传 video_ratio 时使用。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
视频比例字符串(如 "9:16"),查询失败返回空字符串。
|
|
||||||
"""
|
|
||||||
if not template_id:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
repo = SQLAlchemyTemplateRepository(db)
|
|
||||||
template = repo.get(template_id, user_id)
|
|
||||||
if template:
|
|
||||||
mode = getattr(template, "mode", "") or ""
|
|
||||||
ratio = _TEMPLATE_MODE_TO_RATIO.get(mode.strip(), "")
|
|
||||||
if ratio:
|
|
||||||
logger.info(
|
|
||||||
"[预览生成] 从模板 mode=%s 推断 video_ratio=%s",
|
|
||||||
mode,
|
|
||||||
ratio,
|
|
||||||
)
|
|
||||||
return ratio
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"[预览生成] 查询模板失败,跳过 video_ratio 推断: template_id=%s",
|
|
||||||
template_id,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_strategy_id_from_template(template_id: str, db: Session, user_id: str = "") -> str:
|
|
||||||
"""从模板读取 editing_mode / mode 作为 strategy_id。
|
|
||||||
|
|
||||||
优先查新模板系统(EditTemplate.editing_mode),fallback 旧模板(Template.mode)。
|
|
||||||
Worker 端使用 strategy_id 作为渲染 mode,为空则默认 one_take。
|
|
||||||
"""
|
|
||||||
if not template_id:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# 优先查新模板系统
|
|
||||||
try:
|
|
||||||
new_repo = SQLAlchemyEditTemplateRepository(db)
|
|
||||||
new_template = new_repo.get(template_id)
|
|
||||||
if new_template and getattr(new_template, "editing_mode", ""):
|
|
||||||
mode = new_template.editing_mode.strip()
|
|
||||||
if mode:
|
|
||||||
logger.info(
|
|
||||||
"[预览生成] 从新模板 editing_mode=%s (template_id=%s)",
|
|
||||||
mode,
|
|
||||||
template_id,
|
|
||||||
)
|
|
||||||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
|
||||||
if mode in ("pip", "voice_pip"):
|
|
||||||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
|
||||||
mode = "one_take"
|
|
||||||
return mode
|
|
||||||
except Exception:
|
|
||||||
logger.debug(
|
|
||||||
"[预览生成] 新模板查询失败,尝试旧模板: template_id=%s",
|
|
||||||
template_id,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# fallback 旧模板系统
|
|
||||||
try:
|
|
||||||
old_repo = SQLAlchemyTemplateRepository(db)
|
|
||||||
old_template = old_repo.get(template_id, user_id)
|
|
||||||
if old_template:
|
|
||||||
mode = getattr(old_template, "mode", "") or ""
|
|
||||||
mode = mode.strip()
|
|
||||||
if mode:
|
|
||||||
logger.info(
|
|
||||||
"[预览生成] 从旧模板 mode=%s (template_id=%s)",
|
|
||||||
mode,
|
|
||||||
template_id,
|
|
||||||
)
|
|
||||||
# 画中画已下线,pip/voice_pip 统一映射为 one_take
|
|
||||||
if mode in ("pip", "voice_pip"):
|
|
||||||
logger.info("[预览生成] %s → one_take (画中画已下线)", mode)
|
|
||||||
mode = "one_take"
|
|
||||||
return mode
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"[预览生成] 旧模板查询也失败,strategy_id 留空: template_id=%s",
|
|
||||||
template_id,
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def _mark_task_failed(repo, task, reason: str) -> None:
|
|
||||||
"""入队失败时将任务标记为 failed,避免产生僵尸 pending 数据。"""
|
|
||||||
try:
|
|
||||||
task.mark_failed(error_message=f"入队失败:{reason}")
|
|
||||||
repo.update(task)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("[预览生成] 标记任务失败时异常: task_id=%s", task.id)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_preview_response(task, generated_videos: list | None = None) -> PreviewGenerationTaskResponse:
|
|
||||||
"""将领域任务对象转换为预览响应 DTO。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task: GenerationTask 领域对象
|
|
||||||
generated_videos: 生成的视频列表(可选),取第一个作为 video_url
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
PreviewGenerationTaskResponse
|
|
||||||
"""
|
|
||||||
video_url = ""
|
|
||||||
duration = 0.0
|
|
||||||
file_size = 0
|
|
||||||
if generated_videos:
|
|
||||||
first_video = generated_videos[0]
|
|
||||||
raw_url = getattr(first_video, "file_url", "") or ""
|
|
||||||
# rendered/* 已配置公开读,直接用裸 URL
|
|
||||||
if raw_url.startswith("http"):
|
|
||||||
video_url = raw_url
|
|
||||||
else:
|
|
||||||
storage = get_storage_service()
|
|
||||||
video_url = storage.get_url(raw_url)
|
|
||||||
duration = float(getattr(first_video, "duration", 0.0) or 0.0)
|
|
||||||
file_size = int(getattr(first_video, "file_size", 0) or 0)
|
|
||||||
|
|
||||||
# 从 extra_meta / metadata 中提取统计信息(如果有)
|
|
||||||
extra_meta = getattr(task, "extra_meta", {}) or {}
|
|
||||||
clip_count = int(extra_meta.get("clip_count", len(getattr(task, "asset_ids", [])) or 0))
|
|
||||||
transition_count = int(extra_meta.get("transition_count", max(0, clip_count - 1)))
|
|
||||||
material_usage = extra_meta.get("material_usage", {}) or {}
|
|
||||||
|
|
||||||
# 计算生成耗时
|
|
||||||
generate_duration = 0.0
|
|
||||||
started_at = getattr(task, "started_at", None)
|
|
||||||
completed_at = getattr(task, "completed_at", None)
|
|
||||||
if started_at and completed_at:
|
|
||||||
generate_duration = (completed_at - started_at).total_seconds()
|
|
||||||
|
|
||||||
return PreviewGenerationTaskResponse(
|
|
||||||
task_id=task.id,
|
|
||||||
status=task.status.value if hasattr(task.status, "value") else str(task.status),
|
|
||||||
progress=float(task.progress or 0.0),
|
|
||||||
is_preview=bool(getattr(task, "is_preview", True)),
|
|
||||||
resolution=getattr(task, "resolution", "") or "",
|
|
||||||
video_url=video_url,
|
|
||||||
duration=duration,
|
|
||||||
file_size=file_size,
|
|
||||||
clip_count=clip_count,
|
|
||||||
transition_count=transition_count,
|
|
||||||
material_usage=material_usage,
|
|
||||||
error_message=task.error_message or "",
|
|
||||||
created_at=task.created_at,
|
|
||||||
started_at=started_at,
|
|
||||||
finished_at=completed_at,
|
|
||||||
generate_duration=generate_duration,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/preview", response_model=PreviewGenerationTaskResponse, status_code=201)
|
|
||||||
def create_preview_generation_task(
|
|
||||||
request: CreatePreviewGenerationTaskRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
generation_task_repository=Depends(get_generation_task_repository),
|
|
||||||
db: Session = Depends(get_db_session),
|
|
||||||
asset_repo=Depends(get_asset_repository),
|
|
||||||
) -> PreviewGenerationTaskResponse:
|
|
||||||
"""创建预览生成任务。
|
|
||||||
|
|
||||||
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset),确认生成时可直接复用预览产物。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request: 预览任务创建请求(template_id + asset_ids 等)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
201 + 预览任务详情
|
|
||||||
"""
|
|
||||||
user_id = authenticated_user.user.id
|
|
||||||
logger.info(
|
|
||||||
"[预览生成] 接收请求: user_id=%s, template_id=%s, asset_count=%d, preview_count=%d",
|
|
||||||
user_id,
|
|
||||||
request.template_id,
|
|
||||||
len(request.asset_ids),
|
|
||||||
request.preview_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 预检查队列限流
|
|
||||||
try:
|
|
||||||
user_pending = generation_task_repository.count_pending_by_user(user_id)
|
|
||||||
global_pending = generation_task_repository.count_pending_total()
|
|
||||||
if user_pending + 1 > USER_PENDING_LIMIT:
|
|
||||||
raise UserPendingLimitExceeded(user_id=user_id, pending_count=user_pending + 1, limit=USER_PENDING_LIMIT)
|
|
||||||
if global_pending + 1 > GLOBAL_PENDING_LIMIT:
|
|
||||||
raise GlobalQueueFull(pending_count=global_pending + 1, limit=GLOBAL_PENDING_LIMIT)
|
|
||||||
except UserPendingLimitExceeded as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
|
|
||||||
) from e
|
|
||||||
except GlobalQueueFull as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="系统繁忙,请稍后再试",
|
|
||||||
) from e
|
|
||||||
|
|
||||||
# 确定视频比例:优先前端传入,否则从模板 mode 推断
|
|
||||||
video_ratio = request.video_ratio or ""
|
|
||||||
if not video_ratio and request.template_id:
|
|
||||||
video_ratio = _infer_video_ratio_from_template(request.template_id, db, user_id)
|
|
||||||
|
|
||||||
# 从模板读取 editing_mode / mode 作为 strategy_id(渲染 pipeline 的 mode 参数)
|
|
||||||
strategy_id = _resolve_strategy_id_from_template(request.template_id, db, user_id)
|
|
||||||
|
|
||||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
|
||||||
|
|
||||||
try:
|
|
||||||
task = use_case.execute(
|
|
||||||
CreateGenerationTaskCommand(
|
|
||||||
project_id="",
|
|
||||||
asset_library_id="",
|
|
||||||
strategy_id=strategy_id,
|
|
||||||
voice_library_id=request.voice_library_id,
|
|
||||||
template_id=request.template_id,
|
|
||||||
asset_ids=list(request.asset_ids),
|
|
||||||
title_ids=list(request.title_ids),
|
|
||||||
voice_ids=list(request.voice_ids),
|
|
||||||
created_by_user_id=user_id,
|
|
||||||
source_edit_plan_id=request.source_edit_plan_id,
|
|
||||||
asset_select_mode="",
|
|
||||||
batch_id="",
|
|
||||||
video_title=request.video_title,
|
|
||||||
resolution="",
|
|
||||||
bgm_config=request.bgm_config or {},
|
|
||||||
auto_retry_enabled=False,
|
|
||||||
auto_retry_max=0,
|
|
||||||
is_preview=True,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
# 入队执行;若入队失败则标记任务为 failed 避免僵尸数据
|
|
||||||
try:
|
|
||||||
if not safe_enqueue_generation_task(
|
|
||||||
task,
|
|
||||||
generation_task_repository,
|
|
||||||
user_id=user_id,
|
|
||||||
log_prefix="[预览生成]",
|
|
||||||
log_task_status=True,
|
|
||||||
):
|
|
||||||
logger.warning("[预览生成] 任务入队失败: task_id=%s", task.id)
|
|
||||||
_mark_task_failed(generation_task_repository, task, "任务入队失败")
|
|
||||||
raise HTTPException(status_code=500, detail="任务入队失败,请稍后重试")
|
|
||||||
except UserPendingLimitExceeded as e:
|
|
||||||
_mark_task_failed(generation_task_repository, task, "待处理任务超限")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail=f"您的待处理任务过多(当前 {e.pending_count - 1}/{e.limit}),请等待后再提交",
|
|
||||||
) from None
|
|
||||||
except GlobalQueueFull:
|
|
||||||
_mark_task_failed(generation_task_repository, task, "系统队列已满")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="系统繁忙,请稍后再试",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
return _to_preview_response(task)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/preview/{task_id}", response_model=PreviewGenerationTaskResponse)
|
|
||||||
def get_preview_generation_task(
|
|
||||||
task_id: str,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
generation_task_repository=Depends(get_generation_task_repository),
|
|
||||||
generated_video_repository=Depends(get_generated_video_repository),
|
|
||||||
) -> PreviewGenerationTaskResponse:
|
|
||||||
"""查询预览生成任务状态。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
task_id: 任务 ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
预览任务详情(含状态、进度、结果 URL 等)
|
|
||||||
"""
|
|
||||||
use_case = GetGenerationTaskUseCase(generation_task_repository)
|
|
||||||
task = use_case.execute(task_id)
|
|
||||||
if task is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
|
||||||
|
|
||||||
# 权限校验:任务必须属于当前用户(统一转 str 比较,避免 UUID/str 类型差异)
|
|
||||||
task_user_id = str(getattr(task, "created_by_user_id", "") or "")
|
|
||||||
if not task_user_id or task_user_id != str(authenticated_user.user.id):
|
|
||||||
raise HTTPException(status_code=403, detail="无权访问该任务")
|
|
||||||
|
|
||||||
# 校验是否为预览任务
|
|
||||||
if not getattr(task, "is_preview", False):
|
|
||||||
raise HTTPException(status_code=404, detail=f"预览任务 {task_id} 不存在")
|
|
||||||
|
|
||||||
# 查询生成的视频(取第一个)
|
|
||||||
generated_videos = []
|
|
||||||
status_val = task.status.value if hasattr(task.status, "value") else str(task.status)
|
|
||||||
if status_val == "completed":
|
|
||||||
list_use_case = ListGeneratedVideosByTaskUseCase(generated_video_repository)
|
|
||||||
generated_videos = list_use_case.execute(task_id)
|
|
||||||
|
|
||||||
return _to_preview_response(task, generated_videos=generated_videos)
|
|
||||||
@@ -26,7 +26,6 @@ from app.schemas.generated_video import (
|
|||||||
)
|
)
|
||||||
from app.schemas.generation_task import (
|
from app.schemas.generation_task import (
|
||||||
BatchGenerationTaskResponse,
|
BatchGenerationTaskResponse,
|
||||||
ConfirmGenerationRequest,
|
|
||||||
CreateGenerationTaskRequest,
|
CreateGenerationTaskRequest,
|
||||||
GenerationTaskResponse,
|
GenerationTaskResponse,
|
||||||
ListGenerationTasksResponse,
|
ListGenerationTasksResponse,
|
||||||
@@ -39,7 +38,6 @@ from packages.application import (
|
|||||||
GetGenerationTaskUseCase,
|
GetGenerationTaskUseCase,
|
||||||
ListGeneratedVideosByTaskUseCase,
|
ListGeneratedVideosByTaskUseCase,
|
||||||
)
|
)
|
||||||
from packages.domain.smart_match import smart_select_assets
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -63,12 +61,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
|
|||||||
video_title=getattr(task, "video_title", ""),
|
video_title=getattr(task, "video_title", ""),
|
||||||
resolution=getattr(task, "resolution", ""),
|
resolution=getattr(task, "resolution", ""),
|
||||||
bgm_config=getattr(task, "bgm_config", {}) or {},
|
bgm_config=getattr(task, "bgm_config", {}) or {},
|
||||||
is_preview=getattr(task, "is_preview", False),
|
|
||||||
source_task_id=getattr(task, "source_task_id", ""),
|
|
||||||
output_width=getattr(task, "output_width", 1280),
|
|
||||||
output_height=getattr(task, "output_height", 720),
|
|
||||||
cover_url=getattr(task, "cover_url", ""),
|
|
||||||
custom_title=getattr(task, "custom_title", ""),
|
|
||||||
logs=getattr(task, "logs", "[]"),
|
logs=getattr(task, "logs", "[]"),
|
||||||
status=task.status,
|
status=task.status,
|
||||||
progress=task.progress,
|
progress=task.progress,
|
||||||
@@ -132,11 +124,19 @@ def _select_assets_from_library(
|
|||||||
return [a.id for a in selected]
|
return [a.id for a in selected]
|
||||||
|
|
||||||
if mode == "smart":
|
if mode == "smart":
|
||||||
# 智能匹配:统一使用 packages/domain/smart_match.py 的多维评分+多样性选取
|
# 智能匹配:按质量分降序 + 时长降序作为tiebreaker
|
||||||
# 评分维度:质量分(40%) + 时长适配(30%) + 新鲜度(20%) + 未使用加分(10%)
|
# 注意:这里使用简单的 quality_score 排序保持向后兼容
|
||||||
limit = count if count > 0 else None
|
# 更复杂的4维评分+多样性策略由 SmartAssetSelector 服务提供(用于 AI 精选等场景)
|
||||||
results = smart_select_assets(ready_video_assets, limit=limit, kind="video")
|
scored_assets = sorted(
|
||||||
return [r.asset.id for r in results]
|
ready_video_assets,
|
||||||
|
key=lambda a: (
|
||||||
|
-(a.quality_score if a.quality_score is not None else 0.0),
|
||||||
|
-(getattr(a, "duration", 0.0) or 0.0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if count > 0:
|
||||||
|
scored_assets = scored_assets[:count]
|
||||||
|
return [a.id for a in scored_assets]
|
||||||
|
|
||||||
# 默认 all 模式:返回全部 ready 视频素材
|
# 默认 all 模式:返回全部 ready 视频素材
|
||||||
return [a.id for a in ready_video_assets]
|
return [a.id for a in ready_video_assets]
|
||||||
@@ -271,19 +271,13 @@ def create_generation_task(
|
|||||||
detail="系统繁忙,请稍后再试",
|
detail="系统繁忙,请稍后再试",
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
# 画中画已下线:strategy_id 中的 pip/voice_pip 统一映射为 one_take
|
|
||||||
effective_strategy_id = request.strategy_id
|
|
||||||
if effective_strategy_id in ("pip", "voice_pip"):
|
|
||||||
logger.info("画中画已下线,strategy_id %s → one_take", effective_strategy_id)
|
|
||||||
effective_strategy_id = "one_take"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for _ in range(count):
|
for _ in range(count):
|
||||||
task = use_case.execute(
|
task = use_case.execute(
|
||||||
CreateGenerationTaskCommand(
|
CreateGenerationTaskCommand(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
asset_library_id=asset_library_id,
|
asset_library_id=asset_library_id,
|
||||||
strategy_id=effective_strategy_id,
|
strategy_id=request.strategy_id,
|
||||||
voice_library_id=request.voice_library_id,
|
voice_library_id=request.voice_library_id,
|
||||||
template_id=request.template_id,
|
template_id=request.template_id,
|
||||||
asset_ids=resolved_asset_ids,
|
asset_ids=resolved_asset_ids,
|
||||||
@@ -298,12 +292,6 @@ def create_generation_task(
|
|||||||
bgm_config=request.bgm_config,
|
bgm_config=request.bgm_config,
|
||||||
auto_retry_enabled=request.auto_retry_enabled,
|
auto_retry_enabled=request.auto_retry_enabled,
|
||||||
auto_retry_max=request.auto_retry_max,
|
auto_retry_max=request.auto_retry_max,
|
||||||
is_preview=request.is_preview,
|
|
||||||
source_task_id=request.source_task_id,
|
|
||||||
output_width=request.output_width,
|
|
||||||
output_height=request.output_height,
|
|
||||||
cover_url=request.cover_url,
|
|
||||||
custom_title=request.custom_title,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -344,120 +332,6 @@ def create_generation_task(
|
|||||||
return BatchGenerationTaskResponse(items=items, total=len(items))
|
return BatchGenerationTaskResponse(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/confirm", response_model=BatchGenerationTaskResponse)
|
|
||||||
def confirm_generation(
|
|
||||||
task_id: str,
|
|
||||||
request: ConfirmGenerationRequest,
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
generation_task_repository: Any = Depends(get_generation_task_repository),
|
|
||||||
project_repository: Any = Depends(get_project_repository),
|
|
||||||
) -> 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:
|
|
||||||
source_task.mark_confirmed(
|
|
||||||
cover_url=request.cover_url,
|
|
||||||
custom_title=request.custom_title,
|
|
||||||
output_width=request.output_width,
|
|
||||||
output_height=request.output_height,
|
|
||||||
)
|
|
||||||
generation_task_repository.update(source_task)
|
|
||||||
logger.info(
|
|
||||||
"[确认生成] 复用预览产物: task_id=%s, user_id=%s",
|
|
||||||
task_id,
|
|
||||||
authenticated_user.user.id,
|
|
||||||
)
|
|
||||||
return BatchGenerationTaskResponse(
|
|
||||||
items=[_to_generation_task_response(source_task)],
|
|
||||||
total=1,
|
|
||||||
)
|
|
||||||
# 分辨率不一致,跳过复用,走新建任务流程
|
|
||||||
logger.info(
|
|
||||||
"[确认生成] 分辨率不一致,跳过复用: task_id=%s, src=%sx%s, req=%sx%s",
|
|
||||||
task_id,
|
|
||||||
src_w,
|
|
||||||
src_h,
|
|
||||||
req_w,
|
|
||||||
req_h,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 预览任务未完成,创建新的正式任务走渲染流程
|
|
||||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
|
||||||
new_task = use_case.execute(
|
|
||||||
CreateGenerationTaskCommand(
|
|
||||||
project_id=source_task.project_id,
|
|
||||||
asset_library_id=source_task.asset_library_id,
|
|
||||||
strategy_id=source_task.strategy_id,
|
|
||||||
voice_library_id=source_task.voice_library_id,
|
|
||||||
template_id=source_task.template_id,
|
|
||||||
asset_ids=source_task.asset_ids,
|
|
||||||
title_ids=source_task.title_ids,
|
|
||||||
voice_ids=source_task.voice_ids,
|
|
||||||
created_by_user_id=authenticated_user.user.id,
|
|
||||||
source_edit_plan_id=source_task.source_edit_plan_id or "",
|
|
||||||
asset_select_mode=source_task.asset_select_mode,
|
|
||||||
video_title=getattr(source_task, "video_title", ""),
|
|
||||||
resolution=getattr(source_task, "resolution", ""),
|
|
||||||
is_preview=False,
|
|
||||||
source_task_id=task_id,
|
|
||||||
output_width=request.output_width,
|
|
||||||
output_height=request.output_height,
|
|
||||||
cover_url=request.cover_url,
|
|
||||||
custom_title=request.custom_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 调度 worker
|
|
||||||
try:
|
|
||||||
if not safe_enqueue_generation_task(
|
|
||||||
new_task,
|
|
||||||
generation_task_repository,
|
|
||||||
user_id=authenticated_user.user.id,
|
|
||||||
log_prefix="[确认生成]",
|
|
||||||
log_task_status=True,
|
|
||||||
):
|
|
||||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
|
||||||
except UserPendingLimitExceeded:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail="您的待处理任务过多,请等待完成后再提交",
|
|
||||||
) from None
|
|
||||||
except GlobalQueueFull:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="系统繁忙,请稍后再试",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
return BatchGenerationTaskResponse(
|
|
||||||
items=[_to_generation_task_response(new_task)],
|
|
||||||
total=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
||||||
def list_generation_tasks(
|
def list_generation_tasks(
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||||
@@ -555,12 +429,6 @@ def retry_generation_task(
|
|||||||
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
asset_select_mode=getattr(task, "asset_select_mode", ""),
|
||||||
video_title=getattr(task, "video_title", ""),
|
video_title=getattr(task, "video_title", ""),
|
||||||
resolution=getattr(task, "resolution", ""),
|
resolution=getattr(task, "resolution", ""),
|
||||||
is_preview=getattr(task, "is_preview", False),
|
|
||||||
source_task_id=getattr(task, "source_task_id", ""),
|
|
||||||
output_width=getattr(task, "output_width", 1280),
|
|
||||||
output_height=getattr(task, "output_height", 720),
|
|
||||||
cover_url=getattr(task, "cover_url", ""),
|
|
||||||
custom_title=getattr(task, "custom_title", ""),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- bgm.py: BGM 管理
|
- bgm.py: BGM 管理
|
||||||
- effects.py: 转场 + 滤镜
|
- effects.py: 转场 + 滤镜
|
||||||
- export.py: 导出配置
|
- export.py: 导出配置
|
||||||
|
- cover.py: 封面管理 + AI 生成封面
|
||||||
- subtitles.py: 字幕管理
|
- subtitles.py: 字幕管理
|
||||||
- ai_features.py: AI 推荐
|
- ai_features.py: AI 推荐
|
||||||
- generation.py: 生成(触发/进度/记录)
|
- generation.py: 生成(触发/进度/记录)
|
||||||
@@ -30,6 +31,7 @@ from .adjustments import router as adjustments_router
|
|||||||
from .ai_features import router as ai_features_router
|
from .ai_features import router as ai_features_router
|
||||||
from .bgm import router as bgm_router
|
from .bgm import router as bgm_router
|
||||||
from .clips import router as clips_router
|
from .clips import router as clips_router
|
||||||
|
from .cover import router as cover_router
|
||||||
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
from .dependencies import get_draft_plan_id, get_editor_services # noqa: F401
|
||||||
from .draft import router as draft_router
|
from .draft import router as draft_router
|
||||||
from .effects import router as effects_router
|
from .effects import router as effects_router
|
||||||
@@ -49,6 +51,7 @@ _sub_routers = [
|
|||||||
bgm_router,
|
bgm_router,
|
||||||
effects_router,
|
effects_router,
|
||||||
export_router,
|
export_router,
|
||||||
|
cover_router,
|
||||||
subtitles_router,
|
subtitles_router,
|
||||||
ai_features_router,
|
ai_features_router,
|
||||||
generation_router,
|
generation_router,
|
||||||
|
|||||||
@@ -27,14 +27,18 @@ from packages.domain.edit_plan import EditPlanStatus
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _auto_fallback_draft_to_editing(svc: EditPlanService, plan_id: str, plan_check) -> None:
|
def _auto_fallback_draft_to_editing(
|
||||||
|
svc: EditPlanService, plan_id: str, plan_check
|
||||||
|
) -> None:
|
||||||
"""自动兜底 1: draft → editing"""
|
"""自动兜底 1: draft → editing"""
|
||||||
if plan_check.status == EditPlanStatus.DRAFT:
|
if plan_check.status == EditPlanStatus.DRAFT:
|
||||||
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
logger.info("模板编辑器自动兜底: plan=%s draft→editing", plan_id)
|
||||||
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
svc.transition_status(plan_id, EditPlanStatus.EDITING)
|
||||||
|
|
||||||
|
|
||||||
def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_check, db: Session) -> None:
|
def _auto_fallback_copy_template_clips(
|
||||||
|
svc: EditPlanService, plan_id: str, plan_check, db: Session
|
||||||
|
) -> None:
|
||||||
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
"""自动兜底 2: 无片段 + 有 template_id → 从模板复制片段配置"""
|
||||||
existing_clips = svc.count_clips(plan_id)
|
existing_clips = svc.count_clips(plan_id)
|
||||||
if existing_clips == 0 and plan_check.template_id:
|
if existing_clips == 0 and plan_check.template_id:
|
||||||
@@ -49,15 +53,15 @@ def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_
|
|||||||
for cfg in configs:
|
for cfg in configs:
|
||||||
svc.create_clip(
|
svc.create_clip(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
clip_type=cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
clip_type=cfg.clip_type.value
|
||||||
|
if hasattr(cfg.clip_type, "value")
|
||||||
|
else cfg.clip_type,
|
||||||
order=cfg.order,
|
order=cfg.order,
|
||||||
template_clip_config_id=cfg.id,
|
template_clip_config_id=cfg.id,
|
||||||
duration=cfg.default_duration,
|
duration=cfg.default_duration,
|
||||||
transition_effect=(
|
transition_effect=cfg.transition_effect.value
|
||||||
cfg.transition_effect.value
|
if hasattr(cfg.transition_effect, "value")
|
||||||
if hasattr(cfg.transition_effect, "value")
|
else cfg.transition_effect,
|
||||||
else cfg.transition_effect
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
"模板编辑器自动兜底: plan=%s 从 template_clip_configs 复制了 %d 个片段",
|
||||||
@@ -86,20 +90,14 @@ def _auto_fallback_copy_template_clips(svc: EditPlanService, plan_id: str, plan_
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check) -> list:
|
def _auto_fallback_assign_assets(
|
||||||
|
svc: EditPlanService, plan_id: str, plan_check
|
||||||
|
) -> list:
|
||||||
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
"""自动兜底 3: 为没有素材的片段分配素材。返回剩余无素材片段列表。"""
|
||||||
all_clips = svc.list_clips(plan_id)
|
all_clips = svc.list_clips(plan_id)
|
||||||
clips_without_asset = [c for c in all_clips if not c.asset_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", [])
|
config_asset_ids = (plan_check.config or {}).get("asset_ids", [])
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底3 诊断: plan=%s total_clips=%d " "clips_without_asset=%d config_asset_ids=%r",
|
|
||||||
plan_id,
|
|
||||||
len(all_clips),
|
|
||||||
len(clips_without_asset),
|
|
||||||
config_asset_ids[:5] if config_asset_ids else [],
|
|
||||||
)
|
|
||||||
|
|
||||||
if clips_without_asset and config_asset_ids:
|
if clips_without_asset and config_asset_ids:
|
||||||
logger.info(
|
logger.info(
|
||||||
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
"模板编辑器自动兜底3: plan=%s 为 %d 个无素材片段分配 %d 个指定素材",
|
||||||
@@ -107,42 +105,11 @@ def _auto_fallback_assign_assets(svc: EditPlanService, plan_id: str, plan_check)
|
|||||||
len(clips_without_asset),
|
len(clips_without_asset),
|
||||||
len(config_asset_ids),
|
len(config_asset_ids),
|
||||||
)
|
)
|
||||||
assigned = 0
|
|
||||||
for i, clip in enumerate(clips_without_asset):
|
for i, clip in enumerate(clips_without_asset):
|
||||||
asset_idx = i % len(config_asset_ids)
|
asset_idx = i % len(config_asset_ids)
|
||||||
try:
|
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
||||||
svc.assign_asset(clip.id, config_asset_ids[asset_idx])
|
logger.info("模板编辑器自动兜底3: plan=%s 素材分配完成", plan_id)
|
||||||
assigned += 1
|
clips_without_asset = []
|
||||||
except Exception as exc:
|
|
||||||
logger.error(
|
|
||||||
"模板编辑器自动兜底3: plan=%s clip=%s 分配素材 %s 失败: %s",
|
|
||||||
plan_id,
|
|
||||||
clip.id,
|
|
||||||
config_asset_ids[asset_idx],
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底3: plan=%s 素材分配完成 assigned=%d/%d",
|
|
||||||
plan_id,
|
|
||||||
assigned,
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
|
||||||
# 重新检查剩余无素材片段
|
|
||||||
all_clips_after = svc.list_clips(plan_id)
|
|
||||||
clips_without_asset = [c for c in all_clips_after if not c.asset_id]
|
|
||||||
if clips_without_asset:
|
|
||||||
logger.warning(
|
|
||||||
"模板编辑器自动兜底3: plan=%s 仍有 %d 个片段无素材",
|
|
||||||
plan_id,
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
|
||||||
elif not clips_without_asset:
|
|
||||||
logger.info("模板编辑器自动兜底3: plan=%s 所有片段已有素材,跳过", plan_id)
|
|
||||||
elif not config_asset_ids:
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底3: plan=%s config.asset_ids 为空,跳过分配",
|
|
||||||
plan_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return clips_without_asset
|
return clips_without_asset
|
||||||
|
|
||||||
@@ -154,74 +121,43 @@ def _auto_fallback_auto_material_mode(
|
|||||||
clips_without_asset: list,
|
clips_without_asset: list,
|
||||||
asset_library_repo: Any,
|
asset_library_repo: Any,
|
||||||
asset_repo: Any,
|
asset_repo: Any,
|
||||||
user_id: str = "",
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""自动兜底 4: 自动选素材分配给无素材片段
|
"""自动兜底 4: 项目有视频素材库时自动选素材"""
|
||||||
|
|
||||||
查找策略(按优先级):
|
|
||||||
1. plan 有 project_id → 从项目素材库查找
|
|
||||||
2. plan 无 project_id 但有 user_id → 从用户上传的素材中查找
|
|
||||||
"""
|
|
||||||
if not clips_without_asset:
|
if not clips_without_asset:
|
||||||
return
|
return
|
||||||
|
if not plan_check.project_id:
|
||||||
ready_videos: list = []
|
|
||||||
source_desc = ""
|
|
||||||
|
|
||||||
# 策略 1: 通过 project_id 查找项目素材库
|
|
||||||
if plan_check.project_id:
|
|
||||||
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")
|
|
||||||
]
|
|
||||||
source_desc = f"素材库 {video_lib.name}"
|
|
||||||
|
|
||||||
# 策略 2: 通过 user_id 查找用户上传的素材
|
|
||||||
if not ready_videos and user_id and hasattr(asset_repo, "find_ready_videos_by_user"):
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器自动兜底4: plan=%s project_id 为空,尝试通过 user_id=%s 查找素材",
|
|
||||||
plan_id,
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
ready_videos = asset_repo.find_ready_videos_by_user(user_id)
|
|
||||||
source_desc = f"用户上传 (user_id={user_id[:8]}...)"
|
|
||||||
|
|
||||||
if not ready_videos:
|
|
||||||
logger.warning(
|
|
||||||
"模板编辑器自动兜底4: plan=%s 未找到可用素材 (project_id=%s, user_id=%s)",
|
|
||||||
plan_id,
|
|
||||||
plan_check.project_id or "(empty)",
|
|
||||||
user_id[:8] + "..." if user_id else "(empty)",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段 (来源: %s, 共 %d 个)",
|
"模板编辑器自动兜底4: plan=%s 自动选素材分配给 %d 个无素材片段",
|
||||||
plan_id,
|
plan_id,
|
||||||
len(clips_without_asset),
|
len(clips_without_asset),
|
||||||
source_desc,
|
|
||||||
len(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 个素材给 %d 个片段",
|
|
||||||
plan_id,
|
|
||||||
source_desc,
|
|
||||||
len(ready_videos),
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
)
|
||||||
|
libs = asset_library_repo.find_by_project(plan_check.project_id)
|
||||||
|
video_lib = None
|
||||||
|
for lib in libs:
|
||||||
|
lib_kind = lib.kind.value if hasattr(lib.kind, "value") else lib.kind
|
||||||
|
if lib_kind == "video":
|
||||||
|
video_lib = lib
|
||||||
|
break
|
||||||
|
|
||||||
|
if video_lib:
|
||||||
|
assets = asset_repo.find_by_library(video_lib.id)
|
||||||
|
ready_videos = [
|
||||||
|
a
|
||||||
|
for a in assets
|
||||||
|
if (a.status.value if hasattr(a.status, "value") else a.status) == "ready"
|
||||||
|
and a.mime_type
|
||||||
|
and a.mime_type.startswith("video")
|
||||||
|
]
|
||||||
|
if ready_videos:
|
||||||
|
random.shuffle(ready_videos)
|
||||||
|
for i, clip in enumerate(clips_without_asset):
|
||||||
|
asset = ready_videos[i % len(ready_videos)]
|
||||||
|
svc.assign_asset(clip.id, asset.id)
|
||||||
|
logger.info(
|
||||||
|
"模板编辑器自动兜底4: plan=%s 从素材库 %s 分配了 %d 个素材",
|
||||||
|
plan_id,
|
||||||
|
video_lib.name,
|
||||||
|
len(ready_videos),
|
||||||
|
)
|
||||||
|
|||||||
@@ -24,94 +24,6 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=["Template Editor"])
|
router = APIRouter(tags=["Template Editor"])
|
||||||
|
|
||||||
|
|
||||||
def _build_asset_analyses(
|
|
||||||
asset_ids: list[str],
|
|
||||||
db: Session,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""调用 MediaKit 视频理解,返回 {asset_id: 分析文本}.
|
|
||||||
|
|
||||||
如果 MediaKit 不可用或分析失败,返回空 dict(调用方降级处理)。
|
|
||||||
"""
|
|
||||||
if not asset_ids:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
|
||||||
from packages.shared.mediakit_client import get_mediakit_client
|
|
||||||
from packages.shared.storage import get_shared_storage_service
|
|
||||||
|
|
||||||
client = get_mediakit_client()
|
|
||||||
if not client.is_available:
|
|
||||||
logger.info("MediaKit 未配置,跳过视频理解分析")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
asset_repo = SQLAlchemyAssetRepository(db)
|
|
||||||
storage_svc = get_shared_storage_service()
|
|
||||||
|
|
||||||
# 查找素材并获取下载 URL(使用并行列表保持索引对应,避免 URL 重复导致映射覆盖)
|
|
||||||
video_urls: list[str] = []
|
|
||||||
valid_asset_ids: list[str] = []
|
|
||||||
|
|
||||||
for aid in asset_ids[:10]: # MediaKit 单次最多 10 个视频
|
|
||||||
asset = asset_repo.get(aid)
|
|
||||||
if not asset or not asset.storage_key:
|
|
||||||
continue
|
|
||||||
# 只处理视频素材
|
|
||||||
mime = getattr(asset, "mime_type", "")
|
|
||||||
if not mime.startswith("video/"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
url = storage_svc.get_download_url(asset.storage_key)
|
|
||||||
if url:
|
|
||||||
video_urls.append(url)
|
|
||||||
valid_asset_ids.append(aid)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("获取素材URL失败: asset_id=%s error=%s", aid, str(e))
|
|
||||||
|
|
||||||
if not video_urls:
|
|
||||||
logger.info("无可用视频素材,跳过视频理解分析")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# 调用 MediaKit 视频理解
|
|
||||||
prompt = (
|
|
||||||
"请简要描述这段视频的主要内容,包括:场景(室内/室外/具体场所)、"
|
|
||||||
"主体(人物/物体/动物)、动作/活动、氛围/情绪、主要色调。"
|
|
||||||
"控制在100字以内。"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 限制轮询参数以适配 API 网关超时(nginx 60s)
|
|
||||||
# 视频理解最多 30s(poll_interval=2s * max_poll_attempts=15)
|
|
||||||
# 剩余 30s 留给 LLM 调用
|
|
||||||
contents = client.analyze_videos(
|
|
||||||
video_urls=video_urls,
|
|
||||||
prompt=prompt,
|
|
||||||
level="Economy",
|
|
||||||
poll_interval=2.0,
|
|
||||||
max_poll_attempts=15,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not contents:
|
|
||||||
logger.warning("MediaKit 视频理解未返回结果")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# 将结果映射回 asset_id(通过索引对应)
|
|
||||||
analyses: dict[str, str] = {}
|
|
||||||
for i, content in enumerate(contents):
|
|
||||||
if i < len(valid_asset_ids) and content:
|
|
||||||
analyses[valid_asset_ids[i]] = content
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"MediaKit 视频理解完成: total=%d analyzed=%d",
|
|
||||||
len(video_urls),
|
|
||||||
len(analyses),
|
|
||||||
)
|
|
||||||
return analyses
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("MediaKit 视频理解异常,将降级到无分析模式: %s", str(e))
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/ai-recommend", response_model=AIRecommendResponse)
|
@router.post("/ai-recommend", response_model=AIRecommendResponse)
|
||||||
def editor_ai_recommend(
|
def editor_ai_recommend(
|
||||||
template_id: str,
|
template_id: str,
|
||||||
@@ -134,16 +46,12 @@ def editor_ai_recommend(
|
|||||||
|
|
||||||
from packages.shared.ai_service import run_ai_recommend
|
from packages.shared.ai_service import run_ai_recommend
|
||||||
|
|
||||||
# 调用 MediaKit 视频理解,获取素材内容分析
|
|
||||||
asset_analyses = _build_asset_analyses(body.asset_ids, db)
|
|
||||||
|
|
||||||
result = run_ai_recommend(
|
result = run_ai_recommend(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
template_id=plan.template_id,
|
template_id=plan.template_id,
|
||||||
asset_ids=body.asset_ids,
|
asset_ids=body.asset_ids,
|
||||||
editing_mode=body.editing_mode,
|
editing_mode=body.editing_mode,
|
||||||
target_duration=body.target_duration,
|
target_duration=body.target_duration,
|
||||||
asset_analyses=asset_analyses,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+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)
|
||||||
@@ -18,7 +18,6 @@ from app.dependencies import (
|
|||||||
get_asset_library_repository,
|
get_asset_library_repository,
|
||||||
get_asset_repository,
|
get_asset_repository,
|
||||||
get_db_session,
|
get_db_session,
|
||||||
get_generated_video_repository,
|
|
||||||
)
|
)
|
||||||
from app.schemas.generation_task import GenerationTaskResponse
|
from app.schemas.generation_task import GenerationTaskResponse
|
||||||
from app.services.edit_plan_service import EditPlanService
|
from app.services.edit_plan_service import EditPlanService
|
||||||
@@ -29,7 +28,6 @@ from sqlalchemy.orm import Session
|
|||||||
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
from packages.adapters.sqlalchemy_impl.generation_task_repository import (
|
||||||
SQLAlchemyGenerationTaskRepository,
|
SQLAlchemyGenerationTaskRepository,
|
||||||
)
|
)
|
||||||
from packages.application.generated_videos import ListGeneratedVideosByTaskUseCase
|
|
||||||
from packages.application.generation_tasks import (
|
from packages.application.generation_tasks import (
|
||||||
CreateGenerationTaskCommand,
|
CreateGenerationTaskCommand,
|
||||||
CreateGenerationTaskUseCase,
|
CreateGenerationTaskUseCase,
|
||||||
@@ -73,60 +71,25 @@ def generate_editor_draft(
|
|||||||
_auto_fallback_copy_template_clips(plan_svc, plan_id, plan_check, db)
|
_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)
|
clips_without_asset = _auto_fallback_assign_assets(plan_svc, plan_id, plan_check)
|
||||||
_auto_fallback_auto_material_mode(
|
_auto_fallback_auto_material_mode(
|
||||||
plan_svc,
|
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
||||||
plan_id,
|
|
||||||
plan_check,
|
|
||||||
clips_without_asset,
|
|
||||||
asset_library_repo,
|
|
||||||
asset_repo,
|
|
||||||
user_id=str(current_user.user.id),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 检查是否可复用已完成的预览产物(预览品质已与正式一致)
|
# 检查是否可生成
|
||||||
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
|
||||||
reusable_task = _find_reusable_preview_task(gen_task_repo, plan_id, plan_check)
|
|
||||||
if reusable_task:
|
|
||||||
# 复用预览产物:标记为正式产出,跳过渲染
|
|
||||||
reusable_task.mark_confirmed()
|
|
||||||
gen_task_repo.update(reusable_task)
|
|
||||||
|
|
||||||
# 将产物 URL 写入 plan config
|
|
||||||
rendered_url = _get_task_output_url(reusable_task, gen_task_repo, db)
|
|
||||||
plan_svc.update_plan_config(
|
|
||||||
plan_id,
|
|
||||||
{
|
|
||||||
"generation_task_id": reusable_task.id,
|
|
||||||
"rendered_storage_key": rendered_url, # 统一用 rendered_storage_key
|
|
||||||
},
|
|
||||||
)
|
|
||||||
plan_svc.transition_status(plan_id, EditPlanStatus.COMPLETED)
|
|
||||||
|
|
||||||
updated_plan = plan_svc.get_plan_or_raise(plan_id)
|
|
||||||
logger.info(
|
|
||||||
"模板编辑器复用预览产物: template_id=%s plan_id=%s task_id=%s by user=%s",
|
|
||||||
template_id,
|
|
||||||
plan_id,
|
|
||||||
reusable_task.id,
|
|
||||||
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=reusable_task.id,
|
|
||||||
clip_count=len((plan_check.config or {}).get("clips", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
|
||||||
try:
|
try:
|
||||||
can_gen, reason = plan_svc.can_generate(plan_id)
|
can_gen, reason = plan_svc.can_generate(plan_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||||
|
) from exc
|
||||||
if not can_gen:
|
if not can_gen:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=reason)
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST, detail=reason
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
clip_count = plan_svc.mark_clips_ready(plan_id)
|
clip_count = plan_svc.mark_clips_ready(plan_id)
|
||||||
|
|
||||||
|
gen_task_repo = SQLAlchemyGenerationTaskRepository(db)
|
||||||
user_id = current_user.user.id
|
user_id = current_user.user.id
|
||||||
_check_queue_limits(gen_task_repo, user_id)
|
_check_queue_limits(gen_task_repo, user_id)
|
||||||
|
|
||||||
@@ -160,7 +123,9 @@ def generate_editor_draft(
|
|||||||
|
|
||||||
return EditPlanGenerateResponse(
|
return EditPlanGenerateResponse(
|
||||||
plan_id=plan_id,
|
plan_id=plan_id,
|
||||||
plan_status=updated_plan.status.value if hasattr(updated_plan.status, "value") else updated_plan.status,
|
plan_status=updated_plan.status.value
|
||||||
|
if hasattr(updated_plan.status, "value")
|
||||||
|
else updated_plan.status,
|
||||||
generation_task_id=gen_task.id,
|
generation_task_id=gen_task.id,
|
||||||
clip_count=clip_count,
|
clip_count=clip_count,
|
||||||
)
|
)
|
||||||
@@ -182,58 +147,6 @@ def generate_editor_draft(
|
|||||||
) from _e
|
) from _e
|
||||||
|
|
||||||
|
|
||||||
def _find_reusable_preview_task(gen_task_repo, plan_id: str, plan) -> "object | None":
|
|
||||||
"""查找该 plan 关联的已完成预览任务,判断是否可复用。
|
|
||||||
|
|
||||||
复用条件:
|
|
||||||
1. 存在 source_edit_plan_id == plan_id 的已完成预览任务
|
|
||||||
2. plan 在预览完成后未被修改(updated_at <= 预览完成时间)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
可复用的 GenerationTask,或 None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tasks = gen_task_repo.list_by_source_edit_plan(plan_id)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for task in tasks:
|
|
||||||
if not getattr(task, "is_preview", False):
|
|
||||||
continue
|
|
||||||
if not task.is_completed:
|
|
||||||
continue
|
|
||||||
# 检查 plan 是否在预览完成后被修改
|
|
||||||
completed_at = getattr(task, "completed_at", None)
|
|
||||||
if completed_at and hasattr(plan, "updated_at"):
|
|
||||||
plan_updated = plan.updated_at
|
|
||||||
# 如果 plan.updated_at 为空,无法判断是否修改过,跳过
|
|
||||||
if plan_updated is None:
|
|
||||||
continue
|
|
||||||
# 如果 plan 在预览完成后又被修改了,不能复用
|
|
||||||
if plan_updated > completed_at:
|
|
||||||
continue
|
|
||||||
return task
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_task_output_url(task, gen_task_repo, db) -> str:
|
|
||||||
"""获取任务的输出视频 URL。"""
|
|
||||||
try:
|
|
||||||
video_repo = get_generated_video_repository(db)
|
|
||||||
use_case = ListGeneratedVideosByTaskUseCase(video_repo)
|
|
||||||
videos = use_case.execute(task.id)
|
|
||||||
if videos:
|
|
||||||
url = getattr(videos[0], "file_url", "") or ""
|
|
||||||
# 规范化:合并路径中的双斜杠(保留协议头 ://)
|
|
||||||
if url:
|
|
||||||
import re as _re
|
|
||||||
url = _re.sub(r"(?<!:)//", "/", url)
|
|
||||||
return url
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
@router.get("/generation-status", response_model=EditPlanGenerationStatusResponse)
|
||||||
def get_editor_generation_status(
|
def get_editor_generation_status(
|
||||||
template_id: str,
|
template_id: str,
|
||||||
@@ -247,7 +160,9 @@ def get_editor_generation_status(
|
|||||||
try:
|
try:
|
||||||
gen_status = plan_svc.get_generation_status(plan_id)
|
gen_status = plan_svc.get_generation_status(plan_id)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)
|
||||||
|
) from exc
|
||||||
|
|
||||||
plan = gen_status["plan"]
|
plan = gen_status["plan"]
|
||||||
clips = gen_status["clips"]
|
clips = gen_status["clips"]
|
||||||
@@ -265,22 +180,25 @@ def get_editor_generation_status(
|
|||||||
for c in clips
|
for c in clips
|
||||||
]
|
]
|
||||||
|
|
||||||
raw_video_url = (plan.config or {}).get("rendered_storage_key", "") or (plan.config or {}).get("rendered_url", "")
|
raw_video_url = (plan.config or {}).get("rendered_url", "")
|
||||||
video_url = ""
|
video_url = ""
|
||||||
if raw_video_url:
|
if raw_video_url:
|
||||||
if raw_video_url.startswith("http"):
|
try:
|
||||||
video_url = raw_video_url # 已经是完整 URL
|
video_url = storage_service.get_download_url(
|
||||||
else:
|
raw_video_url, expires_seconds=86400
|
||||||
try:
|
)
|
||||||
video_url = storage_service.get_url(raw_video_url) # storage_key -> 完整 URL
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.warning(
|
||||||
logger.warning("生成视频URL获取失败: template_id=%s error=%s", template_id, e)
|
"生成视频签名URL失败: template_id=%s error=%s", template_id, e
|
||||||
video_url = raw_video_url
|
)
|
||||||
|
video_url = raw_video_url
|
||||||
|
|
||||||
progress = gen_status.get("progress", 0.0)
|
progress = gen_status.get("progress", 0.0)
|
||||||
error_message = gen_status.get("error_message", "")
|
error_message = gen_status.get("error_message", "")
|
||||||
gen_task_status = gen_status.get("generation_task_status")
|
gen_task_status = gen_status.get("generation_task_status")
|
||||||
plan_status_val = plan.status.value if hasattr(plan.status, "value") else plan.status
|
plan_status_val = (
|
||||||
|
plan.status.value if hasattr(plan.status, "value") else plan.status
|
||||||
|
)
|
||||||
if plan_status_val == "completed" and progress < 100:
|
if plan_status_val == "completed" and progress < 100:
|
||||||
progress = 100.0
|
progress = 100.0
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,29 @@ class AIRecommendResponse(BaseModel):
|
|||||||
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
confidence: float = Field(..., ge=0.0, le=1.0, description="AI 推荐置信度 (0~1)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 封面生成 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateCoverRequest(BaseModel):
|
||||||
|
"""AI 封面生成请求体"""
|
||||||
|
|
||||||
|
asset_ids: List[str] = Field(default_factory=list, description="素材 ID 列表(确定视频来源)")
|
||||||
|
cover_type: str = Field(
|
||||||
|
default="ai_frame",
|
||||||
|
description="封面类型: ai_frame / manual / upload / ai_regenerate",
|
||||||
|
)
|
||||||
|
frame_time: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
ge=0.0,
|
||||||
|
description="手动选帧时间点(秒),仅 cover_type=manual 时有效",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateCoverResponse(BaseModel):
|
||||||
|
"""AI 封面生成响应体"""
|
||||||
|
|
||||||
|
plan_id: str = Field(..., description="剪辑计划 ID")
|
||||||
|
cover: dict[str, Any] = Field(..., description="封面数据(type / image_url / frame_time 等)")
|
||||||
|
|
||||||
|
|
||||||
# ── BGM ────────────────────────────────────────────────────────────────────
|
# ── BGM ────────────────────────────────────────────────────────────────────
|
||||||
@@ -234,6 +257,43 @@ class ClipsFromAssetsResponse(BaseModel):
|
|||||||
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
# ── 封面配置 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class CoverConfigResponse(BaseModel):
|
||||||
|
"""封面配置响应"""
|
||||||
|
|
||||||
|
type: str = Field(..., description="封面类型: ai_frame / manual / upload")
|
||||||
|
image_url: str = Field(default="", description="封面图片 URL")
|
||||||
|
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||||
|
|
||||||
|
|
||||||
|
class CoverUpdateRequest(BaseModel):
|
||||||
|
"""更新封面配置请求"""
|
||||||
|
|
||||||
|
type: Optional[str] = Field(default=None, description="封面类型")
|
||||||
|
image_url: Optional[str] = Field(default=None, description="封面图片 URL")
|
||||||
|
frame_time: Optional[float] = Field(default=None, ge=0.0, description="抽帧时间点(秒)")
|
||||||
|
|
||||||
|
|
||||||
|
class CoverExtractRequest(BaseModel):
|
||||||
|
"""从片段抽帧生成封面请求"""
|
||||||
|
|
||||||
|
clip_id: str = Field(..., description="片段 ID")
|
||||||
|
frame_time: float = Field(1.0, ge=0.0, description="抽帧时间点(秒)")
|
||||||
|
|
||||||
|
|
||||||
|
class CoverSmartRequest(BaseModel):
|
||||||
|
"""智能选帧请求"""
|
||||||
|
|
||||||
|
clip_id: Optional[str] = Field(default=None, description="指定片段 ID(不传则用第一个视频片段)")
|
||||||
|
|
||||||
|
|
||||||
|
class CoverGenerateResponse(BaseModel):
|
||||||
|
"""封面生成响应"""
|
||||||
|
|
||||||
|
type: str = Field(..., description="封面类型")
|
||||||
|
image_url: str = Field(..., description="封面图片 URL")
|
||||||
|
frame_time: Optional[float] = Field(default=None, description="抽帧时间点(秒)")
|
||||||
|
|
||||||
|
|
||||||
# ── 导出配置 ────────────────────────────────────────────────────────────────
|
# ── 导出配置 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from app.dependencies import get_cosyvoice_service, get_voice_clone_profile_repo
|
|||||||
from app.schemas.voice_clone import (
|
from app.schemas.voice_clone import (
|
||||||
CreateVoiceCloneRequest,
|
CreateVoiceCloneRequest,
|
||||||
ListVoiceCloneResponse,
|
ListVoiceCloneResponse,
|
||||||
VoiceClonePreviewResponse,
|
|
||||||
VoiceCloneProfileResponse,
|
VoiceCloneProfileResponse,
|
||||||
VoiceCloneStatusResponse,
|
VoiceCloneStatusResponse,
|
||||||
)
|
)
|
||||||
@@ -20,7 +19,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
|||||||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
|
||||||
SQLAlchemyVoiceCloneProfileRepository,
|
SQLAlchemyVoiceCloneProfileRepository,
|
||||||
)
|
)
|
||||||
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
|
from packages.application.cosyvoice_service import CosyVoiceService
|
||||||
from packages.application.voice_clone.use_cases import (
|
from packages.application.voice_clone.use_cases import (
|
||||||
DeleteVoiceCloneUseCase,
|
DeleteVoiceCloneUseCase,
|
||||||
GetVoiceCloneStatusUseCase,
|
GetVoiceCloneStatusUseCase,
|
||||||
@@ -37,13 +36,6 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# 克隆音色试听缓存(减少重复TTS调用)
|
|
||||||
# key: clone_id, value: (audio_url, duration, file_size, text, timestamp)
|
|
||||||
_clone_preview_cache: dict[str, tuple[str, float, int, str, float]] = {}
|
|
||||||
CLONE_PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
|
||||||
# 默认试听文本
|
|
||||||
CLONE_PREVIEW_TEMPLATE = "你好,这是我的克隆音色,很高兴能为你配音。"
|
|
||||||
|
|
||||||
|
|
||||||
def _to_response(profile) -> VoiceCloneProfileResponse:
|
def _to_response(profile) -> VoiceCloneProfileResponse:
|
||||||
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
# source_audio_url 是用户传入的原始 URL(可能是外部地址),不做预签名转换
|
||||||
@@ -231,78 +223,3 @@ def retry_voice_clone(
|
|||||||
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
logger.error(f"Failed to mark profile as failed after dispatch error: {inner_e}")
|
||||||
|
|
||||||
return _to_response(profile)
|
return _to_response(profile)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{clone_id}/preview", response_model=VoiceClonePreviewResponse)
|
|
||||||
def get_voice_clone_preview(
|
|
||||||
clone_id: str,
|
|
||||||
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
|
|
||||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
||||||
repository: SQLAlchemyVoiceCloneProfileRepository = Depends(get_voice_clone_profile_repository),
|
|
||||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
|
||||||
) -> VoiceClonePreviewResponse:
|
|
||||||
"""获取克隆音色试听音频(实时 TTS 合成)。
|
|
||||||
|
|
||||||
- 克隆音色必须处于 ready 状态
|
|
||||||
- 使用默认试听文本时,结果缓存 7 天
|
|
||||||
- 可传入自定义 text 参数试听不同文本
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
|
|
||||||
use_case = GetVoiceCloneUseCase(repository)
|
|
||||||
try:
|
|
||||||
profile = use_case.execute(clone_id, authenticated_user.user.id)
|
|
||||||
except VoiceCloneNotFoundError as _e:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Voice clone not found") from _e
|
|
||||||
|
|
||||||
if not profile.is_ready:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=f"Voice clone is not ready (current status: {profile.status})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 有自定义文本时不缓存
|
|
||||||
use_cache = not text.strip()
|
|
||||||
|
|
||||||
if use_cache and clone_id in _clone_preview_cache:
|
|
||||||
audio_url, duration, file_size, cached_text, cached_at = _clone_preview_cache[clone_id]
|
|
||||||
if time.time() - cached_at < CLONE_PREVIEW_CACHE_TTL:
|
|
||||||
return VoiceClonePreviewResponse(
|
|
||||||
clone_id=clone_id,
|
|
||||||
voice_id=profile.voice_id,
|
|
||||||
audio_url=audio_url,
|
|
||||||
text=cached_text,
|
|
||||||
duration=duration,
|
|
||||||
file_size=file_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 合成试听音频
|
|
||||||
preview_text = text.strip() or CLONE_PREVIEW_TEMPLATE
|
|
||||||
try:
|
|
||||||
result = cosyvoice.synthesize_speech(
|
|
||||||
text=preview_text,
|
|
||||||
voice_id=profile.voice_id,
|
|
||||||
format="mp3",
|
|
||||||
speed=1.0,
|
|
||||||
)
|
|
||||||
except CosyVoiceError as e:
|
|
||||||
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
|
|
||||||
|
|
||||||
# 缓存(仅默认试听文本)
|
|
||||||
if use_cache:
|
|
||||||
_clone_preview_cache[clone_id] = (
|
|
||||||
result.audio_url,
|
|
||||||
result.duration,
|
|
||||||
result.file_size,
|
|
||||||
preview_text,
|
|
||||||
time.time(),
|
|
||||||
)
|
|
||||||
|
|
||||||
return VoiceClonePreviewResponse(
|
|
||||||
clone_id=clone_id,
|
|
||||||
voice_id=profile.voice_id,
|
|
||||||
audio_url=result.audio_url,
|
|
||||||
text=preview_text,
|
|
||||||
duration=result.duration,
|
|
||||||
file_size=result.file_size,
|
|
||||||
)
|
|
||||||
|
|||||||
Executable → Regular
+16
-129
@@ -5,8 +5,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from app.api.routes._helpers import get_user_plan
|
from app.api.routes._helpers import get_user_plan
|
||||||
@@ -44,7 +42,6 @@ from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
|
|||||||
from packages.ports.user_repository import UserRepository
|
from packages.ports.user_repository import UserRepository
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
|
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
|
||||||
# key: voice_id, value: (audio_url, timestamp)
|
# key: voice_id, value: (audio_url, timestamp)
|
||||||
@@ -54,65 +51,6 @@ PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
|
|||||||
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
|
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
|
||||||
|
|
||||||
|
|
||||||
def _resolve_preset_preview_url(
|
|
||||||
voice_id: str,
|
|
||||||
fallback_url: str,
|
|
||||||
cosyvoice: CosyVoiceService,
|
|
||||||
) -> str:
|
|
||||||
"""为预置音色获取有效的 preview_url.
|
|
||||||
|
|
||||||
优先从内存缓存读取;缓存失效时调用 CosyVoice 重新合成;
|
|
||||||
合成失败时降级返回硬编码 URL(可能已过期,但不会报错)。
|
|
||||||
"""
|
|
||||||
# 检查缓存
|
|
||||||
if voice_id in _preset_preview_cache:
|
|
||||||
audio_url, cached_at = _preset_preview_cache[voice_id]
|
|
||||||
if time.time() - cached_at < PREVIEW_CACHE_TTL:
|
|
||||||
return audio_url
|
|
||||||
|
|
||||||
# 缓存失效,调用 CosyVoice 合成
|
|
||||||
preset = get_preset_voice_by_id(voice_id)
|
|
||||||
if preset is None:
|
|
||||||
return fallback_url
|
|
||||||
|
|
||||||
preview_text = PREVIEW_TEMPLATE.format(name=preset.name)
|
|
||||||
try:
|
|
||||||
result = cosyvoice.synthesize_speech(
|
|
||||||
text=preview_text,
|
|
||||||
voice_id=voice_id,
|
|
||||||
format="mp3",
|
|
||||||
speed=1.0,
|
|
||||||
)
|
|
||||||
audio_url = result.audio_url
|
|
||||||
_preset_preview_cache[voice_id] = (audio_url, time.time())
|
|
||||||
logger.info("Preset voice preview generated: %s", voice_id)
|
|
||||||
return audio_url
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Failed to generate preview for %s, using fallback: %s", voice_id, e)
|
|
||||||
return fallback_url
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_all_preset_preview_urls(
|
|
||||||
presets: list,
|
|
||||||
cosyvoice: CosyVoiceService,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""顺序解析所有预置音色的 preview_url.
|
|
||||||
|
|
||||||
采用顺序调用(而非并行)以避免触发 DashScope API 速率限制。
|
|
||||||
首次调用后结果缓存 7 天,后续请求直接命中缓存。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
voice_id -> preview_url 映射
|
|
||||||
"""
|
|
||||||
result_map: dict[str, str] = {}
|
|
||||||
for p in presets:
|
|
||||||
try:
|
|
||||||
result_map[p.voice_id] = _resolve_preset_preview_url(p.voice_id, p.preview_url, cosyvoice)
|
|
||||||
except Exception:
|
|
||||||
result_map[p.voice_id] = p.preview_url
|
|
||||||
return result_map
|
|
||||||
|
|
||||||
|
|
||||||
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
|
||||||
return SQLAlchemyVoiceLibraryRepository(session)
|
return SQLAlchemyVoiceLibraryRepository(session)
|
||||||
|
|
||||||
@@ -180,16 +118,8 @@ def _to_unified_response(item, profile_id_map: dict | None = None, sign_url=None
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None = None) -> UnifiedVoiceItemResponse:
|
def _preset_to_unified_response(preset) -> UnifiedVoiceItemResponse:
|
||||||
"""将预置音色转换为统一响应格式。
|
"""将预置音色转换为统一响应格式。"""
|
||||||
|
|
||||||
Args:
|
|
||||||
preset: 预置音色对象
|
|
||||||
preview_url_map: voice_id -> preview_url 动态映射,优先使用
|
|
||||||
"""
|
|
||||||
preview_url = preset.preview_url
|
|
||||||
if preview_url_map and preset.voice_id in preview_url_map:
|
|
||||||
preview_url = preview_url_map[preset.voice_id]
|
|
||||||
return UnifiedVoiceItemResponse(
|
return UnifiedVoiceItemResponse(
|
||||||
id=preset.voice_id,
|
id=preset.voice_id,
|
||||||
type="preset",
|
type="preset",
|
||||||
@@ -199,40 +129,11 @@ def _preset_to_unified_response(preset, preview_url_map: dict[str, str] | None =
|
|||||||
language=preset.language,
|
language=preset.language,
|
||||||
voice_id=preset.voice_id,
|
voice_id=preset.voice_id,
|
||||||
voice_provider="cosyvoice",
|
voice_provider="cosyvoice",
|
||||||
preview_url=preview_url,
|
preview_url=preset.preview_url,
|
||||||
tags=preset.tags or [],
|
tags=preset.tags or [],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _clone_profile_to_unified_response(profile) -> UnifiedVoiceItemResponse:
|
|
||||||
"""将克隆音色档案转换为统一响应格式。
|
|
||||||
|
|
||||||
注意:克隆音色是「音色模型」(可用于 TTS 合成任意文本),
|
|
||||||
不同于配音库条目(具体的配音作品)。
|
|
||||||
"""
|
|
||||||
return UnifiedVoiceItemResponse(
|
|
||||||
id=profile.id,
|
|
||||||
type="clone",
|
|
||||||
name=profile.name,
|
|
||||||
description=profile.description or "",
|
|
||||||
gender=profile.gender or "unknown",
|
|
||||||
language=profile.language or "zh-CN",
|
|
||||||
voice_id=profile.voice_id or "",
|
|
||||||
voice_provider=profile.voice_model or "cosyvoice",
|
|
||||||
audio_url="", # 克隆音色没有预合成音频,需通过 /voice-clones/{id}/preview 试听
|
|
||||||
preview_url="", # 试听需实时合成,前端调用 preview 接口
|
|
||||||
duration=0,
|
|
||||||
file_size=0,
|
|
||||||
status=profile.status.value if hasattr(profile.status, "value") else str(profile.status),
|
|
||||||
tags=[],
|
|
||||||
user_id=profile.user_id,
|
|
||||||
project_id=None,
|
|
||||||
voice_clone_profile_id=profile.id,
|
|
||||||
created_at=profile.created_at,
|
|
||||||
updated_at=profile.updated_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==================== 统一配音列表(预置 + 克隆)====================
|
# ==================== 统一配音列表(预置 + 克隆)====================
|
||||||
|
|
||||||
|
|
||||||
@@ -249,7 +150,6 @@ def list_voices_unified(
|
|||||||
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
voice_repository: SQLAlchemyVoiceLibraryRepository = Depends(_get_voice_repository),
|
||||||
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
clone_profile_repository: SQLAlchemyVoiceCloneProfileRepository = Depends(_get_clone_profile_repository),
|
||||||
sign_url=Depends(get_audio_url_signer),
|
sign_url=Depends(get_audio_url_signer),
|
||||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
|
||||||
) -> UnifiedVoiceListResponse:
|
) -> UnifiedVoiceListResponse:
|
||||||
"""获取配音列表(预置音色 + 用户克隆音色)。
|
"""获取配音列表(预置音色 + 用户克隆音色)。
|
||||||
|
|
||||||
@@ -265,30 +165,19 @@ def list_voices_unified(
|
|||||||
has_preset = type is None or type == "preset"
|
has_preset = type is None or type == "preset"
|
||||||
has_clone = type is None or type == "clone"
|
has_clone = type is None or type == "clone"
|
||||||
|
|
||||||
# 获取预置音色(动态生成 preview_url)
|
# 获取预置音色
|
||||||
if has_preset:
|
if has_preset:
|
||||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
preset_items = [_preset_to_unified_response(p) for p in PRESET_VOICES]
|
||||||
preset_items = [_preset_to_unified_response(p, preview_url_map) for p in PRESET_VOICES]
|
|
||||||
preset_count = len(preset_items)
|
preset_count = len(preset_items)
|
||||||
|
|
||||||
# 获取克隆音色(从 voice_clone_profile 读取,ready 状态的克隆音色)
|
# 获取克隆音色
|
||||||
if has_clone:
|
if has_clone:
|
||||||
# status_filter 映射:不传则默认只返回 ready 状态(可用的克隆音色)
|
use_case = ListVoiceLibraryUseCase(voice_repository)
|
||||||
# 前端可以传 status=all 获取所有状态,或传具体状态过滤
|
clone_items_raw, clone_count = use_case.execute(user_id, status=status_filter, skip=skip, limit=limit)
|
||||||
filter_status = None
|
# 批量查询 voice_id → profile_id 映射,填充 voice_clone_profile_id
|
||||||
if status_filter and status_filter != "all":
|
voice_ids = [i.voice_id for i in clone_items_raw if i.voice_id]
|
||||||
filter_status = status_filter
|
profile_id_map = clone_profile_repository.find_profile_ids_by_voice_ids(voice_ids) if voice_ids else {}
|
||||||
elif not status_filter:
|
clone_items = [_to_unified_response(i, profile_id_map, sign_url) for i in clone_items_raw]
|
||||||
filter_status = "ready"
|
|
||||||
|
|
||||||
clone_profiles = clone_profile_repository.list_by_user(
|
|
||||||
user_id,
|
|
||||||
status=filter_status,
|
|
||||||
limit=limit,
|
|
||||||
offset=skip,
|
|
||||||
)
|
|
||||||
clone_count = clone_profile_repository.count_by_user(user_id, status=filter_status)
|
|
||||||
clone_items = [_clone_profile_to_unified_response(p) for p in clone_profiles]
|
|
||||||
|
|
||||||
# 组装结果
|
# 组装结果
|
||||||
if type == "preset":
|
if type == "preset":
|
||||||
@@ -315,15 +204,11 @@ def list_voices_unified(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/presets", response_model=PresetVoiceListResponse)
|
@router.get("/presets", response_model=PresetVoiceListResponse)
|
||||||
def list_preset_voices(
|
def list_preset_voices() -> PresetVoiceListResponse:
|
||||||
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
|
|
||||||
) -> PresetVoiceListResponse:
|
|
||||||
"""获取预置音色列表。
|
"""获取预置音色列表。
|
||||||
|
|
||||||
不需要认证,返回所有系统预置的 CosyVoice 音色。
|
不需要认证,返回所有系统预置的 CosyVoice 音色。
|
||||||
preview_url 通过 CosyVoice 动态生成,不依赖硬编码的过期 URL。
|
|
||||||
"""
|
"""
|
||||||
preview_url_map = _resolve_all_preset_preview_urls(PRESET_VOICES, cosyvoice)
|
|
||||||
items = [
|
items = [
|
||||||
PresetVoiceItemResponse(
|
PresetVoiceItemResponse(
|
||||||
voice_id=p.voice_id,
|
voice_id=p.voice_id,
|
||||||
@@ -331,7 +216,7 @@ def list_preset_voices(
|
|||||||
description=p.description,
|
description=p.description,
|
||||||
gender=p.gender,
|
gender=p.gender,
|
||||||
language=p.language,
|
language=p.language,
|
||||||
preview_url=preview_url_map.get(p.voice_id, p.preview_url),
|
preview_url=p.preview_url,
|
||||||
tags=p.tags or [],
|
tags=p.tags or [],
|
||||||
)
|
)
|
||||||
for p in PRESET_VOICES
|
for p in PRESET_VOICES
|
||||||
@@ -351,6 +236,8 @@ def get_preset_voice_preview(
|
|||||||
- 相同 voice_id 重复调用直接返回缓存的音频URL
|
- 相同 voice_id 重复调用直接返回缓存的音频URL
|
||||||
- 可传入自定义 text 参数试听不同文本
|
- 可传入自定义 text 参数试听不同文本
|
||||||
"""
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
preset = get_preset_voice_by_id(voice_id)
|
preset = get_preset_voice_by_id(voice_id)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
|
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRe
|
|||||||
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
from packages.adapters.sqlalchemy_impl.classification_job_repository import (
|
||||||
SQLAlchemyClassificationJobRepository,
|
SQLAlchemyClassificationJobRepository,
|
||||||
)
|
)
|
||||||
from packages.adapters.sqlalchemy_impl.cover_template_repository import (
|
|
||||||
SQLAlchemyCoverTemplateRepository,
|
|
||||||
)
|
|
||||||
from packages.adapters.sqlalchemy_impl.duplication_repository import (
|
from packages.adapters.sqlalchemy_impl.duplication_repository import (
|
||||||
SQLAlchemyDuplicationRecordRepository,
|
SQLAlchemyDuplicationRecordRepository,
|
||||||
)
|
)
|
||||||
@@ -131,13 +128,6 @@ def get_project_repository(
|
|||||||
return SQLAlchemyProjectRepository(session)
|
return SQLAlchemyProjectRepository(session)
|
||||||
|
|
||||||
|
|
||||||
def get_cover_template_repository(
|
|
||||||
session: Session = Depends(get_db_session),
|
|
||||||
) -> SQLAlchemyCoverTemplateRepository:
|
|
||||||
"""Provide the SQLAlchemy cover template repository implementation."""
|
|
||||||
return SQLAlchemyCoverTemplateRepository(session)
|
|
||||||
|
|
||||||
|
|
||||||
def get_tag_repository(
|
def get_tag_repository(
|
||||||
session: Session = Depends(get_db_session),
|
session: Session = Depends(get_db_session),
|
||||||
) -> TagRepository:
|
) -> TagRepository:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class CreateAssetRequest(BaseModel):
|
class CreateAssetRequest(BaseModel):
|
||||||
project_id: str | None = Field(default=None, description="可选,不传时从 library.project_id 自动推导")
|
project_id: str = Field(..., min_length=1)
|
||||||
library_id: str = Field(..., min_length=1)
|
library_id: str = Field(..., min_length=1)
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
storage_key: str = Field(..., min_length=1, max_length=255)
|
storage_key: str = Field(..., min_length=1, max_length=255)
|
||||||
@@ -101,30 +101,3 @@ class ListAssetsResponse(BaseModel):
|
|||||||
total: int = Field(default=0, ge=0)
|
total: int = Field(default=0, ge=0)
|
||||||
skip: int = Field(default=0, ge=0)
|
skip: int = Field(default=0, ge=0)
|
||||||
limit: int = Field(default=100, ge=1)
|
limit: int = Field(default=100, ge=1)
|
||||||
|
|
||||||
|
|
||||||
class SmartMatchRequest(BaseModel):
|
|
||||||
"""智能选素材请求。"""
|
|
||||||
|
|
||||||
library_id: str = Field(..., min_length=1, description="素材库 ID")
|
|
||||||
limit: int | None = Field(default=None, ge=1, le=200, description="最大返回数量,不传则返回全部匹配素材")
|
|
||||||
kind: str | None = Field(
|
|
||||||
default=None,
|
|
||||||
pattern="^(video|image|audio)$",
|
|
||||||
description="按文件类型过滤,不传则返回所有类型",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SmartMatchItem(BaseModel):
|
|
||||||
"""智能选素材结果条目。"""
|
|
||||||
|
|
||||||
asset: AssetResponse
|
|
||||||
score: float = Field(..., ge=0, le=100, description="综合得分 0-100")
|
|
||||||
breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细")
|
|
||||||
|
|
||||||
|
|
||||||
class SmartMatchResponse(BaseModel):
|
|
||||||
"""智能选素材响应。"""
|
|
||||||
|
|
||||||
items: list[SmartMatchItem]
|
|
||||||
total_candidates: int = Field(default=0, ge=0, description="参与评分的候选素材总数")
|
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"""封面模板 Schema。"""
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CoverTemplateConfig(BaseModel):
|
|
||||||
"""封面模板配置。"""
|
|
||||||
|
|
||||||
background_enabled: bool = Field(default=True, description="是否启用背景")
|
|
||||||
background_color: str = Field(default="#000000", description="背景颜色")
|
|
||||||
portrait_enabled: bool = Field(default=True, description="是否显示人像")
|
|
||||||
title_text: str = Field(default="", description="主标题文字")
|
|
||||||
subtitle_text: str = Field(default="", description="副标题文字")
|
|
||||||
mask_enabled: bool = Field(default=False, description="是否启用蒙版")
|
|
||||||
|
|
||||||
|
|
||||||
class CreateCoverTemplateRequest(BaseModel):
|
|
||||||
"""创建封面模板请求。"""
|
|
||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
|
|
||||||
thumbnail_url: str = Field(default="", description="缩略图 URL")
|
|
||||||
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateCoverTemplateRequest(BaseModel):
|
|
||||||
"""更新封面模板请求。"""
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, min_length=1, max_length=200, description="模板名称")
|
|
||||||
thumbnail_url: str | None = Field(default=None, description="缩略图 URL")
|
|
||||||
config: CoverTemplateConfig | None = Field(default=None, description="模板配置")
|
|
||||||
|
|
||||||
|
|
||||||
class CoverTemplateResponse(BaseModel):
|
|
||||||
"""封面模板响应。"""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
thumbnail_url: str
|
|
||||||
is_system: bool
|
|
||||||
created_at: datetime
|
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
|
|
||||||
class ListCoverTemplatesResponse(BaseModel):
|
|
||||||
"""封面模板列表响应。"""
|
|
||||||
|
|
||||||
items: list[CoverTemplateResponse]
|
|
||||||
total: int = Field(default=0, ge=0)
|
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
class ConfirmGenerationRequest(BaseModel):
|
|
||||||
"""确认生成请求体 — 基于预览任务创建正式生成任务"""
|
|
||||||
|
|
||||||
output_width: int = Field(default=1080, description="输出视频宽度")
|
|
||||||
output_height: int = Field(default=1920, description="输出视频高度")
|
|
||||||
cover_url: str = Field(default="", description="自定义封面图片 URL")
|
|
||||||
custom_title: str = Field(default="", description="自定义视频标题")
|
|
||||||
|
|
||||||
|
|
||||||
class CreateGenerationTaskRequest(BaseModel):
|
class CreateGenerationTaskRequest(BaseModel):
|
||||||
"""创建生成任务请求。
|
"""创建生成任务请求。
|
||||||
|
|
||||||
@@ -66,13 +56,6 @@ class CreateGenerationTaskRequest(BaseModel):
|
|||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
||||||
)
|
)
|
||||||
# ── 预览 / 确认生成 ──
|
|
||||||
is_preview: bool = Field(default=False, description="是否为预览任务")
|
|
||||||
source_task_id: str = Field(default="", description="来源预览任务 ID(确认生成时传入)")
|
|
||||||
output_width: int = Field(default=1280, description="输出视频宽度")
|
|
||||||
output_height: int = Field(default=720, description="输出视频高度")
|
|
||||||
cover_url: str = Field(default="", description="封面图片 URL")
|
|
||||||
custom_title: str = Field(default="", description="自定义视频标题")
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
def _check_at_least_one_mode(self) -> "CreateGenerationTaskRequest":
|
||||||
@@ -103,12 +86,6 @@ class GenerationTaskResponse(BaseModel):
|
|||||||
video_title: str = ""
|
video_title: str = ""
|
||||||
resolution: str = ""
|
resolution: str = ""
|
||||||
bgm_config: dict = Field(default_factory=dict)
|
bgm_config: dict = Field(default_factory=dict)
|
||||||
is_preview: bool = False
|
|
||||||
source_task_id: str = ""
|
|
||||||
output_width: int = 1280
|
|
||||||
output_height: int = 720
|
|
||||||
cover_url: str = ""
|
|
||||||
custom_title: str = ""
|
|
||||||
status: str
|
status: str
|
||||||
progress: float
|
progress: float
|
||||||
result_count: int
|
result_count: int
|
||||||
@@ -145,75 +122,3 @@ class ListGenerationTasksResponse(BaseModel):
|
|||||||
"""用户级生成任务列表响应(跨 project)。"""
|
"""用户级生成任务列表响应(跨 project)。"""
|
||||||
|
|
||||||
items: list[GenerationTaskResponse]
|
items: list[GenerationTaskResponse]
|
||||||
|
|
||||||
|
|
||||||
# ── 预览生成(Phase 1) ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class CreatePreviewGenerationTaskRequest(BaseModel):
|
|
||||||
"""创建预览生成任务请求。
|
|
||||||
|
|
||||||
仅支持模板模式:template_id + asset_ids 等素材 ID 列表。
|
|
||||||
预览渲染品质与正式生成一致(1080p, CRF 23, medium preset)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
template_id: str
|
|
||||||
asset_ids: list[str] = Field(default_factory=list)
|
|
||||||
title_ids: list[str] = Field(default_factory=list)
|
|
||||||
voice_ids: list[str] = Field(default_factory=list)
|
|
||||||
voice_library_id: str = Field(
|
|
||||||
default="", description="配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材"
|
|
||||||
)
|
|
||||||
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
|
|
||||||
duration: float = Field(default=0.0, ge=0, description="期望视频时长(秒),0 表示由模板决定")
|
|
||||||
video_ratio: str = Field(default="", description="视频比例,如 16:9 / 9:16,为空使用模板默认")
|
|
||||||
bgm_config: dict = Field(
|
|
||||||
default_factory=dict,
|
|
||||||
description="自定义BGM配置,覆盖模板BGM设置。支持 enabled/source/asset_id/preset_id/audio_url/volume 等字段",
|
|
||||||
)
|
|
||||||
preview_count: int = Field(
|
|
||||||
default=1,
|
|
||||||
ge=1,
|
|
||||||
le=10,
|
|
||||||
description="预览视频生成数量,范围 1-10,默认 1",
|
|
||||||
)
|
|
||||||
source_edit_plan_id: str = Field(
|
|
||||||
default="",
|
|
||||||
description="关联的编辑计划ID(可选),用于确认生成时复用预览产物",
|
|
||||||
)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _check_template_id(self) -> "CreatePreviewGenerationTaskRequest":
|
|
||||||
if not self.template_id.strip():
|
|
||||||
raise ValueError("template_id 不能为空")
|
|
||||||
return self
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _check_asset_ids(self) -> "CreatePreviewGenerationTaskRequest":
|
|
||||||
if not self.asset_ids and not self.title_ids and not self.voice_ids:
|
|
||||||
raise ValueError("asset_ids/title_ids/voice_ids 至少需要提供一个")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class PreviewGenerationTaskResponse(BaseModel):
|
|
||||||
"""预览生成任务响应。
|
|
||||||
|
|
||||||
包含任务状态、进度、分辨率、生成结果 URL 等关键字段。
|
|
||||||
"""
|
|
||||||
|
|
||||||
task_id: str
|
|
||||||
status: str
|
|
||||||
progress: float
|
|
||||||
is_preview: bool = True
|
|
||||||
resolution: str = ""
|
|
||||||
video_url: str = ""
|
|
||||||
duration: float = 0.0
|
|
||||||
file_size: int = 0
|
|
||||||
clip_count: int = 0
|
|
||||||
transition_count: int = 0
|
|
||||||
material_usage: dict = Field(default_factory=dict)
|
|
||||||
error_message: str = ""
|
|
||||||
created_at: datetime | None = None
|
|
||||||
started_at: datetime | None = None
|
|
||||||
finished_at: datetime | None = None
|
|
||||||
generate_duration: float = 0.0
|
|
||||||
|
|||||||
Executable → Regular
-22
@@ -63,25 +63,3 @@ class ListVoiceCloneResponse(BaseModel):
|
|||||||
|
|
||||||
items: List[VoiceCloneProfileResponse]
|
items: List[VoiceCloneProfileResponse]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class VoiceClonePreviewResponse(BaseModel):
|
|
||||||
"""克隆音色试听响应。"""
|
|
||||||
|
|
||||||
clone_id: str
|
|
||||||
"""音色克隆档案 ID"""
|
|
||||||
|
|
||||||
voice_id: str
|
|
||||||
"""CosyVoice 音色 ID"""
|
|
||||||
|
|
||||||
audio_url: str
|
|
||||||
"""试听音频 URL"""
|
|
||||||
|
|
||||||
text: str
|
|
||||||
"""试听文本"""
|
|
||||||
|
|
||||||
duration: float = 0.0
|
|
||||||
"""音频时长(秒)"""
|
|
||||||
|
|
||||||
file_size: int = 0
|
|
||||||
"""文件大小(字节)"""
|
|
||||||
|
|||||||
@@ -12,13 +12,12 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
|
import random
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from packages.domain.ai_parsing import generate_titles_fallback as _generate_titles_fallback_base
|
|
||||||
from packages.domain.ai_parsing import keyword_match_fallback as _semantic_match_fallback_base
|
|
||||||
from packages.domain.ai_parsing import parse_semantic_match_response as _parse_semantic_match_base
|
|
||||||
from packages.domain.ai_parsing import parse_titles_from_response as _parse_titles_from_response
|
|
||||||
from packages.shared.ai_client import get_doubao_client
|
from packages.shared.ai_client import get_doubao_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -65,9 +64,85 @@ def _generate_titles_fallback(
|
|||||||
style: str = "viral",
|
style: str = "viral",
|
||||||
count: int = 5,
|
count: int = 5,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""本地降级:基于模板规则生成标题(薄包装,转发到 ai_parsing 模块)."""
|
"""本地降级:基于模板规则生成标题.
|
||||||
|
|
||||||
|
当豆包 API 不可用或调用失败时使用,保证接口始终有返回。
|
||||||
|
"""
|
||||||
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
style_info = TITLE_STYLES.get(style, TITLE_STYLES["viral"])
|
||||||
return _generate_titles_fallback_base(description, style_info, count)
|
examples = style_info["examples"]
|
||||||
|
|
||||||
|
# 从描述中提取关键词(取前几个词)
|
||||||
|
keywords = [w for w in description.strip().split() if len(w) > 1][:3]
|
||||||
|
keyword = keywords[0] if keywords else "精彩内容"
|
||||||
|
|
||||||
|
# 基于模板生成
|
||||||
|
templates = [
|
||||||
|
f"「{keyword}」{examples[0][:10]}...",
|
||||||
|
f"{keyword}:{examples[1]}",
|
||||||
|
f"关于{keyword},你不知道的3件事",
|
||||||
|
f"{keyword}入门指南,新手必看",
|
||||||
|
f"深度解析:{keyword}背后的秘密",
|
||||||
|
f"{keyword}怎么做?手把手教你",
|
||||||
|
f"干货分享 | {keyword}全攻略",
|
||||||
|
f"建议收藏:{keyword}实用技巧",
|
||||||
|
f"{keyword}避坑指南,别再踩雷了",
|
||||||
|
f"一分钟搞懂{keyword}",
|
||||||
|
]
|
||||||
|
|
||||||
|
random.shuffle(templates)
|
||||||
|
return templates[: min(count, len(templates))]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_titles_from_response(content: str) -> List[str]:
|
||||||
|
"""从模型返回中解析标题列表.
|
||||||
|
|
||||||
|
支持多种返回格式:
|
||||||
|
- JSON 数组: ["标题1", "标题2"]
|
||||||
|
- 编号列表: 1. 标题1 / 2. 标题2
|
||||||
|
- 换行分隔: 标题1\n标题2
|
||||||
|
- 带破折号: - 标题1
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 尝试解析 JSON
|
||||||
|
try:
|
||||||
|
# 清理可能的 markdown 代码块标记
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.strip("`")
|
||||||
|
if cleaned.lower().startswith("json"):
|
||||||
|
cleaned = cleaned[4:]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [str(item).strip() for item in data if str(item).strip()]
|
||||||
|
if isinstance(data, dict) and "titles" in data:
|
||||||
|
titles = data["titles"]
|
||||||
|
if isinstance(titles, list):
|
||||||
|
return [str(t).strip() for t in titles if str(t).strip()]
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 尝试按行解析
|
||||||
|
titles: List[str] = []
|
||||||
|
for line in content.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
# 去掉编号前缀 "1. " "1、" "(1)"
|
||||||
|
import re
|
||||||
|
|
||||||
|
line = re.sub(r"^[\d]+[\.、\))]\s*", "", line)
|
||||||
|
# 去掉破折号前缀 "- " "• "
|
||||||
|
line = re.sub(r"^[-•·]\s*", "", line)
|
||||||
|
# 去掉引号
|
||||||
|
line = line.strip('"').strip("'").strip("「」")
|
||||||
|
if line and len(line) < 100: # 过滤过长的行
|
||||||
|
titles.append(line)
|
||||||
|
|
||||||
|
return titles
|
||||||
|
|
||||||
|
|
||||||
def generate_smart_titles(
|
def generate_smart_titles(
|
||||||
@@ -166,19 +241,132 @@ def _semantic_match_fallback(
|
|||||||
description: str,
|
description: str,
|
||||||
assets: List[Dict[str, Any]],
|
assets: List[Dict[str, Any]],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""本地降级:基于关键词的简单匹配(薄包装,转发到 ai_parsing 模块)."""
|
"""本地降级:基于关键词的简单匹配.
|
||||||
return _semantic_match_fallback_base(description, assets)
|
|
||||||
|
计算描述中的关键词与素材名称/标签/描述的重叠度,
|
||||||
|
作为匹配度评分。0-1分。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 提取关键词(中文按2字以上片段,英文按单词)
|
||||||
|
desc = description.lower()
|
||||||
|
# 简单分词:提取2字以上的中文字符串和英文单词
|
||||||
|
keywords = set()
|
||||||
|
# 英文单词
|
||||||
|
for word in re.findall(r"[a-zA-Z]{3,}", desc):
|
||||||
|
keywords.add(word)
|
||||||
|
# 中文2-4字片段
|
||||||
|
for i in range(len(desc)):
|
||||||
|
for j in range(i + 2, min(i + 5, len(desc) + 1)):
|
||||||
|
fragment = desc[i:j]
|
||||||
|
if all("\u4e00" <= c <= "\u9fff" for c in fragment):
|
||||||
|
keywords.add(fragment)
|
||||||
|
|
||||||
|
if not keywords:
|
||||||
|
# 没有关键词时给所有素材中等分数
|
||||||
|
for asset in assets:
|
||||||
|
asset["match_score"] = 0.5
|
||||||
|
asset["match_reason"] = "fallback_default"
|
||||||
|
return assets
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for asset in assets:
|
||||||
|
# 组合素材的文本信息:名称 + 标签 + 描述
|
||||||
|
asset_text_parts = [
|
||||||
|
str(asset.get("name", "")).lower(),
|
||||||
|
" ".join(str(t) for t in asset.get("tags", [])).lower(),
|
||||||
|
str(asset.get("description", "")).lower(),
|
||||||
|
]
|
||||||
|
asset_text = " | ".join(asset_text_parts)
|
||||||
|
|
||||||
|
# 计算匹配度:命中关键词占比 + 稀有关键词加权
|
||||||
|
hit_count = 0
|
||||||
|
hit_keywords = []
|
||||||
|
for kw in keywords:
|
||||||
|
if kw in asset_text:
|
||||||
|
hit_count += 1
|
||||||
|
hit_keywords.append(kw)
|
||||||
|
|
||||||
|
# 基础匹配度 = 命中关键词数 / 总关键词数(开根号平滑)
|
||||||
|
base_score = math.sqrt(hit_count / len(keywords)) if keywords else 0.5
|
||||||
|
|
||||||
|
# 名称命中加分(名称匹配更重要)
|
||||||
|
name = str(asset.get("name", "")).lower()
|
||||||
|
name_hits = sum(1 for kw in hit_keywords if kw in name)
|
||||||
|
name_bonus = min(0.2, name_hits * 0.05)
|
||||||
|
|
||||||
|
score = min(1.0, base_score * 0.8 + name_bonus)
|
||||||
|
score = round(score, 3)
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
**asset,
|
||||||
|
"match_score": score,
|
||||||
|
"match_reason": "fallback_keyword",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按匹配度降序
|
||||||
|
results.sort(key=lambda x: x["match_score"], reverse=True)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _parse_semantic_match_response(
|
def _parse_semantic_match_response(
|
||||||
content: str,
|
content: str,
|
||||||
asset_ids: List[str],
|
asset_ids: List[str],
|
||||||
) -> Optional[Dict[str, float]]:
|
) -> Optional[Dict[str, float]]:
|
||||||
"""从模型返回中解析素材匹配度(薄包装,转发到 ai_parsing 模块)."""
|
"""从模型返回中解析素材匹配度.
|
||||||
result = _parse_semantic_match_base(content, asset_ids)
|
|
||||||
if result is None:
|
期望格式:JSON 对象 {asset_id: score} 或 {"matches": [{asset_id, score}]}
|
||||||
|
score 范围 0-1。
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
return None
|
return None
|
||||||
return dict(result)
|
|
||||||
|
# 尝试解析 JSON
|
||||||
|
try:
|
||||||
|
cleaned = content.strip()
|
||||||
|
if cleaned.startswith("```"):
|
||||||
|
cleaned = cleaned.strip("`")
|
||||||
|
if cleaned.lower().startswith("json"):
|
||||||
|
cleaned = cleaned[4:]
|
||||||
|
cleaned = cleaned.strip()
|
||||||
|
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
|
||||||
|
result: Dict[str, float] = {}
|
||||||
|
|
||||||
|
# 格式1: {"asset_id1": 0.8, "asset_id2": 0.6}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if "matches" in data and isinstance(data["matches"], list):
|
||||||
|
# 格式2: {"matches": [{"asset_id": "...", "score": 0.8}]}
|
||||||
|
for item in data["matches"]:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
aid = item.get("asset_id") or item.get("id")
|
||||||
|
score = item.get("score", 0)
|
||||||
|
if aid and isinstance(score, (int, float)):
|
||||||
|
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||||
|
else:
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
result[str(key)] = max(0.0, min(1.0, float(value)))
|
||||||
|
|
||||||
|
# 格式3: [{"asset_id": "...", "score": 0.8}]
|
||||||
|
elif isinstance(data, list):
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
aid = item.get("asset_id") or item.get("id")
|
||||||
|
score = item.get("score", 0)
|
||||||
|
if aid and isinstance(score, (int, float)):
|
||||||
|
result[str(aid)] = max(0.0, min(1.0, float(score)))
|
||||||
|
|
||||||
|
if len(result) >= max(1, len(asset_ids) // 2): # 至少一半素材有评分才算成功
|
||||||
|
return result
|
||||||
|
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def semantic_match_assets(
|
def semantic_match_assets(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,11 +16,6 @@ from packages.adapters.sqlalchemy_impl import (
|
|||||||
SQLAlchemyEditPlanRepository,
|
SQLAlchemyEditPlanRepository,
|
||||||
SQLAlchemyGenerationTaskRepository,
|
SQLAlchemyGenerationTaskRepository,
|
||||||
)
|
)
|
||||||
from packages.domain.clip_operations import calculate_merge as _calc_merge
|
|
||||||
from packages.domain.clip_operations import calculate_shift_orders as _calc_shift_orders
|
|
||||||
from packages.domain.clip_operations import calculate_split as _calc_split
|
|
||||||
from packages.domain.clip_operations import validate_merge_clips as _validate_merge
|
|
||||||
from packages.domain.clip_operations import validate_split_time as _validate_split
|
|
||||||
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
from packages.domain.edit_plan import EditPlan, EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
|
|
||||||
@@ -389,45 +384,36 @@ class EditPlanService:
|
|||||||
clip = self.get_clip_or_raise(clip_id)
|
clip = self.get_clip_or_raise(clip_id)
|
||||||
plan_id = clip.plan_id
|
plan_id = clip.plan_id
|
||||||
|
|
||||||
# 纯逻辑:校验 + 计算
|
if split_time <= 0 or split_time >= clip.duration:
|
||||||
_validate_split(split_time, clip.duration)
|
raise ValueError(f"分割时间必须在 (0, {clip.duration:.3f}) 范围内,当前: {split_time}")
|
||||||
split = _calc_split(
|
|
||||||
duration=clip.duration,
|
|
||||||
split_time=split_time,
|
|
||||||
start_time=clip.start_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._auto_resume_editing(plan_id)
|
self._auto_resume_editing(plan_id)
|
||||||
|
|
||||||
|
original_duration = clip.duration
|
||||||
|
left_duration = round(split_time, 3)
|
||||||
|
right_duration = round(original_duration - split_time, 3)
|
||||||
original_order = clip.order
|
original_order = clip.order
|
||||||
|
|
||||||
# 更新左半部分(原片段)
|
# 更新左半部分(原片段)
|
||||||
clip.duration = split.left_duration
|
clip.duration = left_duration
|
||||||
left_clip = self._clip_repo.update(clip)
|
left_clip = self._clip_repo.update(clip)
|
||||||
|
|
||||||
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
# 后面片段的 order 全部 +1(给右半部分腾位置)
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||||
shifts = _calc_shift_orders(
|
for c in all_clips:
|
||||||
all_clips,
|
if c.order > original_order and c.id != clip_id:
|
||||||
threshold_order=original_order,
|
c.order += 1
|
||||||
shift=1,
|
self._clip_repo.update(c)
|
||||||
excluded_ids={clip_id},
|
|
||||||
id_attr="id",
|
|
||||||
order_attr="order",
|
|
||||||
)
|
|
||||||
for c, new_order in shifts:
|
|
||||||
c.order = new_order
|
|
||||||
self._clip_repo.update(c)
|
|
||||||
|
|
||||||
# 创建右半部分新片段(继承原片段的大部分属性)
|
# 创建右半部分新片段(继承原片段的大部分属性)
|
||||||
right_config = dict(clip.config) if clip.config else {}
|
right_config = dict(clip.config) if clip.config else {}
|
||||||
# 素材裁剪信息
|
# 素材裁剪信息
|
||||||
if clip.asset_id:
|
if clip.asset_id:
|
||||||
# 右半部分从 split_time 开始播放
|
# 右半部分从 split_time 开始播放
|
||||||
right_config["trim_start"] = split.right_trim_start
|
right_config["trim_start"] = left_duration
|
||||||
# 左半部分在 split_time 处结束
|
# 左半部分在 split_time 处结束
|
||||||
left_config = dict(left_clip.config) if left_clip.config else {}
|
left_config = dict(left_clip.config) if left_clip.config else {}
|
||||||
left_config["trim_end"] = split.left_trim_end
|
left_config["trim_end"] = right_duration
|
||||||
left_clip.config = left_config
|
left_clip.config = left_config
|
||||||
left_clip = self._clip_repo.update(left_clip)
|
left_clip = self._clip_repo.update(left_clip)
|
||||||
|
|
||||||
@@ -438,8 +424,8 @@ class EditPlanService:
|
|||||||
template_clip_config_id=clip.template_clip_config_id,
|
template_clip_config_id=clip.template_clip_config_id,
|
||||||
asset_id=clip.asset_id,
|
asset_id=clip.asset_id,
|
||||||
text_content=clip.text_content,
|
text_content=clip.text_content,
|
||||||
start_time=split.right_start_time,
|
start_time=clip.start_time + left_duration,
|
||||||
duration=split.right_duration,
|
duration=right_duration,
|
||||||
transition_effect=clip.transition_effect,
|
transition_effect=clip.transition_effect,
|
||||||
transition_duration=clip.transition_duration,
|
transition_duration=clip.transition_duration,
|
||||||
playback_speed=clip.playback_speed,
|
playback_speed=clip.playback_speed,
|
||||||
@@ -452,8 +438,8 @@ class EditPlanService:
|
|||||||
clip_id,
|
clip_id,
|
||||||
plan_id,
|
plan_id,
|
||||||
split_time,
|
split_time,
|
||||||
split.left_duration,
|
left_duration,
|
||||||
split.right_duration,
|
right_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -482,45 +468,70 @@ class EditPlanService:
|
|||||||
clip = self.get_clip_or_raise(cid)
|
clip = self.get_clip_or_raise(cid)
|
||||||
clips.append(clip)
|
clips.append(clip)
|
||||||
|
|
||||||
# 纯逻辑:校验 + 计算
|
# 校验:同一计划
|
||||||
plan_id, first_order = _validate_merge(clips)
|
plan_id = clips[0].plan_id
|
||||||
merge = _calc_merge(clips)
|
for c in clips[1:]:
|
||||||
|
if c.plan_id != plan_id:
|
||||||
|
raise ValueError("只能合并同一计划下的片段")
|
||||||
|
|
||||||
|
# 按 order 排序
|
||||||
|
clips.sort(key=lambda c: c.order)
|
||||||
|
|
||||||
|
# 校验:order 连续
|
||||||
|
for i in range(1, len(clips)):
|
||||||
|
if clips[i].order != clips[i - 1].order + 1:
|
||||||
|
raise ValueError(f"片段不连续:order {clips[i-1].order} → {clips[i].order}")
|
||||||
|
|
||||||
|
# 校验:类型一致
|
||||||
|
clip_type = clips[0].clip_type
|
||||||
|
for c in clips[1:]:
|
||||||
|
if c.clip_type != clip_type:
|
||||||
|
raise ValueError("只能合并相同类型的片段")
|
||||||
|
|
||||||
self._auto_resume_editing(plan_id)
|
self._auto_resume_editing(plan_id)
|
||||||
|
|
||||||
|
# 计算合并后的属性
|
||||||
|
first_clip = clips[0]
|
||||||
|
total_duration = round(sum(c.duration for c in clips), 3)
|
||||||
|
first_order = first_clip.order
|
||||||
|
|
||||||
|
# 合并文案(用换行连接)
|
||||||
|
merged_text = "\n".join(c.text_content for c in clips if c.text_content.strip())
|
||||||
|
|
||||||
|
# 合并 config(后面的覆盖前面的)
|
||||||
|
merged_config: Dict[str, Any] = {}
|
||||||
|
for c in clips:
|
||||||
|
if c.config:
|
||||||
|
merged_config.update(c.config)
|
||||||
|
# 清理 trim 相关字段(合并后就是完整片段了)
|
||||||
|
merged_config.pop("trim_start", None)
|
||||||
|
merged_config.pop("trim_end", None)
|
||||||
|
|
||||||
# 更新第一个片段(保留它作为合并结果)
|
# 更新第一个片段(保留它作为合并结果)
|
||||||
first_clip = sorted(clips, key=lambda c: c.order)[0]
|
first_clip.duration = total_duration
|
||||||
first_clip.duration = merge.total_duration
|
first_clip.text_content = merged_text
|
||||||
first_clip.text_content = merge.merged_text
|
first_clip.config = merged_config
|
||||||
first_clip.config = merge.merged_config
|
|
||||||
# 转场保留第一个的(合并后的入点转场)
|
# 转场保留第一个的(合并后的入点转场)
|
||||||
# playback_speed 取第一个的
|
# playback_speed 取第一个的
|
||||||
merged_clip = self._clip_repo.update(first_clip)
|
merged_clip = self._clip_repo.update(first_clip)
|
||||||
|
|
||||||
# 删除其余片段
|
# 删除其余片段
|
||||||
rest_ids = [c.id for c in clips if c.id != merged_clip.id]
|
for c in clips[1:]:
|
||||||
for cid in rest_ids:
|
self._clip_repo.delete(c.id)
|
||||||
self._clip_repo.delete(cid)
|
|
||||||
|
|
||||||
# 后面的片段 order 前移 (len - 1) 位
|
# 后面的片段 order 前移 (len - 1) 位
|
||||||
|
shift = len(clips) - 1
|
||||||
all_clips = self._clip_repo.list_by_plan(plan_id)
|
all_clips = self._clip_repo.list_by_plan(plan_id)
|
||||||
shifts = _calc_shift_orders(
|
for c in all_clips:
|
||||||
all_clips,
|
if c.order > first_order and c.id != merged_clip.id:
|
||||||
threshold_order=first_order,
|
c.order -= shift
|
||||||
shift=-merge.shift_amount,
|
self._clip_repo.update(c)
|
||||||
excluded_ids={merged_clip.id},
|
|
||||||
id_attr="id",
|
|
||||||
order_attr="order",
|
|
||||||
)
|
|
||||||
for c, new_order in shifts:
|
|
||||||
c.order = new_order
|
|
||||||
self._clip_repo.update(c)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
"合并片段: plan_id=%s count=%d total_duration=%.3fs",
|
||||||
plan_id,
|
plan_id,
|
||||||
len(clips),
|
len(clips),
|
||||||
merge.total_duration,
|
total_duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
return merged_clip
|
return merged_clip
|
||||||
@@ -571,10 +582,6 @@ class EditPlanService:
|
|||||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||||
"""检查是否可以触发渲染
|
"""检查是否可以触发渲染
|
||||||
|
|
||||||
包含最后一道防线的自动修复:
|
|
||||||
- 如果 clips 存在但都没有 asset_id,且 config.asset_ids 非空,
|
|
||||||
直接在内部执行素材分配,不再依赖前置 fallback 链路。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (can_generate, reason)
|
tuple: (can_generate, reason)
|
||||||
"""
|
"""
|
||||||
@@ -589,69 +596,10 @@ class EditPlanService:
|
|||||||
if not clips:
|
if not clips:
|
||||||
return False, "请先添加片段后再生成视频"
|
return False, "请先添加片段后再生成视频"
|
||||||
|
|
||||||
# 检查是否至少有一个片段分配了素材
|
|
||||||
has_asset = any(c.asset_id for c in clips)
|
|
||||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
|
||||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
|
||||||
logger.info(
|
|
||||||
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
|
|
||||||
plan_id,
|
|
||||||
plan.status,
|
|
||||||
len(clips),
|
|
||||||
clips_with_asset_count,
|
|
||||||
config_asset_ids_count,
|
|
||||||
)
|
|
||||||
if not has_asset:
|
|
||||||
# ── 最后防线:自动从 config.asset_ids 分配素材 ──
|
|
||||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
|
||||||
if config_asset_ids:
|
|
||||||
logger.warning(
|
|
||||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
|
|
||||||
plan_id,
|
|
||||||
len(clips),
|
|
||||||
len(config_asset_ids),
|
|
||||||
)
|
|
||||||
clips_without_asset = [c for c in clips if not c.asset_id]
|
|
||||||
assigned_count = 0
|
|
||||||
for i, clip in enumerate(clips_without_asset):
|
|
||||||
asset_idx = i % len(config_asset_ids)
|
|
||||||
try:
|
|
||||||
self.assign_asset(clip.id, config_asset_ids[asset_idx])
|
|
||||||
assigned_count += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"can_generate 最后防线: plan=%s clip=%s 分配素材 %s 失败: %s",
|
|
||||||
plan_id,
|
|
||||||
clip.id,
|
|
||||||
config_asset_ids[asset_idx],
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"can_generate 最后防线: plan=%s 已为 %d/%d 个片段分配素材",
|
|
||||||
plan_id,
|
|
||||||
assigned_count,
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
|
||||||
# 重新加载 clips 验证分配结果
|
|
||||||
clips = self._clip_repo.list_by_plan(plan_id)
|
|
||||||
if not any(c.asset_id for c in clips):
|
|
||||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
|
|
||||||
plan_id,
|
|
||||||
len(clips),
|
|
||||||
)
|
|
||||||
return False, "没有可渲染的就绪片段,请确保已选择素材"
|
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def mark_clips_ready(self, plan_id: str) -> int:
|
def mark_clips_ready(self, plan_id: str) -> int:
|
||||||
"""将已分配素材的 pending 片段标记为 ready
|
"""将所有 pending 状态的片段标记为 ready
|
||||||
|
|
||||||
只标记同时满足以下条件的片段:
|
|
||||||
- status == PENDING
|
|
||||||
- asset_id 非空(已分配素材)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: 标记的片段数量
|
int: 标记的片段数量
|
||||||
@@ -662,16 +610,10 @@ class EditPlanService:
|
|||||||
)
|
)
|
||||||
count = 0
|
count = 0
|
||||||
for clip in clips:
|
for clip in clips:
|
||||||
if clip.asset_id:
|
clip.mark_ready()
|
||||||
clip.mark_ready()
|
self._clip_repo.update(clip)
|
||||||
self._clip_repo.update(clip)
|
count += 1
|
||||||
count += 1
|
logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
|
||||||
logger.info(
|
|
||||||
"标记片段就绪: plan_id=%s marked=%d total_pending=%d",
|
|
||||||
plan_id,
|
|
||||||
count,
|
|
||||||
len(clips),
|
|
||||||
)
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ from packages.domain.template_clip_config import (
|
|||||||
TemplateClipConfig,
|
TemplateClipConfig,
|
||||||
TransitionEffect,
|
TransitionEffect,
|
||||||
)
|
)
|
||||||
from packages.domain.template_clip_converter import (
|
|
||||||
clip_configs_to_snapshots,
|
|
||||||
clips_to_template_clip_configs,
|
|
||||||
filter_plan_config_to_template,
|
|
||||||
snapshots_to_template_clip_configs,
|
|
||||||
validate_template_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -128,7 +121,9 @@ class EditTemplateService:
|
|||||||
ValueError: 名称为空或重复
|
ValueError: 名称为空或重复
|
||||||
"""
|
"""
|
||||||
# 名称校验
|
# 名称校验
|
||||||
clean_name = validate_template_name(name)
|
clean_name = name.strip()
|
||||||
|
if not clean_name:
|
||||||
|
raise ValueError("模板名称不能为空")
|
||||||
|
|
||||||
# 名称重复检查
|
# 名称重复检查
|
||||||
existing = self._template_repo.list_all(skip=0, limit=1000)
|
existing = self._template_repo.list_all(skip=0, limit=1000)
|
||||||
@@ -476,7 +471,12 @@ class EditTemplateService:
|
|||||||
raise ValueError(f"模板名称已存在: {clean_name}")
|
raise ValueError(f"模板名称已存在: {clean_name}")
|
||||||
|
|
||||||
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
# 从计划 config 中提取模板级配置,去掉运行时/素材相关字段
|
||||||
template_config = filter_plan_config_to_template(plan.config)
|
plan_config = plan.config or {}
|
||||||
|
template_config: dict[str, Any] = {}
|
||||||
|
for key, value in plan_config.items():
|
||||||
|
# 跳过明显的运行时/实例字段,保留风格/模式类配置
|
||||||
|
if key not in {"asset_ids", "source_edit_plan_id", "generation_task_id"}:
|
||||||
|
template_config[key] = value
|
||||||
|
|
||||||
template = EditTemplate.create(
|
template = EditTemplate.create(
|
||||||
name=clean_name,
|
name=clean_name,
|
||||||
@@ -497,7 +497,40 @@ class EditTemplateService:
|
|||||||
|
|
||||||
# 5. 转换每个片段为模板片段配置
|
# 5. 转换每个片段为模板片段配置
|
||||||
created_configs: List[TemplateClipConfig] = []
|
created_configs: List[TemplateClipConfig] = []
|
||||||
for clip_config_obj in clips_to_template_clip_configs(created_template.id, clips):
|
for clip in clips:
|
||||||
|
clip_config: dict[str, Any] = {}
|
||||||
|
# 播放速度存入 config
|
||||||
|
if clip.playback_speed and clip.playback_speed != 1.0:
|
||||||
|
clip_config["playback_speed"] = clip.playback_speed
|
||||||
|
# 片段自有 config 合并(优先级:clip.config 覆盖上面的)
|
||||||
|
if clip.config:
|
||||||
|
clip_config.update(clip.config)
|
||||||
|
# 去掉素材相关字段
|
||||||
|
clip_config.pop("asset_info", None)
|
||||||
|
clip_config.pop("source_asset_id", None)
|
||||||
|
|
||||||
|
# 转场效果兼容校验
|
||||||
|
try:
|
||||||
|
transition = TransitionEffect(clip.transition_effect)
|
||||||
|
except ValueError:
|
||||||
|
transition = TransitionEffect.CUT
|
||||||
|
|
||||||
|
# 片段类型兼容校验
|
||||||
|
try:
|
||||||
|
clip_type = ClipType(clip.clip_type)
|
||||||
|
except ValueError:
|
||||||
|
clip_type = ClipType.MAIN
|
||||||
|
|
||||||
|
clip_config_obj = TemplateClipConfig.create(
|
||||||
|
template_id=created_template.id,
|
||||||
|
clip_type=clip_type,
|
||||||
|
order=clip.order,
|
||||||
|
min_duration=clip.duration,
|
||||||
|
max_duration=clip.duration,
|
||||||
|
text_template=clip.text_content or "",
|
||||||
|
transition_effect=transition,
|
||||||
|
config=clip_config,
|
||||||
|
)
|
||||||
created = self._clip_config_repo.create(clip_config_obj)
|
created = self._clip_config_repo.create(clip_config_obj)
|
||||||
created_configs.append(created)
|
created_configs.append(created)
|
||||||
|
|
||||||
@@ -646,6 +679,8 @@ class EditTemplateService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
ValueError: 模板/草稿不存在,或草稿不属于该模板
|
||||||
"""
|
"""
|
||||||
|
from packages.domain.template_clip_config import TemplateClipConfig
|
||||||
|
|
||||||
# 1. 校验模板和草稿
|
# 1. 校验模板和草稿
|
||||||
template = self.get_template_or_raise(template_id)
|
template = self.get_template_or_raise(template_id)
|
||||||
draft = self._plan_repo.get(draft_plan_id)
|
draft = self._plan_repo.get(draft_plan_id)
|
||||||
@@ -665,14 +700,39 @@ class EditTemplateService:
|
|||||||
editing_mode = config.get("editing_mode", "one_take")
|
editing_mode = config.get("editing_mode", "one_take")
|
||||||
|
|
||||||
# 4. 提取模板配置(去掉草稿/运行时字段)
|
# 4. 提取模板配置(去掉草稿/运行时字段)
|
||||||
template_config = filter_plan_config_to_template(draft.config)
|
draft_config = draft.config or {}
|
||||||
|
template_config: dict[str, Any] = {}
|
||||||
|
skip_keys = {
|
||||||
|
"is_template_draft",
|
||||||
|
"asset_ids",
|
||||||
|
"source_edit_plan_id",
|
||||||
|
"generation_task_id",
|
||||||
|
}
|
||||||
|
for key, value in draft_config.items():
|
||||||
|
if key not in skip_keys:
|
||||||
|
template_config[key] = value
|
||||||
|
|
||||||
# 5. 事务更新
|
# 5. 事务更新
|
||||||
try:
|
try:
|
||||||
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
# 5.0 先保存旧版快照(发布前的状态),用于回滚
|
||||||
old_version = template.version or 1
|
old_version = template.version or 1
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||||
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
old_clip_snapshots = [
|
||||||
|
{
|
||||||
|
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||||
|
"order": cfg.order,
|
||||||
|
"min_duration": cfg.min_duration,
|
||||||
|
"max_duration": cfg.max_duration,
|
||||||
|
"text_template": cfg.text_template or "",
|
||||||
|
"transition_effect": (
|
||||||
|
cfg.transition_effect.value
|
||||||
|
if hasattr(cfg.transition_effect, "value")
|
||||||
|
else cfg.transition_effect
|
||||||
|
),
|
||||||
|
"config": cfg.config or {},
|
||||||
|
}
|
||||||
|
for cfg in old_clip_configs
|
||||||
|
]
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
from packages.domain.template_version import EditTemplateVersion
|
||||||
|
|
||||||
@@ -699,7 +759,46 @@ class EditTemplateService:
|
|||||||
|
|
||||||
# 创建新的片段配置
|
# 创建新的片段配置
|
||||||
created_configs: list[TemplateClipConfig] = []
|
created_configs: list[TemplateClipConfig] = []
|
||||||
for config_obj in clips_to_template_clip_configs(template_id, draft_clips):
|
for clip in draft_clips:
|
||||||
|
clip_config: dict[str, Any] = {}
|
||||||
|
# 播放速度存入 config
|
||||||
|
if clip.playback_speed and clip.playback_speed != 1.0:
|
||||||
|
clip_config["playback_speed"] = clip.playback_speed
|
||||||
|
# 片段自有 config 合并
|
||||||
|
if clip.config:
|
||||||
|
clip_config.update(clip.config)
|
||||||
|
# 去掉素材相关字段
|
||||||
|
clip_config.pop("asset_info", None)
|
||||||
|
clip_config.pop("source_asset_id", None)
|
||||||
|
|
||||||
|
# 转场效果兼容校验
|
||||||
|
try:
|
||||||
|
from packages.domain.template_clip_config import (
|
||||||
|
TransitionEffect,
|
||||||
|
)
|
||||||
|
|
||||||
|
transition = TransitionEffect(clip.transition_effect)
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
transition = TransitionEffect.CUT # type: ignore
|
||||||
|
|
||||||
|
# 片段类型兼容校验
|
||||||
|
try:
|
||||||
|
from packages.domain.template_clip_config import ClipType
|
||||||
|
|
||||||
|
clip_type = ClipType(clip.clip_type)
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
clip_type = ClipType.MAIN # type: ignore
|
||||||
|
|
||||||
|
config_obj = TemplateClipConfig.create(
|
||||||
|
template_id=template_id,
|
||||||
|
clip_type=clip_type,
|
||||||
|
order=clip.order,
|
||||||
|
min_duration=clip.duration,
|
||||||
|
max_duration=clip.duration,
|
||||||
|
text_template=clip.text_content or "",
|
||||||
|
transition_effect=transition,
|
||||||
|
config=clip_config,
|
||||||
|
)
|
||||||
created = self._clip_config_repo.create(config_obj)
|
created = self._clip_config_repo.create(config_obj)
|
||||||
created_configs.append(created)
|
created_configs.append(created)
|
||||||
|
|
||||||
@@ -744,6 +843,8 @@ class EditTemplateService:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: 模板/版本不存在
|
ValueError: 模板/版本不存在
|
||||||
"""
|
"""
|
||||||
|
from packages.domain.template_clip_config import TemplateClipConfig
|
||||||
|
|
||||||
template = self.get_template_or_raise(template_id)
|
template = self.get_template_or_raise(template_id)
|
||||||
|
|
||||||
# 1. 读取目标版本快照
|
# 1. 读取目标版本快照
|
||||||
@@ -756,7 +857,22 @@ class EditTemplateService:
|
|||||||
try:
|
try:
|
||||||
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
# 2. 先保存当前状态快照(当前版本号),确保回滚可撤销
|
||||||
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
old_clip_configs = self._clip_config_repo.list_by_template(template_id)
|
||||||
old_clip_snapshots = clip_configs_to_snapshots(old_clip_configs)
|
old_clip_snapshots = [
|
||||||
|
{
|
||||||
|
"clip_type": cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type,
|
||||||
|
"order": cfg.order,
|
||||||
|
"min_duration": cfg.min_duration,
|
||||||
|
"max_duration": cfg.max_duration,
|
||||||
|
"text_template": cfg.text_template or "",
|
||||||
|
"transition_effect": (
|
||||||
|
cfg.transition_effect.value
|
||||||
|
if hasattr(cfg.transition_effect, "value")
|
||||||
|
else cfg.transition_effect
|
||||||
|
),
|
||||||
|
"config": cfg.config or {},
|
||||||
|
}
|
||||||
|
for cfg in old_clip_configs
|
||||||
|
]
|
||||||
|
|
||||||
from packages.domain.template_version import EditTemplateVersion
|
from packages.domain.template_version import EditTemplateVersion
|
||||||
|
|
||||||
@@ -789,7 +905,37 @@ class EditTemplateService:
|
|||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
|
|
||||||
for config_obj in snapshots_to_template_clip_configs(template_id, target_version.clip_configs):
|
for clip_snap in target_version.clip_configs:
|
||||||
|
# 转场效果兼容校验
|
||||||
|
try:
|
||||||
|
from packages.domain.template_clip_config import TransitionEffect
|
||||||
|
|
||||||
|
transition = TransitionEffect(clip_snap.get("transition_effect", "cut"))
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
from packages.domain.template_clip_config import TransitionEffect
|
||||||
|
|
||||||
|
transition = TransitionEffect.CUT
|
||||||
|
|
||||||
|
# 片段类型兼容校验
|
||||||
|
try:
|
||||||
|
from packages.domain.template_clip_config import ClipType
|
||||||
|
|
||||||
|
clip_type = ClipType(clip_snap.get("clip_type", "main"))
|
||||||
|
except (ValueError, ImportError):
|
||||||
|
from packages.domain.template_clip_config import ClipType
|
||||||
|
|
||||||
|
clip_type = ClipType.MAIN
|
||||||
|
|
||||||
|
config_obj = TemplateClipConfig.create(
|
||||||
|
template_id=template_id,
|
||||||
|
clip_type=clip_type,
|
||||||
|
order=clip_snap.get("order", 0),
|
||||||
|
min_duration=clip_snap.get("min_duration", 0.0),
|
||||||
|
max_duration=clip_snap.get("max_duration", 0.0),
|
||||||
|
text_template=clip_snap.get("text_template", ""),
|
||||||
|
transition_effect=transition,
|
||||||
|
config=clip_snap.get("config", {}) or {},
|
||||||
|
)
|
||||||
self._clip_config_repo.create(config_obj)
|
self._clip_config_repo.create(config_obj)
|
||||||
|
|
||||||
self._db.commit()
|
self._db.commit()
|
||||||
|
|||||||
@@ -26,13 +26,7 @@ from packages.domain.edit_plan import EditPlan
|
|||||||
from packages.domain.edit_plan_clip import EditPlanClip
|
from packages.domain.edit_plan_clip import EditPlanClip
|
||||||
from packages.domain.edit_template import EditTemplate
|
from packages.domain.edit_template import EditTemplate
|
||||||
from packages.domain.editing_mode import EditingMode
|
from packages.domain.editing_mode import EditingMode
|
||||||
from packages.domain.plan_generator_utils import (
|
from packages.domain.template_clip_config import ClipType, TemplateClipConfig
|
||||||
create_clips_from_configs,
|
|
||||||
distribute_assets,
|
|
||||||
generate_default_clips,
|
|
||||||
map_clip_types_for_mode,
|
|
||||||
)
|
|
||||||
from packages.domain.template_clip_config import TemplateClipConfig
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -49,10 +43,9 @@ class PlanGeneratorService:
|
|||||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Session, asset_repo=None) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||||
self._asset_repo = asset_repo
|
|
||||||
|
|
||||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,7 +58,6 @@ class PlanGeneratorService:
|
|||||||
project_id: str = "",
|
project_id: str = "",
|
||||||
created_by_user_id: str = "",
|
created_by_user_id: str = "",
|
||||||
name: str = "",
|
name: str = "",
|
||||||
random_preview: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""基于模板+素材生成剪辑计划
|
"""基于模板+素材生成剪辑计划
|
||||||
|
|
||||||
@@ -76,7 +68,6 @@ class PlanGeneratorService:
|
|||||||
project_id: 所属项目 ID
|
project_id: 所属项目 ID
|
||||||
created_by_user_id: 创建者用户 ID
|
created_by_user_id: 创建者用户 ID
|
||||||
name: 计划名称(为空则自动取模板名)
|
name: 计划名称(为空则自动取模板名)
|
||||||
random_preview: 是否启用随机预览模式(随机选素材+随机截取片段)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||||
@@ -118,17 +109,7 @@ class PlanGeneratorService:
|
|||||||
|
|
||||||
# 4. 按 editing_mode 分配素材
|
# 4. 按 editing_mode 分配素材
|
||||||
if asset_ids:
|
if asset_ids:
|
||||||
# 如果是随机预览模式,获取素材时长信息
|
self._distribute_assets(clips, asset_ids, editing_mode)
|
||||||
asset_durations = None
|
|
||||||
if random_preview and self._asset_repo:
|
|
||||||
asset_durations = self._fetch_asset_durations(asset_ids)
|
|
||||||
self._distribute_assets(
|
|
||||||
clips,
|
|
||||||
asset_ids,
|
|
||||||
editing_mode,
|
|
||||||
random_selection=random_preview,
|
|
||||||
asset_durations=asset_durations,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 5. 持久化所有 clips 并计算总时长
|
# 5. 持久化所有 clips 并计算总时长
|
||||||
created_clips: List[EditPlanClip] = []
|
created_clips: List[EditPlanClip] = []
|
||||||
@@ -182,18 +163,83 @@ class PlanGeneratorService:
|
|||||||
plan_id: str,
|
plan_id: str,
|
||||||
clip_configs: List[TemplateClipConfig],
|
clip_configs: List[TemplateClipConfig],
|
||||||
) -> List[EditPlanClip]:
|
) -> List[EditPlanClip]:
|
||||||
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化).
|
"""从 TemplateClipConfig 列表创建 EditPlanClip 列表(未持久化)"""
|
||||||
|
clips: List[EditPlanClip] = []
|
||||||
|
# 按 order 排序
|
||||||
|
sorted_configs = sorted(clip_configs, key=lambda c: c.order)
|
||||||
|
|
||||||
委托给 plan_generator_utils.create_clips_from_configs 纯函数。
|
for cfg in sorted_configs:
|
||||||
"""
|
# 计算时长:取 min_duration 和 max_duration 的中间值
|
||||||
return create_clips_from_configs(plan_id, clip_configs)
|
if cfg.min_duration > 0 and cfg.max_duration > 0:
|
||||||
|
duration = (cfg.min_duration + cfg.max_duration) / 2
|
||||||
|
elif cfg.min_duration > 0:
|
||||||
|
duration = cfg.min_duration
|
||||||
|
elif cfg.max_duration > 0:
|
||||||
|
duration = cfg.max_duration
|
||||||
|
else:
|
||||||
|
duration = _DEFAULT_CLIP_DURATION
|
||||||
|
|
||||||
|
# clip_type 可能是枚举或字符串
|
||||||
|
clip_type = cfg.clip_type.value if hasattr(cfg.clip_type, "value") else cfg.clip_type
|
||||||
|
|
||||||
|
# transition_effect 可能是枚举或字符串
|
||||||
|
transition = (
|
||||||
|
cfg.transition_effect.value if hasattr(cfg.transition_effect, "value") else cfg.transition_effect
|
||||||
|
)
|
||||||
|
|
||||||
|
# 从 clip config 中解析 playback_speed(兼容 speed_ratio 字段名)
|
||||||
|
clip_cfg = cfg.config or {}
|
||||||
|
playback_speed = clip_cfg.get("playback_speed", clip_cfg.get("speed_ratio", 1.0)) or 1.0
|
||||||
|
|
||||||
|
clip = EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=clip_type,
|
||||||
|
order=cfg.order,
|
||||||
|
template_clip_config_id=cfg.id,
|
||||||
|
text_content=getattr(cfg, "text_template", "") or "",
|
||||||
|
duration=duration,
|
||||||
|
transition_effect=transition or "cut",
|
||||||
|
playback_speed=playback_speed,
|
||||||
|
config=clip_cfg,
|
||||||
|
)
|
||||||
|
clips.append(clip)
|
||||||
|
|
||||||
|
return clips
|
||||||
|
|
||||||
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
|
def _map_clip_types_for_mode(self, clips: List[EditPlanClip], editing_mode: str) -> None:
|
||||||
"""将 MAIN 类型片段按 editing_mode 映射为对应角色类型.
|
"""将模板 clip_config 生成的 MAIN 类型片段,按 editing_mode 映射为对应角色类型。
|
||||||
|
|
||||||
委托给 plan_generator_utils.map_clip_types_for_mode 纯函数。
|
模板的 clip_config 使用 ClipType 枚举(main/intro/outro 等),
|
||||||
|
但 PIP / VOICE_PIP 模式的素材分配和渲染分层依赖特定的 clip_type 命名
|
||||||
|
(overlay / background / corner_voice / b_roll)。
|
||||||
|
|
||||||
|
映射规则(仅修改 MAIN 类型片段,非 MAIN 片段保持原类型):
|
||||||
|
- PIP: 第1个 MAIN → main(背景),其余 MAIN → overlay(画中画)
|
||||||
|
- VOICE_PIP: 第1个 → background,第2个 → corner_voice,第3+个 → b_roll
|
||||||
|
- ONE_TAKE / VOICE_OVER: 保持 main 不变
|
||||||
"""
|
"""
|
||||||
map_clip_types_for_mode(clips, editing_mode)
|
from packages.domain.template_clip_config import ClipType
|
||||||
|
|
||||||
|
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||||
|
if not main_clips:
|
||||||
|
return
|
||||||
|
|
||||||
|
if editing_mode == EditingMode.PIP.value:
|
||||||
|
# 第1个 main 保持(背景层),其余改为 overlay(画中画层)
|
||||||
|
for i, clip in enumerate(main_clips):
|
||||||
|
if i > 0:
|
||||||
|
clip.clip_type = "overlay"
|
||||||
|
|
||||||
|
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||||
|
for i, clip in enumerate(main_clips):
|
||||||
|
if i == 0:
|
||||||
|
clip.clip_type = "background"
|
||||||
|
elif i == 1:
|
||||||
|
clip.clip_type = "corner_voice"
|
||||||
|
else:
|
||||||
|
clip.clip_type = "b_roll"
|
||||||
|
|
||||||
|
# ONE_TAKE / VOICE_OVER: 保持 main 不变,无需处理
|
||||||
|
|
||||||
def _generate_default_clips(
|
def _generate_default_clips(
|
||||||
self,
|
self,
|
||||||
@@ -201,45 +247,191 @@ class PlanGeneratorService:
|
|||||||
editing_mode: str,
|
editing_mode: str,
|
||||||
asset_count: int,
|
asset_count: int,
|
||||||
) -> List[EditPlanClip]:
|
) -> List[EditPlanClip]:
|
||||||
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构.
|
"""无 clip_configs 时,根据 editing_mode 生成默认 clip 结构
|
||||||
|
|
||||||
委托给 plan_generator_utils.generate_default_clips 纯函数。
|
- ONE_TAKE: N 个 main clips(N = asset_count,至少1个)
|
||||||
|
- PIP: 1 个 main + (N-1) 个 overlay(N = asset_count)
|
||||||
|
- VOICE_OVER: N 个 main clips + 标记需要配音
|
||||||
|
- VOICE_PIP: 1 个 background + 1 个 corner_voice + (N-2) 个 b_roll
|
||||||
"""
|
"""
|
||||||
return generate_default_clips(plan_id, editing_mode, asset_count)
|
n = max(asset_count, 1)
|
||||||
|
clips: List[EditPlanClip] = []
|
||||||
|
order = 0
|
||||||
|
|
||||||
|
if editing_mode == EditingMode.PIP.value:
|
||||||
|
# 1 个 main(全屏背景)
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=ClipType.MAIN.value,
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
# 剩余为 overlay
|
||||||
|
for _ in range(1, n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="overlay",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||||
|
# N 个 main clips(B-roll)
|
||||||
|
for _ in range(n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=ClipType.MAIN.value,
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
config={"role": "b_roll"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||||
|
# 1 个 background
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="background",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
# 1 个 corner_voice
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="corner_voice",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
# 剩余为 b_roll
|
||||||
|
for _ in range(2, n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type="b_roll",
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
# ONE_TAKE: N 个 main clips
|
||||||
|
for _ in range(n):
|
||||||
|
clips.append(
|
||||||
|
EditPlanClip.create(
|
||||||
|
plan_id=plan_id,
|
||||||
|
clip_type=ClipType.MAIN.value,
|
||||||
|
order=order,
|
||||||
|
duration=_DEFAULT_CLIP_DURATION,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order += 1
|
||||||
|
|
||||||
|
return clips
|
||||||
|
|
||||||
def _distribute_assets(
|
def _distribute_assets(
|
||||||
self,
|
self,
|
||||||
clips: List[EditPlanClip],
|
clips: List[EditPlanClip],
|
||||||
asset_ids: List[str],
|
asset_ids: List[str],
|
||||||
editing_mode: str,
|
editing_mode: str,
|
||||||
*,
|
|
||||||
random_selection: bool = False,
|
|
||||||
asset_durations: dict[str, float] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化)
|
||||||
|
|
||||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
分配策略:
|
||||||
|
- ONE_TAKE: 素材按顺序依次分配给 main 类型 clips
|
||||||
|
- PIP: 第1个素材→main(全屏背景),其余→交替分配给 overlay clips
|
||||||
|
- VOICE_OVER: 素材→main clips (B-roll)
|
||||||
|
- VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll
|
||||||
"""
|
"""
|
||||||
distribute_assets(
|
if not asset_ids or not clips:
|
||||||
clips,
|
return
|
||||||
asset_ids,
|
|
||||||
editing_mode,
|
|
||||||
random_selection=random_selection,
|
|
||||||
asset_durations=asset_durations,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
if editing_mode == EditingMode.ONE_TAKE.value:
|
||||||
"""从数据库获取素材时长信息.
|
self._distribute_one_take(clips, asset_ids)
|
||||||
|
elif editing_mode == EditingMode.PIP.value:
|
||||||
|
self._distribute_pip(clips, asset_ids)
|
||||||
|
elif editing_mode == EditingMode.VOICE_OVER.value:
|
||||||
|
self._distribute_voice_over(clips, asset_ids)
|
||||||
|
elif editing_mode == EditingMode.VOICE_PIP.value:
|
||||||
|
self._distribute_voice_pip(clips, asset_ids)
|
||||||
|
else:
|
||||||
|
# 未知模式,退化为 one_take
|
||||||
|
self._distribute_one_take(clips, asset_ids)
|
||||||
|
|
||||||
Args:
|
def _distribute_one_take(
|
||||||
asset_ids: 素材 ID 列表
|
self,
|
||||||
|
clips: List[EditPlanClip],
|
||||||
|
asset_ids: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""ONE_TAKE: 素材按顺序依次分配给 main 类型 clips"""
|
||||||
|
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||||
|
for i, clip in enumerate(main_clips):
|
||||||
|
if i < len(asset_ids):
|
||||||
|
clip.assign_asset(asset_ids[i])
|
||||||
|
|
||||||
Returns:
|
def _distribute_pip(
|
||||||
dict: 素材 ID -> 时长(秒)映射
|
self,
|
||||||
"""
|
clips: List[EditPlanClip],
|
||||||
durations: dict[str, float] = {}
|
asset_ids: List[str],
|
||||||
for asset_id in asset_ids:
|
) -> None:
|
||||||
asset = self._asset_repo.get(asset_id)
|
"""PIP: 第1个素材→main(全屏背景),其余→overlay clips"""
|
||||||
if asset and hasattr(asset, "duration"):
|
# 第1个素材 → main clip
|
||||||
durations[asset_id] = float(asset.duration or 0.0)
|
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||||
return durations
|
if main_clips and asset_ids:
|
||||||
|
main_clips[0].assign_asset(asset_ids[0])
|
||||||
|
|
||||||
|
# 其余素材 → overlay clips
|
||||||
|
overlay_clips = [c for c in clips if c.clip_type == "overlay"]
|
||||||
|
remaining = asset_ids[1:]
|
||||||
|
for i, clip in enumerate(overlay_clips):
|
||||||
|
if i < len(remaining):
|
||||||
|
clip.assign_asset(remaining[i])
|
||||||
|
|
||||||
|
def _distribute_voice_over(
|
||||||
|
self,
|
||||||
|
clips: List[EditPlanClip],
|
||||||
|
asset_ids: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""VOICE_OVER: 素材→main clips (B-roll)"""
|
||||||
|
main_clips = [c for c in clips if c.clip_type == ClipType.MAIN.value]
|
||||||
|
for i, clip in enumerate(main_clips):
|
||||||
|
if i < len(asset_ids):
|
||||||
|
clip.assign_asset(asset_ids[i])
|
||||||
|
|
||||||
|
def _distribute_voice_pip(
|
||||||
|
self,
|
||||||
|
clips: List[EditPlanClip],
|
||||||
|
asset_ids: List[str],
|
||||||
|
) -> None:
|
||||||
|
"""VOICE_PIP: 第1个→background, 第2个→corner_voice, 其余→b_roll"""
|
||||||
|
bg_clips = [c for c in clips if c.clip_type == "background"]
|
||||||
|
corner_clips = [c for c in clips if c.clip_type == "corner_voice"]
|
||||||
|
broll_clips = [c for c in clips if c.clip_type == "b_roll"]
|
||||||
|
|
||||||
|
# 第1个素材 → background
|
||||||
|
if bg_clips and len(asset_ids) > 0:
|
||||||
|
bg_clips[0].assign_asset(asset_ids[0])
|
||||||
|
|
||||||
|
# 第2个素材 → corner_voice
|
||||||
|
if corner_clips and len(asset_ids) > 1:
|
||||||
|
corner_clips[0].assign_asset(asset_ids[1])
|
||||||
|
|
||||||
|
# 其余素材 → b_roll
|
||||||
|
remaining = asset_ids[2:]
|
||||||
|
for i, clip in enumerate(broll_clips):
|
||||||
|
if i < len(remaining):
|
||||||
|
clip.assign_asset(remaining[i])
|
||||||
|
|||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
"""SmartAssetSelector — 智能素材选择服务.
|
||||||
|
|
||||||
|
根据多维度评分从素材库中自动选择最优视频素材,
|
||||||
|
用于一键生成等需要自动选取素材的场景。
|
||||||
|
|
||||||
|
评分维度(加权求和,总分 0-1):
|
||||||
|
- 质量分(quality_score):权重 0.5 — 来自人工或AI的质量评分
|
||||||
|
- 分辨率适配:权重 0.2 — 分辨率越接近 1080p 得分越高
|
||||||
|
- 时长合理性:权重 0.2 — 3-30 秒区间最佳,过短/过长扣分
|
||||||
|
- 码率质量:权重 0.1 — 用文件大小/时长估算,码率适中得分高
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 最低质量分门槛:自动过滤低质量素材
|
||||||
|
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
||||||
|
- 兼容全部模式:素材库模式和项目模式都可用
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── 评分权重 ──────────────────────────────────────────────────────────────────
|
||||||
|
_WEIGHT_QUALITY = 0.5
|
||||||
|
_WEIGHT_RESOLUTION = 0.2
|
||||||
|
_WEIGHT_DURATION = 0.2
|
||||||
|
_WEIGHT_BITRATE = 0.1
|
||||||
|
|
||||||
|
# ── 评分参数 ──────────────────────────────────────────────────────────────────
|
||||||
|
_TARGET_WIDTH = 1920 # 目标分辨率宽度基准
|
||||||
|
_TARGET_HEIGHT = 1080 # 目标分辨率高度基准
|
||||||
|
_MIN_QUALITY_SCORE = 30.0 # 最低质量分门槛(低于此值的素材直接排除)
|
||||||
|
_OPTIMAL_DURATION_MIN = 3.0 # 最佳时长区间(秒)
|
||||||
|
_OPTIMAL_DURATION_MAX = 30.0
|
||||||
|
|
||||||
|
# ── 多样性分桶 ───────────────────────────────────────────────────────────────
|
||||||
|
_SHORT_BUCKET_MAX = 5.0 # 短素材:< 5s
|
||||||
|
_MEDIUM_BUCKET_MAX = 15.0 # 中素材:5-15s
|
||||||
|
# 长素材:> 15s
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SmartSelectResult:
|
||||||
|
"""智能选择结果."""
|
||||||
|
|
||||||
|
selected_ids: list[str]
|
||||||
|
total_candidates: int
|
||||||
|
filtered_out: int # 被质量门槛过滤的数量
|
||||||
|
avg_score: float
|
||||||
|
details: list[AssetScoreDetail]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AssetScoreDetail:
|
||||||
|
"""单个素材的评分详情."""
|
||||||
|
|
||||||
|
asset_id: str
|
||||||
|
total_score: float
|
||||||
|
quality_score: float
|
||||||
|
resolution_score: float
|
||||||
|
duration_score: float
|
||||||
|
bitrate_score: float
|
||||||
|
duration: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class SmartAssetSelector:
|
||||||
|
"""智能素材选择器.
|
||||||
|
|
||||||
|
从一组素材中按综合评分选择最优的 N 个,
|
||||||
|
同时保证时长分布的多样性。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
min_quality_score: float = _MIN_QUALITY_SCORE,
|
||||||
|
target_width: int = _TARGET_WIDTH,
|
||||||
|
target_height: int = _TARGET_HEIGHT,
|
||||||
|
):
|
||||||
|
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 = 0
|
||||||
|
for asset in assets:
|
||||||
|
status = getattr(asset, "status", None)
|
||||||
|
status_val = status.value if hasattr(status, "value") else str(status)
|
||||||
|
if status_val != "ready":
|
||||||
|
continue
|
||||||
|
mime_type = getattr(asset, "mime_type", "") or ""
|
||||||
|
if not mime_type.startswith("video"):
|
||||||
|
continue
|
||||||
|
quality = getattr(asset, "quality_score", None)
|
||||||
|
if quality is not None and quality < self.min_quality_score:
|
||||||
|
filtered_out += 1
|
||||||
|
continue
|
||||||
|
candidates.append(asset)
|
||||||
|
|
||||||
|
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 = self._score_asset(asset)
|
||||||
|
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 = self._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
|
||||||
|
|
||||||
|
# ── 内部方法 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _score_asset(self, asset) -> AssetScoreDetail:
|
||||||
|
"""对单个素材进行多维度评分."""
|
||||||
|
# 质量分
|
||||||
|
quality = getattr(asset, "quality_score", None)
|
||||||
|
quality_score = (quality / 100.0) if quality is not None else 0.5
|
||||||
|
|
||||||
|
# 分辨率评分:越接近目标分辨率得分越高
|
||||||
|
width = getattr(asset, "width", None)
|
||||||
|
height = getattr(asset, "height", None)
|
||||||
|
resolution_score = self._score_resolution(width, height)
|
||||||
|
|
||||||
|
# 时长评分:在最佳区间内得分高,过短过长扣分
|
||||||
|
duration = getattr(asset, "duration", None)
|
||||||
|
duration_score = self._score_duration(duration)
|
||||||
|
|
||||||
|
# 码率评分:用 file_size/duration 估算,适中得分高
|
||||||
|
file_size = getattr(asset, "file_size", 0) or 0
|
||||||
|
bitrate_score = self._score_bitrate(file_size, duration)
|
||||||
|
|
||||||
|
# 加权总分
|
||||||
|
total = (
|
||||||
|
_WEIGHT_QUALITY * quality_score
|
||||||
|
+ _WEIGHT_RESOLUTION * resolution_score
|
||||||
|
+ _WEIGHT_DURATION * duration_score
|
||||||
|
+ _WEIGHT_BITRATE * bitrate_score
|
||||||
|
)
|
||||||
|
|
||||||
|
return AssetScoreDetail(
|
||||||
|
asset_id=asset.id,
|
||||||
|
total_score=round(total, 4),
|
||||||
|
quality_score=round(quality_score, 4),
|
||||||
|
resolution_score=round(resolution_score, 4),
|
||||||
|
duration_score=round(duration_score, 4),
|
||||||
|
bitrate_score=round(bitrate_score, 4),
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
||||||
|
"""分辨率评分:越接近目标分辨率得分越高,低于480p扣分严重."""
|
||||||
|
if width is None or height is None or width <= 0 or height <= 0:
|
||||||
|
return 0.5 # 未知分辨率给中评分
|
||||||
|
|
||||||
|
target_pixels = self.target_width * self.target_height
|
||||||
|
actual_pixels = width * height
|
||||||
|
|
||||||
|
# 计算像素数比例
|
||||||
|
ratio = actual_pixels / target_pixels
|
||||||
|
|
||||||
|
if ratio >= 1.0:
|
||||||
|
# 高于或等于目标分辨率:满分,略高不扣分(4K也给满分)
|
||||||
|
return 1.0
|
||||||
|
else:
|
||||||
|
# 低于目标分辨率:线性衰减,但最低不低于 0.1
|
||||||
|
# 例如:720p (921600) / 1080p (2073600) = 0.44 → 得分 0.6
|
||||||
|
score = 0.3 + 0.7 * ratio
|
||||||
|
return max(0.1, min(1.0, score))
|
||||||
|
|
||||||
|
def _score_duration(self, duration: float | None) -> float:
|
||||||
|
"""时长评分:3-30秒最佳,过短或过长都扣分."""
|
||||||
|
if duration is None or duration <= 0:
|
||||||
|
return 0.5 # 未知时长给中评分
|
||||||
|
|
||||||
|
if _OPTIMAL_DURATION_MIN <= duration <= _OPTIMAL_DURATION_MAX:
|
||||||
|
# 最佳区间:满分
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
if duration < _OPTIMAL_DURATION_MIN:
|
||||||
|
# 太短:线性衰减,1秒以下给 0.3
|
||||||
|
ratio = duration / _OPTIMAL_DURATION_MIN
|
||||||
|
return 0.3 + 0.7 * ratio
|
||||||
|
|
||||||
|
# 太长:每超过最佳区间上限10秒扣 0.1 分,最低 0.2
|
||||||
|
excess = duration - _OPTIMAL_DURATION_MAX
|
||||||
|
penalty = min(0.8, excess / 10.0 * 0.1)
|
||||||
|
return max(0.2, 1.0 - penalty)
|
||||||
|
|
||||||
|
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
||||||
|
"""码率评分:根据文件大小和时长估算码率,适中得分高."""
|
||||||
|
if not file_size or not duration or duration <= 0:
|
||||||
|
return 0.5 # 未知给中评分
|
||||||
|
|
||||||
|
# 估算码率(bps)
|
||||||
|
bitrate = (file_size * 8) / duration
|
||||||
|
|
||||||
|
# 最佳码率范围:2-8 Mbps
|
||||||
|
optimal_low = 2_000_000 # 2 Mbps
|
||||||
|
optimal_high = 8_000_000 # 8 Mbps
|
||||||
|
|
||||||
|
if optimal_low <= bitrate <= optimal_high:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
if bitrate < optimal_low:
|
||||||
|
# 码率太低:线性衰减
|
||||||
|
ratio = bitrate / optimal_low
|
||||||
|
return 0.3 + 0.7 * ratio
|
||||||
|
|
||||||
|
# 码率太高(文件太大):适度扣分
|
||||||
|
excess = bitrate / optimal_high - 1.0
|
||||||
|
penalty = min(0.5, excess * 0.2)
|
||||||
|
return max(0.5, 1.0 - penalty)
|
||||||
|
|
||||||
|
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
||||||
|
"""多样性选择:按时长分桶,保证每个桶都有素材.
|
||||||
|
|
||||||
|
策略:
|
||||||
|
1. 按时长分为三桶:短(<5s)、中(5-15s)、长(>15s)
|
||||||
|
2. 每个桶配额 = max(1, count / 3)
|
||||||
|
3. 先从每桶按配额取最高分的
|
||||||
|
4. 剩余名额从全局最高分中取(不重复)
|
||||||
|
"""
|
||||||
|
# 分桶
|
||||||
|
short_bucket = [d for d in scored if d.duration is not None and d.duration < _SHORT_BUCKET_MAX]
|
||||||
|
medium_bucket = [
|
||||||
|
d for d in scored if d.duration is not None and _SHORT_BUCKET_MAX <= d.duration < _MEDIUM_BUCKET_MAX
|
||||||
|
]
|
||||||
|
long_bucket = [d for d in scored if d.duration is not None and d.duration >= _MEDIUM_BUCKET_MAX]
|
||||||
|
unknown_bucket = [d for d in scored if d.duration is None]
|
||||||
|
|
||||||
|
buckets = [short_bucket, medium_bucket, long_bucket]
|
||||||
|
bucket_names = ["short", "medium", "long"]
|
||||||
|
|
||||||
|
# 每个桶基础配额(至少1个,如果桶非空且需要的话)
|
||||||
|
base_quota = max(1, count // 3)
|
||||||
|
|
||||||
|
selected: list[AssetScoreDetail] = []
|
||||||
|
selected_ids: set[str] = set()
|
||||||
|
|
||||||
|
# 先按配额从每个桶取
|
||||||
|
for bucket, _name in zip(buckets, bucket_names, strict=False):
|
||||||
|
quota = min(base_quota, len(bucket))
|
||||||
|
if quota <= 0:
|
||||||
|
continue
|
||||||
|
# 桶内已经按分数排好序了,直接取前 quota 个
|
||||||
|
for item in bucket[:quota]:
|
||||||
|
if item.asset_id not in selected_ids:
|
||||||
|
selected.append(item)
|
||||||
|
selected_ids.add(item.asset_id)
|
||||||
|
if len(selected) >= count:
|
||||||
|
return selected
|
||||||
|
|
||||||
|
# 剩余名额:从全局(未被选中的)中按分数高低取
|
||||||
|
remaining_needed = count - len(selected)
|
||||||
|
if remaining_needed > 0:
|
||||||
|
for item in scored:
|
||||||
|
if item.asset_id not in selected_ids:
|
||||||
|
selected.append(item)
|
||||||
|
selected_ids.add(item.asset_id)
|
||||||
|
if len(selected) >= count:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 如果还不够(不应该发生),加上未知时长的
|
||||||
|
if len(selected) < count and unknown_bucket:
|
||||||
|
for item in unknown_bucket:
|
||||||
|
if item.asset_id not in selected_ids:
|
||||||
|
selected.append(item)
|
||||||
|
selected_ids.add(item.asset_id)
|
||||||
|
if len(selected) >= count:
|
||||||
|
break
|
||||||
|
|
||||||
|
return selected[:count]
|
||||||
@@ -29,33 +29,47 @@ from packages.adapters.sqlalchemy_impl.edit_plan_repository import (
|
|||||||
)
|
)
|
||||||
from packages.domain.edit_plan import EditPlanStatus
|
from packages.domain.edit_plan import EditPlanStatus
|
||||||
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
from packages.domain.edit_plan_clip import EditPlanClip, EditPlanClipStatus
|
||||||
from packages.domain.video_filter_builder import (
|
from packages.domain.template_clip_config import TransitionEffect
|
||||||
DEFAULT_FPS,
|
|
||||||
DEFAULT_OUTPUT_HEIGHT,
|
|
||||||
DEFAULT_OUTPUT_WIDTH,
|
|
||||||
DEFAULT_TRANSITION_DURATION,
|
|
||||||
ClipFilterChain,
|
|
||||||
build_clip_filter,
|
|
||||||
)
|
|
||||||
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_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
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── 常量(向后兼容别名) ──────────────────────────────────────────────────────
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||||
# 实际定义已迁移至 packages/domain/video_filter_builder.py
|
|
||||||
|
|
||||||
|
DEFAULT_OUTPUT_WIDTH = 1280
|
||||||
|
DEFAULT_OUTPUT_HEIGHT = 720
|
||||||
|
DEFAULT_FPS = 25
|
||||||
DEFAULT_CODEC = "libx264"
|
DEFAULT_CODEC = "libx264"
|
||||||
DEFAULT_CRF = 23
|
DEFAULT_CRF = 23
|
||||||
DEFAULT_PRESET = "medium"
|
DEFAULT_PRESET = "medium"
|
||||||
|
|
||||||
|
# xfade 转场映射:TransitionEffect → FFmpeg xfade transition 名称
|
||||||
|
_XFADE_TRANSITION_MAP: dict[str, str] = {
|
||||||
|
TransitionEffect.FADE: "fade",
|
||||||
|
TransitionEffect.SLIDE_LEFT: "slideleft",
|
||||||
|
TransitionEffect.SLIDE_RIGHT: "slideright",
|
||||||
|
TransitionEffect.DISSOLVE: "dissolve",
|
||||||
|
TransitionEffect.WIPE: "wipeleft",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 转场默认时长(秒)
|
||||||
|
DEFAULT_TRANSITION_DURATION = 0.5
|
||||||
|
|
||||||
|
|
||||||
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
# ── 数据结构 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClipFilterChain:
|
||||||
|
"""单个片段的滤镜链描述。"""
|
||||||
|
|
||||||
|
clip_id: str
|
||||||
|
input_index: int
|
||||||
|
video_label: str
|
||||||
|
audio_label: str | None
|
||||||
|
filters: list[str]
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ComposeCommand:
|
class ComposeCommand:
|
||||||
"""完整的 FFmpeg 合成命令描述。"""
|
"""完整的 FFmpeg 合成命令描述。"""
|
||||||
@@ -374,7 +388,7 @@ class VideoComposeService:
|
|||||||
EditPlanStatus.EDITING,
|
EditPlanStatus.EDITING,
|
||||||
EditPlanStatus.RENDERING,
|
EditPlanStatus.RENDERING,
|
||||||
),
|
),
|
||||||
"rendered_url": plan.config.get("rendered_storage_key", "") or plan.config.get("rendered_url", ""),
|
"rendered_url": plan.config.get("rendered_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── 内部方法 ──────────────────────────────────────────────────────────
|
# ── 内部方法 ──────────────────────────────────────────────────────────
|
||||||
@@ -387,8 +401,62 @@ class VideoComposeService:
|
|||||||
output_height: int,
|
output_height: int,
|
||||||
fps: int,
|
fps: int,
|
||||||
) -> ClipFilterChain:
|
) -> ClipFilterChain:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_clip_filter。"""
|
"""为单个片段构建滤镜链。
|
||||||
return build_clip_filter(clip, input_index, output_width, output_height, fps)
|
|
||||||
|
滤镜顺序:
|
||||||
|
1. scale — 等比缩放到目标分辨率(保证覆盖)
|
||||||
|
2. crop — 居中裁剪到目标分辨率
|
||||||
|
3. fps — 统一输出帧率(concat 要求所有输入帧率一致)
|
||||||
|
4. setpts — 重置时间戳 + 偏移
|
||||||
|
5. trim — 视频时长裁剪
|
||||||
|
6. atrim — 音频时长裁剪(如有音频流)
|
||||||
|
"""
|
||||||
|
duration = clip.duration if clip.duration > 0 else 5.0 # 默认 5 秒
|
||||||
|
start = clip.start_time
|
||||||
|
|
||||||
|
filters: list[str] = []
|
||||||
|
|
||||||
|
# 1. scale: 等比缩放(保持比例,不裁剪)
|
||||||
|
filters.append(f"scale={output_width}:{output_height}" f":force_original_aspect_ratio=decrease")
|
||||||
|
|
||||||
|
# 2. pad: 居中+留黑边到目标分辨率(保持原始比例,不裁剪内容)
|
||||||
|
filters.append(f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2:black")
|
||||||
|
|
||||||
|
# 3. format: 统一像素格式为 yuv420p(H.264 标准格式,concat 要求所有输入像素格式一致)
|
||||||
|
# 不同素材可能是 yuv420p / yuv422p / yuv444p / nv12 等,必须统一
|
||||||
|
filters.append("format=yuv420p")
|
||||||
|
|
||||||
|
# 4. fps: 统一帧率(concat 要求所有输入帧率一致)
|
||||||
|
# 放在 pad 之后、setpts 之前,确保分辨率和帧率都已统一
|
||||||
|
if fps and fps > 0:
|
||||||
|
filters.append(f"fps={fps}")
|
||||||
|
|
||||||
|
# 3. setpts: 重置时间戳
|
||||||
|
if start > 0:
|
||||||
|
filters.append(f"setpts=PTS-STARTPTS+{start}/TB")
|
||||||
|
else:
|
||||||
|
filters.append("setpts=PTS-STARTPTS")
|
||||||
|
|
||||||
|
# 4. trim: 视频时长
|
||||||
|
filters.append(f"trim=0:{duration}")
|
||||||
|
filters.append("setpts=PTS-STARTPTS") # trim 后需要重置 PTS
|
||||||
|
|
||||||
|
video_label = f"v{input_index}"
|
||||||
|
|
||||||
|
# 5. 音频标签:仅当片段类型可能有音频时才设置
|
||||||
|
# title/subtitle 是纯文字/图片卡片,没有音频流
|
||||||
|
clip_type = clip.clip_type.lower() if clip.clip_type else ""
|
||||||
|
has_audio_stream = clip_type not in ("title", "subtitle")
|
||||||
|
audio_label = f"a{input_index}" if has_audio_stream else None
|
||||||
|
|
||||||
|
return ClipFilterChain(
|
||||||
|
clip_id=clip.id,
|
||||||
|
input_index=input_index,
|
||||||
|
video_label=video_label,
|
||||||
|
audio_label=audio_label,
|
||||||
|
filters=filters,
|
||||||
|
duration=duration,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_filter_complex(
|
def _build_filter_complex(
|
||||||
@@ -398,30 +466,102 @@ class VideoComposeService:
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_filter_complex。"""
|
"""构建完整的 filter_complex 字符串。
|
||||||
return _build_filter_complex(clip_chains, output_width, output_height, transition_duration, transitions)
|
|
||||||
|
策略:
|
||||||
|
- 单片段:直接输出
|
||||||
|
- 多片段 + 全 cut:使用 concat 滤镜(高效)
|
||||||
|
- 多片段 + 有转场:使用 xfade 滤镜链
|
||||||
|
|
||||||
|
返回 (filter_complex_string, estimated_total_duration)。
|
||||||
|
"""
|
||||||
|
n = len(clip_chains)
|
||||||
|
|
||||||
|
if n == 0:
|
||||||
|
return "", 0.0
|
||||||
|
|
||||||
|
# ── 单片段 ─────────────────────────────────────────────────────
|
||||||
|
if n == 1:
|
||||||
|
chain = clip_chains[0]
|
||||||
|
filter_str = _chain_filters(chain.filters, chain.video_label)
|
||||||
|
# 音频
|
||||||
|
if chain.audio_label:
|
||||||
|
filter_str += f";[0:a]{chain.audio_label}"
|
||||||
|
total_duration = chain.duration
|
||||||
|
return filter_str, total_duration
|
||||||
|
|
||||||
|
# ── 检查是否有转场 ─────────────────────────────────────────────
|
||||||
|
has_transitions = any(t != TransitionEffect.CUT and t != "cut" for t in transitions)
|
||||||
|
|
||||||
|
if not has_transitions:
|
||||||
|
return _build_concat_filter(clip_chains)
|
||||||
|
|
||||||
|
# ── 有转场:使用 xfade ─────────────────────────────────────────
|
||||||
|
return _build_xfade_filter(
|
||||||
|
clip_chains=clip_chains,
|
||||||
|
transition_duration=transition_duration,
|
||||||
|
transitions=transitions,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
def _has_audio(clip_chains: list[ClipFilterChain]) -> bool:
|
||||||
"""向后兼容:委托给 video_filter_builder.has_audio。"""
|
"""是否有任何片段包含音频流。"""
|
||||||
return _has_audio_func(clip_chains)
|
return any(c.audio_label is not None for c in clip_chains)
|
||||||
|
|
||||||
|
|
||||||
# ── 模块级辅助函数(向后兼容别名) ──────────────────────────────────────────
|
# ── 模块级辅助函数 ────────────────────────────────────────────────────────────
|
||||||
# 实际实现已迁移至 packages/domain/video_filter_builder.py
|
|
||||||
# 保留此处别名以兼容现有测试与调用方
|
|
||||||
|
|
||||||
|
|
||||||
def _chain_filters(filters: list[str], output_label: str) -> str:
|
def _chain_filters(filters: list[str], output_label: str) -> str:
|
||||||
"""向后兼容:委托给 video_filter_builder.chain_filters。"""
|
"""将滤镜列表串联为 FFmpeg 滤镜字符串。"""
|
||||||
return _chain_filters_func(filters, output_label)
|
filter_body = ",".join(filters)
|
||||||
|
return f"[0:v]{filter_body}[{output_label}]"
|
||||||
|
|
||||||
|
|
||||||
def _build_concat_filter(
|
def _build_concat_filter(
|
||||||
clip_chains: list[ClipFilterChain],
|
clip_chains: list[ClipFilterChain],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_concat_filter。"""
|
"""构建 concat 滤镜(无转场,高效拼接)。
|
||||||
return _build_concat_filter_func(clip_chains)
|
|
||||||
|
格式:
|
||||||
|
[0:v]filters[v0]; [1:v]filters[v1]; ...
|
||||||
|
[v0][v1]...[vN]concat=n=N:v=1:a=0[outv]
|
||||||
|
"""
|
||||||
|
n = len(clip_chains)
|
||||||
|
parts: list[str] = []
|
||||||
|
total_duration = 0.0
|
||||||
|
|
||||||
|
# 每个片段的滤镜链
|
||||||
|
for idx, chain in enumerate(clip_chains):
|
||||||
|
filter_body = ",".join(chain.filters)
|
||||||
|
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||||
|
total_duration += chain.duration
|
||||||
|
|
||||||
|
# concat 滤镜
|
||||||
|
concat_inputs = "".join(f"[{c.video_label}]" for c in clip_chains)
|
||||||
|
concat_filter = f"{concat_inputs}concat=n={n}:v=1:a=0[outv]"
|
||||||
|
parts.append(concat_filter)
|
||||||
|
|
||||||
|
# 音频 concat(如果有)— 先统一音频格式再拼接,否则不同采样率/声道会导致concat失败
|
||||||
|
audio_parts: list[str] = []
|
||||||
|
for idx, chain in enumerate(clip_chains):
|
||||||
|
if chain.audio_label:
|
||||||
|
# aformat: 统一采样率48000Hz + 双声道stereo + fltp采样格式(AAC标准格式)
|
||||||
|
audio_filters = [
|
||||||
|
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||||
|
f"atrim=0:{chain.duration}",
|
||||||
|
"asetpts=PTS-STARTPTS",
|
||||||
|
]
|
||||||
|
audio_parts.append(f"[{idx}:a]{','.join(audio_filters)}[{chain.audio_label}]")
|
||||||
|
|
||||||
|
if audio_parts:
|
||||||
|
parts.extend(audio_parts)
|
||||||
|
audio_inputs = "".join(f"[{c.audio_label}]" for c in clip_chains if c.audio_label)
|
||||||
|
audio_count = sum(1 for c in clip_chains if c.audio_label)
|
||||||
|
if audio_count > 0:
|
||||||
|
parts.append(f"{audio_inputs}concat=n={audio_count}:v=0:a=1[outa]")
|
||||||
|
|
||||||
|
return ";".join(parts), total_duration
|
||||||
|
|
||||||
|
|
||||||
def _build_xfade_filter(
|
def _build_xfade_filter(
|
||||||
@@ -429,5 +569,80 @@ def _build_xfade_filter(
|
|||||||
transition_duration: float,
|
transition_duration: float,
|
||||||
transitions: list[str],
|
transitions: list[str],
|
||||||
) -> tuple[str, float]:
|
) -> tuple[str, float]:
|
||||||
"""向后兼容:委托给 video_filter_builder.build_xfade_filter。"""
|
"""构建 xfade 转场滤镜链。
|
||||||
return _build_xfade_filter_func(clip_chains, transition_duration, transitions)
|
|
||||||
|
每两个相邻片段之间插入 xfade 转场。
|
||||||
|
offset = 前一个片段的累积时长 - 转场时长。
|
||||||
|
|
||||||
|
格式(2 片段):
|
||||||
|
[0:v]filters[v0]; [1:v]filters[v1];
|
||||||
|
[v0][v1]xfade=transition=fade:duration=0.5:offset=4.5[outv]
|
||||||
|
|
||||||
|
格式(3+ 片段):
|
||||||
|
[v0][v1]xfade=...[tmp1]; [tmp1][v2]xfade=...[outv]
|
||||||
|
"""
|
||||||
|
n = len(clip_chains)
|
||||||
|
parts: list[str] = []
|
||||||
|
total_duration = 0.0
|
||||||
|
|
||||||
|
# 每个片段的滤镜链
|
||||||
|
for idx, chain in enumerate(clip_chains):
|
||||||
|
filter_body = ",".join(chain.filters)
|
||||||
|
parts.append(f"[{idx}:v]{filter_body}[{chain.video_label}]")
|
||||||
|
total_duration += chain.duration
|
||||||
|
|
||||||
|
# xfade 链
|
||||||
|
if n == 1:
|
||||||
|
# 单片段不需要 xfade
|
||||||
|
parts.append(f"[{clip_chains[0].video_label}]copy[outv]")
|
||||||
|
return ";".join(parts), total_duration
|
||||||
|
|
||||||
|
# 计算每个转场的 offset
|
||||||
|
cumulative = 0.0
|
||||||
|
prev_label = clip_chains[0].video_label
|
||||||
|
|
||||||
|
for i in range(1, n):
|
||||||
|
cumulative += clip_chains[i - 1].duration
|
||||||
|
offset = max(0.0, cumulative - transition_duration * i)
|
||||||
|
|
||||||
|
# 获取转场类型
|
||||||
|
transition = transitions[i] if i < len(transitions) else "cut"
|
||||||
|
xfade_transition = _XFADE_TRANSITION_MAP.get(transition, "fade")
|
||||||
|
|
||||||
|
if i == n - 1:
|
||||||
|
# 最后一个转场,输出到 [outv]
|
||||||
|
out_label = "outv"
|
||||||
|
else:
|
||||||
|
out_label = f"xf{i}"
|
||||||
|
|
||||||
|
parts.append(
|
||||||
|
f"[{prev_label}][{clip_chains[i].video_label}]"
|
||||||
|
f"xfade=transition={xfade_transition}"
|
||||||
|
f":duration={transition_duration}"
|
||||||
|
f":offset={offset:.3f}"
|
||||||
|
f"[{out_label}]"
|
||||||
|
)
|
||||||
|
prev_label = out_label
|
||||||
|
|
||||||
|
# 总时长需要减去转场重叠部分
|
||||||
|
total_duration -= transition_duration * (n - 1)
|
||||||
|
|
||||||
|
# 音频:先 aformat 归一化再 concat(不同采样率/声道/采样格式会导致concat失败)
|
||||||
|
audio_chains_with_label = [(c, c.audio_label) for c in clip_chains if c.audio_label]
|
||||||
|
if len(audio_chains_with_label) >= 2:
|
||||||
|
normalized_audio_labels: list[str] = []
|
||||||
|
for chain, _ in audio_chains_with_label:
|
||||||
|
norm_label = f"anorm_{chain.video_label}"
|
||||||
|
audio_filters = [
|
||||||
|
"aformat=sample_rates=48000:channel_layouts=stereo:sample_fmts=fltp",
|
||||||
|
f"atrim=0:{chain.duration}",
|
||||||
|
"asetpts=PTS-STARTPTS",
|
||||||
|
]
|
||||||
|
parts.append(f"[{chain.audio_label}]{','.join(audio_filters)}[{norm_label}]")
|
||||||
|
normalized_audio_labels.append(norm_label)
|
||||||
|
audio_inputs = "".join(f"[{label}]" for label in normalized_audio_labels)
|
||||||
|
parts.append(f"{audio_inputs}concat=n={len(normalized_audio_labels)}:v=0:a=1[outa]")
|
||||||
|
elif len(audio_chains_with_label) == 1:
|
||||||
|
parts.append(f"[{audio_chains_with_label[0][0].audio_label}]acopy[outa]")
|
||||||
|
|
||||||
|
return ";".join(parts), max(0.0, total_duration)
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ type AssetListResponse = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Core generation flow", () => {
|
test.describe("Core generation flow", () => {
|
||||||
test.describe.configure({ timeout: 360_000 })
|
test.describe.configure({ timeout: 180_000 })
|
||||||
|
|
||||||
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
test("walks through 7-step wizard and starts generation", async ({ page, request }) => {
|
||||||
test.setTimeout(360_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
await routeBrowserApiToTestApi(page)
|
await routeBrowserApiToTestApi(page)
|
||||||
const suffix = Date.now().toString(36)
|
const suffix = Date.now().toString(36)
|
||||||
@@ -196,50 +196,37 @@ test.describe("Core generation flow", () => {
|
|||||||
await materialLabel.locator("input[type='checkbox']").check()
|
await materialLabel.locator("input[type='checkbox']").check()
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
await page.getByRole("button", { name: "下一步" }).click()
|
||||||
|
|
||||||
// Step 3: voice (可选步骤,新注册用户无配音素材,直接跳过)
|
// Step 3: preview (纯展示页,AI 智能匹配预览)
|
||||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible({ timeout: 15000 })
|
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible()
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
await page.getByRole("button", { name: "下一步" }).click()
|
||||||
|
|
||||||
// Step 4: preview — 需要先生成预览视频,才能进入下一步
|
// Step 4: title
|
||||||
await expect(page.getByRole("heading", { name: /生成预览/ })).toBeVisible({ timeout: 15000 })
|
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
|
||||||
// 点击"生成预览"按钮触发预览生成
|
|
||||||
await page.locator(".xx-preview-generate-btn").click()
|
|
||||||
// 等待预览生成完成(后端渲染,可能需要较长时间)
|
|
||||||
await expect(page.getByText("预览生成成功")).toBeVisible({ timeout: 300_000 })
|
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
|
||||||
|
|
||||||
// Step 5: title
|
|
||||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
|
||||||
// 等待组件完全渲染
|
|
||||||
await page.waitForTimeout(2000)
|
|
||||||
|
|
||||||
// Antd AutoComplete 的 placeholder 渲染在 span 上,input 无 placeholder 属性
|
|
||||||
// 使用 Antd AutoComplete 特有的 class 定位输入框
|
|
||||||
const titleInput = page.locator(".ant-select-auto-complete input")
|
|
||||||
await expect(titleInput).toBeVisible({ timeout: 5000 })
|
|
||||||
|
|
||||||
const titleText = `E2E Test ${suffix}`
|
const titleText = `E2E Test ${suffix}`
|
||||||
await titleInput.fill(titleText)
|
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
||||||
|
await page.getByRole("button", { name: "下一步" }).click()
|
||||||
|
|
||||||
|
// Step 5: voice
|
||||||
|
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
||||||
|
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
||||||
|
await firstVoiceCard.click()
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
await page.getByRole("button", { name: "下一步" }).click()
|
||||||
|
|
||||||
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
// Step 6: cover (默认 AI 智能选帧模式,直接下一步)
|
||||||
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible({ timeout: 15000 })
|
await expect(page.getByRole("heading", { name: /选择封面/ })).toBeVisible()
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
await page.getByRole("button", { name: "下一步" }).click()
|
||||||
|
|
||||||
// Step 7: confirm and generate
|
// Step 7: confirm and generate
|
||||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||||
|
|
||||||
// Wait for generation API to be called
|
// Wait for generation API to be called
|
||||||
// 确认生成走新流程:POST /tasks/{taskId}/confirm(复用预览产物)
|
// 新架构:GET 草稿自动创建 → PUT 更新内容 → POST /generate 触发生成
|
||||||
// 或旧流程:POST /editor/generate(向后兼容)
|
// 等 generate 接口返回,确认生成流程启动
|
||||||
const generatePromise = page.waitForResponse(
|
const generatePromise = page.waitForResponse(
|
||||||
(response) => {
|
(response) => {
|
||||||
const url = response.url()
|
const url = response.url()
|
||||||
const path = new URL(url).pathname
|
const path = new URL(url).pathname
|
||||||
return (
|
return response.request().method() === "POST" && path.endsWith("/editor/generate")
|
||||||
response.request().method() === "POST" &&
|
|
||||||
(path.endsWith("/confirm") || path.endsWith("/editor/generate"))
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
@@ -255,15 +242,10 @@ test.describe("Core generation flow", () => {
|
|||||||
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
`[E2E DEBUG] 触发生成接口失败: status=${genResp.status()} url=${genResp.url()} body=${body.slice(0, 500)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Generate API may return 400 in test env if template has no ready segments
|
expect(genResp.ok()).toBeTruthy()
|
||||||
// That is OK for a wizard flow smoke test
|
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
||||||
if (genResp.ok()) {
|
expect(genData.plan_id).toBeTruthy()
|
||||||
const genData = (await genResp.json()) as { plan_id: string; generation_task_id: string }
|
expect(genData.generation_task_id).toBeTruthy()
|
||||||
expect(genData.plan_id).toBeTruthy()
|
|
||||||
expect(genData.generation_task_id).toBeTruthy()
|
|
||||||
} else {
|
|
||||||
console.log(`[E2E] Generate API returned ${genResp.status()}, wizard flow test still passes`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generation may fail in test env (no worker), that's OK
|
// Generation may fail in test env (no worker), that's OK
|
||||||
// Just verify the flow started - check page shows generation-related UI
|
// Just verify the flow started - check page shows generation-related UI
|
||||||
|
|||||||
Generated
+26
-14
@@ -1847,7 +1847,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@testing-library/dom": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1937,7 +1937,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/aria-query": {
|
"node_modules/@types/aria-query": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -3112,7 +3112,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4028,6 +4028,18 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4456,7 +4468,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -4998,7 +5010,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pretty-format": {
|
"node_modules/pretty-format": {
|
||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5014,7 +5026,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/pretty-format/node_modules/ansi-styles": {
|
"node_modules/pretty-format/node_modules/ansi-styles": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -5026,6 +5038,14 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pretty-format/node_modules/react-is": {
|
||||||
|
"version": "17.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
@@ -5723,14 +5743,6 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-is": {
|
|
||||||
"version": "17.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
Generated
-6117
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
esbuild: set this to true or false
|
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
/**
|
||||||
|
* 素材相关 API
|
||||||
|
* Phase 1 重构:去掉 project_id,素材直接归属用户
|
||||||
|
*/
|
||||||
|
import apiClient from "./client"
|
||||||
|
import { getOrCreateDefaultProject } from "./projects"
|
||||||
|
|
||||||
|
/** 素材元数据 */
|
||||||
|
export interface AssetMetadata {
|
||||||
|
/** 时长(秒) */
|
||||||
|
duration?: number
|
||||||
|
/** 宽度(像素) */
|
||||||
|
width?: number
|
||||||
|
/** 高度(像素) */
|
||||||
|
height?: number
|
||||||
|
/** 比特率(bps) */
|
||||||
|
bitrate?: number
|
||||||
|
/** 编码格式 */
|
||||||
|
codec?: string
|
||||||
|
/** 帧率 */
|
||||||
|
fps?: number
|
||||||
|
/** 采样率(Hz) */
|
||||||
|
sample_rate?: number
|
||||||
|
/** 声道数 */
|
||||||
|
channels?: number
|
||||||
|
/** 其他扩展字段 */
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 素材分类状态 */
|
||||||
|
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
|
||||||
|
|
||||||
|
/** 素材条目 */
|
||||||
|
export interface AssetItem {
|
||||||
|
id: string
|
||||||
|
library_id: string
|
||||||
|
name: string
|
||||||
|
storage_key: string
|
||||||
|
mime_type: string
|
||||||
|
metadata: AssetMetadata
|
||||||
|
file_size?: number
|
||||||
|
file_url?: string
|
||||||
|
thumbnail_url?: string
|
||||||
|
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||||
|
duration?: number
|
||||||
|
status?: string
|
||||||
|
classification_status?: AssetClassificationStatus | null
|
||||||
|
quality_score?: number | null
|
||||||
|
tag_ids?: string[]
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 素材库 */
|
||||||
|
export interface AssetLibraryItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
kind: "video" | "voice" | "image"
|
||||||
|
asset_count?: number
|
||||||
|
total_size?: number
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 入库任务 */
|
||||||
|
export interface IngestJob {
|
||||||
|
id: string
|
||||||
|
library_id: string
|
||||||
|
storage_key: string
|
||||||
|
status: "pending" | "processing" | "completed" | "failed"
|
||||||
|
error_message: string
|
||||||
|
result_asset_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类任务 */
|
||||||
|
export interface ClassificationJob {
|
||||||
|
id: string
|
||||||
|
asset_id: string
|
||||||
|
status: "pending" | "processing" | "completed" | "failed"
|
||||||
|
classification: string
|
||||||
|
confidence: number
|
||||||
|
error_message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 素材诊断信息 */
|
||||||
|
export interface AssetDiagnosis {
|
||||||
|
readiness_score: number
|
||||||
|
readiness_label: string
|
||||||
|
total_assets: number
|
||||||
|
ready_assets: number
|
||||||
|
video_assets: number
|
||||||
|
image_assets: number
|
||||||
|
voice_assets: number
|
||||||
|
total_duration_seconds: number
|
||||||
|
estimated_video_count: number
|
||||||
|
used_assets: number
|
||||||
|
unused_assets: number
|
||||||
|
pending_review_assets: number
|
||||||
|
smart_views: Array<{
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
gaps: Array<{
|
||||||
|
key: string
|
||||||
|
severity: "critical" | "warning" | "info"
|
||||||
|
message: string
|
||||||
|
recommendation: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||||
|
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (assetId) params.asset_id = assetId
|
||||||
|
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 素材库 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 获取当前用户的所有素材库 */
|
||||||
|
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||||
|
const response = await apiClient.get("/asset-libraries")
|
||||||
|
return response.data.items || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||||
|
export const createAssetLibrary = async (data: {
|
||||||
|
name: string
|
||||||
|
kind: "video" | "voice" | "image"
|
||||||
|
}): Promise<AssetLibraryItem> => {
|
||||||
|
// 后端要求 project_id,前端自动管理默认项目
|
||||||
|
const project = await getOrCreateDefaultProject()
|
||||||
|
const response = await apiClient.post("/asset-libraries", {
|
||||||
|
project_id: project.id,
|
||||||
|
...data,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
|
||||||
|
export const ensureDefaultLibrary = async (data: {
|
||||||
|
project_id: string
|
||||||
|
kind: "video" | "voice" | "image"
|
||||||
|
}): Promise<AssetLibraryItem> => {
|
||||||
|
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除素材库 */
|
||||||
|
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 素材 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 获取素材库下的所有素材 */
|
||||||
|
export const getAssets = async (
|
||||||
|
libraryId: string,
|
||||||
|
options?: { status?: string; page?: number; page_size?: number },
|
||||||
|
): Promise<{ items: AssetItem[]; total: number }> => {
|
||||||
|
const params: Record<string, string | number> = { library_id: libraryId }
|
||||||
|
// 默认拉取所有非删除状态的素材(ready/ingesting/processing/uploading/error/failed)
|
||||||
|
// 让用户能看到"处理中"的素材,不会以为上传失败了
|
||||||
|
if (options?.status) {
|
||||||
|
params.status = options.status
|
||||||
|
}
|
||||||
|
if (options?.page) params.page = options.page
|
||||||
|
if (options?.page_size) params.page_size = options.page_size
|
||||||
|
const response = await apiClient.get("/assets", { params })
|
||||||
|
const data = response.data || {}
|
||||||
|
const items: AssetItem[] = data.items || []
|
||||||
|
const total: number = typeof data.total === "number" ? data.total : items.length
|
||||||
|
return { items, total }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||||
|
export const getAssetsByKind = async (
|
||||||
|
kind: string,
|
||||||
|
filters?: {
|
||||||
|
keyword?: string
|
||||||
|
gender?: string
|
||||||
|
style?: string
|
||||||
|
tag_ids?: string[]
|
||||||
|
limit?: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
},
|
||||||
|
): Promise<AssetItem[]> => {
|
||||||
|
const params: Record<string, string | number> = { kind }
|
||||||
|
if (filters?.keyword) params.keyword = filters.keyword
|
||||||
|
if (filters?.gender) params.gender = filters.gender
|
||||||
|
if (filters?.style) params.style = filters.style
|
||||||
|
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||||
|
if (filters?.limit) params.limit = filters.limit
|
||||||
|
if (filters?.page) params.page = filters.page
|
||||||
|
if (filters?.page_size) params.page_size = filters.page_size
|
||||||
|
const response = await apiClient.get("/assets", { params })
|
||||||
|
return response.data.items || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||||
|
export const createAsset = async (data: {
|
||||||
|
library_id: string
|
||||||
|
name: string
|
||||||
|
storage_key: string
|
||||||
|
mime_type: string
|
||||||
|
metadata?: AssetMetadata
|
||||||
|
}): Promise<AssetItem> => {
|
||||||
|
const response = await apiClient.post("/assets", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新素材(名称、metadata 等) */
|
||||||
|
export const updateAsset = async (
|
||||||
|
assetId: string,
|
||||||
|
data: { name?: string; metadata?: AssetMetadata },
|
||||||
|
): Promise<AssetItem> => {
|
||||||
|
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新素材审核状态 */
|
||||||
|
export const updateAssetReviewStatus = async (
|
||||||
|
assetId: string,
|
||||||
|
reviewStatus: "pending_review" | "approved" | "rejected",
|
||||||
|
): Promise<AssetItem> => {
|
||||||
|
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||||
|
review_status: reviewStatus,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除素材 */
|
||||||
|
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/assets/${assetId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 上传 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 表单上传素材(小文件) */
|
||||||
|
export const uploadAsset = async (
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
|
||||||
|
const response = await apiClient.post("/upload", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
timeout: 30 * 60 * 1000,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预签名直传准备 */
|
||||||
|
export const prepareDirectUpload = async (data: {
|
||||||
|
project_id: string
|
||||||
|
library_id: string
|
||||||
|
filename: string
|
||||||
|
content_type: string
|
||||||
|
file_size: number
|
||||||
|
}): Promise<{
|
||||||
|
upload_url: string
|
||||||
|
method: string
|
||||||
|
storage_key: string
|
||||||
|
expires_at: string
|
||||||
|
fields: Record<string, string>
|
||||||
|
max_size_bytes: number
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.post("/upload/direct/prepare", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 直传完成确认 */
|
||||||
|
export const completeDirectUpload = async (data: {
|
||||||
|
project_id: string
|
||||||
|
library_id: string
|
||||||
|
storage_key: string
|
||||||
|
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||||
|
const response = await apiClient.post("/upload/direct/complete", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||||
|
export const uploadAssetDirect = async (data: {
|
||||||
|
file: File
|
||||||
|
library_id: string
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||||
|
// 后端要求 project_id,前端自动获取默认项目
|
||||||
|
const project = await getOrCreateDefaultProject()
|
||||||
|
|
||||||
|
const prepared = await prepareDirectUpload({
|
||||||
|
project_id: project.id,
|
||||||
|
library_id: data.library_id,
|
||||||
|
filename: data.file.name,
|
||||||
|
content_type: data.file.type || "application/octet-stream",
|
||||||
|
file_size: data.file.size,
|
||||||
|
})
|
||||||
|
|
||||||
|
const directForm = new FormData()
|
||||||
|
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||||
|
directForm.append("file", data.file)
|
||||||
|
|
||||||
|
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open(prepared.method, prepared.upload_url)
|
||||||
|
|
||||||
|
// 超时 10 分钟
|
||||||
|
xhr.timeout = 10 * 60 * 1000
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable && data.onProgress) {
|
||||||
|
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve()
|
||||||
|
} else {
|
||||||
|
// 解析 OSS 返回的 XML 错误信息
|
||||||
|
let ossError = ""
|
||||||
|
try {
|
||||||
|
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
|
||||||
|
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
|
||||||
|
if (codeMatch || msgMatch) {
|
||||||
|
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 无法解析响应体
|
||||||
|
}
|
||||||
|
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
|
||||||
|
console.error("[OSS Upload] 直传失败:", {
|
||||||
|
url: prepared.upload_url,
|
||||||
|
storage_key: prepared.storage_key,
|
||||||
|
status: xhr.status,
|
||||||
|
statusText: xhr.statusText,
|
||||||
|
})
|
||||||
|
reject(new Error(detail))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xhr.onerror = () => {
|
||||||
|
console.error("[OSS Upload] 网络错误:", {
|
||||||
|
url: prepared.upload_url,
|
||||||
|
storage_key: prepared.storage_key,
|
||||||
|
})
|
||||||
|
reject(new Error("OSS 上传网络错误,请检查网络连接"))
|
||||||
|
}
|
||||||
|
xhr.ontimeout = () => {
|
||||||
|
console.error("[OSS Upload] 上传超时:", {
|
||||||
|
url: prepared.upload_url,
|
||||||
|
storage_key: prepared.storage_key,
|
||||||
|
})
|
||||||
|
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
|
||||||
|
}
|
||||||
|
xhr.send(directForm)
|
||||||
|
})
|
||||||
|
|
||||||
|
return completeDirectUpload({
|
||||||
|
project_id: project.id,
|
||||||
|
library_id: data.library_id,
|
||||||
|
storage_key: prepared.storage_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 入库 / 分类任务 ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** 查询入库任务状态 */
|
||||||
|
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||||
|
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提交素材分类任务 */
|
||||||
|
export const submitClassificationJob = async (data: {
|
||||||
|
asset_id: string
|
||||||
|
}): Promise<ClassificationJob> => {
|
||||||
|
const response = await apiClient.post("/classification-jobs", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询分类任务状态 */
|
||||||
|
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||||
|
const response = await apiClient.get(`/classification-jobs/${jobId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 批量操作 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 批量操作结果 */
|
||||||
|
export interface BatchOperationResult {
|
||||||
|
succeeded: string[]
|
||||||
|
failed: string[]
|
||||||
|
total: number
|
||||||
|
success_count: number
|
||||||
|
failure_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
|
||||||
|
const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
|
||||||
|
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
|
||||||
|
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
|
||||||
|
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
|
||||||
|
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
|
||||||
|
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
|
||||||
|
return { succeeded, failed, total, success_count, failure_count }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除素材 */
|
||||||
|
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-delete", {
|
||||||
|
asset_ids: assetIds,
|
||||||
|
})
|
||||||
|
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量打标签 */
|
||||||
|
export const batchTagAssets = async (data: {
|
||||||
|
asset_ids: string[]
|
||||||
|
tags: string[]
|
||||||
|
mode: "add" | "replace"
|
||||||
|
}): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-tag", data)
|
||||||
|
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量改分类 */
|
||||||
|
export const batchClassifyAssets = async (data: {
|
||||||
|
asset_ids: string[]
|
||||||
|
category: string
|
||||||
|
}): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-classify", data)
|
||||||
|
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量智能标记 */
|
||||||
|
export const batchMarkAssets = async (data: {
|
||||||
|
asset_ids: string[]
|
||||||
|
smart_view: "recommended" | "caution" | "high_risk"
|
||||||
|
}): Promise<BatchOperationResult> => {
|
||||||
|
const response = await apiClient.post("/assets/batch-mark", data)
|
||||||
|
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||||
|
}
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/**
|
|
||||||
* 素材 CRUD API
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type { AssetItem, AssetMetadata } from "./types"
|
|
||||||
|
|
||||||
/** 获取素材库下的所有素材 */
|
|
||||||
export const getAssets = async (
|
|
||||||
libraryId: string,
|
|
||||||
options?: {
|
|
||||||
status?: string
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
},
|
|
||||||
): Promise<{ items: AssetItem[]; total: number }> => {
|
|
||||||
const params: Record<string, string | number> = { library_id: libraryId }
|
|
||||||
if (options?.status) params.status = options.status
|
|
||||||
if (options?.page) params.page = options.page
|
|
||||||
if (options?.page_size) params.page_size = options.page_size
|
|
||||||
const response = await apiClient.get("/assets", { params })
|
|
||||||
const data = response.data || {}
|
|
||||||
const items: AssetItem[] = data.items || []
|
|
||||||
const total: number = typeof data.total === "number" ? data.total : items.length
|
|
||||||
return { items, total }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
|
||||||
export const getAssetsByKind = async (
|
|
||||||
kind: string,
|
|
||||||
filters?: {
|
|
||||||
keyword?: string
|
|
||||||
gender?: string
|
|
||||||
style?: string
|
|
||||||
tag_ids?: string[]
|
|
||||||
limit?: number
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
},
|
|
||||||
): Promise<AssetItem[]> => {
|
|
||||||
const params: Record<string, string | number> = { kind }
|
|
||||||
if (filters?.keyword) params.keyword = filters.keyword
|
|
||||||
if (filters?.gender) params.gender = filters.gender
|
|
||||||
if (filters?.style) params.style = filters.style
|
|
||||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
|
||||||
if (filters?.limit) params.limit = filters.limit
|
|
||||||
if (filters?.page) params.page = filters.page
|
|
||||||
if (filters?.page_size) params.page_size = filters.page_size
|
|
||||||
const response = await apiClient.get("/assets", { params })
|
|
||||||
return response.data.items || []
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 智能匹配素材(后端 AI 选素材)
|
|
||||||
* 调用后端 smart-match 端点,由后端根据素材库内容智能选择素材
|
|
||||||
*/
|
|
||||||
export const smartMatchAssets = async (libraryId: string): Promise<{ items: AssetItem[] }> => {
|
|
||||||
const response = await apiClient.post("/assets/smart-match", {
|
|
||||||
library_id: libraryId,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
|
||||||
export const createAsset = async (data: {
|
|
||||||
library_id: string
|
|
||||||
name: string
|
|
||||||
storage_key: string
|
|
||||||
mime_type: string
|
|
||||||
metadata?: AssetMetadata
|
|
||||||
}): Promise<AssetItem> => {
|
|
||||||
const response = await apiClient.post("/assets", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新素材(名称、metadata 等) */
|
|
||||||
export const updateAsset = async (
|
|
||||||
assetId: string,
|
|
||||||
data: { name?: string; metadata?: AssetMetadata },
|
|
||||||
): Promise<AssetItem> => {
|
|
||||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新素材审核状态 */
|
|
||||||
export const updateAssetReviewStatus = async (
|
|
||||||
assetId: string,
|
|
||||||
reviewStatus: "pending_review" | "approved" | "rejected",
|
|
||||||
): Promise<AssetItem> => {
|
|
||||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
|
||||||
review_status: reviewStatus,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除素材 */
|
|
||||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/assets/${assetId}`)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* 素材批量操作 API
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type { BatchOperationResult } from "./types"
|
|
||||||
|
|
||||||
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
|
|
||||||
export const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
|
|
||||||
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
|
|
||||||
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
|
|
||||||
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
|
|
||||||
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
|
|
||||||
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
|
|
||||||
return { succeeded, failed, total, success_count, failure_count }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量删除素材 */
|
|
||||||
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
|
|
||||||
const response = await apiClient.post("/assets/batch-delete", {
|
|
||||||
asset_ids: assetIds,
|
|
||||||
})
|
|
||||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量打标签 */
|
|
||||||
export const batchTagAssets = async (data: {
|
|
||||||
asset_ids: string[]
|
|
||||||
tags: string[]
|
|
||||||
mode: "add" | "replace"
|
|
||||||
}): Promise<BatchOperationResult> => {
|
|
||||||
const response = await apiClient.post("/assets/batch-tag", data)
|
|
||||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量改分类 */
|
|
||||||
export const batchClassifyAssets = async (data: {
|
|
||||||
asset_ids: string[]
|
|
||||||
category: string
|
|
||||||
}): Promise<BatchOperationResult> => {
|
|
||||||
const response = await apiClient.post("/assets/batch-classify", data)
|
|
||||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量智能标记 */
|
|
||||||
export const batchMarkAssets = async (data: {
|
|
||||||
asset_ids: string[]
|
|
||||||
smart_view: "recommended" | "caution" | "high_risk"
|
|
||||||
}): Promise<BatchOperationResult> => {
|
|
||||||
const response = await apiClient.post("/assets/batch-mark", data)
|
|
||||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* 素材诊断 API
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type { AssetDiagnosis } from "./types"
|
|
||||||
|
|
||||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
|
||||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
|
||||||
const params: Record<string, string> = {}
|
|
||||||
if (assetId) params.asset_id = assetId
|
|
||||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
/**
|
|
||||||
* 素材相关 API — 按模块拆分后的统一入口
|
|
||||||
* 保持与原 assets.ts 相同的导出结构,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type {
|
|
||||||
AssetMetadata,
|
|
||||||
AssetClassificationStatus,
|
|
||||||
AssetItem,
|
|
||||||
AssetLibraryItem,
|
|
||||||
IngestJob,
|
|
||||||
ClassificationJob,
|
|
||||||
AssetDiagnosis,
|
|
||||||
BatchOperationResult,
|
|
||||||
UploadResult,
|
|
||||||
DirectUploadPrepareResult,
|
|
||||||
DirectUploadCompleteResult,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// 素材诊断
|
|
||||||
export { getAssetDiagnosis } from "./diagnosis"
|
|
||||||
|
|
||||||
// 素材库
|
|
||||||
export {
|
|
||||||
getAssetLibraries,
|
|
||||||
createAssetLibrary,
|
|
||||||
ensureDefaultLibrary,
|
|
||||||
deleteAssetLibrary,
|
|
||||||
} from "./libraries"
|
|
||||||
|
|
||||||
// 素材 CRUD + 智能匹配
|
|
||||||
export {
|
|
||||||
getAssets,
|
|
||||||
getAssetsByKind,
|
|
||||||
smartMatchAssets,
|
|
||||||
createAsset,
|
|
||||||
updateAsset,
|
|
||||||
updateAssetReviewStatus,
|
|
||||||
deleteAsset,
|
|
||||||
} from "./assets"
|
|
||||||
|
|
||||||
// 上传
|
|
||||||
export { uploadAsset, prepareDirectUpload, completeDirectUpload, uploadAssetDirect } from "./upload"
|
|
||||||
|
|
||||||
// 任务
|
|
||||||
export { getIngestJob, submitClassificationJob, getClassificationJob } from "./jobs"
|
|
||||||
|
|
||||||
// 批量操作
|
|
||||||
export {
|
|
||||||
normalizeBatchResult,
|
|
||||||
batchDeleteAssets,
|
|
||||||
batchTagAssets,
|
|
||||||
batchClassifyAssets,
|
|
||||||
batchMarkAssets,
|
|
||||||
} from "./batch"
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
/**
|
|
||||||
* 入库任务 & 分类任务 API
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type { IngestJob, ClassificationJob } from "./types"
|
|
||||||
|
|
||||||
/** 查询入库任务状态 */
|
|
||||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
|
||||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提交素材分类任务 */
|
|
||||||
export const submitClassificationJob = async (data: {
|
|
||||||
asset_id: string
|
|
||||||
}): Promise<ClassificationJob> => {
|
|
||||||
const response = await apiClient.post("/classification-jobs", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询分类任务状态 */
|
|
||||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
|
||||||
const response = await apiClient.get(`/classification-jobs/${jobId}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* 素材库 API
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import { getOrCreateDefaultProject } from "../projects"
|
|
||||||
import type { AssetLibraryItem } from "./types"
|
|
||||||
|
|
||||||
/** 获取当前用户的所有素材库 */
|
|
||||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
|
||||||
const response = await apiClient.get("/asset-libraries")
|
|
||||||
return response.data.items || []
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
|
||||||
export const createAssetLibrary = async (data: {
|
|
||||||
name: string
|
|
||||||
kind: "video" | "voice" | "image"
|
|
||||||
}): Promise<AssetLibraryItem> => {
|
|
||||||
const project = await getOrCreateDefaultProject()
|
|
||||||
const response = await apiClient.post("/asset-libraries", {
|
|
||||||
project_id: project.id,
|
|
||||||
...data,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确保项目下指定 kind 的默认素材库存在 */
|
|
||||||
export const ensureDefaultLibrary = async (data: {
|
|
||||||
project_id: string
|
|
||||||
kind: "video" | "voice" | "image"
|
|
||||||
}): Promise<AssetLibraryItem> => {
|
|
||||||
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除素材库 */
|
|
||||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* 素材相关类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 素材元数据 */
|
|
||||||
export interface AssetMetadata {
|
|
||||||
/** 时长(秒) */
|
|
||||||
duration?: number
|
|
||||||
/** 宽度(像素) */
|
|
||||||
width?: number
|
|
||||||
/** 高度(像素) */
|
|
||||||
height?: number
|
|
||||||
/** 比特率(bps) */
|
|
||||||
bitrate?: number
|
|
||||||
/** 编码格式 */
|
|
||||||
codec?: string
|
|
||||||
/** 帧率 */
|
|
||||||
fps?: number
|
|
||||||
/** 采样率(Hz) */
|
|
||||||
sample_rate?: number
|
|
||||||
/** 声道数 */
|
|
||||||
channels?: number
|
|
||||||
/** 其他扩展字段 */
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 素材分类状态 */
|
|
||||||
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
|
|
||||||
|
|
||||||
/** 素材条目 */
|
|
||||||
export interface AssetItem {
|
|
||||||
id: string
|
|
||||||
library_id: string
|
|
||||||
name: string
|
|
||||||
storage_key: string
|
|
||||||
mime_type: string
|
|
||||||
metadata: AssetMetadata
|
|
||||||
file_size?: number
|
|
||||||
file_url?: string
|
|
||||||
thumbnail_url?: string
|
|
||||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
|
||||||
duration?: number
|
|
||||||
status?: string
|
|
||||||
classification_status?: AssetClassificationStatus | null
|
|
||||||
quality_score?: number | null
|
|
||||||
tag_ids?: string[]
|
|
||||||
created_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 素材库 */
|
|
||||||
export interface AssetLibraryItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
kind: "video" | "voice" | "image"
|
|
||||||
asset_count?: number
|
|
||||||
total_size?: number
|
|
||||||
created_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 入库任务 */
|
|
||||||
export interface IngestJob {
|
|
||||||
id: string
|
|
||||||
library_id: string
|
|
||||||
storage_key: string
|
|
||||||
status: "pending" | "processing" | "completed" | "failed"
|
|
||||||
error_message: string
|
|
||||||
result_asset_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分类任务 */
|
|
||||||
export interface ClassificationJob {
|
|
||||||
id: string
|
|
||||||
asset_id: string
|
|
||||||
status: "pending" | "processing" | "completed" | "failed"
|
|
||||||
classification: string
|
|
||||||
confidence: number
|
|
||||||
error_message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 素材诊断信息 */
|
|
||||||
export interface AssetDiagnosis {
|
|
||||||
readiness_score: number
|
|
||||||
readiness_label: string
|
|
||||||
total_assets: number
|
|
||||||
ready_assets: number
|
|
||||||
video_assets: number
|
|
||||||
image_assets: number
|
|
||||||
voice_assets: number
|
|
||||||
total_duration_seconds: number
|
|
||||||
estimated_video_count: number
|
|
||||||
used_assets: number
|
|
||||||
unused_assets: number
|
|
||||||
pending_review_assets: number
|
|
||||||
smart_views: Array<{
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
count: number
|
|
||||||
description: string
|
|
||||||
}>
|
|
||||||
gaps: Array<{
|
|
||||||
key: string
|
|
||||||
severity: "critical" | "warning" | "info"
|
|
||||||
message: string
|
|
||||||
recommendation: string
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量操作结果 */
|
|
||||||
export interface BatchOperationResult {
|
|
||||||
succeeded: string[]
|
|
||||||
failed: string[]
|
|
||||||
total: number
|
|
||||||
success_count: number
|
|
||||||
failure_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上传返回 */
|
|
||||||
export interface UploadResult {
|
|
||||||
storage_key: string
|
|
||||||
ingest_job_id: string
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预签名直传准备返回 */
|
|
||||||
export interface DirectUploadPrepareResult {
|
|
||||||
upload_url: string
|
|
||||||
method: string
|
|
||||||
storage_key: string
|
|
||||||
expires_at: string
|
|
||||||
fields: Record<string, string>
|
|
||||||
max_size_bytes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 直传完成确认返回 */
|
|
||||||
export interface DirectUploadCompleteResult {
|
|
||||||
storage_key: string
|
|
||||||
ingest_job_id: string
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* 上传相关 API(表单上传 + OSS 直传)
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import { getOrCreateDefaultProject } from "../projects"
|
|
||||||
import type { UploadResult, DirectUploadPrepareResult, DirectUploadCompleteResult } from "./types"
|
|
||||||
|
|
||||||
/** 表单上传素材(小文件) */
|
|
||||||
export const uploadAsset = async (formData: FormData): Promise<UploadResult> => {
|
|
||||||
const response = await apiClient.post("/upload", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
timeout: 30 * 60 * 1000,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预签名直传准备 */
|
|
||||||
export const prepareDirectUpload = async (data: {
|
|
||||||
project_id: string
|
|
||||||
library_id: string
|
|
||||||
filename: string
|
|
||||||
content_type: string
|
|
||||||
file_size: number
|
|
||||||
}): Promise<DirectUploadPrepareResult> => {
|
|
||||||
const response = await apiClient.post("/upload/direct/prepare", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 直传完成确认 */
|
|
||||||
export const completeDirectUpload = async (data: {
|
|
||||||
project_id: string
|
|
||||||
library_id: string
|
|
||||||
storage_key: string
|
|
||||||
}): Promise<DirectUploadCompleteResult> => {
|
|
||||||
const response = await apiClient.post("/upload/direct/complete", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
|
||||||
export const uploadAssetDirect = async (data: {
|
|
||||||
file: File
|
|
||||||
library_id: string
|
|
||||||
onProgress?: (percent: number) => void
|
|
||||||
}): Promise<DirectUploadCompleteResult> => {
|
|
||||||
const project = await getOrCreateDefaultProject()
|
|
||||||
|
|
||||||
const prepared = await prepareDirectUpload({
|
|
||||||
project_id: project.id,
|
|
||||||
library_id: data.library_id,
|
|
||||||
filename: data.file.name,
|
|
||||||
content_type: data.file.type || "application/octet-stream",
|
|
||||||
file_size: data.file.size,
|
|
||||||
})
|
|
||||||
|
|
||||||
const directForm = new FormData()
|
|
||||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
|
||||||
directForm.append("file", data.file)
|
|
||||||
|
|
||||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const xhr = new XMLHttpRequest()
|
|
||||||
xhr.open(prepared.method, prepared.upload_url)
|
|
||||||
|
|
||||||
// 超时 10 分钟
|
|
||||||
xhr.timeout = 10 * 60 * 1000
|
|
||||||
|
|
||||||
xhr.upload.onprogress = (e) => {
|
|
||||||
if (e.lengthComputable && data.onProgress) {
|
|
||||||
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xhr.onload = () => {
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
|
||||||
resolve()
|
|
||||||
} else {
|
|
||||||
// 解析 OSS 返回的 XML 错误信息
|
|
||||||
let ossError = ""
|
|
||||||
try {
|
|
||||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
|
|
||||||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
|
|
||||||
if (codeMatch || msgMatch) {
|
|
||||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 无法解析响应体
|
|
||||||
}
|
|
||||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
|
|
||||||
console.error("[OSS Upload] 直传失败:", {
|
|
||||||
url: prepared.upload_url,
|
|
||||||
storage_key: prepared.storage_key,
|
|
||||||
status: xhr.status,
|
|
||||||
statusText: xhr.statusText,
|
|
||||||
})
|
|
||||||
reject(new Error(detail))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xhr.onerror = () => {
|
|
||||||
console.error("[OSS Upload] 网络错误:", {
|
|
||||||
url: prepared.upload_url,
|
|
||||||
storage_key: prepared.storage_key,
|
|
||||||
})
|
|
||||||
reject(new Error("OSS 上传网络错误,请检查网络连接"))
|
|
||||||
}
|
|
||||||
xhr.ontimeout = () => {
|
|
||||||
console.error("[OSS Upload] 上传超时:", {
|
|
||||||
url: prepared.upload_url,
|
|
||||||
storage_key: prepared.storage_key,
|
|
||||||
})
|
|
||||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
|
|
||||||
}
|
|
||||||
xhr.send(directForm)
|
|
||||||
})
|
|
||||||
|
|
||||||
return completeDirectUpload({
|
|
||||||
project_id: project.id,
|
|
||||||
library_id: data.library_id,
|
|
||||||
storage_key: prepared.storage_key,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* 认证相关 API
|
||||||
|
*/
|
||||||
|
import axios from "axios"
|
||||||
|
import apiClient from "./client"
|
||||||
|
|
||||||
|
// 类型定义
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token?: string | null
|
||||||
|
token_type: string
|
||||||
|
expires_in: number
|
||||||
|
user_id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
username: string
|
||||||
|
display_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
is_email_verified: boolean
|
||||||
|
email_verified: boolean
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserResponse {
|
||||||
|
id?: string
|
||||||
|
user_id?: string
|
||||||
|
email: string
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
is_email_verified?: boolean
|
||||||
|
email_verified?: boolean
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeUser = (data: UserResponse): User => {
|
||||||
|
const userId = data.id ?? data.user_id ?? ""
|
||||||
|
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: userId,
|
||||||
|
user_id: userId,
|
||||||
|
email: data.email,
|
||||||
|
username: data.username,
|
||||||
|
display_name: data.display_name,
|
||||||
|
is_email_verified: emailVerified,
|
||||||
|
email_verified: emailVerified,
|
||||||
|
created_at: data.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||||
|
const response = await apiClient.post("/auth/login", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||||
|
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||||
|
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||||
|
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册
|
||||||
|
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.post("/auth/register", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
export const logout = async (): Promise<void> => {
|
||||||
|
await apiClient.post("/auth/logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户
|
||||||
|
export const getCurrentUser = async (): Promise<User> => {
|
||||||
|
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||||
|
return normalizeUser(response.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求密码重置
|
||||||
|
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置密码
|
||||||
|
export const resetPassword = async (
|
||||||
|
token: string,
|
||||||
|
newPassword: string,
|
||||||
|
): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.post("/auth/reset-password", {
|
||||||
|
token,
|
||||||
|
new_password: newPassword,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证邮箱
|
||||||
|
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.post("/auth/verify-email", { token })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========== 微信登录 ========== */
|
||||||
|
|
||||||
|
export interface WechatAuthUrlResponse {
|
||||||
|
auth_url: string
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatCallbackResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token?: string | null
|
||||||
|
user_id: string
|
||||||
|
display_name: string
|
||||||
|
avatar_url: string
|
||||||
|
is_new_user: boolean
|
||||||
|
binding_complete: boolean
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendVerificationCodeRequest {
|
||||||
|
target: "email" | "phone"
|
||||||
|
value: string
|
||||||
|
purpose: "bind" | "login" | "reset_password"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BindContactRequest {
|
||||||
|
email?: string
|
||||||
|
email_code?: string
|
||||||
|
phone?: string
|
||||||
|
phone_code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BindContactResponse {
|
||||||
|
success: boolean
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取微信授权链接
|
||||||
|
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
||||||
|
const response = await apiClient.get("/auth/wechat/url")
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信回调登录
|
||||||
|
export const wechatCallback = async (
|
||||||
|
code: string,
|
||||||
|
state: string,
|
||||||
|
): Promise<WechatCallbackResponse> => {
|
||||||
|
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送验证码
|
||||||
|
export const sendVerificationCode = async (
|
||||||
|
data: SendVerificationCodeRequest,
|
||||||
|
): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.post("/auth/send-verification-code", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定联系方式
|
||||||
|
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
||||||
|
const response = await apiClient.post("/auth/bind-contact", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
import type { SendVerificationCodeRequest, BindContactRequest, BindContactResponse } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送验证码
|
|
||||||
*/
|
|
||||||
export const sendVerificationCode = async (
|
|
||||||
data: SendVerificationCodeRequest,
|
|
||||||
): Promise<{ message: string }> => {
|
|
||||||
const response = await apiClient.post("/auth/send-verification-code", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定联系方式
|
|
||||||
*/
|
|
||||||
export const bindContact = async (data: BindContactRequest): Promise<BindContactResponse> => {
|
|
||||||
const response = await apiClient.post("/auth/bind-contact", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
import type { User, UserResponse } from "./types"
|
|
||||||
import { normalizeUser } from "./user"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前用户
|
|
||||||
*/
|
|
||||||
export const getCurrentUser = async (): Promise<User> => {
|
|
||||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
|
||||||
return normalizeUser(response.data)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证邮箱
|
|
||||||
*/
|
|
||||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
|
||||||
const response = await apiClient.post("/auth/verify-email", { token })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* 认证相关 API
|
|
||||||
* 保持向后兼容,从子模块 re-export
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type {
|
|
||||||
LoginRequest,
|
|
||||||
LoginResponse,
|
|
||||||
RegisterRequest,
|
|
||||||
User,
|
|
||||||
UserResponse,
|
|
||||||
WechatAuthUrlResponse,
|
|
||||||
WechatCallbackResponse,
|
|
||||||
SendVerificationCodeRequest,
|
|
||||||
BindContactRequest,
|
|
||||||
BindContactResponse,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// 用户工具函数
|
|
||||||
export { normalizeUser } from "./user"
|
|
||||||
|
|
||||||
// 登录/注册/登出/刷新
|
|
||||||
export { login, refreshAccessToken, register, logout } from "./login"
|
|
||||||
|
|
||||||
// 当前用户
|
|
||||||
export { getCurrentUser } from "./currentUser"
|
|
||||||
|
|
||||||
// 密码重置
|
|
||||||
export { requestPasswordReset, resetPassword } from "./password"
|
|
||||||
|
|
||||||
// 邮箱验证
|
|
||||||
export { verifyEmail } from "./email"
|
|
||||||
|
|
||||||
// 微信登录
|
|
||||||
export { getWechatAuthUrl, wechatCallback } from "./wechat"
|
|
||||||
|
|
||||||
// 联系方式
|
|
||||||
export { sendVerificationCode, bindContact } from "./contact"
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import axios from "axios"
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type { LoginRequest, LoginResponse, RegisterRequest } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录
|
|
||||||
*/
|
|
||||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
|
||||||
const response = await apiClient.post("/auth/login", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 刷新 access_token(使用裸 axios 避免拦截器递归)
|
|
||||||
*/
|
|
||||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
|
||||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
|
||||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册
|
|
||||||
*/
|
|
||||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
|
||||||
const response = await apiClient.post("/auth/register", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登出
|
|
||||||
*/
|
|
||||||
export const logout = async (): Promise<void> => {
|
|
||||||
await apiClient.post("/auth/logout")
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 请求密码重置
|
|
||||||
*/
|
|
||||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
|
||||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 重置密码
|
|
||||||
*/
|
|
||||||
export const resetPassword = async (
|
|
||||||
token: string,
|
|
||||||
newPassword: string,
|
|
||||||
): Promise<{ message: string }> => {
|
|
||||||
const response = await apiClient.post("/auth/reset-password", {
|
|
||||||
token,
|
|
||||||
new_password: newPassword,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/**
|
|
||||||
* 认证相关类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
access_token: string
|
|
||||||
refresh_token?: string | null
|
|
||||||
token_type: string
|
|
||||||
expires_in: number
|
|
||||||
user_id: string
|
|
||||||
email: string
|
|
||||||
username: string
|
|
||||||
display_name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterRequest {
|
|
||||||
email: string
|
|
||||||
password: string
|
|
||||||
username: string
|
|
||||||
display_name?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
email: string
|
|
||||||
username: string
|
|
||||||
display_name: string
|
|
||||||
is_email_verified: boolean
|
|
||||||
email_verified: boolean
|
|
||||||
created_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserResponse {
|
|
||||||
id?: string
|
|
||||||
user_id?: string
|
|
||||||
email: string
|
|
||||||
username: string
|
|
||||||
display_name: string
|
|
||||||
is_email_verified?: boolean
|
|
||||||
email_verified?: boolean
|
|
||||||
created_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WechatAuthUrlResponse {
|
|
||||||
auth_url: string
|
|
||||||
state: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WechatCallbackResponse {
|
|
||||||
access_token: string
|
|
||||||
refresh_token?: string | null
|
|
||||||
user_id: string
|
|
||||||
display_name: string
|
|
||||||
avatar_url: string
|
|
||||||
is_new_user: boolean
|
|
||||||
binding_complete: boolean
|
|
||||||
expires_in: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendVerificationCodeRequest {
|
|
||||||
target: "email" | "phone"
|
|
||||||
value: string
|
|
||||||
purpose: "bind" | "login" | "reset_password"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BindContactRequest {
|
|
||||||
email?: string
|
|
||||||
email_code?: string
|
|
||||||
phone?: string
|
|
||||||
phone_code?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BindContactResponse {
|
|
||||||
success: boolean
|
|
||||||
user: User
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { User, UserResponse } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 规范化用户数据,兼容不同后端返回格式
|
|
||||||
*/
|
|
||||||
export const normalizeUser = (data: UserResponse): User => {
|
|
||||||
const userId = data.id ?? data.user_id ?? ""
|
|
||||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: userId,
|
|
||||||
user_id: userId,
|
|
||||||
email: data.email,
|
|
||||||
username: data.username,
|
|
||||||
display_name: data.display_name,
|
|
||||||
is_email_verified: emailVerified,
|
|
||||||
email_verified: emailVerified,
|
|
||||||
created_at: data.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
import type { WechatAuthUrlResponse, WechatCallbackResponse } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取微信授权链接
|
|
||||||
*/
|
|
||||||
export const getWechatAuthUrl = async (): Promise<WechatAuthUrlResponse> => {
|
|
||||||
const response = await apiClient.get("/auth/wechat/url")
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 微信回调登录
|
|
||||||
*/
|
|
||||||
export const wechatCallback = async (
|
|
||||||
code: string,
|
|
||||||
state: string,
|
|
||||||
): Promise<WechatCallbackResponse> => {
|
|
||||||
const response = await apiClient.post("/auth/wechat/callback", { code, state })
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* BGM 预设音乐 API
|
||||||
|
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||||
|
*/
|
||||||
|
import apiClient from "./client"
|
||||||
|
|
||||||
|
/* ──────────── 类型 ──────────── */
|
||||||
|
|
||||||
|
/** BGM 风格分类 */
|
||||||
|
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
|
||||||
|
|
||||||
|
/** BGM 预设项 */
|
||||||
|
export interface BgmPreset {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: BgmCategory
|
||||||
|
/** 音频文件 URL */
|
||||||
|
url: string
|
||||||
|
/** 时长(秒) */
|
||||||
|
duration: number
|
||||||
|
/** 关键词标签 */
|
||||||
|
tags: string[]
|
||||||
|
/** 封面图 URL */
|
||||||
|
cover_url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BGM 预设列表查询参数 */
|
||||||
|
export interface BgmPresetsQuery {
|
||||||
|
category?: BgmCategory | string
|
||||||
|
keyword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BGM 混音配置(嵌入模板) */
|
||||||
|
export interface BgmMixConfig {
|
||||||
|
/** 是否启用 BGM */
|
||||||
|
enabled: boolean
|
||||||
|
/** 选中的 BGM ID */
|
||||||
|
music_id: string
|
||||||
|
/** BGM 音量 0-100 */
|
||||||
|
volume: number
|
||||||
|
/** 淡入时长(秒) 0-3 */
|
||||||
|
fade_in: number
|
||||||
|
/** 淡出时长(秒) 0-3 */
|
||||||
|
fade_out: number
|
||||||
|
/** 人声闪避(sidechain) */
|
||||||
|
voice_dodge: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 默认 BGM 混音配置 */
|
||||||
|
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||||
|
enabled: false,
|
||||||
|
music_id: "",
|
||||||
|
volume: 50,
|
||||||
|
fade_in: 0.5,
|
||||||
|
fade_out: 0.5,
|
||||||
|
voice_dodge: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ──────────── API ──────────── */
|
||||||
|
|
||||||
|
/** 获取 BGM 预设列表 */
|
||||||
|
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
||||||
|
const searchParams: Record<string, string> = {}
|
||||||
|
if (params?.category) searchParams.category = params.category
|
||||||
|
if (params?.keyword) searchParams.keyword = params.keyword
|
||||||
|
const res = await apiClient.get("/bgm/presets", { params: searchParams })
|
||||||
|
return res.data?.data ?? res.data ?? []
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* BGM 预设音乐 API 函数
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type { BgmPreset, BgmPresetsQuery } from "./types"
|
|
||||||
|
|
||||||
/** 获取 BGM 预设列表 */
|
|
||||||
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
|
||||||
const searchParams: Record<string, string> = {}
|
|
||||||
if (params?.category) searchParams.category = params.category
|
|
||||||
if (params?.keyword) searchParams.keyword = params.keyword
|
|
||||||
const res = await apiClient.get("/bgm/presets", { params: searchParams })
|
|
||||||
return res.data?.data ?? res.data ?? []
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* BGM 相关常量
|
|
||||||
*/
|
|
||||||
import type { BgmMixConfig } from "./types"
|
|
||||||
|
|
||||||
/** 默认 BGM 混音配置 */
|
|
||||||
export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
|
||||||
enabled: false,
|
|
||||||
music_id: "",
|
|
||||||
volume: 50,
|
|
||||||
fade_in: 0.5,
|
|
||||||
fade_out: 0.5,
|
|
||||||
voice_dodge: true,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* BGM API — 目录化入口
|
|
||||||
* 保持与原 bgm.ts 相同导出,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type { BgmCategory, BgmPreset, BgmPresetsQuery, BgmMixConfig } from "./types"
|
|
||||||
|
|
||||||
// 常量
|
|
||||||
export { DEFAULT_BGM_MIX_CONFIG } from "./constants"
|
|
||||||
|
|
||||||
// API 函数
|
|
||||||
export { getBgmPresets } from "./bgm"
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
/**
|
|
||||||
* BGM 相关类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** BGM 风格分类 */
|
|
||||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
|
|
||||||
|
|
||||||
/** BGM 预设项 */
|
|
||||||
export interface BgmPreset {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
category: BgmCategory
|
|
||||||
/** 音频文件 URL */
|
|
||||||
url: string
|
|
||||||
/** 时长(秒) */
|
|
||||||
duration: number
|
|
||||||
/** 关键词标签 */
|
|
||||||
tags: string[]
|
|
||||||
/** 封面图 URL */
|
|
||||||
cover_url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** BGM 预设列表查询参数 */
|
|
||||||
export interface BgmPresetsQuery {
|
|
||||||
category?: BgmCategory | string
|
|
||||||
keyword?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** BGM 混音配置(嵌入模板) */
|
|
||||||
export interface BgmMixConfig {
|
|
||||||
/** 是否启用 BGM */
|
|
||||||
enabled: boolean
|
|
||||||
/** 选中的 BGM ID */
|
|
||||||
music_id: string
|
|
||||||
/** BGM 音量 0-100 */
|
|
||||||
volume: number
|
|
||||||
/** 淡入时长(秒) 0-3 */
|
|
||||||
fade_in: number
|
|
||||||
/** 淡出时长(秒) 0-3 */
|
|
||||||
fade_out: number
|
|
||||||
/** 人声闪避(sidechain) */
|
|
||||||
voice_dodge: boolean
|
|
||||||
}
|
|
||||||
@@ -57,21 +57,7 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 401 → 尝试刷新 Token
|
// 401 → 尝试刷新 Token
|
||||||
// 排除 auth 端点:登录/注册/找回密码的 401 是正常业务响应(如密码错误),
|
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||||
// 不应触发 token 刷新或登出跳转,走后面的错误提示逻辑即可
|
|
||||||
const requestUrl = originalRequest?.url || ""
|
|
||||||
const isAuthEndpoint =
|
|
||||||
requestUrl.includes("/auth/login") ||
|
|
||||||
requestUrl.includes("/auth/register") ||
|
|
||||||
requestUrl.includes("/auth/forgot-password") ||
|
|
||||||
requestUrl.includes("/auth/reset-password")
|
|
||||||
|
|
||||||
if (
|
|
||||||
error.response?.status === 401 &&
|
|
||||||
originalRequest &&
|
|
||||||
!originalRequest._retry &&
|
|
||||||
!isAuthEndpoint
|
|
||||||
) {
|
|
||||||
const refreshToken = useAuthStore.getState().refreshToken
|
const refreshToken = useAuthStore.getState().refreshToken
|
||||||
|
|
||||||
// 无 refresh_token → 直接登出
|
// 无 refresh_token → 直接登出
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* 封面模板 CRUD API
|
|
||||||
* 后端路由: /api/v1/cover-templates
|
|
||||||
*/
|
|
||||||
import apiClient from "./client"
|
|
||||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
|
||||||
|
|
||||||
export interface CoverTemplateListResponse {
|
|
||||||
items: CoverTemplate[]
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoverTemplateCreateRequest {
|
|
||||||
name: string
|
|
||||||
config?: {
|
|
||||||
background_enabled?: boolean
|
|
||||||
background_color?: string
|
|
||||||
portrait_enabled?: boolean
|
|
||||||
title_text?: string
|
|
||||||
subtitle_text?: string
|
|
||||||
mask_enabled?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
|
|
||||||
|
|
||||||
/** 获取封面模板列表 */
|
|
||||||
export async function fetchCoverTemplates(): Promise<CoverTemplateListResponse> {
|
|
||||||
const response = await apiClient.get<CoverTemplateListResponse>("/cover-templates")
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建封面模板 */
|
|
||||||
export async function createCoverTemplate(
|
|
||||||
data: CoverTemplateCreateRequest,
|
|
||||||
): Promise<CoverTemplate> {
|
|
||||||
const response = await apiClient.post<CoverTemplate>("/cover-templates", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新封面模板 */
|
|
||||||
export async function updateCoverTemplate(
|
|
||||||
id: string,
|
|
||||||
data: CoverTemplateUpdateRequest,
|
|
||||||
): Promise<CoverTemplate> {
|
|
||||||
const response = await apiClient.put<CoverTemplate>(`/cover-templates/${id}`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除封面模板(系统模板不可删) */
|
|
||||||
export async function deleteCoverTemplate(id: string): Promise<void> {
|
|
||||||
await apiClient.delete(`/cover-templates/${id}`)
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 查重相关类型定义
|
* 查重 API 模块
|
||||||
|
* 提供视频查重相关接口
|
||||||
*/
|
*/
|
||||||
|
import apiClient from "./client"
|
||||||
|
|
||||||
/** 查重记录状态 */
|
/** 查重记录状态 */
|
||||||
export type DuplicationStatus = "pending" | "processing" | "completed" | "failed"
|
export type DuplicationStatus = "pending" | "processing" | "completed" | "failed"
|
||||||
@@ -60,3 +62,38 @@ export interface DuplicationUploadResponse {
|
|||||||
/** 消息 */
|
/** 消息 */
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ API 函数 ============
|
||||||
|
|
||||||
|
/** 上传视频进行查重 */
|
||||||
|
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", file)
|
||||||
|
const response = await apiClient.post("/duplication/upload", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取查重记录列表 */
|
||||||
|
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
||||||
|
const response = await apiClient.get("/duplication/records")
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取查重详情 */
|
||||||
|
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
|
||||||
|
const response = await apiClient.get(`/duplication/records/${recordId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除查重记录 */
|
||||||
|
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/duplication/records/${recordId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新查重 */
|
||||||
|
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
|
||||||
|
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
/**
|
|
||||||
* 查重 API 函数
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type { DuplicationDetail, DuplicationRecord, DuplicationUploadResponse } from "./types"
|
|
||||||
|
|
||||||
/** 上传视频进行查重 */
|
|
||||||
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", file)
|
|
||||||
const response = await apiClient.post("/duplication/upload", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取查重记录列表 */
|
|
||||||
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
|
||||||
const response = await apiClient.get("/duplication/records")
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取查重详情 */
|
|
||||||
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
|
|
||||||
const response = await apiClient.get(`/duplication/records/${recordId}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除查重记录 */
|
|
||||||
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/duplication/records/${recordId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 重新查重 */
|
|
||||||
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
|
|
||||||
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
/**
|
|
||||||
* 查重 API — 目录化入口
|
|
||||||
* 保持与原 duplication.ts 相同导出,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type {
|
|
||||||
DuplicationStatus,
|
|
||||||
DuplicationRecord,
|
|
||||||
DuplicateSegment,
|
|
||||||
DuplicationDetail,
|
|
||||||
DuplicationUploadResponse,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// API 函数
|
|
||||||
export {
|
|
||||||
uploadForDuplication,
|
|
||||||
getDuplicationRecords,
|
|
||||||
getDuplicationDetail,
|
|
||||||
deleteDuplicationRecord,
|
|
||||||
retryDuplication,
|
|
||||||
} from "./duplication"
|
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* 模板编辑器 API
|
||||||
|
* 对接后端 /api/v1/templates 路由
|
||||||
|
*/
|
||||||
|
import apiClient from "./client"
|
||||||
|
import type {
|
||||||
|
WatermarkConfig,
|
||||||
|
IntroOutroConfig,
|
||||||
|
PipConfig,
|
||||||
|
FilterConfig,
|
||||||
|
ChromaKeyConfig,
|
||||||
|
StickerConfig,
|
||||||
|
CoverConfig,
|
||||||
|
} from "@/pages/editing-planner/types"
|
||||||
|
|
||||||
|
/* ──────────── 类型定义 ──────────── */
|
||||||
|
|
||||||
|
/** 模板模式(后端枚举值) */
|
||||||
|
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||||
|
|
||||||
|
/** 模式显示名称映射 */
|
||||||
|
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||||
|
pip: "混剪",
|
||||||
|
voice_over: "人物口播",
|
||||||
|
one_take: "一镜到底",
|
||||||
|
voice_pip: "口播+混剪",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模式颜色映射 */
|
||||||
|
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||||
|
pip: "blue",
|
||||||
|
voice_over: "green",
|
||||||
|
one_take: "orange",
|
||||||
|
voice_pip: "purple",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标题配置 */
|
||||||
|
export interface TitleConfig {
|
||||||
|
ai_auto_select: boolean
|
||||||
|
content: string
|
||||||
|
font_preset: string
|
||||||
|
font_color: string
|
||||||
|
font_size: number
|
||||||
|
position: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字幕配置 */
|
||||||
|
export interface SubtitleConfig {
|
||||||
|
enabled: boolean
|
||||||
|
position: string
|
||||||
|
font: string
|
||||||
|
color: string
|
||||||
|
size: number
|
||||||
|
animation: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BGM 配置 */
|
||||||
|
export interface BgmConfig {
|
||||||
|
enabled: boolean
|
||||||
|
music_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板片段 */
|
||||||
|
export interface TemplateSegment {
|
||||||
|
id?: string
|
||||||
|
segment_order: number
|
||||||
|
duration_min: number
|
||||||
|
duration_max: number
|
||||||
|
material_type: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剪辑模板 */
|
||||||
|
export interface EditingTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mode: TemplateMode
|
||||||
|
category: string
|
||||||
|
tags: string[]
|
||||||
|
title_config: TitleConfig
|
||||||
|
subtitle_config: SubtitleConfig
|
||||||
|
bgm_config: BgmConfig
|
||||||
|
estimated_duration: number
|
||||||
|
segments: TemplateSegment[]
|
||||||
|
/** 水印配置(后端就绪后启用) */
|
||||||
|
watermark_config?: WatermarkConfig
|
||||||
|
/** 片头片尾配置(后端就绪后启用) */
|
||||||
|
intro_outro_config?: IntroOutroConfig
|
||||||
|
/** 混剪配置 */
|
||||||
|
pip_config?: PipConfig
|
||||||
|
/** 滤镜调色配置 */
|
||||||
|
filter_config?: FilterConfig
|
||||||
|
/** 绿幕抠像配置 */
|
||||||
|
green_screen_config?: ChromaKeyConfig
|
||||||
|
/** 贴纸配置 */
|
||||||
|
sticker_config?: StickerConfig
|
||||||
|
/** 封面配置 */
|
||||||
|
cover_config?: CoverConfig
|
||||||
|
is_active?: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模板分类 */
|
||||||
|
export interface TemplateCategory {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建/更新模板请求体 */
|
||||||
|
export interface SaveTemplatePayload {
|
||||||
|
name: string
|
||||||
|
mode: TemplateMode
|
||||||
|
category: string
|
||||||
|
tags: string[]
|
||||||
|
title_config: TitleConfig
|
||||||
|
subtitle_config: SubtitleConfig
|
||||||
|
bgm_config: BgmConfig
|
||||||
|
estimated_duration: number
|
||||||
|
segments: Omit<TemplateSegment, "id">[]
|
||||||
|
/** 水印配置(后端就绪后启用) */
|
||||||
|
watermark_config?: WatermarkConfig
|
||||||
|
/** 片头片尾配置(后端就绪后启用) */
|
||||||
|
intro_outro_config?: IntroOutroConfig
|
||||||
|
/** 混剪配置 */
|
||||||
|
pip_config?: PipConfig
|
||||||
|
/** 滤镜调色配置 */
|
||||||
|
filter_config?: FilterConfig
|
||||||
|
/** 绿幕抠像配置 */
|
||||||
|
green_screen_config?: ChromaKeyConfig
|
||||||
|
/** 贴纸配置 */
|
||||||
|
sticker_config?: StickerConfig
|
||||||
|
/** 封面配置 */
|
||||||
|
cover_config?: CoverConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用模板生成请求体 */
|
||||||
|
export interface GenerateFromTemplatePayload {
|
||||||
|
voiceover_duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证警告详情 */
|
||||||
|
export interface ValidationWarningDetails {
|
||||||
|
/** 相关字段名 */
|
||||||
|
field?: string
|
||||||
|
/** 期望值 */
|
||||||
|
expected?: string | number
|
||||||
|
/** 实际值 */
|
||||||
|
actual?: string | number
|
||||||
|
/** 建议值 */
|
||||||
|
suggested?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证/生成响应 */
|
||||||
|
export interface ValidateWarning {
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
details?: ValidationWarningDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用模板生成响应 */
|
||||||
|
export interface GenerateFromTemplateResponse {
|
||||||
|
template: EditingTemplate
|
||||||
|
warnings: ValidateWarning[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表响应(带分页) */
|
||||||
|
export interface ListTemplatesResponse {
|
||||||
|
items: EditingTemplate[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类列表响应 */
|
||||||
|
export interface ListCategoriesResponse {
|
||||||
|
items: TemplateCategory[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ API 函数 ============
|
||||||
|
|
||||||
|
/** 获取模板列表 */
|
||||||
|
export const getEditingTemplates = async (params?: {
|
||||||
|
category?: string
|
||||||
|
tag?: string
|
||||||
|
skip?: number
|
||||||
|
limit?: number
|
||||||
|
}): Promise<EditingTemplate[]> => {
|
||||||
|
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||||
|
params: {
|
||||||
|
skip: params?.skip ?? 0,
|
||||||
|
limit: params?.limit ?? 50,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
let list = response.data.items
|
||||||
|
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||||
|
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取模板详情 */
|
||||||
|
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||||
|
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建模板 */
|
||||||
|
export const createEditingTemplate = async (
|
||||||
|
data: SaveTemplatePayload,
|
||||||
|
): Promise<EditingTemplate> => {
|
||||||
|
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新模板 */
|
||||||
|
export const updateEditingTemplate = async (
|
||||||
|
id: string,
|
||||||
|
data: SaveTemplatePayload,
|
||||||
|
): Promise<EditingTemplate> => {
|
||||||
|
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除模板 */
|
||||||
|
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/templates/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取模板分类列表 */
|
||||||
|
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||||
|
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||||
|
return response.data.items
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用模板生成视频(调用 validate 端点) */
|
||||||
|
export const generateFromTemplate = async (
|
||||||
|
templateId: string,
|
||||||
|
data: GenerateFromTemplatePayload,
|
||||||
|
): Promise<GenerateFromTemplateResponse> => {
|
||||||
|
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||||
|
`/templates/${templateId}/validate`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/**
|
|
||||||
* 模板编辑器常量
|
|
||||||
*/
|
|
||||||
import type { TemplateMode } from "./types"
|
|
||||||
|
|
||||||
/** 模式显示名称映射 */
|
|
||||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
|
||||||
pip: "混剪",
|
|
||||||
voice_over: "人物口播",
|
|
||||||
one_take: "一镜到底",
|
|
||||||
voice_pip: "口播+混剪",
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模式颜色映射 */
|
|
||||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
|
||||||
pip: "blue",
|
|
||||||
voice_over: "green",
|
|
||||||
one_take: "orange",
|
|
||||||
voice_pip: "purple",
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* 模板编辑器 API — 目录化入口
|
|
||||||
* 保持与原 editing-planner.ts 相同导出,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type {
|
|
||||||
TemplateMode,
|
|
||||||
TitleConfig,
|
|
||||||
SubtitleConfig,
|
|
||||||
BgmConfig,
|
|
||||||
TemplateSegment,
|
|
||||||
EditingTemplate,
|
|
||||||
TemplateCategory,
|
|
||||||
SaveTemplatePayload,
|
|
||||||
GenerateFromTemplatePayload,
|
|
||||||
ValidationWarningDetails,
|
|
||||||
ValidateWarning,
|
|
||||||
GenerateFromTemplateResponse,
|
|
||||||
ListTemplatesResponse,
|
|
||||||
ListCategoriesResponse,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// 常量
|
|
||||||
export { MODE_LABELS, MODE_COLORS } from "./constants"
|
|
||||||
|
|
||||||
// API 函数
|
|
||||||
export {
|
|
||||||
getEditingTemplates,
|
|
||||||
getEditingTemplate,
|
|
||||||
createEditingTemplate,
|
|
||||||
updateEditingTemplate,
|
|
||||||
deleteEditingTemplate,
|
|
||||||
getTemplateCategories,
|
|
||||||
generateFromTemplate,
|
|
||||||
} from "./templates"
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
/**
|
|
||||||
* 模板编辑器 API 函数
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type {
|
|
||||||
EditingTemplate,
|
|
||||||
TemplateCategory,
|
|
||||||
SaveTemplatePayload,
|
|
||||||
GenerateFromTemplatePayload,
|
|
||||||
GenerateFromTemplateResponse,
|
|
||||||
ListTemplatesResponse,
|
|
||||||
ListCategoriesResponse,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
/** 获取模板列表 */
|
|
||||||
export const getEditingTemplates = async (params?: {
|
|
||||||
category?: string
|
|
||||||
tag?: string
|
|
||||||
skip?: number
|
|
||||||
limit?: number
|
|
||||||
}): Promise<EditingTemplate[]> => {
|
|
||||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
|
||||||
params: {
|
|
||||||
skip: params?.skip ?? 0,
|
|
||||||
limit: params?.limit ?? 50,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
let list = response.data.items
|
|
||||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
|
||||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取模板详情 */
|
|
||||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
|
||||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建模板 */
|
|
||||||
export const createEditingTemplate = async (
|
|
||||||
data: SaveTemplatePayload,
|
|
||||||
): Promise<EditingTemplate> => {
|
|
||||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新模板 */
|
|
||||||
export const updateEditingTemplate = async (
|
|
||||||
id: string,
|
|
||||||
data: SaveTemplatePayload,
|
|
||||||
): Promise<EditingTemplate> => {
|
|
||||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 删除模板 */
|
|
||||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/templates/${id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取模板分类列表 */
|
|
||||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
|
||||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
|
||||||
return response.data.items
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 使用模板生成视频(调用 validate 端点) */
|
|
||||||
export const generateFromTemplate = async (
|
|
||||||
templateId: string,
|
|
||||||
data: GenerateFromTemplatePayload,
|
|
||||||
): Promise<GenerateFromTemplateResponse> => {
|
|
||||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
|
||||||
`/templates/${templateId}/validate`,
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/**
|
|
||||||
* 模板编辑器类型定义
|
|
||||||
*/
|
|
||||||
import type {
|
|
||||||
WatermarkConfig,
|
|
||||||
IntroOutroConfig,
|
|
||||||
PipConfig,
|
|
||||||
FilterConfig,
|
|
||||||
ChromaKeyConfig,
|
|
||||||
StickerConfig,
|
|
||||||
} from "@/pages/editing-planner/types"
|
|
||||||
import type { CoverConfig } from "@/pages/generate/types/cover"
|
|
||||||
|
|
||||||
/** 模板模式(后端枚举值) */
|
|
||||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
|
||||||
|
|
||||||
/** 标题配置 */
|
|
||||||
export interface TitleConfig {
|
|
||||||
ai_auto_select: boolean
|
|
||||||
content: string
|
|
||||||
font_preset: string
|
|
||||||
font_color: string
|
|
||||||
font_size: number
|
|
||||||
position: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 字幕配置 */
|
|
||||||
export interface SubtitleConfig {
|
|
||||||
enabled: boolean
|
|
||||||
position: string
|
|
||||||
font: string
|
|
||||||
color: string
|
|
||||||
size: number
|
|
||||||
animation: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** BGM 配置 */
|
|
||||||
export interface BgmConfig {
|
|
||||||
enabled: boolean
|
|
||||||
music_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模板片段 */
|
|
||||||
export interface TemplateSegment {
|
|
||||||
id?: string
|
|
||||||
segment_order: number
|
|
||||||
duration_min: number
|
|
||||||
duration_max: number
|
|
||||||
material_type: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 剪辑模板 */
|
|
||||||
export interface EditingTemplate {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
mode: TemplateMode
|
|
||||||
category: string
|
|
||||||
tags: string[]
|
|
||||||
title_config: TitleConfig
|
|
||||||
subtitle_config: SubtitleConfig
|
|
||||||
bgm_config: BgmConfig
|
|
||||||
estimated_duration: number
|
|
||||||
segments: TemplateSegment[]
|
|
||||||
watermark_config?: WatermarkConfig
|
|
||||||
intro_outro_config?: IntroOutroConfig
|
|
||||||
pip_config?: PipConfig
|
|
||||||
filter_config?: FilterConfig
|
|
||||||
green_screen_config?: ChromaKeyConfig
|
|
||||||
sticker_config?: StickerConfig
|
|
||||||
cover_config?: CoverConfig
|
|
||||||
is_active?: boolean
|
|
||||||
created_at: string
|
|
||||||
updated_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 模板分类 */
|
|
||||||
export interface TemplateCategory {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
created_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建/更新模板请求体 */
|
|
||||||
export interface SaveTemplatePayload {
|
|
||||||
name: string
|
|
||||||
mode: TemplateMode
|
|
||||||
category: string
|
|
||||||
tags: string[]
|
|
||||||
title_config: TitleConfig
|
|
||||||
subtitle_config: SubtitleConfig
|
|
||||||
bgm_config: BgmConfig
|
|
||||||
estimated_duration: number
|
|
||||||
segments: Omit<TemplateSegment, "id">[]
|
|
||||||
watermark_config?: WatermarkConfig
|
|
||||||
intro_outro_config?: IntroOutroConfig
|
|
||||||
pip_config?: PipConfig
|
|
||||||
filter_config?: FilterConfig
|
|
||||||
green_screen_config?: ChromaKeyConfig
|
|
||||||
sticker_config?: StickerConfig
|
|
||||||
cover_config?: CoverConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 使用模板生成请求体 */
|
|
||||||
export interface GenerateFromTemplatePayload {
|
|
||||||
voiceover_duration: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 验证警告详情 */
|
|
||||||
export interface ValidationWarningDetails {
|
|
||||||
field?: string
|
|
||||||
expected?: string | number
|
|
||||||
actual?: string | number
|
|
||||||
suggested?: string | number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 验证警告 */
|
|
||||||
export interface ValidateWarning {
|
|
||||||
code: string
|
|
||||||
message: string
|
|
||||||
details?: ValidationWarningDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 使用模板生成响应 */
|
|
||||||
export interface GenerateFromTemplateResponse {
|
|
||||||
template: EditingTemplate
|
|
||||||
warnings: ValidateWarning[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 列表响应(带分页) */
|
|
||||||
export interface ListTemplatesResponse {
|
|
||||||
items: EditingTemplate[]
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分类列表响应 */
|
|
||||||
export interface ListCategoriesResponse {
|
|
||||||
items: TemplateCategory[]
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
import type { ConfirmGenerationRequest, ConfirmGenerationResponse } from "./types"
|
|
||||||
|
|
||||||
/** 确认生成 — 基于预览任务创建正式生成任务 */
|
|
||||||
export const confirmGeneration = async (
|
|
||||||
taskId: string,
|
|
||||||
params: ConfirmGenerationRequest,
|
|
||||||
): Promise<ConfirmGenerationResponse> => {
|
|
||||||
const response = await apiClient.post<ConfirmGenerationResponse>(
|
|
||||||
`/generation/tasks/${taskId}/confirm`,
|
|
||||||
params,
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
|
|
||||||
export interface GenerateCoverRequest {
|
|
||||||
asset_ids: string[]
|
|
||||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
|
||||||
frame_time?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GenerateCoverResponse {
|
|
||||||
plan_id: string
|
|
||||||
cover: {
|
|
||||||
scheme?: string
|
|
||||||
asset_id?: string
|
|
||||||
frame_time?: number
|
|
||||||
image_url?: string
|
|
||||||
thumbnail_url?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** AI 生成封面 — 从预览视频中抽帧 */
|
|
||||||
export async function generateCover(
|
|
||||||
templateId: string,
|
|
||||||
data: GenerateCoverRequest,
|
|
||||||
): Promise<GenerateCoverResponse> {
|
|
||||||
const response = await apiClient.post<GenerateCoverResponse>(
|
|
||||||
"/generation/generate-cover",
|
|
||||||
{ ...data, template_id: templateId },
|
|
||||||
{
|
|
||||||
timeout: 300000,
|
|
||||||
params: { template_id: templateId },
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export type {
|
|
||||||
PreviewStatus,
|
|
||||||
CreatePreviewRequest,
|
|
||||||
CreatePreviewResponse,
|
|
||||||
PreviewTaskResponse,
|
|
||||||
ConfirmGenerationRequest,
|
|
||||||
ConfirmGenerationResponse,
|
|
||||||
ConfirmGenerationTaskItem,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
export { createPreview, getPreviewStatus } from "./preview"
|
|
||||||
export { confirmGeneration } from "./confirm"
|
|
||||||
|
|
||||||
export { generateCover } from "./cover"
|
|
||||||
export type { GenerateCoverRequest, GenerateCoverResponse } from "./cover"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import apiClient from "../client"
|
|
||||||
import type { CreatePreviewRequest, CreatePreviewResponse, PreviewTaskResponse } from "./types"
|
|
||||||
|
|
||||||
/** 创建预览生成任务(单版本) */
|
|
||||||
export const createPreview = async (
|
|
||||||
params: CreatePreviewRequest,
|
|
||||||
): Promise<CreatePreviewResponse> => {
|
|
||||||
const response = await apiClient.post<CreatePreviewResponse>("/generation/preview", params)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询预览任务状态及结果 */
|
|
||||||
export const getPreviewStatus = async (taskId: string): Promise<PreviewTaskResponse> => {
|
|
||||||
const response = await apiClient.get<PreviewTaskResponse>(`/generation/preview/${taskId}`)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
/** 预览任务状态 */
|
|
||||||
export type PreviewStatus = "pending" | "generating" | "completed" | "failed" | "cancelled"
|
|
||||||
|
|
||||||
/** 创建预览任务请求 */
|
|
||||||
export interface CreatePreviewRequest {
|
|
||||||
template_id: string
|
|
||||||
asset_ids: string[]
|
|
||||||
source_edit_plan_id?: string
|
|
||||||
title_ids?: string[]
|
|
||||||
voice_ids?: string[]
|
|
||||||
/** 配音素材库ID(用户上传的音频或AI配音),对应配音选择页面选择的配音素材 */
|
|
||||||
voice_library_id?: string
|
|
||||||
video_title?: string
|
|
||||||
duration?: number
|
|
||||||
video_ratio?: string
|
|
||||||
bgm_config?: {
|
|
||||||
enabled: boolean
|
|
||||||
preset_id?: string
|
|
||||||
volume?: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建预览任务响应 */
|
|
||||||
export interface CreatePreviewResponse {
|
|
||||||
task_id: string
|
|
||||||
status: PreviewStatus
|
|
||||||
is_preview: boolean
|
|
||||||
resolution: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 预览任务详情响应 */
|
|
||||||
export interface PreviewTaskResponse {
|
|
||||||
task_id: string
|
|
||||||
status: PreviewStatus
|
|
||||||
progress: number
|
|
||||||
is_preview: boolean
|
|
||||||
resolution: string
|
|
||||||
video_url?: string
|
|
||||||
duration?: number
|
|
||||||
file_size?: number
|
|
||||||
clip_count?: number
|
|
||||||
transition_count?: number
|
|
||||||
material_usage?: number
|
|
||||||
error_message?: string
|
|
||||||
created_at: string
|
|
||||||
started_at?: string
|
|
||||||
finished_at?: string
|
|
||||||
generate_duration?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确认生成请求体 — 基于预览任务创建正式生成任务 */
|
|
||||||
export interface ConfirmGenerationRequest {
|
|
||||||
/** 输出视频宽度,默认 1080 */
|
|
||||||
output_width?: number
|
|
||||||
/** 输出视频高度,默认 1920 */
|
|
||||||
output_height?: number
|
|
||||||
/** 自定义封面图片 URL */
|
|
||||||
cover_url?: string
|
|
||||||
/** 自定义视频标题 */
|
|
||||||
custom_title?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确认生成响应 */
|
|
||||||
export interface ConfirmGenerationResponse {
|
|
||||||
items: ConfirmGenerationTaskItem[]
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 确认生成返回的任务项 */
|
|
||||||
export interface ConfirmGenerationTaskItem {
|
|
||||||
id: string
|
|
||||||
project_id: string
|
|
||||||
asset_library_id: string
|
|
||||||
strategy_id: string
|
|
||||||
voice_library_id: string
|
|
||||||
template_id: string
|
|
||||||
asset_ids: string[]
|
|
||||||
title_ids: string[]
|
|
||||||
voice_ids: string[]
|
|
||||||
source_edit_plan_id: string
|
|
||||||
asset_select_mode: string
|
|
||||||
batch_id: string
|
|
||||||
is_preview: boolean
|
|
||||||
source_task_id: string
|
|
||||||
output_width: number
|
|
||||||
output_height: number
|
|
||||||
cover_url: string
|
|
||||||
custom_title: string
|
|
||||||
status: string
|
|
||||||
progress: number
|
|
||||||
result_count: number
|
|
||||||
error_message: string
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 成品 / 视频相关 API
|
||||||
|
* 后端实际接口:/videos
|
||||||
|
*/
|
||||||
|
import apiClient from "./client"
|
||||||
|
|
||||||
|
/** 复核状态 */
|
||||||
|
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||||
|
|
||||||
|
/** 成品条目 */
|
||||||
|
export interface ProductItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
video_url?: string
|
||||||
|
thumbnail_url?: string
|
||||||
|
duration_seconds?: number
|
||||||
|
file_size?: number
|
||||||
|
resolution?: string
|
||||||
|
status: "processing" | "completed" | "failed"
|
||||||
|
/** 复核状态 */
|
||||||
|
review_status?: ReviewStatus
|
||||||
|
/** 所属项目 ID */
|
||||||
|
project_id?: string
|
||||||
|
/** 所属项目名称 */
|
||||||
|
project_name?: string
|
||||||
|
/** 查重率(百分比) */
|
||||||
|
duplicate_rate?: number
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表查询参数 */
|
||||||
|
export interface ProductListParams {
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
project_id?: string
|
||||||
|
review_status?: ReviewStatus | "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页响应 */
|
||||||
|
export interface ProductListResponse {
|
||||||
|
items: ProductItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量下载任务状态 */
|
||||||
|
export interface BatchDownloadStatus {
|
||||||
|
job_id: string
|
||||||
|
status: "processing" | "completed" | "failed"
|
||||||
|
/** 完成后返回的下载 URL */
|
||||||
|
download_url?: string
|
||||||
|
/** 进度百分比 */
|
||||||
|
progress?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端 /videos 接口返回的原始视频条目 */
|
||||||
|
interface VideoItem {
|
||||||
|
id: string
|
||||||
|
project_id: string
|
||||||
|
generation_task_id: string
|
||||||
|
name: string
|
||||||
|
file_url: string
|
||||||
|
file_size: number
|
||||||
|
duration: number
|
||||||
|
thumbnail_url: string | null
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
fps: number
|
||||||
|
status: string
|
||||||
|
review_status: ReviewStatus
|
||||||
|
generation_params: Record<string, unknown>
|
||||||
|
download_url: string
|
||||||
|
generated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将后端 VideoItem 映射为 ProductItem 格式
|
||||||
|
*/
|
||||||
|
function mapVideoToProductItem(video: VideoItem): ProductItem {
|
||||||
|
return {
|
||||||
|
id: video.id,
|
||||||
|
title: video.name || "未命名视频",
|
||||||
|
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
||||||
|
video_url: video.download_url || video.file_url,
|
||||||
|
thumbnail_url: video.thumbnail_url || undefined,
|
||||||
|
duration_seconds: video.duration,
|
||||||
|
file_size: video.file_size,
|
||||||
|
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
||||||
|
status:
|
||||||
|
video.status === "completed"
|
||||||
|
? "completed"
|
||||||
|
: video.status === "failed"
|
||||||
|
? "failed"
|
||||||
|
: "processing",
|
||||||
|
review_status: video.review_status,
|
||||||
|
project_id: video.project_id,
|
||||||
|
// 后端 /videos 接口暂无 project_name 字段
|
||||||
|
project_name: undefined,
|
||||||
|
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
||||||
|
created_at: video.generated_at,
|
||||||
|
updated_at: video.generated_at,
|
||||||
|
// 后端 /videos 接口暂无 duplicate_rate 字段
|
||||||
|
duplicate_rate: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取成品列表(支持分页和筛选) */
|
||||||
|
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||||
|
const response = await apiClient.get("/videos", { params })
|
||||||
|
const data = response.data
|
||||||
|
const videos: VideoItem[] = Array.isArray(data?.items)
|
||||||
|
? data.items
|
||||||
|
: Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: []
|
||||||
|
return videos.map(mapVideoToProductItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个成品详情 */
|
||||||
|
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
||||||
|
const response = await apiClient.get(`/videos/${productId}`)
|
||||||
|
return mapVideoToProductItem(response.data as VideoItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除成品
|
||||||
|
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
||||||
|
* 待后端实现后自动生效
|
||||||
|
*/
|
||||||
|
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/videos/${productId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取成品下载链接
|
||||||
|
* 直接使用列表返回的 download_url(带OSS签名)
|
||||||
|
*/
|
||||||
|
export const getProductDownloadUrl = async (
|
||||||
|
productId: string,
|
||||||
|
): Promise<{ url: string; expires_at: string }> => {
|
||||||
|
// 优先从列表缓存取;如果没有则调详情接口
|
||||||
|
const product = await getProduct(productId)
|
||||||
|
if (!product.video_url) throw new Error("下载链接不可用")
|
||||||
|
return { url: product.video_url, expires_at: "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||||
|
export const updateReviewStatus = async (
|
||||||
|
productId: string,
|
||||||
|
status: ReviewStatus,
|
||||||
|
): Promise<ProductItem> => {
|
||||||
|
// 后端暂无 /videos/{id}/review 端点
|
||||||
|
// 暂时返回当前状态,后续可扩展
|
||||||
|
const product = await getProduct(productId)
|
||||||
|
return { ...product, review_status: status }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||||
|
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||||
|
// 后端暂无 /videos/batch-download 端点
|
||||||
|
// 暂时返回模拟 job_id,后续可扩展
|
||||||
|
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||||
|
return { job_id: `mock-${Date.now()}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||||
|
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||||
|
// 后端暂无 /videos/batch-download/{jobId} 端点
|
||||||
|
// 暂时返回模拟状态,后续可扩展
|
||||||
|
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||||
|
return { job_id: jobId, status: "processing", progress: 0 }
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* 成品 / 视频相关 API — 目录化入口
|
|
||||||
* 保持与原 products.ts 相同导出,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type {
|
|
||||||
ReviewStatus,
|
|
||||||
ProductItem,
|
|
||||||
ProductListParams,
|
|
||||||
ProductListResponse,
|
|
||||||
BatchDownloadStatus,
|
|
||||||
VideoItem,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
export { mapVideoToProductItem } from "./utils"
|
|
||||||
|
|
||||||
// API 函数
|
|
||||||
export {
|
|
||||||
getProducts,
|
|
||||||
getProduct,
|
|
||||||
deleteProduct,
|
|
||||||
getProductDownloadUrl,
|
|
||||||
updateReviewStatus,
|
|
||||||
batchDownload,
|
|
||||||
getBatchDownloadStatus,
|
|
||||||
} from "./products"
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/**
|
|
||||||
* 成品 / 视频相关 API 函数
|
|
||||||
* 后端实际接口:/videos
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type {
|
|
||||||
BatchDownloadStatus,
|
|
||||||
ProductItem,
|
|
||||||
ProductListParams,
|
|
||||||
VideoItem,
|
|
||||||
ReviewStatus,
|
|
||||||
} from "./types"
|
|
||||||
import { mapVideoToProductItem } from "./utils"
|
|
||||||
|
|
||||||
/** 获取成品列表(支持分页和筛选) */
|
|
||||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
|
||||||
const response = await apiClient.get("/videos", { params })
|
|
||||||
const data = response.data
|
|
||||||
const videos: VideoItem[] = Array.isArray(data?.items)
|
|
||||||
? data.items
|
|
||||||
: Array.isArray(data)
|
|
||||||
? data
|
|
||||||
: []
|
|
||||||
return videos.map(mapVideoToProductItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取单个成品详情 */
|
|
||||||
export const getProduct = async (productId: string): Promise<ProductItem> => {
|
|
||||||
const response = await apiClient.get(`/videos/${productId}`)
|
|
||||||
return mapVideoToProductItem(response.data as VideoItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除成品
|
|
||||||
* 注意:后端暂未实现 /videos DELETE 接口,调用会返回 405
|
|
||||||
* 待后端实现后自动生效
|
|
||||||
*/
|
|
||||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/videos/${productId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取成品下载链接
|
|
||||||
* 直接使用列表返回的 download_url(带OSS签名)
|
|
||||||
*/
|
|
||||||
export const getProductDownloadUrl = async (
|
|
||||||
productId: string,
|
|
||||||
): Promise<{ url: string; expires_at: string }> => {
|
|
||||||
// 优先从列表缓存取;如果没有则调详情接口
|
|
||||||
const product = await getProduct(productId)
|
|
||||||
if (!product.video_url) throw new Error("下载链接不可用")
|
|
||||||
return { url: product.video_url, expires_at: "" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
|
||||||
export const updateReviewStatus = async (
|
|
||||||
productId: string,
|
|
||||||
status: ReviewStatus,
|
|
||||||
): Promise<ProductItem> => {
|
|
||||||
// 后端暂无 /videos/{id}/review 端点
|
|
||||||
// 暂时返回当前状态,后续可扩展
|
|
||||||
const product = await getProduct(productId)
|
|
||||||
return { ...product, review_status: status }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
|
||||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
|
||||||
// 后端暂无 /videos/batch-download 端点
|
|
||||||
// 暂时返回模拟 job_id,后续可扩展
|
|
||||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
|
||||||
return { job_id: `mock-${Date.now()}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
|
||||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
|
||||||
// 后端暂无 /videos/batch-download/{jobId} 端点
|
|
||||||
// 暂时返回模拟状态,后续可扩展
|
|
||||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
|
||||||
return { job_id: jobId, status: "processing", progress: 0 }
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
/**
|
|
||||||
* 成品 / 视频相关类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** 复核状态 */
|
|
||||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
|
||||||
|
|
||||||
/** 成品条目 */
|
|
||||||
export interface ProductItem {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
video_url?: string
|
|
||||||
thumbnail_url?: string
|
|
||||||
duration_seconds?: number
|
|
||||||
file_size?: number
|
|
||||||
resolution?: string
|
|
||||||
status: "processing" | "completed" | "failed"
|
|
||||||
/** 复核状态 */
|
|
||||||
review_status?: ReviewStatus
|
|
||||||
/** 所属项目 ID */
|
|
||||||
project_id?: string
|
|
||||||
/** 所属项目名称 */
|
|
||||||
project_name?: string
|
|
||||||
/** 查重率(百分比) */
|
|
||||||
duplicate_rate?: number
|
|
||||||
created_at?: string
|
|
||||||
updated_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 列表查询参数 */
|
|
||||||
export interface ProductListParams {
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
project_id?: string
|
|
||||||
review_status?: ReviewStatus | "all"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 分页响应 */
|
|
||||||
export interface ProductListResponse {
|
|
||||||
items: ProductItem[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 批量下载任务状态 */
|
|
||||||
export interface BatchDownloadStatus {
|
|
||||||
job_id: string
|
|
||||||
status: "processing" | "completed" | "failed"
|
|
||||||
/** 完成后返回的下载 URL */
|
|
||||||
download_url?: string
|
|
||||||
/** 进度百分比 */
|
|
||||||
progress?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端 /videos 接口返回的原始视频条目 */
|
|
||||||
export interface VideoItem {
|
|
||||||
id: string
|
|
||||||
project_id: string
|
|
||||||
generation_task_id: string
|
|
||||||
name: string
|
|
||||||
file_url: string
|
|
||||||
file_size: number
|
|
||||||
duration: number
|
|
||||||
thumbnail_url: string | null
|
|
||||||
width: number
|
|
||||||
height: number
|
|
||||||
fps: number
|
|
||||||
status: string
|
|
||||||
review_status: ReviewStatus
|
|
||||||
generation_params: Record<string, unknown>
|
|
||||||
download_url: string
|
|
||||||
generated_at: string
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
/**
|
|
||||||
* 成品数据转换工具函数
|
|
||||||
*/
|
|
||||||
import type { ProductItem, VideoItem } from "./types"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将后端 VideoItem 映射为 ProductItem 格式
|
|
||||||
*/
|
|
||||||
export function mapVideoToProductItem(video: VideoItem): ProductItem {
|
|
||||||
return {
|
|
||||||
id: video.id,
|
|
||||||
title: video.name || "未命名视频",
|
|
||||||
// 优先用 download_url(带签名)播放,file_url 无签名无法访问
|
|
||||||
video_url: video.download_url || video.file_url,
|
|
||||||
thumbnail_url: video.thumbnail_url || undefined,
|
|
||||||
duration_seconds: video.duration,
|
|
||||||
file_size: video.file_size,
|
|
||||||
resolution: video.width && video.height ? `${video.width}x${video.height}` : undefined,
|
|
||||||
status:
|
|
||||||
video.status === "completed"
|
|
||||||
? "completed"
|
|
||||||
: video.status === "failed"
|
|
||||||
? "failed"
|
|
||||||
: "processing",
|
|
||||||
review_status: video.review_status,
|
|
||||||
project_id: video.project_id,
|
|
||||||
// 后端 /videos 接口暂无 project_name 字段
|
|
||||||
project_name: undefined,
|
|
||||||
// 后端字段名为 generated_at,映射为 created_at 供前端统一使用
|
|
||||||
created_at: video.generated_at,
|
|
||||||
updated_at: video.generated_at,
|
|
||||||
// 后端 /videos 接口暂无 duplicate_rate 字段
|
|
||||||
duplicate_rate: undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* 项目相关 API 函数
|
* 项目相关 API
|
||||||
* 素材库需要 project_id,前端自动管理默认项目
|
* 素材库需要 project_id,前端自动管理默认项目
|
||||||
*/
|
*/
|
||||||
import apiClient from "../client"
|
import apiClient from "./client"
|
||||||
import type { BackendListProjectsResponse, BackendProjectResponse, ProjectItem } from "./types"
|
|
||||||
import { toProjectItem } from "./utils"
|
export interface ProjectItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端 ProjectResponse 只返回 id, name, description */
|
||||||
|
interface BackendProjectResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端 ListProjectsResponse 返回 { items: [...] } */
|
||||||
|
interface BackendListProjectsResponse {
|
||||||
|
items: BackendProjectResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
})
|
||||||
|
|
||||||
/** 获取当前用户的项目列表 */
|
/** 获取当前用户的项目列表 */
|
||||||
export const getProjects = async (): Promise<ProjectItem[]> => {
|
export const getProjects = async (): Promise<ProjectItem[]> => {
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* 项目 API — 目录化入口
|
|
||||||
* 保持与原 projects.ts 相同导出,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type { ProjectItem, BackendProjectResponse, BackendListProjectsResponse } from "./types"
|
|
||||||
|
|
||||||
// 工具函数
|
|
||||||
export { toProjectItem } from "./utils"
|
|
||||||
|
|
||||||
// API 函数
|
|
||||||
export { getProjects, createProject, getOrCreateDefaultProject } from "./projects"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
/**
|
|
||||||
* 项目相关类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface ProjectItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端 ProjectResponse 只返回 id, name, description */
|
|
||||||
export interface BackendProjectResponse {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端 ListProjectsResponse 返回 { items: [...] } */
|
|
||||||
export interface BackendListProjectsResponse {
|
|
||||||
items: BackendProjectResponse[]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* 项目相关工具函数
|
|
||||||
*/
|
|
||||||
import type { BackendProjectResponse, ProjectItem } from "./types"
|
|
||||||
|
|
||||||
export const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
description: item.description,
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* 订阅相关类型定义
|
* 订阅 API 模块
|
||||||
|
* 对接后端订阅管理接口
|
||||||
*/
|
*/
|
||||||
|
import apiClient from "./client"
|
||||||
|
|
||||||
/** 套餐类型 */
|
/** 套餐类型 */
|
||||||
export type PlanType = "free" | "standard" | "pro" | "enterprise"
|
export type PlanType = "free" | "standard" | "pro" | "enterprise"
|
||||||
@@ -63,3 +65,42 @@ export interface ChangePlanResponse {
|
|||||||
message: string
|
message: string
|
||||||
new_subscription?: SubscriptionInfo
|
new_subscription?: SubscriptionInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ API 函数 ============
|
||||||
|
|
||||||
|
/** 获取当前订阅信息 */
|
||||||
|
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||||
|
const response = await apiClient.get("/subscription/current")
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取账单记录列表 */
|
||||||
|
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||||
|
const response = await apiClient.get("/subscription/billing-records")
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 升级/降级套餐 */
|
||||||
|
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||||
|
const response = await apiClient.post("/subscription/change-plan", request)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消订阅 */
|
||||||
|
export const cancelSubscription = async (): Promise<{
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
}> => {
|
||||||
|
const response = await apiClient.post("/subscription/cancel")
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换自动续费 */
|
||||||
|
export const toggleAutoRenew = async (
|
||||||
|
enabled: boolean,
|
||||||
|
): Promise<{ success: boolean; message: string }> => {
|
||||||
|
const response = await apiClient.post("/subscription/toggle-auto-renew", {
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/**
|
|
||||||
* 订阅 API — 目录化入口
|
|
||||||
* 保持与原 subscription.ts 相同导出,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type {
|
|
||||||
PlanType,
|
|
||||||
SubscriptionStatus,
|
|
||||||
BillingStatus,
|
|
||||||
BillingCycle,
|
|
||||||
Plan,
|
|
||||||
SubscriptionInfo,
|
|
||||||
BillingRecord,
|
|
||||||
ChangePlanRequest,
|
|
||||||
ChangePlanResponse,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
// API 函数
|
|
||||||
export {
|
|
||||||
getCurrentSubscription,
|
|
||||||
getBillingRecords,
|
|
||||||
changePlan,
|
|
||||||
cancelSubscription,
|
|
||||||
toggleAutoRenew,
|
|
||||||
} from "./subscription"
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* 订阅相关 API 函数
|
|
||||||
*/
|
|
||||||
import apiClient from "../client"
|
|
||||||
import type {
|
|
||||||
BillingRecord,
|
|
||||||
ChangePlanRequest,
|
|
||||||
ChangePlanResponse,
|
|
||||||
SubscriptionInfo,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
/** 获取当前订阅信息 */
|
|
||||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
|
||||||
const response = await apiClient.get("/subscription/current")
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取账单记录列表 */
|
|
||||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
|
||||||
const response = await apiClient.get("/subscription/billing-records")
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 升级/降级套餐 */
|
|
||||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
|
||||||
const response = await apiClient.post("/subscription/change-plan", request)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消订阅 */
|
|
||||||
export const cancelSubscription = async (): Promise<{
|
|
||||||
success: boolean
|
|
||||||
message: string
|
|
||||||
}> => {
|
|
||||||
const response = await apiClient.post("/subscription/cancel")
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 切换自动续费 */
|
|
||||||
export const toggleAutoRenew = async (
|
|
||||||
enabled: boolean,
|
|
||||||
): Promise<{ success: boolean; message: string }> => {
|
|
||||||
const response = await apiClient.post("/subscription/toggle-auto-renew", {
|
|
||||||
enabled,
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 标签 CRUD API 函数
|
* 标签 CRUD API
|
||||||
* P3 标签体系:对接后端标签表
|
* P3 标签体系:对接后端标签表
|
||||||
*/
|
*/
|
||||||
import apiClient from "../client"
|
import apiClient from "./client"
|
||||||
import type { TagItem } from "./types"
|
|
||||||
|
export interface TagItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
created_at?: string
|
||||||
|
usage_count?: number
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取当前用户所有标签 */
|
/** 获取当前用户所有标签 */
|
||||||
export const getTags = async (): Promise<TagItem[]> => {
|
export const getTags = async (): Promise<TagItem[]> => {
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* 标签 API — 目录化入口
|
|
||||||
* 保持与原 tags.ts 相同导出,向后兼容
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 类型
|
|
||||||
export type { TagItem } from "./types"
|
|
||||||
|
|
||||||
// API 函数
|
|
||||||
export { getTags, createTag, deleteTag, tagAsset, untagAsset } from "./tags"
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* 标签相关类型定义
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface TagItem {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
created_at?: string
|
|
||||||
usage_count?: number
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 任务相关类型定义
|
* 任务相关 API
|
||||||
|
* 对接后端任务中心 API:
|
||||||
|
* - POST /api/v1/generation/tasks — 创建生成任务
|
||||||
|
* - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选)
|
||||||
|
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
||||||
|
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
||||||
*/
|
*/
|
||||||
|
import apiClient from "./client"
|
||||||
|
|
||||||
|
/* ──────────── 类型定义 ──────────── */
|
||||||
|
|
||||||
/** 任务状态 */
|
/** 任务状态 */
|
||||||
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
||||||
@@ -77,3 +85,39 @@ export interface CreateGenerationTaskResponse {
|
|||||||
result_count: number
|
result_count: number
|
||||||
error_message: string
|
error_message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────── API 函数 ──────────── */
|
||||||
|
|
||||||
|
/** 创建生成任务(智能剪辑) */
|
||||||
|
export const createGenerationTask = async (
|
||||||
|
params: CreateGenerationTaskRequest,
|
||||||
|
): Promise<CreateGenerationTaskResponse> => {
|
||||||
|
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取任务列表(支持分页和筛选) */
|
||||||
|
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||||
|
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||||
|
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||||
|
const { data } = await apiClient.get("/tasks")
|
||||||
|
return data.items || data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取单个任务详情(含 error_info) */
|
||||||
|
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||||
|
const { data } = await apiClient.get(`/tasks/${taskId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重试失败的任务 */
|
||||||
|
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||||
|
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user