Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 862340ace5 |
@@ -21,8 +21,9 @@ 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' }}
|
||||||
jobs:
|
jobs:
|
||||||
check-frontend-only:
|
check-frontend-only:
|
||||||
name: Check if frontend-only change
|
name: Check if frontend-only change
|
||||||
@@ -37,7 +38,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 +91,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 +181,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
|
||||||
@@ -259,7 +260,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
|
||||||
@@ -340,7 +341,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
|
||||||
@@ -411,7 +412,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
|
||||||
@@ -457,15 +458,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 +526,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 +576,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 +605,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
|
||||||
@@ -795,7 +789,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 +921,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 +1073,31 @@ 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-$$"
|
||||||
|
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 +1137,29 @@ 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-$$"
|
||||||
|
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,11 +1192,6 @@ jobs:
|
|||||||
runs-on: runtime-builder
|
runs-on: runtime-builder
|
||||||
timeout-minutes: ${{ matrix.timeout }}
|
timeout-minutes: ${{ matrix.timeout }}
|
||||||
needs:
|
needs:
|
||||||
- validate-code-quality
|
|
||||||
- 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')
|
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref_name == 'main')
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -1200,7 +1221,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,7 +1265,7 @@ 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"
|
||||||
@@ -1324,7 +1345,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 +1476,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 +1492,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 +1536,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
|
||||||
@@ -1573,7 +1595,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
|
||||||
@@ -1662,8 +1684,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
curl -sfH "Authorization: token $GITHUB_TOKEN" -o /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 /tmp/_ci_checkout.sh
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/raw/scripts/ci/step_checkout.sh?ref=${GITHUB_SHA}" \
|
||||||
|
| bash
|
||||||
|
|
||||||
- name: Evaluate CI Gate
|
- name: Evaluate CI Gate
|
||||||
id: gate
|
id: gate
|
||||||
@@ -1695,30 +1718,6 @@ jobs:
|
|||||||
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
echo " frontend-lint: $RESULT_FRONTEND_LINT"
|
||||||
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
echo " frontend-unit-test: $RESULT_FRONTEND_UNIT"
|
||||||
echo " build-pr: $RESULT_BUILD_PR"
|
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 ""
|
echo ""
|
||||||
|
|
||||||
# 判断PR类型
|
# 判断PR类型
|
||||||
@@ -1734,13 +1733,11 @@ jobs:
|
|||||||
"validate-migration:$RESULT_MIGRATION"
|
"validate-migration:$RESULT_MIGRATION"
|
||||||
"frontend-lint:$RESULT_FRONTEND_LINT"
|
"frontend-lint:$RESULT_FRONTEND_LINT"
|
||||||
"build-pr:$RESULT_BUILD_PR"
|
"build-pr:$RESULT_BUILD_PR"
|
||||||
"ai-code-review:$AI_REVIEW_STATUS"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 后端检查
|
# 后端检查
|
||||||
REQUIRED_BACKEND=(
|
REQUIRED_BACKEND=(
|
||||||
"unit-tests:$RESULT_UNIT_TESTS"
|
"unit-tests:$RESULT_UNIT_TESTS"
|
||||||
"integration-tests:$RESULT_INTEGRATION"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 前端检查
|
# 前端检查
|
||||||
@@ -1770,11 +1767,6 @@ jobs:
|
|||||||
for item in "${REQUIRED_GENERAL[@]}"; do
|
for item in "${REQUIRED_GENERAL[@]}"; do
|
||||||
name="${item%%:*}"
|
name="${item%%:*}"
|
||||||
result="${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"
|
check_job "$name" "$result"
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -1826,4 +1818,4 @@ jobs:
|
|||||||
[ "${{ steps.gate.outputs.gate_result }}" = "success" ] || STATUS="error"
|
[ "${{ steps.gate.outputs.gate_result }}" = "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
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
runs-on: ci-l2
|
runs-on: ci-l2
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.report.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
runs-on: ci-l2
|
runs-on: ci-l2
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
report: ${{ steps.e2e.outputs.report }}
|
report: ${{ steps.smoke.outputs.report }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
|
|||||||
Regular → Executable
+1
-6
@@ -8,11 +8,6 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pr-automation-${{ gitea.event.pull_request.number }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
auto-approve:
|
auto-approve:
|
||||||
name: Auto Approve on CI Green
|
name: Auto Approve on CI Green
|
||||||
@@ -61,7 +56,7 @@ jobs:
|
|||||||
name: Auto Merge on CI Green + Approved
|
name: Auto Merge on CI Green + Approved
|
||||||
runs-on: ci-check
|
runs-on: ci-check
|
||||||
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
if: github.event_name == 'pull_request' && !github.event.pull_request.draft && github.event.pull_request.base.ref == 'develop'
|
||||||
timeout-minutes: 3 # 短作业模式:检查一次,不满足就退出,由pr-auto-scan每5分钟定时兜底
|
timeout-minutes: 45 # 长等待模式:等CI全绿后自动合并,不遗漏任何PR
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
shell: sh
|
shell: sh
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ jobs:
|
|||||||
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
PREVIEW_SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
preview_host="${PREVIEW_SSH_HOST:-47.98.113.167}"
|
preview_host="${PREVIEW_SSH_HOST:-172.30.18.197}"
|
||||||
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
preview_user="${PREVIEW_SSH_USER:-deploy}"
|
||||||
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
preview_port="${PREVIEW_SSH_PORT:-22222}"
|
||||||
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
preview_dir="/var/www/preview/pr-${PR_NUMBER}"
|
||||||
|
|||||||
@@ -95,10 +95,13 @@ jobs:
|
|||||||
set -eu
|
set -eu
|
||||||
cd apps/web
|
cd apps/web
|
||||||
|
|
||||||
|
# Config npm mirror for speed
|
||||||
|
npm config set registry https://registry.npmmirror.com
|
||||||
|
|
||||||
# Install dependencies with retry
|
# Install dependencies with retry
|
||||||
for i in 1 2 3; do
|
for i in 1 2 3; do
|
||||||
npm ci --registry=https://registry.npmmirror.com --no-audit --no-fund && break
|
npm ci --no-audit --no-fund && break
|
||||||
echo "npm install failed, retry $i/3..."
|
echo "npm ci failed, retry $i/3..."
|
||||||
[ $i -eq 3 ] && exit 1
|
[ $i -eq 3 ] && exit 1
|
||||||
rm -rf node_modules
|
rm -rf node_modules
|
||||||
sleep 5
|
sleep 5
|
||||||
@@ -106,12 +109,12 @@ jobs:
|
|||||||
|
|
||||||
# TypeScript check
|
# TypeScript check
|
||||||
echo "=== TypeScript check ==="
|
echo "=== TypeScript check ==="
|
||||||
./node_modules/.bin/tsc --noEmit
|
npx --no-install tsc --noEmit
|
||||||
|
|
||||||
# Vite build
|
# Vite build
|
||||||
echo "=== Vite build ==="
|
echo "=== Vite build ==="
|
||||||
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
export VITE_API_URL=https://staging-api.xiaoxiajianji.com
|
||||||
./node_modules/.bin/vite build
|
npx --no-install vite build
|
||||||
|
|
||||||
echo "=== Build completed ==="
|
echo "=== Build completed ==="
|
||||||
ls -la dist/
|
ls -la dist/
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
---
|
|
||||||
AIGC:
|
|
||||||
Label: "1"
|
|
||||||
ContentProducer: 001191110102MACQD9K64018705
|
|
||||||
ProduceID: 15868733686388_0/project_7655981463858544923-files/docs/1197_preview_generation_proposal.md
|
|
||||||
ReservedCode1: ""
|
|
||||||
ContentPropagator: 001191110102MACQD9K64028705
|
|
||||||
PropagateID: 15868733686388#1785468313901
|
|
||||||
ReservedCode2: ""
|
|
||||||
---
|
|
||||||
# #1197 预览生成接口方案评估
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
智能剪辑「一键生成」流程中,第3步预览生成当前被跳过,直接进入下一步。需要实现真正的预览生成功能,让用户在正式生成前能看到效果预览。
|
|
||||||
|
|
||||||
## 现状分析
|
|
||||||
|
|
||||||
### 现有生成链路
|
|
||||||
|
|
||||||
```
|
|
||||||
API 触发生成 → GenerationTask入库 → Celery异步任务 → UnifiedRenderService渲染 → OSS上传 → 更新状态
|
|
||||||
```
|
|
||||||
|
|
||||||
**关键节点:**
|
|
||||||
1. **API层**:`POST /generation-tasks` 或 `POST /templates/{id}/generate` 触发生成
|
|
||||||
2. **任务调度**:Celery task `worker.generate_video`
|
|
||||||
3. **渲染引擎**:`UnifiedRenderService`(统一渲染引擎,已接入9个效果层)
|
|
||||||
4. **输出配置**:默认 720p (1280x720),支持 `resolution` 字段自定义
|
|
||||||
5. **产物存储**:`GeneratedVideo` 表记录,OSS 存储视频文件
|
|
||||||
|
|
||||||
### 已有可复用能力
|
|
||||||
|
|
||||||
| 能力 | 位置 | 是否可复用 |
|
|
||||||
|------|------|-----------|
|
|
||||||
| 任务创建与状态管理 | `GenerationTask` + `CreateGenerationTaskUseCase` | ✅ 是 |
|
|
||||||
| 素材下载与预处理 | `_download_video_assets` / `_download_voice_asset` | ✅ 是 |
|
|
||||||
| 统一渲染引擎 | `UnifiedRenderService` | ✅ 是 |
|
|
||||||
| 分辨率配置 | `resolution` 字段已支持 | ✅ 是 |
|
|
||||||
| 混音与后处理 | `_render_video` 内流程 | ✅ 是 |
|
|
||||||
| OSS 上传与查重 | `_upload_and_dedup` | ✅ 是 |
|
|
||||||
| 进度追踪 | `append_log` / `progress` 字段 | ✅ 是 |
|
|
||||||
|
|
||||||
## 方案对比
|
|
||||||
|
|
||||||
### 方案A:复用现有生成链路 + is_preview 标记(推荐)
|
|
||||||
|
|
||||||
**思路**:在现有 GenerationTask 上加 `is_preview` 标记,预览生成走完整链路但参数降级。
|
|
||||||
|
|
||||||
**改动点:**
|
|
||||||
1. **数据模型**:`GenerationTask` 加 `is_preview: bool` 字段(默认 false);`GeneratedVideo` 加 `is_preview: bool`
|
|
||||||
2. **API 层**:生成接口加 `is_preview` 参数,预览任务不计入配额
|
|
||||||
3. **渲染参数**:预览模式下自动调整
|
|
||||||
- 分辨率:480p (854x480)
|
|
||||||
- 时长:限制前 15 秒(或模板第一个片段)
|
|
||||||
- 码率:降低至 1.5Mbps(正式 4Mbps)
|
|
||||||
- 效果层:跳过高级转场/粒子特效等耗时效果
|
|
||||||
4. **任务调度**:预览任务走低优先级队列(或复用现有队列,标记优先级)
|
|
||||||
5. **前端对接**:预览生成结果带 `is_preview=true` 标记,前端展示"预览"标签
|
|
||||||
|
|
||||||
**优点:**
|
|
||||||
- 代码复用率 90%+,改动最小
|
|
||||||
- 与正式生成逻辑一致,预览效果真实可信
|
|
||||||
- 进度查询、结果展示等功能直接复用
|
|
||||||
- 后续可平滑升级:预览满意后一键转正式生成
|
|
||||||
|
|
||||||
**缺点:**
|
|
||||||
- 需要区分预览和正式任务,避免数据混淆
|
|
||||||
- 预览任务和正式任务竞争同一队列资源(可后续优化为独立队列)
|
|
||||||
|
|
||||||
**开发量估算**:2-3 天
|
|
||||||
- 数据模型 + 迁移:0.5 天
|
|
||||||
- API 层改造:0.5 天
|
|
||||||
- 渲染参数降级:1 天
|
|
||||||
- 测试 + 联调:1 天
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 方案B:新建独立预览接口 + 轻量渲染逻辑
|
|
||||||
|
|
||||||
**思路**:新建独立的预览生成接口,使用简化的渲染逻辑(如只拼接素材+基础配音,跳过大部分效果)。
|
|
||||||
|
|
||||||
**改动点:**
|
|
||||||
1. 新增 `PreviewTask` 数据模型
|
|
||||||
2. 新增 `POST /api/v1/preview/generate` 接口
|
|
||||||
3. 新增独立的 Celery task `worker.generate_preview`
|
|
||||||
4. 简化渲染流程:只做素材裁剪+拼接+配音,跳过转场/滤镜/字幕特效等
|
|
||||||
|
|
||||||
**优点:**
|
|
||||||
- 完全隔离,不影响正式生成链路
|
|
||||||
- 可以做极致优化,预览生成速度快
|
|
||||||
- 数据模型清晰,不会混淆
|
|
||||||
|
|
||||||
**缺点:**
|
|
||||||
- 代码重复率高,两套生成逻辑维护成本翻倍
|
|
||||||
- 预览效果与正式生成可能不一致(效果层差异)
|
|
||||||
- 前端需要对接两套接口
|
|
||||||
- 无法从预览升级为正式生成(需重新走完整流程)
|
|
||||||
|
|
||||||
**开发量估算**:4-5 天
|
|
||||||
- 数据模型 + 接口:1 天
|
|
||||||
- 简化渲染逻辑:2 天
|
|
||||||
- 测试 + 联调:1-2 天
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 方案C:图片预览(首帧/关键帧截图)
|
|
||||||
|
|
||||||
**思路**:不生成视频,只生成几张关键帧的预览图片。
|
|
||||||
|
|
||||||
**优点:**
|
|
||||||
- 生成速度极快(秒级)
|
|
||||||
- 资源消耗小
|
|
||||||
|
|
||||||
**缺点:**
|
|
||||||
- 预览效果差,用户无法感知动态效果
|
|
||||||
- 无法验证配音、转场、节奏等时间维度的效果
|
|
||||||
- 用户体验不佳,不如"真预览"有说服力
|
|
||||||
|
|
||||||
**开发量估算**:1-2 天
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 推荐方案:方案A(复用现有生成链路)
|
|
||||||
|
|
||||||
### 核心理由
|
|
||||||
|
|
||||||
1. **效果保真**:预览和正式生成用同一套渲染引擎,效果一致,用户信任度高
|
|
||||||
2. **开发效率**:90% 代码复用,2-3 天可上线
|
|
||||||
3. **可扩展性强**:后续可加「预览转正式」「低分辨率快速预览」等增强功能
|
|
||||||
4. **维护成本低**:一套生成逻辑,bug 修复和新功能同时生效
|
|
||||||
|
|
||||||
### 详细设计
|
|
||||||
|
|
||||||
#### 1. 数据模型变更
|
|
||||||
|
|
||||||
```python
|
|
||||||
# GenerationTask 新增字段
|
|
||||||
is_preview: bool = False
|
|
||||||
"""是否为预览生成"""
|
|
||||||
|
|
||||||
preview_of: str = ""
|
|
||||||
"""预览对应的正式任务 ID(或反向关联)"""
|
|
||||||
|
|
||||||
# GeneratedVideo 新增字段
|
|
||||||
is_preview: bool = False
|
|
||||||
"""是否为预览视频"""
|
|
||||||
```
|
|
||||||
|
|
||||||
**迁移**:alembic 新增 migration,两个表各加 1-2 个字段。
|
|
||||||
|
|
||||||
#### 2. API 层
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/generation-tasks
|
|
||||||
Body 增加 is_preview: bool = false
|
|
||||||
|
|
||||||
POST /api/v1/templates/{id}/generate
|
|
||||||
Query 增加 is_preview: bool = false
|
|
||||||
```
|
|
||||||
|
|
||||||
**配额处理**:预览生成不计入用户配额,不占用生成次数限制。
|
|
||||||
|
|
||||||
#### 3. 渲染参数降级
|
|
||||||
|
|
||||||
| 参数 | 正式生成 | 预览生成 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| 分辨率 | 720p (1280x720) | 480p (854x480) |
|
|
||||||
| 码率 | 4 Mbps | 1.5 Mbps |
|
|
||||||
| 时长 | 完整时长 | 前 15 秒(或第一段) |
|
|
||||||
| 帧率 | 30 fps | 24 fps |
|
|
||||||
| 转场效果 | 完整转场 | 仅淡入淡出(或简单切) |
|
|
||||||
| 特效滤镜 | 全部启用 | 跳过粒子/光效等高级效果 |
|
|
||||||
| 字幕 | 完整渲染 | 正常渲染(字幕是核心信息) |
|
|
||||||
| 配音 | 完整混音 | 正常混音(配音是核心信息) |
|
|
||||||
|
|
||||||
**实现方式**:在 `_render_video` 或 UnifiedRenderService 入口处,根据 `is_preview` 标记调整渲染配置。
|
|
||||||
|
|
||||||
#### 4. 任务调度
|
|
||||||
|
|
||||||
- 初期复用现有队列,预览任务正常排队
|
|
||||||
- 后续如需优化,可拆分独立预览队列(低优先级)
|
|
||||||
- 预览任务可设置较短超时时间
|
|
||||||
|
|
||||||
#### 5. 前端对接
|
|
||||||
|
|
||||||
- 调用生成接口时传 `is_preview=true`
|
|
||||||
- 结果列表中预览视频带「预览」标签
|
|
||||||
- 预览满意后可一键「升级为正式生成」(重新触发全分辨率生成,可复用素材下载缓存)
|
|
||||||
|
|
||||||
### 实施步骤
|
|
||||||
|
|
||||||
**Phase 1(MVP,2天):**
|
|
||||||
1. 数据模型 + 迁移
|
|
||||||
2. API 层支持 is_preview 参数
|
|
||||||
3. 渲染分辨率降级(480p)
|
|
||||||
4. 不计入配额
|
|
||||||
5. 基础测试
|
|
||||||
|
|
||||||
**Phase 2(优化,1-2天):**
|
|
||||||
1. 时长限制(前15秒)
|
|
||||||
2. 效果层降级(跳高级效果)
|
|
||||||
3. 预览任务低优先级队列
|
|
||||||
4. 预览转正式生成功能
|
|
||||||
|
|
||||||
## 与前端对齐点
|
|
||||||
|
|
||||||
1. 预览生成的触发时机(第3步自动生成?用户点击才生成?)
|
|
||||||
2. 预览时长是固定15秒还是完整但低清?
|
|
||||||
3. 是否需要「预览转正式生成」功能
|
|
||||||
4. 预览视频的展示形态(和正式视频一样还是有特殊UI)
|
|
||||||
|
|
||||||
## 风险与注意事项
|
|
||||||
|
|
||||||
1. **数据混淆**:确保统计、计费、列表展示时正确区分预览和正式任务
|
|
||||||
2. **存储成本**:预览视频也占 OSS 空间,可设置自动清理(7天后自动删除)
|
|
||||||
3. **用户预期**:要明确告诉用户这是预览,效果和正式生成一致但清晰度低
|
|
||||||
4. **并发压力**:如果用户频繁生成预览,可能增加系统负载,需要限流
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> 本内容由 Coze AI 生成,请遵循相关法律法规及《人工智能生成合成内容标识办法》使用与传播。
|
|
||||||
@@ -1,382 +0,0 @@
|
|||||||
# #1197 预览生成接口技术方案(v2)
|
|
||||||
|
|
||||||
> 更新说明:v2 新增「多版本预览生成」能力,支持一个模板生成多个不重复的预览视频,左侧列表展示,用户可挑选满意的版本转正式生成。
|
|
||||||
|
|
||||||
## 1. 背景与目标
|
|
||||||
|
|
||||||
**现状**:智能剪辑「一键生成」第3步预览生成被跳过,用户直接进入正式生成,缺少效果预览环节。
|
|
||||||
|
|
||||||
**目标**:
|
|
||||||
1. ✅ 实现真正的预览生成(低分辨率快速出片)
|
|
||||||
2. ✅ **支持生成 1~N 个不重复的预览版本**(默认 3 个),左侧列表展示
|
|
||||||
3. ✅ 预览满意后可一键转正式生成(复用素材下载缓存)
|
|
||||||
4. ✅ 不计入用户配额,不占用正式生成次数
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 现有生成链路分析
|
|
||||||
|
|
||||||
### 2.1 链路总览
|
|
||||||
|
|
||||||
```
|
|
||||||
API 触发生成 → GenerationTask入库 → Celery异步任务
|
|
||||||
→ 下载素材 → 构建plan/clips → UnifiedRenderService渲染
|
|
||||||
→ 混音后处理 → OSS上传 + 查重 → 更新状态
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 决定视频差异的变量
|
|
||||||
|
|
||||||
要做"多个不重复版本",先分析哪些环节可以引入变化:
|
|
||||||
|
|
||||||
| 变量 | 当前行为 | 能否引入变化 | 影响程度 |
|
|
||||||
|------|---------|------------|---------|
|
|
||||||
| 素材选择 | 按 asset_ids 顺序全用 | ✅ 可随机选择子集/不同组合 | 大 |
|
|
||||||
| 素材排序 | 按 asset_ids 顺序 | ✅ 可 shuffle 重排 | 大 |
|
|
||||||
| 配音选择 | 固定 voice_library_id | ✅ 可选不同音色 | 中 |
|
|
||||||
| 标题选择 | 固定 title_ids 或随机选 | ✅ 可选不同标题 | 中 |
|
|
||||||
| BGM | 固定 bgm_config | ✅ 可选不同BGM | 小 |
|
|
||||||
| 转场效果 | 模板固定 | ✅ 可随机化转场类型 | 小 |
|
|
||||||
| 播放速度 | 模板固定 | ✅ 可微调速度 | 小 |
|
|
||||||
| 分辨率/码率 | 固定 | ✅ 预览可降级 | 不影响内容 |
|
|
||||||
|
|
||||||
### 2.3 可复用能力
|
|
||||||
|
|
||||||
- 任务创建与状态管理:`GenerationTask` + `CreateGenerationTaskUseCase`
|
|
||||||
- 素材下载与预处理:`_download_all_assets`
|
|
||||||
- 统一渲染引擎:`UnifiedRenderService`
|
|
||||||
- 分辨率配置:`resolution` 字段已支持
|
|
||||||
- 批量任务:`batch_id` 字段已存在(可用于预览组)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 总体方案:复用现有链路 + 多变体引擎
|
|
||||||
|
|
||||||
**核心思路**:沿用 v1 的"复用现有生成链路 + is_preview 标记"方案,在此基础上增加「多版本生成」能力。
|
|
||||||
|
|
||||||
**架构**:
|
|
||||||
```
|
|
||||||
预览生成请求(count=N)
|
|
||||||
↓
|
|
||||||
创建预览批次(preview_batch)
|
|
||||||
↓
|
|
||||||
变体引擎生成 N 个变体参数(variation seed + 参数组合)
|
|
||||||
↓
|
|
||||||
为每个变体创建 1 个 GenerationTask(is_preview=true)
|
|
||||||
↓
|
|
||||||
N 个 Celery 任务并行执行(走现有生成链路,参数降级)
|
|
||||||
↓
|
|
||||||
N 个结果汇聚,前端左侧列表展示
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 详细设计
|
|
||||||
|
|
||||||
### 4.1 数据模型变更
|
|
||||||
|
|
||||||
#### 4.1.1 GenerationTask 新增字段
|
|
||||||
|
|
||||||
```python
|
|
||||||
# 现有字段保留,新增:
|
|
||||||
is_preview: bool = False
|
|
||||||
"""是否为预览生成"""
|
|
||||||
|
|
||||||
preview_batch_id: str = ""
|
|
||||||
"""预览批次 ID(同批次的 N 个预览共享一个 batch)"""
|
|
||||||
|
|
||||||
variant_seed: int = 0
|
|
||||||
"""变体种子,用于控制随机化行为(素材选择、排序、转场等)"""
|
|
||||||
|
|
||||||
variant_params: dict = field(default_factory=dict)
|
|
||||||
"""变体参数快照(记录本次使用了哪些素材、标题、配音等,可追溯)
|
|
||||||
{
|
|
||||||
"asset_ids": [...], # 实际选用的素材子集
|
|
||||||
"title_id": "", # 选用的标题
|
|
||||||
"voice_id": "", # 选用的配音
|
|
||||||
"transition_style": "", # 转场风格
|
|
||||||
"bgm_track": "", # BGM 音轨
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.1.2 GeneratedVideo 新增字段
|
|
||||||
|
|
||||||
```python
|
|
||||||
is_preview: bool = False
|
|
||||||
"""是否为预览视频"""
|
|
||||||
|
|
||||||
preview_batch_id: str = ""
|
|
||||||
"""所属预览批次"""
|
|
||||||
|
|
||||||
variant_index: int = 0
|
|
||||||
"""在批次中的序号(0, 1, 2...)"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.1.3 迁移方案
|
|
||||||
|
|
||||||
alembic 新增 migration,两个表各加 4 个字段,默认值为空/false,无数据回填成本。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.2 变体引擎(Variant Engine)
|
|
||||||
|
|
||||||
**核心组件**:根据 count 和 seed,生成 N 组互不相同的生成参数。
|
|
||||||
|
|
||||||
#### 4.2.1 变纬度设计
|
|
||||||
|
|
||||||
| 维度 | 策略 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| **素材子集选择** | 从素材池中随机选 M 个(M=min(素材数, 模板clip数*2)) | 版本差异最大的来源 |
|
|
||||||
| **素材排序** | 随机打乱顺序 | 影响叙事节奏 |
|
|
||||||
| **标题选择** | 从 title_ids 中随机选 1 个 | 影响文案内容 |
|
|
||||||
| **配音选择** | 从 voice_ids 中随机选 1 个(如有多个) | 影响听觉体验 |
|
|
||||||
| **转场风格** | 从预设转场池中随机选 1 种 | 影响视觉过渡 |
|
|
||||||
| **BGM 选择** | 从 bgm 列表中随机选 1 首(如有配置) | 影响氛围 |
|
|
||||||
|
|
||||||
#### 4.2.2 去重机制
|
|
||||||
|
|
||||||
- 同一批次内,变体参数必须两两不同(至少素材组合或排序不同)
|
|
||||||
- 使用 `variant_seed` 保证可复现(相同 seed → 相同变体)
|
|
||||||
- 如果素材数量不足导致无法生成 N 个不同版本,按实际能生成的数量返回
|
|
||||||
|
|
||||||
#### 4.2.3 接口设计
|
|
||||||
|
|
||||||
```python
|
|
||||||
def generate_variants(
|
|
||||||
count: int,
|
|
||||||
seed: int,
|
|
||||||
asset_pool: list[str], # 可用素材 ID 列表
|
|
||||||
title_pool: list[str] = [], # 可用标题 ID 列表
|
|
||||||
voice_pool: list[str] = [], # 可用配音 ID 列表
|
|
||||||
template_id: str = "",
|
|
||||||
) -> list[dict]:
|
|
||||||
"""
|
|
||||||
生成 count 组变体参数。
|
|
||||||
|
|
||||||
每组参数包含:asset_ids(选用的素材+排序)、title_id、voice_id、
|
|
||||||
transition_style 等,确保两两不同。
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.3 API 层设计
|
|
||||||
|
|
||||||
#### 4.3.1 预览生成接口
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/templates/{template_id}/generate-preview
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"asset_library_id": "lib_xxx",
|
|
||||||
"asset_ids": ["asset_1", "asset_2", ...],
|
|
||||||
"title_ids": ["title_1", "title_2"],
|
|
||||||
"voice_ids": ["voice_1", "voice_2"],
|
|
||||||
"bgm_config": {},
|
|
||||||
"count": 3,
|
|
||||||
"seed": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 默认 | 说明 |
|
|
||||||
|------|------|------|------|------|
|
|
||||||
| template_id | path | ✅ | - | 模板 ID |
|
|
||||||
| asset_library_id | body | ✅ | - | 素材库 ID |
|
|
||||||
| asset_ids | body | ✅ | - | 素材池(从中选子集/排序) |
|
|
||||||
| title_ids | body | - | [] | 标题池(可选,不传则不用标题) |
|
|
||||||
| voice_ids | body | - | [] | 配音池(可选) |
|
|
||||||
| bgm_config | body | - | {} | BGM 配置 |
|
|
||||||
| count | body | - | 3 | 生成几个预览版本(1~10) |
|
|
||||||
| seed | body | - | 0 | 随机种子,0 表示随机 |
|
|
||||||
|
|
||||||
**响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"preview_batch_id": "pb_xxx",
|
|
||||||
"count": 3,
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"task_id": "gen_xxx_0",
|
|
||||||
"variant_index": 0,
|
|
||||||
"status": "processing"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"task_id": "gen_xxx_1",
|
|
||||||
"variant_index": 1,
|
|
||||||
"status": "processing"
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3.2 预览批次查询接口
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/v1/preview-batches/{batch_id}
|
|
||||||
```
|
|
||||||
|
|
||||||
返回批次内所有预览任务的状态、结果(已完成的带 video_url)。
|
|
||||||
|
|
||||||
**响应**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"preview_batch_id": "pb_xxx",
|
|
||||||
"count": 3,
|
|
||||||
"completed_count": 2,
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"task_id": "gen_xxx_0",
|
|
||||||
"variant_index": 0,
|
|
||||||
"status": "completed",
|
|
||||||
"video_url": "https://oss.xxx/preview/xxx.mp4",
|
|
||||||
"duration": 15.5,
|
|
||||||
"thumbnail_url": "https://oss.xxx/preview/xxx.jpg"
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4.3.3 预览转正式生成
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/preview-batches/{batch_id}/tasks/{task_id}/promote
|
|
||||||
```
|
|
||||||
|
|
||||||
将某个预览版本升级为正式生成(复用素材缓存,重新全分辨率渲染)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.4 渲染参数降级
|
|
||||||
|
|
||||||
预览模式下自动调整以下参数:
|
|
||||||
|
|
||||||
| 参数 | 正式生成 | 预览生成 |
|
|
||||||
|------|---------|---------|
|
|
||||||
| 分辨率 | 720p (1280x720) | 480p (854x480) |
|
|
||||||
| 码率 | 4 Mbps | 1.5 Mbps |
|
|
||||||
| 帧率 | 30 fps | 24 fps |
|
|
||||||
| 时长 | 完整时长 | 前 15 秒(或第一段完整clip) |
|
|
||||||
| 转场效果 | 完整转场 | 仅淡入淡出 |
|
|
||||||
| 高级特效 | 全部启用 | 跳过粒子/光效等 |
|
|
||||||
| 字幕 | 完整渲染 | 正常渲染 |
|
|
||||||
| 配音 | 完整混音 | 正常混音 |
|
|
||||||
| 输出质量 | high | medium |
|
|
||||||
|
|
||||||
**实现位置**:`_render_video` 函数入口处,根据 `is_preview` 标记调整渲染配置。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4.5 任务调度
|
|
||||||
|
|
||||||
- **并行执行**:N 个预览任务并行提交到 Celery,不排队等待
|
|
||||||
- **低优先级**:预览任务走独立队列(`preview_queue`),不抢占正式生成资源
|
|
||||||
- **超时控制**:预览任务超时时间 5 分钟(正式 30 分钟)
|
|
||||||
- **自动清理**:预览视频 7 天后自动从 OSS 删除,任务记录标记为 archived
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 前端对接要点
|
|
||||||
|
|
||||||
### 5.1 交互流程
|
|
||||||
|
|
||||||
```
|
|
||||||
第2步选素材 → 第3步点击"生成预览"
|
|
||||||
→ 显示 loading + 进度
|
|
||||||
→ 预览陆续完成,左侧列表逐张出现
|
|
||||||
→ 用户点击左侧不同版本,右侧预览区切换
|
|
||||||
→ 用户选中满意版本 → 点击"正式生成"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 需要对齐的接口
|
|
||||||
|
|
||||||
1. **预览创建**:`POST /templates/{id}/generate-preview`
|
|
||||||
2. **批次状态轮询**:`GET /preview-batches/{id}`(建议 2s 轮询,或走 SSE)
|
|
||||||
3. **预览转正式**:`POST /preview-batches/{id}/tasks/{task_id}/promote`
|
|
||||||
|
|
||||||
### 5.3 数据格式对齐
|
|
||||||
|
|
||||||
预览视频条目结构:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "gen_xxx",
|
|
||||||
"variant_index": 0,
|
|
||||||
"status": "completed",
|
|
||||||
"video_url": "https://...",
|
|
||||||
"duration": 15.5,
|
|
||||||
"file_size": 2850000,
|
|
||||||
"thumbnail_url": "https://...",
|
|
||||||
"is_preview": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 配额与计费
|
|
||||||
|
|
||||||
- 预览生成**不计入**用户配额
|
|
||||||
- 同一模板 + 同一素材池,每天最多生成 3 次多版本预览(防滥用)
|
|
||||||
- 单个预览批次最多 10 个版本
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 实施步骤
|
|
||||||
|
|
||||||
### Phase 1:单版本预览(MVP,2 天)
|
|
||||||
1. 数据模型 + 迁移(is_preview 字段)
|
|
||||||
2. API 层支持 is_preview 参数
|
|
||||||
3. 渲染分辨率降级(480p)
|
|
||||||
4. 不计入配额
|
|
||||||
5. 基础测试
|
|
||||||
|
|
||||||
### Phase 2:多版本预览(3 天)
|
|
||||||
1. 变体引擎实现(素材随机选择 + 排序 + 去重)
|
|
||||||
2. preview_batch 批次管理
|
|
||||||
3. 批量创建 N 个预览任务
|
|
||||||
4. 批次查询接口
|
|
||||||
5. 前端联调
|
|
||||||
|
|
||||||
### Phase 3:预览转正式 + 优化(2 天)
|
|
||||||
1. 预览转正式生成接口(promote)
|
|
||||||
2. 素材下载缓存复用
|
|
||||||
3. 独立预览队列(低优先级)
|
|
||||||
4. 自动清理机制
|
|
||||||
5. 完整测试 + 压测
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 风险与注意事项
|
|
||||||
|
|
||||||
| 风险 | 影响 | 应对 |
|
|
||||||
|------|------|------|
|
|
||||||
| 并发预览任务过多打满 worker | 正式生成被阻塞 | 独立预览队列 + 限流 |
|
|
||||||
| 变体生成的视频差异不够大 | 用户觉得"都一样" | 优先素材子集+排序差异,保证视觉差异 |
|
|
||||||
| 预览视频占用 OSS 存储 | 存储成本上升 | 7 天自动清理 + 低码率 |
|
|
||||||
| N 个版本同时下载重复素材 | 带宽浪费 | 批次内共享一次下载(Phase 3 优化) |
|
|
||||||
| 用户预期管理 | 以为预览就是最终效果 | 明确标注"预览版",说明分辨率差异 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. 开发量估算
|
|
||||||
|
|
||||||
| 阶段 | 后端 | 前端 | 合计 |
|
|
||||||
|------|------|------|------|
|
|
||||||
| Phase 1 单版本预览 | 2 天 | 1 天 | 3 天 |
|
|
||||||
| Phase 2 多版本预览 | 3 天 | 2 天 | 5 天 |
|
|
||||||
| Phase 3 转正式+优化 | 2 天 | 1 天 | 3 天 |
|
|
||||||
| **总计** | **7 天** | **4 天** | **~7 天(并行)** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. 与 v1 方案的差异总结
|
|
||||||
|
|
||||||
1. **新增多版本能力**:从"生成1个预览"升级为"生成N个不重复预览"
|
|
||||||
2. **新增变体引擎**:负责素材选择/排序/配音/标题的随机化
|
|
||||||
3. **新增批次概念**:preview_batch 管理一组预览任务
|
|
||||||
4. **新增 promote 接口**:预览转正式生成
|
|
||||||
5. **独立队列**:预览不抢占正式生成资源
|
|
||||||
6. **开发量**:从 2-3 天增加到约 7 天(后端)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"""#1197 - 预览生成:generation_tasks 表新增 is_preview 字段
|
|
||||||
|
|
||||||
Revision ID: 053
|
|
||||||
Revises: 052
|
|
||||||
Create Date: 2026-08-15
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. generation_tasks 表新增 is_preview 字段,标记是否为预览生成任务(低清 480p)
|
|
||||||
2. 默认 False,与现有正式生成任务兼容
|
|
||||||
3. 加索引以支持按预览/正式任务筛选
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import context, op
|
|
||||||
|
|
||||||
revision = "053_generation_task_is_preview"
|
|
||||||
down_revision = "052_generation_task_bgm_config"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if context.get_context().dialect.name == "postgresql":
|
|
||||||
# 检查列是否已存在(幂等)
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT column_name FROM information_schema.columns "
|
|
||||||
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column("is_preview", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
|
||||||
)
|
|
||||||
# 加索引
|
|
||||||
op.create_index(
|
|
||||||
"ix_generation_tasks_is_preview",
|
|
||||||
"generation_tasks",
|
|
||||||
["is_preview"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if context.get_context().dialect.name == "postgresql":
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT column_name FROM information_schema.columns "
|
|
||||||
"WHERE table_name = 'generation_tasks' AND column_name = 'is_preview'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
op.drop_index("ix_generation_tasks_is_preview", table_name="generation_tasks")
|
|
||||||
op.drop_column("generation_tasks", "is_preview")
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""确认生成 API 改造:为 generation_tasks 表添加 source_task_id、output_width、output_height、cover_url、custom_title 字段
|
|
||||||
|
|
||||||
Revision ID: 054_confirm_gen_fields
|
|
||||||
Revises: 053_generation_task_is_preview
|
|
||||||
Create Date: 2026-08-16
|
|
||||||
|
|
||||||
Changes:
|
|
||||||
1. generation_tasks 表新增 source_task_id(来源预览任务 ID,带索引)
|
|
||||||
2. generation_tasks 表新增 output_width / output_height(动态输出分辨率)
|
|
||||||
3. generation_tasks 表新增 cover_url / custom_title(自定义封面和标题)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = "054_confirm_gen_fields"
|
|
||||||
down_revision = "053_generation_task_is_preview"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
is_pg = conn.dialect.name == "postgresql"
|
|
||||||
|
|
||||||
if is_pg:
|
|
||||||
# 幂等检查:source_task_id 列是否已存在
|
|
||||||
result = conn.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT column_name FROM information_schema.columns "
|
|
||||||
"WHERE table_name = 'generation_tasks' AND column_name = 'source_task_id'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if result.scalar() is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
# source_task_id
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column("source_task_id", sa.String(32), nullable=False, server_default=""),
|
|
||||||
)
|
|
||||||
|
|
||||||
# output_width
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column("output_width", sa.Integer, nullable=False, server_default=sa.text("1280")),
|
|
||||||
)
|
|
||||||
|
|
||||||
# output_height
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column("output_height", sa.Integer, nullable=False, server_default=sa.text("720")),
|
|
||||||
)
|
|
||||||
|
|
||||||
# cover_url
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column("cover_url", sa.String(1000), nullable=False, server_default=""),
|
|
||||||
)
|
|
||||||
|
|
||||||
# custom_title
|
|
||||||
op.add_column(
|
|
||||||
"generation_tasks",
|
|
||||||
sa.Column("custom_title", sa.String(500), nullable=False, server_default=""),
|
|
||||||
)
|
|
||||||
|
|
||||||
# 索引
|
|
||||||
op.create_index(
|
|
||||||
"ix_generation_tasks_source_task_id",
|
|
||||||
"generation_tasks",
|
|
||||||
["source_task_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_index("ix_generation_tasks_source_task_id", table_name="generation_tasks")
|
|
||||||
op.drop_column("generation_tasks", "custom_title")
|
|
||||||
op.drop_column("generation_tasks", "cover_url")
|
|
||||||
op.drop_column("generation_tasks", "output_height")
|
|
||||||
op.drop_column("generation_tasks", "output_width")
|
|
||||||
op.drop_column("generation_tasks", "source_task_id")
|
|
||||||
@@ -7,7 +7,6 @@ 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.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_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
|
||||||
@@ -88,11 +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(
|
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,403 +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_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()
|
|
||||||
|
|
||||||
PREVIEW_RESOLUTION = "854x480"
|
|
||||||
|
|
||||||
# 模板 mode → 视频比例映射
|
|
||||||
_TEMPLATE_MODE_TO_RATIO = {
|
|
||||||
"pip": "9:16",
|
|
||||||
"standard": "16:9",
|
|
||||||
"square": "1:1",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _calc_preview_resolution(video_ratio: str = "") -> str:
|
|
||||||
"""根据视频比例计算预览分辨率(短边 480,长边按比例)。
|
|
||||||
|
|
||||||
支持的比例:16:9, 9:16, 1:1, 4:3, 3:4, 其他默认 16:9。
|
|
||||||
"""
|
|
||||||
ratio_map = {
|
|
||||||
"16:9": "854x480",
|
|
||||||
"9:16": "480x854",
|
|
||||||
"1:1": "480x480",
|
|
||||||
"4:3": "640x480",
|
|
||||||
"3:4": "480x640",
|
|
||||||
}
|
|
||||||
return ratio_map.get(video_ratio.strip(), PREVIEW_RESOLUTION)
|
|
||||||
|
|
||||||
|
|
||||||
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 _sign_video_url(raw_url: str) -> str:
|
|
||||||
"""为私有 OSS bucket 的视频 URL 生成预签名下载链接。
|
|
||||||
|
|
||||||
有效期 2 小时,签名失败时降级返回原始 URL。
|
|
||||||
"""
|
|
||||||
if not raw_url:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
storage = get_storage_service()
|
|
||||||
signed = storage.get_download_url(raw_url, expires_seconds=7200)
|
|
||||||
# 如果返回的 URL 与原始 URL 完全不同且不是签名 URL(说明 bucket 未配置),
|
|
||||||
# 降级返回原始 URL
|
|
||||||
if signed and signed != raw_url:
|
|
||||||
return signed
|
|
||||||
if signed == raw_url:
|
|
||||||
return raw_url
|
|
||||||
# signed 为空或与 raw_url 无关,返回原始
|
|
||||||
return raw_url
|
|
||||||
except Exception:
|
|
||||||
logger.warning("[预览] URL签名失败,降级返回原始URL: %s", raw_url[:100], exc_info=True)
|
|
||||||
return raw_url
|
|
||||||
|
|
||||||
|
|
||||||
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 ""
|
|
||||||
# P0 修复:私有 bucket 需要预签名 URL,否则前端 403 → 黑屏
|
|
||||||
video_url = _sign_video_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", PREVIEW_RESOLUTION) or PREVIEW_RESOLUTION,
|
|
||||||
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),
|
|
||||||
) -> PreviewGenerationTaskResponse:
|
|
||||||
"""创建预览生成任务。
|
|
||||||
|
|
||||||
预览为完整时长的低清版(480p + 低码率),效果与正式生成一致,仅清晰度降低。
|
|
||||||
|
|
||||||
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="",
|
|
||||||
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="",
|
|
||||||
asset_select_mode="",
|
|
||||||
batch_id="",
|
|
||||||
video_title=request.video_title,
|
|
||||||
resolution=_calc_preview_resolution(video_ratio),
|
|
||||||
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,83 +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:
|
|
||||||
"""确认生成 — 基于预览任务创建正式生成任务。
|
|
||||||
|
|
||||||
查找预览任务,复制其配置,创建新的正式生成任务(is_preview=False),
|
|
||||||
使用高分辨率,复用 worker.generate_video 渲染路径。
|
|
||||||
"""
|
|
||||||
# 1. 查找源预览任务
|
|
||||||
source_task = generation_task_repository.get(task_id)
|
|
||||||
if source_task is None:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Preview task {task_id} not found")
|
|
||||||
|
|
||||||
# 2. 权限检查
|
|
||||||
if source_task.created_by_user_id and source_task.created_by_user_id != authenticated_user.user.id:
|
|
||||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
|
||||||
if source_task.project_id:
|
|
||||||
check_project_access(source_task.project_id, authenticated_user.user.id, project_repository)
|
|
||||||
|
|
||||||
# 3. 创建正式生成任务,复制预览任务的配置
|
|
||||||
use_case = CreateGenerationTaskUseCase(generation_task_repository)
|
|
||||||
new_task = use_case.execute(
|
|
||||||
CreateGenerationTaskCommand(
|
|
||||||
project_id=source_task.project_id,
|
|
||||||
asset_library_id=source_task.asset_library_id,
|
|
||||||
strategy_id=source_task.strategy_id,
|
|
||||||
voice_library_id=source_task.voice_library_id,
|
|
||||||
template_id=source_task.template_id,
|
|
||||||
asset_ids=source_task.asset_ids,
|
|
||||||
title_ids=source_task.title_ids,
|
|
||||||
voice_ids=source_task.voice_ids,
|
|
||||||
created_by_user_id=authenticated_user.user.id,
|
|
||||||
source_edit_plan_id=source_task.source_edit_plan_id or "",
|
|
||||||
asset_select_mode=source_task.asset_select_mode,
|
|
||||||
video_title=getattr(source_task, "video_title", ""),
|
|
||||||
resolution=getattr(source_task, "resolution", ""),
|
|
||||||
is_preview=False,
|
|
||||||
source_task_id=task_id,
|
|
||||||
output_width=request.output_width,
|
|
||||||
output_height=request.output_height,
|
|
||||||
cover_url=request.cover_url,
|
|
||||||
custom_title=request.custom_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 调度 worker.generate_video(同一条渲染路径)
|
|
||||||
try:
|
|
||||||
if not safe_enqueue_generation_task(
|
|
||||||
new_task,
|
|
||||||
generation_task_repository,
|
|
||||||
user_id=authenticated_user.user.id,
|
|
||||||
log_prefix="[确认生成]",
|
|
||||||
log_task_status=True,
|
|
||||||
):
|
|
||||||
logger.warning("[确认生成] 入队失败: task_id=%s", new_task.id)
|
|
||||||
except UserPendingLimitExceeded:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=429,
|
|
||||||
detail="您的待处理任务过多,请等待完成后再提交",
|
|
||||||
) from None
|
|
||||||
except GlobalQueueFull:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail="系统繁忙,请稍后再试",
|
|
||||||
) from None
|
|
||||||
|
|
||||||
return BatchGenerationTaskResponse(
|
|
||||||
items=[_to_generation_task_response(new_task)],
|
|
||||||
total=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks", response_model=ListGenerationTasksResponse)
|
@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),
|
||||||
@@ -518,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:
|
||||||
|
|||||||
@@ -98,15 +98,6 @@ def _auto_fallback_assign_assets(
|
|||||||
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 个指定素材",
|
||||||
@@ -114,42 +105,11 @@ def _auto_fallback_assign_assets(
|
|||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -184,29 +184,6 @@ def editor_generate_cover(
|
|||||||
_, plan_svc = services
|
_, plan_svc = services
|
||||||
plan = plan_svc.get_plan_or_raise(plan_id)
|
plan = plan_svc.get_plan_or_raise(plan_id)
|
||||||
|
|
||||||
# 获取第一个视频的下载 URL(用于 MediaKit 抽帧)
|
|
||||||
primary_video_url = None
|
|
||||||
if body.asset_ids and body.cover_type in ("ai_frame", "ai_regenerate"):
|
|
||||||
try:
|
|
||||||
from app.database import get_db_session
|
|
||||||
|
|
||||||
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
|
|
||||||
from packages.shared.storage import get_shared_storage_service
|
|
||||||
|
|
||||||
with get_db_session() as session:
|
|
||||||
asset_repo = SQLAlchemyAssetRepository(session)
|
|
||||||
first_asset = asset_repo.get(body.asset_ids[0])
|
|
||||||
if first_asset and first_asset.storage_key:
|
|
||||||
storage_svc = get_shared_storage_service()
|
|
||||||
primary_video_url = storage_svc.get_download_url(first_asset.storage_key)
|
|
||||||
logger.info(
|
|
||||||
"获取视频URL用于封面生成: asset_id=%s url=%s",
|
|
||||||
body.asset_ids[0],
|
|
||||||
primary_video_url[:80] if primary_video_url else None,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("获取视频URL失败,将使用stub封面: %s", str(e))
|
|
||||||
|
|
||||||
from packages.shared.ai_service import run_generate_cover
|
from packages.shared.ai_service import run_generate_cover
|
||||||
|
|
||||||
cover_data = run_generate_cover(
|
cover_data = run_generate_cover(
|
||||||
@@ -214,7 +191,6 @@ def editor_generate_cover(
|
|||||||
asset_ids=body.asset_ids,
|
asset_ids=body.asset_ids,
|
||||||
cover_type=body.cover_type,
|
cover_type=body.cover_type,
|
||||||
frame_time=body.frame_time,
|
frame_time=body.frame_time,
|
||||||
primary_video_url=primary_video_url,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
current_config = dict(plan.config) if plan.config else {}
|
current_config = dict(plan.config) if plan.config else {}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ def generate_editor_draft(
|
|||||||
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
plan_svc, plan_id, plan_check, clips_without_asset, asset_library_repo, asset_repo
|
||||||
)
|
)
|
||||||
|
|
||||||
# 检查是否可生成(含最后防线自动修复 + 诊断日志)
|
# 检查是否可生成
|
||||||
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:
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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,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,68 +122,3 @@ class ListGenerationTasksResponse(BaseModel):
|
|||||||
"""用户级生成任务列表响应(跨 project)。"""
|
"""用户级生成任务列表响应(跨 project)。"""
|
||||||
|
|
||||||
items: list[GenerationTaskResponse]
|
items: list[GenerationTaskResponse]
|
||||||
|
|
||||||
|
|
||||||
# ── 预览生成(Phase 1) ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
|
|
||||||
class CreatePreviewGenerationTaskRequest(BaseModel):
|
|
||||||
"""创建预览生成任务请求。
|
|
||||||
|
|
||||||
仅支持模板模式:template_id + asset_ids 等素材 ID 列表。
|
|
||||||
预览为完整时长低清版(480p + 低码率)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
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)
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
@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
|
|
||||||
"""文件大小(字节)"""
|
|
||||||
|
|||||||
@@ -571,10 +571,6 @@ class EditPlanService:
|
|||||||
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
def can_generate(self, plan_id: str) -> tuple[bool, str]:
|
||||||
"""检查是否可以触发渲染
|
"""检查是否可以触发渲染
|
||||||
|
|
||||||
包含最后一道防线的自动修复:
|
|
||||||
- 如果 clips 存在但都没有 asset_id,且 config.asset_ids 非空,
|
|
||||||
直接在内部执行素材分配,不再依赖前置 fallback 链路。
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (can_generate, reason)
|
tuple: (can_generate, reason)
|
||||||
"""
|
"""
|
||||||
@@ -589,69 +585,10 @@ class EditPlanService:
|
|||||||
if not clips:
|
if not clips:
|
||||||
return False, "请先添加片段后再生成视频"
|
return False, "请先添加片段后再生成视频"
|
||||||
|
|
||||||
# 检查是否至少有一个片段分配了素材
|
|
||||||
has_asset = any(c.asset_id for c in clips)
|
|
||||||
config_asset_ids_count = len((plan.config or {}).get("asset_ids", []))
|
|
||||||
clips_with_asset_count = sum(1 for c in clips if c.asset_id)
|
|
||||||
logger.info(
|
|
||||||
"can_generate 诊断: plan=%s status=%s total_clips=%d " "clips_with_asset=%d config_asset_ids_count=%d",
|
|
||||||
plan_id,
|
|
||||||
plan.status,
|
|
||||||
len(clips),
|
|
||||||
clips_with_asset_count,
|
|
||||||
config_asset_ids_count,
|
|
||||||
)
|
|
||||||
if not has_asset:
|
|
||||||
# ── 最后防线:自动从 config.asset_ids 分配素材 ──
|
|
||||||
config_asset_ids = (plan.config or {}).get("asset_ids", [])
|
|
||||||
if config_asset_ids:
|
|
||||||
logger.warning(
|
|
||||||
"can_generate 最后防线触发: plan=%s clips=%d 均无素材," "从 config.asset_ids(%d个) 自动分配",
|
|
||||||
plan_id,
|
|
||||||
len(clips),
|
|
||||||
len(config_asset_ids),
|
|
||||||
)
|
|
||||||
clips_without_asset = [c for c in clips if not c.asset_id]
|
|
||||||
assigned_count = 0
|
|
||||||
for i, clip in enumerate(clips_without_asset):
|
|
||||||
asset_idx = i % len(config_asset_ids)
|
|
||||||
try:
|
|
||||||
self.assign_asset(clip.id, config_asset_ids[asset_idx])
|
|
||||||
assigned_count += 1
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"can_generate 最后防线: plan=%s clip=%s 分配素材 %s 失败: %s",
|
|
||||||
plan_id,
|
|
||||||
clip.id,
|
|
||||||
config_asset_ids[asset_idx],
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"can_generate 最后防线: plan=%s 已为 %d/%d 个片段分配素材",
|
|
||||||
plan_id,
|
|
||||||
assigned_count,
|
|
||||||
len(clips_without_asset),
|
|
||||||
)
|
|
||||||
# 重新加载 clips 验证分配结果
|
|
||||||
clips = self._clip_repo.list_by_plan(plan_id)
|
|
||||||
if not any(c.asset_id for c in clips):
|
|
||||||
return False, "没有可渲染的就绪片段,自动修复后仍未分配素材"
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
"can_generate 失败: plan=%s clips=%d 均无素材," "且 config.asset_ids 为空,无法自动修复",
|
|
||||||
plan_id,
|
|
||||||
len(clips),
|
|
||||||
)
|
|
||||||
return False, "没有可渲染的就绪片段,请确保已选择素材"
|
|
||||||
|
|
||||||
return True, ""
|
return True, ""
|
||||||
|
|
||||||
def mark_clips_ready(self, plan_id: str) -> int:
|
def mark_clips_ready(self, plan_id: str) -> int:
|
||||||
"""将已分配素材的 pending 片段标记为 ready
|
"""将所有 pending 状态的片段标记为 ready
|
||||||
|
|
||||||
只标记同时满足以下条件的片段:
|
|
||||||
- status == PENDING
|
|
||||||
- asset_id 非空(已分配素材)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: 标记的片段数量
|
int: 标记的片段数量
|
||||||
@@ -662,16 +599,10 @@ class EditPlanService:
|
|||||||
)
|
)
|
||||||
count = 0
|
count = 0
|
||||||
for clip in clips:
|
for clip in clips:
|
||||||
if clip.asset_id:
|
clip.mark_ready()
|
||||||
clip.mark_ready()
|
self._clip_repo.update(clip)
|
||||||
self._clip_repo.update(clip)
|
count += 1
|
||||||
count += 1
|
logger.info("标记片段就绪: plan_id=%s count=%d", plan_id, count)
|
||||||
logger.info(
|
|
||||||
"标记片段就绪: plan_id=%s marked=%d total_pending=%d",
|
|
||||||
plan_id,
|
|
||||||
count,
|
|
||||||
len(clips),
|
|
||||||
)
|
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
def update_plan_config(self, plan_id: str, config_updates: Dict[str, Any]) -> EditPlan:
|
||||||
|
|||||||
@@ -49,10 +49,9 @@ class PlanGeneratorService:
|
|||||||
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
基于模板 + 素材,自动生成 EditPlan 及 EditPlanClip 列表。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, db: Session, asset_repo=None) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
self._plan_repo = SQLAlchemyEditPlanRepository(db)
|
||||||
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
self._clip_repo = SQLAlchemyEditPlanClipRepository(db)
|
||||||
self._asset_repo = asset_repo
|
|
||||||
|
|
||||||
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
# ── 公开接口 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -65,7 +64,6 @@ class PlanGeneratorService:
|
|||||||
project_id: str = "",
|
project_id: str = "",
|
||||||
created_by_user_id: str = "",
|
created_by_user_id: str = "",
|
||||||
name: str = "",
|
name: str = "",
|
||||||
random_preview: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""基于模板+素材生成剪辑计划
|
"""基于模板+素材生成剪辑计划
|
||||||
|
|
||||||
@@ -76,7 +74,6 @@ class PlanGeneratorService:
|
|||||||
project_id: 所属项目 ID
|
project_id: 所属项目 ID
|
||||||
created_by_user_id: 创建者用户 ID
|
created_by_user_id: 创建者用户 ID
|
||||||
name: 计划名称(为空则自动取模板名)
|
name: 计划名称(为空则自动取模板名)
|
||||||
random_preview: 是否启用随机预览模式(随机选素材+随机截取片段)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
dict: {"plan": EditPlan, "clips": List[EditPlanClip]}
|
||||||
@@ -118,17 +115,7 @@ class PlanGeneratorService:
|
|||||||
|
|
||||||
# 4. 按 editing_mode 分配素材
|
# 4. 按 editing_mode 分配素材
|
||||||
if asset_ids:
|
if asset_ids:
|
||||||
# 如果是随机预览模式,获取素材时长信息
|
self._distribute_assets(clips, asset_ids, editing_mode)
|
||||||
asset_durations = None
|
|
||||||
if 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] = []
|
||||||
@@ -212,34 +199,9 @@ class PlanGeneratorService:
|
|||||||
clips: List[EditPlanClip],
|
clips: List[EditPlanClip],
|
||||||
asset_ids: List[str],
|
asset_ids: List[str],
|
||||||
editing_mode: str,
|
editing_mode: str,
|
||||||
*,
|
|
||||||
random_selection: bool = False,
|
|
||||||
asset_durations: dict[str, float] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
"""按 editing_mode 将素材分配到 clips(就地修改,未持久化).
|
||||||
|
|
||||||
委托给 plan_generator_utils.distribute_assets 纯函数。
|
委托给 plan_generator_utils.distribute_assets 纯函数。
|
||||||
"""
|
"""
|
||||||
distribute_assets(
|
distribute_assets(clips, asset_ids, editing_mode)
|
||||||
clips,
|
|
||||||
asset_ids,
|
|
||||||
editing_mode,
|
|
||||||
random_selection=random_selection,
|
|
||||||
asset_durations=asset_durations,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
|
||||||
"""从数据库获取素材时长信息.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
asset_ids: 素材 ID 列表
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 素材 ID -> 时长(秒)映射
|
|
||||||
"""
|
|
||||||
durations: dict[str, float] = {}
|
|
||||||
for asset_id in asset_ids:
|
|
||||||
asset = self._asset_repo.get(asset_id)
|
|
||||||
if asset and hasattr(asset, "duration"):
|
|
||||||
durations[asset_id] = float(asset.duration or 0.0)
|
|
||||||
return durations
|
|
||||||
|
|||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
"""SmartAssetSelector — 智能素材选择服务.
|
||||||
|
|
||||||
|
根据多维度评分从素材库中自动选择最优视频素材,
|
||||||
|
用于一键生成等需要自动选取素材的场景。
|
||||||
|
|
||||||
|
评分维度(加权求和,总分 0-1):
|
||||||
|
- 质量分(quality_score):权重 0.5 — 来自人工或AI的质量评分
|
||||||
|
- 分辨率适配:权重 0.2 — 分辨率越接近 1080p 得分越高
|
||||||
|
- 时长合理性:权重 0.2 — 3-30 秒区间最佳,过短/过长扣分
|
||||||
|
- 码率质量:权重 0.1 — 用文件大小/时长估算,码率适中得分高
|
||||||
|
|
||||||
|
特性:
|
||||||
|
- 最低质量分门槛:自动过滤低质量素材
|
||||||
|
- 时长多样性:保证选出的素材时长分布均匀(短/中/长各占一定比例)
|
||||||
|
- 兼容全部模式:素材库模式和项目模式都可用
|
||||||
|
|
||||||
|
纯逻辑部分已抽离到 packages.domain.asset_scoring。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from packages.domain.asset_scoring import MEDIUM_BUCKET_MAX as _MEDIUM_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||||
|
from packages.domain.asset_scoring import SHORT_BUCKET_MAX as _SHORT_BUCKET_MAX # noqa: F401 - re-export for tests
|
||||||
|
from packages.domain.asset_scoring import (
|
||||||
|
AssetScoreDetail,
|
||||||
|
SmartSelectResult,
|
||||||
|
diverse_selection,
|
||||||
|
filter_candidates,
|
||||||
|
score_asset_detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SmartAssetSelector:
|
||||||
|
"""智能素材选择器.
|
||||||
|
|
||||||
|
从一组素材中按综合评分选择最优的 N 个,
|
||||||
|
同时保证时长分布的多样性。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
min_quality_score: float = 30.0,
|
||||||
|
target_width: int = 1920,
|
||||||
|
target_height: int = 1080,
|
||||||
|
):
|
||||||
|
self.min_quality_score = min_quality_score
|
||||||
|
self.target_width = target_width
|
||||||
|
self.target_height = target_height
|
||||||
|
|
||||||
|
# ── 公开方法 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def select(
|
||||||
|
self,
|
||||||
|
assets: list,
|
||||||
|
count: int = 0,
|
||||||
|
*,
|
||||||
|
ensure_diversity: bool = True,
|
||||||
|
) -> SmartSelectResult:
|
||||||
|
"""从素材列表中智能选择最优素材.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
assets: Asset 实体列表(需要有 id/quality_score/width/height/duration/file_size 属性)
|
||||||
|
count: 选取数量,0 表示全部符合条件的
|
||||||
|
ensure_diversity: 是否保证时长多样性(默认开启)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SmartSelectResult 选择结果
|
||||||
|
"""
|
||||||
|
# 1. 过滤:只保留 ready 状态的视频素材 + 最低质量分门槛
|
||||||
|
candidates, filtered_out = filter_candidates(assets, self.min_quality_score)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return SmartSelectResult(
|
||||||
|
selected_ids=[],
|
||||||
|
total_candidates=0,
|
||||||
|
filtered_out=filtered_out,
|
||||||
|
avg_score=0.0,
|
||||||
|
details=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 对每个候选素材评分
|
||||||
|
scored: list[AssetScoreDetail] = []
|
||||||
|
for asset in candidates:
|
||||||
|
detail = score_asset_detail(
|
||||||
|
asset_id=asset.id,
|
||||||
|
quality=getattr(asset, "quality_score", None),
|
||||||
|
width=getattr(asset, "width", None),
|
||||||
|
height=getattr(asset, "height", None),
|
||||||
|
duration=getattr(asset, "duration", None),
|
||||||
|
file_size=getattr(asset, "file_size", 0) or 0,
|
||||||
|
target_width=self.target_width,
|
||||||
|
target_height=self.target_height,
|
||||||
|
)
|
||||||
|
scored.append(detail)
|
||||||
|
|
||||||
|
# 3. 按总分降序排列
|
||||||
|
scored.sort(key=lambda d: d.total_score, reverse=True)
|
||||||
|
|
||||||
|
# 4. 多样性选择(如果需要且数量有限制)
|
||||||
|
if ensure_diversity and count > 0 and len(scored) > count:
|
||||||
|
selected = diverse_selection(scored, count)
|
||||||
|
else:
|
||||||
|
# 无数量限制或不要求多样性,直接按排名取
|
||||||
|
selected = scored if count <= 0 else scored[:count]
|
||||||
|
|
||||||
|
avg_score = sum(d.total_score for d in selected) / len(selected) if selected else 0.0
|
||||||
|
|
||||||
|
result = SmartSelectResult(
|
||||||
|
selected_ids=[d.asset_id for d in selected],
|
||||||
|
total_candidates=len(candidates),
|
||||||
|
filtered_out=filtered_out,
|
||||||
|
avg_score=avg_score,
|
||||||
|
details=selected,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"智能素材选择完成: 候选=%d, 过滤=%d, 选中=%d, 平均分=%.3f",
|
||||||
|
result.total_candidates,
|
||||||
|
result.filtered_out,
|
||||||
|
len(result.selected_ids),
|
||||||
|
result.avg_score,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ── 向后兼容:私有方法别名(委托给 asset_scoring 纯函数) ────────────────
|
||||||
|
|
||||||
|
def _score_asset(self, asset) -> AssetScoreDetail:
|
||||||
|
"""对单个素材进行多维度评分(向后兼容)."""
|
||||||
|
return score_asset_detail(
|
||||||
|
asset_id=asset.id,
|
||||||
|
quality=getattr(asset, "quality_score", None),
|
||||||
|
width=getattr(asset, "width", None),
|
||||||
|
height=getattr(asset, "height", None),
|
||||||
|
duration=getattr(asset, "duration", None),
|
||||||
|
file_size=getattr(asset, "file_size", 0) or 0,
|
||||||
|
target_width=self.target_width,
|
||||||
|
target_height=self.target_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _score_resolution(self, width: int | None, height: int | None) -> float:
|
||||||
|
"""分辨率评分(向后兼容)."""
|
||||||
|
from packages.domain.asset_scoring import score_resolution
|
||||||
|
|
||||||
|
return score_resolution(width, height, self.target_width, self.target_height)
|
||||||
|
|
||||||
|
def _score_duration(self, duration: float | None) -> float:
|
||||||
|
"""时长评分(向后兼容)."""
|
||||||
|
from packages.domain.asset_scoring import score_duration
|
||||||
|
|
||||||
|
return score_duration(duration)
|
||||||
|
|
||||||
|
def _score_bitrate(self, file_size: int, duration: float | None) -> float:
|
||||||
|
"""码率评分(向后兼容)."""
|
||||||
|
from packages.domain.asset_scoring import score_bitrate
|
||||||
|
|
||||||
|
return score_bitrate(file_size, duration)
|
||||||
|
|
||||||
|
def _diverse_selection(self, scored: list[AssetScoreDetail], count: int) -> list[AssetScoreDetail]:
|
||||||
|
"""多样性选择(向后兼容)."""
|
||||||
|
return diverse_selection(scored, count)
|
||||||
@@ -196,33 +196,24 @@ 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: 120_000 })
|
|
||||||
await page.getByRole("button", { name: "下一步" }).click()
|
|
||||||
|
|
||||||
// Step 5: title
|
|
||||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible({ timeout: 15000 })
|
|
||||||
// 如果 AI 自动选择标题模式开启,先切换到手动模式以显示输入框
|
|
||||||
const aiSwitch = page.locator(".xx-title-ai-toggle .xx-switch.active")
|
|
||||||
if (await aiSwitch.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
||||||
await aiSwitch.click()
|
|
||||||
// 等待输入框出现(条件渲染,需要等待 DOM 更新)
|
|
||||||
await expect(page.getByPlaceholder("输入或从标题库选择…")).toBeVisible({ timeout: 5000 })
|
|
||||||
}
|
|
||||||
const titleText = `E2E Test ${suffix}`
|
const titleText = `E2E Test ${suffix}`
|
||||||
await page.getByPlaceholder("输入或从标题库选择…").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
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
esbuild: set this to true or false
|
|
||||||
@@ -49,17 +49,6 @@ export const getAssetsByKind = async (
|
|||||||
return response.data.items || []
|
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) */
|
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||||
export const createAsset = async (data: {
|
export const createAsset = async (data: {
|
||||||
library_id: string
|
library_id: string
|
||||||
|
|||||||
@@ -29,11 +29,10 @@ export {
|
|||||||
deleteAssetLibrary,
|
deleteAssetLibrary,
|
||||||
} from "./libraries"
|
} from "./libraries"
|
||||||
|
|
||||||
// 素材 CRUD + 智能匹配
|
// 素材 CRUD
|
||||||
export {
|
export {
|
||||||
getAssets,
|
getAssets,
|
||||||
getAssetsByKind,
|
getAssetsByKind,
|
||||||
smartMatchAssets,
|
|
||||||
createAsset,
|
createAsset,
|
||||||
updateAsset,
|
updateAsset,
|
||||||
updateAssetReviewStatus,
|
updateAssetReviewStatus,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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,8 +0,0 @@
|
|||||||
export type {
|
|
||||||
PreviewStatus,
|
|
||||||
CreatePreviewRequest,
|
|
||||||
CreatePreviewResponse,
|
|
||||||
PreviewTaskResponse,
|
|
||||||
} from "./types"
|
|
||||||
|
|
||||||
export { createPreview, getPreviewStatus } from "./preview"
|
|
||||||
@@ -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,47 +0,0 @@
|
|||||||
/** 预览任务状态 */
|
|
||||||
export type PreviewStatus = "pending" | "generating" | "completed" | "failed" | "cancelled"
|
|
||||||
|
|
||||||
/** 创建预览任务请求 */
|
|
||||||
export interface CreatePreviewRequest {
|
|
||||||
template_id: string
|
|
||||||
asset_ids: string[]
|
|
||||||
title_ids?: string[]
|
|
||||||
voice_ids?: 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
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ const mapAssetToMediaAsset = (asset: AssetItem): MediaAsset => {
|
|||||||
id: asset.id,
|
id: asset.id,
|
||||||
name: asset.name,
|
name: asset.name,
|
||||||
type: inferMediaType(asset.mime_type || ""),
|
type: inferMediaType(asset.mime_type || ""),
|
||||||
source_url: asset.file_url,
|
|
||||||
thumbnail_url: asset.thumbnail_url,
|
thumbnail_url: asset.thumbnail_url,
|
||||||
duration: asset.duration ?? metaDuration,
|
duration: asset.duration ?? metaDuration,
|
||||||
size: asset.file_size ?? undefined,
|
size: asset.file_size ?? undefined,
|
||||||
|
|||||||
@@ -424,8 +424,6 @@ export interface MediaAsset {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
type: "video" | "image" | "audio"
|
type: "video" | "image" | "audio"
|
||||||
/** 源文件 URL(视频/原图),用于悬浮预览播放 */
|
|
||||||
source_url?: string
|
|
||||||
/** 缩略图 URL */
|
/** 缩略图 URL */
|
||||||
thumbnail_url?: string
|
thumbnail_url?: string
|
||||||
/** 时长(秒),仅 video/audio */
|
/** 时长(秒),仅 video/audio */
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
VoiceCloneListParams,
|
VoiceCloneListParams,
|
||||||
ListVoiceCloneResponse,
|
ListVoiceCloneResponse,
|
||||||
VoiceCloneStatusResponse,
|
VoiceCloneStatusResponse,
|
||||||
VoiceClonePreviewResponse,
|
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||||
@@ -86,17 +85,3 @@ export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> =>
|
|||||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取克隆音色试听音频(实时 TTS 合成) */
|
|
||||||
export const getVoiceClonePreview = async (
|
|
||||||
cloneId: string,
|
|
||||||
text?: string,
|
|
||||||
): Promise<VoiceClonePreviewResponse> => {
|
|
||||||
const searchParams = new URLSearchParams()
|
|
||||||
if (text) searchParams.set("text", text)
|
|
||||||
const qs = searchParams.toString()
|
|
||||||
const response = await apiClient.get<VoiceClonePreviewResponse>(
|
|
||||||
`/voice-clones/${cloneId}/preview${qs ? `?${qs}` : ""}`,
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ export type {
|
|||||||
VoiceCloneStatusResponse,
|
VoiceCloneStatusResponse,
|
||||||
CreateVoiceCloneRequestFull,
|
CreateVoiceCloneRequestFull,
|
||||||
VoiceCloneListParams,
|
VoiceCloneListParams,
|
||||||
VoiceClonePreviewResponse,
|
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
@@ -30,5 +29,4 @@ export {
|
|||||||
updateVoiceClone,
|
updateVoiceClone,
|
||||||
getVoiceCloneStatus,
|
getVoiceCloneStatus,
|
||||||
retryVoiceClone,
|
retryVoiceClone,
|
||||||
getVoiceClonePreview,
|
|
||||||
} from "./clones"
|
} from "./clones"
|
||||||
|
|||||||
@@ -90,24 +90,3 @@ export interface VoiceCloneListParams {
|
|||||||
skip?: number
|
skip?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 克隆音色试听响应 */
|
|
||||||
export interface VoiceClonePreviewResponse {
|
|
||||||
clone_id: string
|
|
||||||
/** 音色克隆档案 ID */
|
|
||||||
|
|
||||||
voice_id: string
|
|
||||||
/** CosyVoice 音色 ID */
|
|
||||||
|
|
||||||
audio_url: string
|
|
||||||
/** 试听音频 URL */
|
|
||||||
|
|
||||||
text: string
|
|
||||||
/** 试听文本 */
|
|
||||||
|
|
||||||
duration: number
|
|
||||||
/** 音频时长(秒) */
|
|
||||||
|
|
||||||
file_size: number
|
|
||||||
/** 文件大小(字节) */
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -456,8 +456,7 @@
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.as-preview-overlay-thumb img,
|
.as-preview-overlay-thumb img {
|
||||||
.as-preview-overlay-thumb video {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|||||||
@@ -12,16 +12,7 @@ const PreviewOverlay: React.FC<PreviewOverlayProps> = ({ asset, position }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
|
<div className="as-preview-overlay" style={{ left: position.x, top: position.y }}>
|
||||||
<div className="as-preview-overlay-thumb">
|
<div className="as-preview-overlay-thumb">
|
||||||
{asset.type === "video" && asset.source_url ? (
|
{asset.thumbnail_url ? (
|
||||||
<video
|
|
||||||
src={asset.source_url}
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
playsInline
|
|
||||||
poster={asset.thumbnail_url}
|
|
||||||
/>
|
|
||||||
) : asset.thumbnail_url ? (
|
|
||||||
<img src={asset.thumbnail_url} alt={asset.name} />
|
<img src={asset.thumbnail_url} alt={asset.name} />
|
||||||
) : (
|
) : (
|
||||||
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
<span className="as-preview-overlay-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||||
|
|||||||
+83
-20
@@ -1,20 +1,84 @@
|
|||||||
import React from "react"
|
import React, { useState, useEffect, useRef } from "react"
|
||||||
import { Modal, Tabs, Form, Input, Button } from "antd"
|
import { Modal, Tabs, Form, Input, Button, message } from "antd"
|
||||||
import { useBindContactForm } from "./hooks/useBindContactForm"
|
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
||||||
import type { BindContactModalProps } from "./types"
|
|
||||||
|
interface BindContactModalProps {
|
||||||
|
open: boolean
|
||||||
|
onSuccess?: (user: BindContactResponse["user"]) => void
|
||||||
|
onCancel?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, onCancel }) => {
|
||||||
const {
|
const [activeTab, setActiveTab] = useState<"email" | "phone">("email")
|
||||||
activeTab,
|
const [form] = Form.useForm()
|
||||||
setActiveTab,
|
const [loading, setLoading] = useState(false)
|
||||||
form,
|
const [codeLoading, setCodeLoading] = useState(false)
|
||||||
loading,
|
const [countdown, setCountdown] = useState(0)
|
||||||
codeLoading,
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
countdown,
|
|
||||||
codeDisabled,
|
useEffect(() => {
|
||||||
handleSendCode,
|
if (countdown > 0) {
|
||||||
handleSubmit,
|
timerRef.current = setInterval(() => {
|
||||||
} = useBindContactForm({ open, onSuccess, onCancel })
|
setCountdown((prev) => prev - 1)
|
||||||
|
}, 1000)
|
||||||
|
} else if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current)
|
||||||
|
timerRef.current = null
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [countdown])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.resetFields()
|
||||||
|
setCountdown(0)
|
||||||
|
}
|
||||||
|
}, [open, form])
|
||||||
|
|
||||||
|
const handleSendCode = async () => {
|
||||||
|
try {
|
||||||
|
const value = form.getFieldValue(activeTab === "email" ? "email" : "phone")
|
||||||
|
if (!value) {
|
||||||
|
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCodeLoading(true)
|
||||||
|
await sendVerificationCode({
|
||||||
|
target: activeTab,
|
||||||
|
value,
|
||||||
|
purpose: "bind",
|
||||||
|
})
|
||||||
|
message.success("验证码已发送")
|
||||||
|
setCountdown(60)
|
||||||
|
} catch (error) {
|
||||||
|
// error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
setCodeLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
const payload =
|
||||||
|
activeTab === "email"
|
||||||
|
? { email: values.email, email_code: values.code }
|
||||||
|
: { phone: values.phone, phone_code: values.code }
|
||||||
|
|
||||||
|
const result = await bindContact(payload)
|
||||||
|
|
||||||
|
message.success("绑定成功")
|
||||||
|
onSuccess?.(result.user)
|
||||||
|
} catch (error) {
|
||||||
|
// error handled by interceptor
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -47,7 +111,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
<Input placeholder="请输入邮箱地址" size="large" />
|
<Input placeholder="请输入邮箱地址" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="email_code"
|
name="code"
|
||||||
label="验证码"
|
label="验证码"
|
||||||
rules={[{ required: true, message: "请输入验证码" }]}
|
rules={[{ required: true, message: "请输入验证码" }]}
|
||||||
>
|
>
|
||||||
@@ -57,7 +121,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={handleSendCode}
|
onClick={handleSendCode}
|
||||||
loading={codeLoading}
|
loading={codeLoading}
|
||||||
disabled={codeDisabled}
|
disabled={countdown > 0}
|
||||||
>
|
>
|
||||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -82,7 +146,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
<Input placeholder="请输入手机号" size="large" />
|
<Input placeholder="请输入手机号" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="phone_code"
|
name="code"
|
||||||
label="验证码"
|
label="验证码"
|
||||||
rules={[{ required: true, message: "请输入验证码" }]}
|
rules={[{ required: true, message: "请输入验证码" }]}
|
||||||
>
|
>
|
||||||
@@ -92,7 +156,7 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={handleSendCode}
|
onClick={handleSendCode}
|
||||||
loading={codeLoading}
|
loading={codeLoading}
|
||||||
disabled={codeDisabled}
|
disabled={countdown > 0}
|
||||||
>
|
>
|
||||||
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
{countdown > 0 ? `${countdown}s 后重发` : "发送验证码"}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -114,4 +178,3 @@ const BindContactModal: React.FC<BindContactModalProps> = ({ open, onSuccess, on
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default BindContactModal
|
export default BindContactModal
|
||||||
export type { BindContactModalProps } from "./types"
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState, useEffect } from "react"
|
|
||||||
import { Form, message } from "antd"
|
|
||||||
import { sendVerificationCode, bindContact, type BindContactResponse } from "@/api/auth"
|
|
||||||
import { useCountdown } from "./useCountdown"
|
|
||||||
|
|
||||||
type ContactType = "email" | "phone"
|
|
||||||
|
|
||||||
interface UseBindContactFormOptions {
|
|
||||||
open: boolean
|
|
||||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
|
||||||
onCancel?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定联系方式表单 Hook
|
|
||||||
* 封装表单状态、验证码发送、提交绑定等业务逻辑
|
|
||||||
*/
|
|
||||||
export const useBindContactForm = ({ open, onSuccess }: UseBindContactFormOptions) => {
|
|
||||||
const [activeTab, setActiveTab] = useState<ContactType>("email")
|
|
||||||
const [form] = Form.useForm()
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [codeLoading, setCodeLoading] = useState(false)
|
|
||||||
const { countdown, isRunning, start: startCountdown, reset: resetCountdown } = useCountdown(60)
|
|
||||||
|
|
||||||
/* ── 打开时重置表单 ── */
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
form.resetFields()
|
|
||||||
resetCountdown()
|
|
||||||
}
|
|
||||||
}, [open, form, resetCountdown])
|
|
||||||
|
|
||||||
/* ── 发送验证码 ── */
|
|
||||||
const handleSendCode = async () => {
|
|
||||||
try {
|
|
||||||
const fieldName = activeTab === "email" ? "email" : "phone"
|
|
||||||
const value = form.getFieldValue(fieldName)
|
|
||||||
if (!value) {
|
|
||||||
message.warning(activeTab === "email" ? "请输入邮箱" : "请输入手机号")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setCodeLoading(true)
|
|
||||||
await sendVerificationCode({
|
|
||||||
target: activeTab,
|
|
||||||
value,
|
|
||||||
purpose: "bind",
|
|
||||||
})
|
|
||||||
message.success("验证码已发送")
|
|
||||||
startCountdown()
|
|
||||||
} catch {
|
|
||||||
// error handled by interceptor
|
|
||||||
} finally {
|
|
||||||
setCodeLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 提交绑定 ── */
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
// 1. 表单验证(失败时 Ant Design Form 会自动展示错误信息)
|
|
||||||
let values
|
|
||||||
try {
|
|
||||||
values = await form.validateFields()
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 调用绑定接口
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const payload =
|
|
||||||
activeTab === "email"
|
|
||||||
? { email: values.email, email_code: values.email_code }
|
|
||||||
: { phone: values.phone, phone_code: values.phone_code }
|
|
||||||
|
|
||||||
const result = await bindContact(payload)
|
|
||||||
|
|
||||||
message.success("绑定成功")
|
|
||||||
onSuccess?.(result.user)
|
|
||||||
} catch {
|
|
||||||
// API 错误由全局拦截器统一处理提示
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeTab,
|
|
||||||
setActiveTab,
|
|
||||||
form,
|
|
||||||
loading,
|
|
||||||
codeLoading,
|
|
||||||
countdown,
|
|
||||||
codeDisabled: isRunning,
|
|
||||||
handleSendCode,
|
|
||||||
handleSubmit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 倒计时 Hook
|
|
||||||
* 用于验证码发送等场景的倒计时控制
|
|
||||||
*/
|
|
||||||
export const useCountdown = (initialSeconds = 60) => {
|
|
||||||
const [countdown, setCountdown] = useState(0)
|
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (countdown > 0) {
|
|
||||||
timerRef.current = setInterval(() => {
|
|
||||||
setCountdown((prev) => prev - 1)
|
|
||||||
}, 1000)
|
|
||||||
} else if (timerRef.current) {
|
|
||||||
clearInterval(timerRef.current)
|
|
||||||
timerRef.current = null
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current)
|
|
||||||
}
|
|
||||||
}, [countdown])
|
|
||||||
|
|
||||||
const start = useCallback(() => {
|
|
||||||
setCountdown(initialSeconds)
|
|
||||||
}, [initialSeconds])
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setCountdown(0)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
countdown,
|
|
||||||
isRunning: countdown > 0,
|
|
||||||
start,
|
|
||||||
reset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { BindContactResponse } from "@/api/auth"
|
|
||||||
|
|
||||||
/** BindContactModal 组件属性 */
|
|
||||||
export interface BindContactModalProps {
|
|
||||||
/** 是否打开 */
|
|
||||||
open: boolean
|
|
||||||
/** 绑定成功回调 */
|
|
||||||
onSuccess?: (user: BindContactResponse["user"]) => void
|
|
||||||
/** 取消回调 */
|
|
||||||
onCancel?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 联系方式类型 */
|
|
||||||
export type ContactType = "email" | "phone"
|
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* PageHead - 页面头部组件(Task 1.4)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 页面标题展示
|
||||||
|
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
||||||
|
* - 右侧操作按钮区(slot,由页面自行填充)
|
||||||
|
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
||||||
|
*
|
||||||
|
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||||
|
* 补充面包屑、操作区等扩展样式。
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||||
|
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||||
|
import "./PageHead.css"
|
||||||
|
|
||||||
|
/* ── 类型定义 ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** 面包屑项 */
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
/** 显示文字 */
|
||||||
|
label: string
|
||||||
|
/** 路由路径,不传则为当前页(不可点击) */
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PageHead 组件属性 */
|
||||||
|
export interface PageHeadProps {
|
||||||
|
/** 页面标题 */
|
||||||
|
title: string
|
||||||
|
/** 页面描述(可选,显示在标题下方) */
|
||||||
|
description?: React.ReactNode
|
||||||
|
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||||
|
breadcrumb?: BreadcrumbItem[]
|
||||||
|
/** 右侧操作区内容(按钮等) */
|
||||||
|
actions?: React.ReactNode
|
||||||
|
/** 是否隐藏面包屑 */
|
||||||
|
hideBreadcrumb?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
|
||||||
|
|
||||||
|
const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||||
|
"/app/dashboard": "首页",
|
||||||
|
"/app/generate": "智能剪辑",
|
||||||
|
"/app/assets": "视频库",
|
||||||
|
"/app/voices": "配音库",
|
||||||
|
"/app/titles": "标题库",
|
||||||
|
"/app/products": "成片库",
|
||||||
|
"/app/templates": "模板库",
|
||||||
|
"/app/history": "任务历史",
|
||||||
|
"/app/admin": "控制台",
|
||||||
|
"/app/admin/users": "用户管理",
|
||||||
|
"/app/admin/analytics": "数据分析",
|
||||||
|
"/app/admin/monitor": "系统监控",
|
||||||
|
"/app/admin/logs": "系统日志",
|
||||||
|
"/app/subscription": "订阅管理",
|
||||||
|
"/app/subscription/upgrade": "升级订阅",
|
||||||
|
"/app/subscription/billing": "账单管理",
|
||||||
|
"/app/profile": "个人设置",
|
||||||
|
"/app/editing-planner": "模板制作",
|
||||||
|
"/app/my-templates": "我的模板",
|
||||||
|
"/app/voice-clone": "我的音色",
|
||||||
|
"/app/voice-materials": "配音库",
|
||||||
|
"/app/accounts": "账号管理",
|
||||||
|
"/app/duplication": "查重",
|
||||||
|
"/app/duplication/results": "查重结果",
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 自动生成面包屑 ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
/** 根据当前路径生成面包屑 */
|
||||||
|
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||||
|
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||||
|
|
||||||
|
// 首页本身不需要面包屑
|
||||||
|
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐级拆分路径,生成中间层级
|
||||||
|
const segments = pathname.split("/").filter(Boolean)
|
||||||
|
let currentPath = ""
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
currentPath += `/${segments[i]}`
|
||||||
|
const title = ROUTE_TITLE_MAP[currentPath]
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
// 最后一级不带 path(当前页面,不可点击)
|
||||||
|
const isLast = i === segments.length - 1
|
||||||
|
items.push({
|
||||||
|
label: title,
|
||||||
|
path: isLast ? undefined : currentPath,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 动态路由段(如 :id),用路径片段做 label
|
||||||
|
const isLast = i === segments.length - 1
|
||||||
|
items.push({
|
||||||
|
label: segments[i],
|
||||||
|
path: isLast ? undefined : currentPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 组件 ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const PageHead: React.FC<PageHeadProps> = ({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
breadcrumb,
|
||||||
|
actions,
|
||||||
|
hideBreadcrumb = false,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
// 使用传入的面包屑或自动生成
|
||||||
|
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||||
|
|
||||||
|
// 首页不显示面包屑
|
||||||
|
const showBreadcrumb =
|
||||||
|
!hideBreadcrumb &&
|
||||||
|
breadcrumbItems.length > 1 &&
|
||||||
|
location.pathname !== "/app" &&
|
||||||
|
location.pathname !== "/app/dashboard"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="xx-page-head">
|
||||||
|
<div className="xx-page-head-left">
|
||||||
|
{/* 面包屑导航 */}
|
||||||
|
{showBreadcrumb && (
|
||||||
|
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||||
|
<ol>
|
||||||
|
{breadcrumbItems.map((item, index) => {
|
||||||
|
const isLast = index === breadcrumbItems.length - 1
|
||||||
|
return (
|
||||||
|
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||||
|
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||||
|
{item.path && !isLast ? (
|
||||||
|
<Link
|
||||||
|
to={item.path}
|
||||||
|
className="xx-page-breadcrumb-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
navigate(item.path!)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||||
|
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标题 + 描述 */}
|
||||||
|
<div className="xx-page-head-title">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
{description && <p>{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧操作区 */}
|
||||||
|
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageHead
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/** 路由 → 标题映射(用于自动生成面包屑) */
|
|
||||||
export const ROUTE_TITLE_MAP: Record<string, string> = {
|
|
||||||
"/app/dashboard": "首页",
|
|
||||||
"/app/generate": "智能剪辑",
|
|
||||||
"/app/assets": "视频库",
|
|
||||||
"/app/voices": "配音库",
|
|
||||||
"/app/titles": "标题库",
|
|
||||||
"/app/products": "成片库",
|
|
||||||
"/app/templates": "模板库",
|
|
||||||
"/app/history": "任务历史",
|
|
||||||
"/app/admin": "控制台",
|
|
||||||
"/app/admin/users": "用户管理",
|
|
||||||
"/app/admin/analytics": "数据分析",
|
|
||||||
"/app/admin/monitor": "系统监控",
|
|
||||||
"/app/admin/logs": "系统日志",
|
|
||||||
"/app/subscription": "订阅管理",
|
|
||||||
"/app/subscription/upgrade": "升级订阅",
|
|
||||||
"/app/subscription/billing": "账单管理",
|
|
||||||
"/app/profile": "个人设置",
|
|
||||||
"/app/editing-planner": "模板制作",
|
|
||||||
"/app/my-templates": "我的模板",
|
|
||||||
"/app/voice-clone": "我的音色",
|
|
||||||
"/app/voice-materials": "配音库",
|
|
||||||
"/app/accounts": "账号管理",
|
|
||||||
"/app/duplication": "查重",
|
|
||||||
"/app/duplication/results": "查重结果",
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
/**
|
|
||||||
* PageHead - 页面头部组件(Task 1.4)
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* - 页面标题展示
|
|
||||||
* - 面包屑导航(自动根据路由生成,也支持手动传入)
|
|
||||||
* - 右侧操作按钮区(slot,由页面自行填充)
|
|
||||||
* - 响应式:移动端简化布局(隐藏面包屑,缩小标题)
|
|
||||||
*
|
|
||||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
|
||||||
* 补充面包屑、操作区等扩展样式。
|
|
||||||
*/
|
|
||||||
import React from "react"
|
|
||||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
|
||||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
|
||||||
import type { PageHeadProps } from "./types"
|
|
||||||
import { generateBreadcrumb } from "./utils"
|
|
||||||
import "./PageHead.css"
|
|
||||||
|
|
||||||
const PageHead: React.FC<PageHeadProps> = ({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
breadcrumb,
|
|
||||||
actions,
|
|
||||||
hideBreadcrumb = false,
|
|
||||||
}) => {
|
|
||||||
const location = useLocation()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
|
|
||||||
// 使用传入的面包屑或自动生成
|
|
||||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
|
||||||
|
|
||||||
// 首页不显示面包屑
|
|
||||||
const showBreadcrumb =
|
|
||||||
!hideBreadcrumb &&
|
|
||||||
breadcrumbItems.length > 1 &&
|
|
||||||
location.pathname !== "/app" &&
|
|
||||||
location.pathname !== "/app/dashboard"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="xx-page-head">
|
|
||||||
<div className="xx-page-head-left">
|
|
||||||
{/* 面包屑导航 */}
|
|
||||||
{showBreadcrumb && (
|
|
||||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
|
||||||
<ol>
|
|
||||||
{breadcrumbItems.map((item, index) => {
|
|
||||||
const isLast = index === breadcrumbItems.length - 1
|
|
||||||
return (
|
|
||||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
|
||||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
|
||||||
{item.path && !isLast ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className="xx-page-breadcrumb-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
navigate(item.path!)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
|
||||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ol>
|
|
||||||
</nav>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 标题 + 描述 */}
|
|
||||||
<div className="xx-page-head-title">
|
|
||||||
<h2>{title}</h2>
|
|
||||||
{description && <p>{description}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧操作区 */}
|
|
||||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PageHead
|
|
||||||
export type { BreadcrumbItem, PageHeadProps } from "./types"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import type React from "react"
|
|
||||||
|
|
||||||
/** 面包屑项 */
|
|
||||||
export interface BreadcrumbItem {
|
|
||||||
/** 显示文字 */
|
|
||||||
label: string
|
|
||||||
/** 路由路径,不传则为当前页(不可点击) */
|
|
||||||
path?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PageHead 组件属性 */
|
|
||||||
export interface PageHeadProps {
|
|
||||||
/** 页面标题 */
|
|
||||||
title: string
|
|
||||||
/** 页面描述(可选,显示在标题下方) */
|
|
||||||
description?: React.ReactNode
|
|
||||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
|
||||||
breadcrumb?: BreadcrumbItem[]
|
|
||||||
/** 右侧操作区内容(按钮等) */
|
|
||||||
actions?: React.ReactNode
|
|
||||||
/** 是否隐藏面包屑 */
|
|
||||||
hideBreadcrumb?: boolean
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { ROUTE_TITLE_MAP } from "./constants"
|
|
||||||
import type { BreadcrumbItem } from "./types"
|
|
||||||
|
|
||||||
/** 根据当前路径生成面包屑 */
|
|
||||||
export const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
|
||||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
|
||||||
|
|
||||||
// 首页本身不需要面包屑
|
|
||||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
// 逐级拆分路径,生成中间层级
|
|
||||||
const segments = pathname.split("/").filter(Boolean)
|
|
||||||
let currentPath = ""
|
|
||||||
|
|
||||||
for (let i = 0; i < segments.length; i++) {
|
|
||||||
currentPath += `/${segments[i]}`
|
|
||||||
const title = ROUTE_TITLE_MAP[currentPath]
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
// 最后一级不带 path(当前页面,不可点击)
|
|
||||||
const isLast = i === segments.length - 1
|
|
||||||
items.push({
|
|
||||||
label: title,
|
|
||||||
path: isLast ? undefined : currentPath,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// 动态路由段(如 :id),用路径片段做 label
|
|
||||||
const isLast = i === segments.length - 1
|
|
||||||
items.push({
|
|
||||||
label: segments[i],
|
|
||||||
path: isLast ? undefined : currentPath,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* CloneModal — 音色克隆弹窗(共享组件)
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 步骤引导:上传/录制音频 → 填写信息 → 提交克隆
|
||||||
|
* - 两种输入方式:上传文件(wav/mp3/m4a,≤10MB)或浏览器原生录音(MediaRecorder)
|
||||||
|
* - 提交:先 uploadAsset 获取真实 URL,再 createVoiceClone
|
||||||
|
* - 进度展示:上传中 → 克隆中 → 完成(三阶段可视化)
|
||||||
|
* - 错误处理:上传失败、格式错误、大小超限、录音权限拒绝等提示
|
||||||
|
*
|
||||||
|
* V21 Design System — 零 antd 直接导入
|
||||||
|
*/
|
||||||
|
import React from "react"
|
||||||
|
import { Modal } from "@/components/ui"
|
||||||
|
import type { CloneModalProps } from "./types/cloneModal"
|
||||||
|
import useCloneModal from "./hooks/useCloneModal"
|
||||||
|
import InputView from "./clone-modal/InputView"
|
||||||
|
import ProgressView from "./clone-modal/ProgressView"
|
||||||
|
import "./clone-modal.css"
|
||||||
|
|
||||||
|
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||||
|
const {
|
||||||
|
phase,
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
|
selectedFile,
|
||||||
|
dragActive,
|
||||||
|
errorMessage,
|
||||||
|
isRecording,
|
||||||
|
recordTime,
|
||||||
|
recordedBlob,
|
||||||
|
canSubmit,
|
||||||
|
isProcessing,
|
||||||
|
setVoiceName,
|
||||||
|
setVoiceDescription,
|
||||||
|
setDragActive,
|
||||||
|
handleFileSelect,
|
||||||
|
handleRecordToggle,
|
||||||
|
handleClose,
|
||||||
|
handleSubmit,
|
||||||
|
} = useCloneModal({ open, onClose, onSuccess })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
title="克隆我的音色"
|
||||||
|
width={560}
|
||||||
|
footer={null}
|
||||||
|
destroyOnClose
|
||||||
|
maskClosable={!isProcessing}
|
||||||
|
keyboard={!isProcessing}
|
||||||
|
>
|
||||||
|
{/* 输入阶段 */}
|
||||||
|
{phase === "input" && (
|
||||||
|
<InputView
|
||||||
|
voiceName={voiceName}
|
||||||
|
voiceDescription={voiceDescription}
|
||||||
|
selectedFile={selectedFile}
|
||||||
|
dragActive={dragActive}
|
||||||
|
isRecording={isRecording}
|
||||||
|
recordTime={recordTime}
|
||||||
|
recordedBlob={recordedBlob}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
canSubmit={canSubmit}
|
||||||
|
onVoiceNameChange={setVoiceName}
|
||||||
|
onVoiceDescChange={setVoiceDescription}
|
||||||
|
onDragActiveChange={setDragActive}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onRecordToggle={handleRecordToggle}
|
||||||
|
onClose={handleClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 进度 / 完成阶段 */}
|
||||||
|
{phase !== "input" && <ProgressView phase={phase} />}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CloneModal
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { ProgressStep } from "./types"
|
|
||||||
|
|
||||||
/** 进度阶段配置 */
|
|
||||||
export const PROGRESS_STEPS: ProgressStep[] = [
|
|
||||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
|
||||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
|
||||||
{ key: "done", label: "完成", icon: "✅" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 支持的音频扩展名 */
|
|
||||||
export const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a", "webm"]
|
|
||||||
|
|
||||||
/** 文件选择器 accept 属性 */
|
|
||||||
export const ACCEPTED_MIME = ".mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm"
|
|
||||||
|
|
||||||
/** 最大文件大小:10MB */
|
|
||||||
export const MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
||||||
|
|
||||||
/** 最长录制时长:5 分钟(秒) */
|
|
||||||
export const MAX_RECORD_SECONDS = 5 * 60
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react"
|
|
||||||
import { MAX_RECORD_SECONDS } from "../constants"
|
|
||||||
import { formatRecordTime } from "../utils"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 录音 Hook
|
|
||||||
* 封装浏览器 MediaRecorder 录音逻辑,含计时、自动停止、时长上限
|
|
||||||
*/
|
|
||||||
export const useAudioRecorder = (onError?: (msg: string) => void) => {
|
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
|
||||||
const [recordTime, setRecordTime] = useState(0)
|
|
||||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
|
||||||
|
|
||||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
|
||||||
const audioChunksRef = useRef<Blob[]>([])
|
|
||||||
const isMountedRef = useRef(true)
|
|
||||||
const isStartingRef = useRef(false)
|
|
||||||
|
|
||||||
/** 重置录音状态 */
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
setIsRecording(false)
|
|
||||||
setRecordTime(0)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
audioChunksRef.current = []
|
|
||||||
isStartingRef.current = false
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
mediaRecorderRef.current = null
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 开始/停止录制 */
|
|
||||||
const toggleRecord = useCallback(async () => {
|
|
||||||
if (isRecording) {
|
|
||||||
// 停止录制
|
|
||||||
setIsRecording(false)
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 防止快速双击导致重复启动
|
|
||||||
if (isStartingRef.current) return
|
|
||||||
isStartingRef.current = true
|
|
||||||
|
|
||||||
// 开始录制
|
|
||||||
try {
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({
|
|
||||||
audio: true,
|
|
||||||
})
|
|
||||||
if (!isMountedRef.current) {
|
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
|
||||||
isStartingRef.current = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const mediaRecorder = new MediaRecorder(stream)
|
|
||||||
mediaRecorderRef.current = mediaRecorder
|
|
||||||
audioChunksRef.current = []
|
|
||||||
|
|
||||||
mediaRecorder.ondataavailable = (event) => {
|
|
||||||
if (event.data.size > 0) {
|
|
||||||
audioChunksRef.current.push(event.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder.onstop = () => {
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器兼容性问题
|
|
||||||
const mimeType = mediaRecorder.mimeType || "audio/webm"
|
|
||||||
const blob = new Blob(audioChunksRef.current, { type: mimeType })
|
|
||||||
setRecordedBlob(blob)
|
|
||||||
// 停止音轨
|
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
|
||||||
isStartingRef.current = false
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaRecorder.start()
|
|
||||||
setIsRecording(true)
|
|
||||||
setRecordTime(0)
|
|
||||||
setRecordedBlob(null)
|
|
||||||
|
|
||||||
recordTimerRef.current = setInterval(() => {
|
|
||||||
setRecordTime((prev) => {
|
|
||||||
const next = prev + 1
|
|
||||||
if (next >= MAX_RECORD_SECONDS) {
|
|
||||||
// 达到时长上限,自动停止
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
setIsRecording(false)
|
|
||||||
if (recordTimerRef.current) {
|
|
||||||
clearInterval(recordTimerRef.current)
|
|
||||||
recordTimerRef.current = null
|
|
||||||
}
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
onError?.("已达最长录制时长(5分钟),已自动停止")
|
|
||||||
}, 0)
|
|
||||||
return MAX_RECORD_SECONDS
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
} catch {
|
|
||||||
isStartingRef.current = false
|
|
||||||
onError?.("无法访问麦克风,请检查浏览器权限设置")
|
|
||||||
}
|
|
||||||
}, [isRecording, onError])
|
|
||||||
|
|
||||||
// 组件卸载时清理
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false
|
|
||||||
isStartingRef.current = false
|
|
||||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
|
||||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
|
||||||
mediaRecorderRef.current.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
isRecording,
|
|
||||||
recordTime,
|
|
||||||
recordedBlob,
|
|
||||||
formattedTime: formatRecordTime(recordTime),
|
|
||||||
toggleRecord,
|
|
||||||
reset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,460 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
|
||||||
import { Modal, Button } from "@/components/ui"
|
|
||||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
|
||||||
import { uploadAsset } from "@/api/assets"
|
|
||||||
import { PROGRESS_STEPS, ACCEPTED_MIME } from "./constants"
|
|
||||||
import { validateFile } from "./utils"
|
|
||||||
import { useAudioRecorder } from "./hooks/useAudioRecorder"
|
|
||||||
import type { CloneModalProps, ModalPhase } from "./types"
|
|
||||||
import "./clone-modal.css"
|
|
||||||
|
|
||||||
/** 根据 MIME 类型推断文件扩展名 */
|
|
||||||
const getExtensionFromMime = (mime: string): string => {
|
|
||||||
if (mime.includes("webm")) return "webm"
|
|
||||||
if (mime.includes("mp4") || mime.includes("m4a")) return "m4a"
|
|
||||||
if (mime.includes("ogg")) return "ogg"
|
|
||||||
if (mime.includes("wav")) return "wav"
|
|
||||||
return "webm"
|
|
||||||
}
|
|
||||||
|
|
||||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
|
||||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
|
||||||
const [voiceName, setVoiceName] = useState("")
|
|
||||||
const [voiceDescription, setVoiceDescription] = useState("")
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
||||||
const [dragActive, setDragActive] = useState(false)
|
|
||||||
const [errorMessage, setErrorMessage] = useState("")
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
|
||||||
const cloneCounterRef = useRef(1)
|
|
||||||
/** 组件挂载状态标记,防止卸载后更新 state */
|
|
||||||
const isMountedRef = useRef(true)
|
|
||||||
const isSubmittingRef = useRef(false)
|
|
||||||
|
|
||||||
/* ── 录音 Hook ──────────────────────────────────── */
|
|
||||||
const {
|
|
||||||
isRecording,
|
|
||||||
recordedBlob,
|
|
||||||
formattedTime,
|
|
||||||
toggleRecord,
|
|
||||||
reset: resetRecorder,
|
|
||||||
} = useAudioRecorder(setErrorMessage)
|
|
||||||
|
|
||||||
/** 生成下一个默认音色名称 */
|
|
||||||
const getNextDefaultName = useCallback((): string => {
|
|
||||||
const name = `我的声音 ${cloneCounterRef.current}`
|
|
||||||
cloneCounterRef.current += 1
|
|
||||||
return name
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/** 重置弹窗状态 */
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setPhase("input")
|
|
||||||
setVoiceName(getNextDefaultName())
|
|
||||||
setVoiceDescription("")
|
|
||||||
setSelectedFile(null)
|
|
||||||
setDragActive(false)
|
|
||||||
if (isSubmittingRef.current) return
|
|
||||||
isSubmittingRef.current = true
|
|
||||||
setErrorMessage("")
|
|
||||||
resetRecorder()
|
|
||||||
}, [getNextDefaultName, resetRecorder])
|
|
||||||
|
|
||||||
/** 关闭弹窗 */
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
resetState()
|
|
||||||
onClose()
|
|
||||||
}, [resetState, onClose])
|
|
||||||
|
|
||||||
/** 弹窗打开时重置状态 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
}, [open, resetState])
|
|
||||||
|
|
||||||
/** 组件挂载/卸载标记 + 清理定时器 */
|
|
||||||
useEffect(() => {
|
|
||||||
isMountedRef.current = true
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
/* ── 文件上传 ──────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleUploadClick = () => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
const error = validateFile(file)
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
if (isSubmittingRef.current) return
|
|
||||||
isSubmittingRef.current = true
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
resetRecorder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.target.value = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 拖拽 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleDrag = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
if (e.type === "dragenter" || e.type === "dragover") {
|
|
||||||
setDragActive(true)
|
|
||||||
} else if (e.type === "dragleave") {
|
|
||||||
setDragActive(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setDragActive(false)
|
|
||||||
const file = e.dataTransfer.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
const error = validateFile(file)
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
if (isSubmittingRef.current) return
|
|
||||||
isSubmittingRef.current = true
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
resetRecorder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 计算属性 ──────────────────────────────────── */
|
|
||||||
|
|
||||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
|
||||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
|
||||||
const canSubmit = hasAudio && !isProcessing
|
|
||||||
|
|
||||||
const progressIndex = PROGRESS_STEPS.findIndex((s) => s.key === phase)
|
|
||||||
|
|
||||||
/* ── 提交 ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const name = voiceName.trim()
|
|
||||||
if (!name || name.length < 2 || name.length > 20) {
|
|
||||||
setErrorMessage("音色名称需在 2-20 个字符之间")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!hasAudio) {
|
|
||||||
setErrorMessage("请上传音频文件或录制一段声音")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSubmittingRef.current) return
|
|
||||||
isSubmittingRef.current = true
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 阶段 1:上传音频
|
|
||||||
setPhase("uploading")
|
|
||||||
|
|
||||||
let fileToUpload: File
|
|
||||||
if (selectedFile) {
|
|
||||||
fileToUpload = selectedFile
|
|
||||||
} else {
|
|
||||||
// 使用浏览器实际生成的 MIME 类型,避免跨浏览器格式不匹配
|
|
||||||
const mimeType = recordedBlob?.type || "audio/webm"
|
|
||||||
const ext = getExtensionFromMime(mimeType)
|
|
||||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.${ext}`, {
|
|
||||||
type: mimeType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", fileToUpload)
|
|
||||||
const uploadResult = await uploadAsset(formData)
|
|
||||||
|
|
||||||
// 组件已卸载则中止后续操作
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
|
|
||||||
// 阶段 2:克隆
|
|
||||||
setPhase("cloning")
|
|
||||||
const result = await createVoiceClone({
|
|
||||||
name,
|
|
||||||
description: voiceDescription.trim() || undefined,
|
|
||||||
audio_url: uploadResult.url,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 组件已卸载则中止后续操作
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
|
|
||||||
isSubmittingRef.current = false
|
|
||||||
// 阶段 3:完成
|
|
||||||
setPhase("done")
|
|
||||||
|
|
||||||
// 2秒后自动关闭
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
onSuccess?.(toVoiceClone(result))
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
}, 2000)
|
|
||||||
} catch (err) {
|
|
||||||
isSubmittingRef.current = false
|
|
||||||
// 组件已卸载则不更新 state
|
|
||||||
if (!isMountedRef.current) return
|
|
||||||
setPhase("input")
|
|
||||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
open={open}
|
|
||||||
onCancel={handleClose}
|
|
||||||
title="克隆我的音色"
|
|
||||||
width={560}
|
|
||||||
footer={null}
|
|
||||||
destroyOnClose
|
|
||||||
maskClosable={!isProcessing}
|
|
||||||
keyboard={!isProcessing}
|
|
||||||
>
|
|
||||||
{/* ── 输入阶段 ────────────────────────────────── */}
|
|
||||||
{phase === "input" && (
|
|
||||||
<div className="xx-clonemodal-body">
|
|
||||||
{/* 步骤引导 */}
|
|
||||||
<div className="xx-clonemodal-steps">
|
|
||||||
<div className="xx-clonemodal-step xx-clonemodal-step--active">
|
|
||||||
<div className="xx-clonemodal-step-number">1</div>
|
|
||||||
<span className="xx-clonemodal-step-label">上传/录制音频</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-clonemodal-step-connector" />
|
|
||||||
<div className="xx-clonemodal-step">
|
|
||||||
<div className="xx-clonemodal-step-number">2</div>
|
|
||||||
<span className="xx-clonemodal-step-label">填写信息</span>
|
|
||||||
</div>
|
|
||||||
<div className="xx-clonemodal-step-connector" />
|
|
||||||
<div className="xx-clonemodal-step">
|
|
||||||
<div className="xx-clonemodal-step-number">3</div>
|
|
||||||
<span className="xx-clonemodal-step-label">提交克隆</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 音色名称 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">
|
|
||||||
音色名称 <span className="xx-clonemodal-required">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="xx-clonemodal-input"
|
|
||||||
value={voiceName}
|
|
||||||
onChange={(e) => setVoiceName(e.target.value)}
|
|
||||||
placeholder="输入音色名称(2-20字符)"
|
|
||||||
maxLength={20}
|
|
||||||
/>
|
|
||||||
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 上传区域 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">上传音频</label>
|
|
||||||
<div
|
|
||||||
className={`xx-clonemodal-upload-zone${dragActive ? " xx-clonemodal-upload-zone--active" : ""}${selectedFile ? " xx-clonemodal-upload-zone--has-file" : ""}`}
|
|
||||||
onClick={handleUploadClick}
|
|
||||||
onDragEnter={handleDrag}
|
|
||||||
onDragOver={handleDrag}
|
|
||||||
onDragLeave={handleDrag}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
>
|
|
||||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
|
||||||
<p className="xx-clonemodal-upload-title">
|
|
||||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
|
||||||
</p>
|
|
||||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={ACCEPTED_MIME}
|
|
||||||
style={{ display: "none" }}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 或分隔 */}
|
|
||||||
<div className="xx-clonemodal-divider">
|
|
||||||
<div className="xx-clonemodal-divider-line" />
|
|
||||||
<span className="xx-clonemodal-divider-text">或</span>
|
|
||||||
<div className="xx-clonemodal-divider-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 录制区域 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">直接录制</label>
|
|
||||||
<div className="xx-clonemodal-record-area">
|
|
||||||
<div className="xx-clonemodal-record-info">
|
|
||||||
<p className="xx-clonemodal-record-hint">
|
|
||||||
{isRecording
|
|
||||||
? `录制中 ${formattedTime}`
|
|
||||||
: recordedBlob
|
|
||||||
? `已录制 ${formattedTime}`
|
|
||||||
: "点击按钮开始录制(最长 5 分钟)"}
|
|
||||||
</p>
|
|
||||||
{isRecording && (
|
|
||||||
<div className="xx-clonemodal-record-wave">
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
<span className="xx-clonemodal-record-wave-bar" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`xx-clonemodal-record-btn${isRecording ? " xx-clonemodal-record-btn--recording" : ""}`}
|
|
||||||
onClick={toggleRecord}
|
|
||||||
title={isRecording ? "停止录制" : "开始录制"}
|
|
||||||
>
|
|
||||||
{isRecording ? "⏹" : "🎙️"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 音色描述 */}
|
|
||||||
<div className="xx-clonemodal-field">
|
|
||||||
<label className="xx-clonemodal-label">音色描述</label>
|
|
||||||
<textarea
|
|
||||||
className="xx-clonemodal-textarea"
|
|
||||||
value={voiceDescription}
|
|
||||||
onChange={(e) => setVoiceDescription(e.target.value)}
|
|
||||||
placeholder="可选,描述这个音色的特点(最多100字符)"
|
|
||||||
maxLength={100}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 错误提示 */}
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="xx-clonemodal-error">
|
|
||||||
<span className="xx-clonemodal-error-icon">⚠️</span>
|
|
||||||
<span>{errorMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 提示 */}
|
|
||||||
<div className="xx-clonemodal-tip">
|
|
||||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
|
||||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<div className="xx-clonemodal-footer">
|
|
||||||
<Button buttonType="ghost" onClick={handleClose}>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
|
|
||||||
🎤 开始克隆
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 进度阶段(上传中 / 克隆中) ────────────── */}
|
|
||||||
{isProcessing && (
|
|
||||||
<div className="xx-clonemodal-progress-body">
|
|
||||||
{/* 步骤指示器 */}
|
|
||||||
<div className="xx-clonemodal-steps-progress">
|
|
||||||
{PROGRESS_STEPS.map((step, idx) => {
|
|
||||||
const isActive = idx === progressIndex
|
|
||||||
const isDone = idx < progressIndex
|
|
||||||
const stepClass = [
|
|
||||||
"xx-clonemodal-step-progress",
|
|
||||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
|
||||||
isDone ? "xx-clonemodal-step-progress--done" : "",
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={step.key}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<div
|
|
||||||
className={`xx-clonemodal-step-connector${isDone ? " xx-clonemodal-step-connector--done" : ""}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className={stepClass}>
|
|
||||||
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
|
|
||||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 当前阶段描述 */}
|
|
||||||
<div className="xx-clonemodal-progress-info">
|
|
||||||
{phase === "uploading" && (
|
|
||||||
<>
|
|
||||||
<div className="xx-clonemodal-progress-spinner" />
|
|
||||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
|
||||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{phase === "cloning" && (
|
|
||||||
<>
|
|
||||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
|
||||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
|
||||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── 完成阶段 ────────────────────────────────── */}
|
|
||||||
{phase === "done" && (
|
|
||||||
<div className="xx-clonemodal-progress-body">
|
|
||||||
{/* 步骤指示器(全部完成) */}
|
|
||||||
<div className="xx-clonemodal-steps-progress">
|
|
||||||
{PROGRESS_STEPS.map((step, idx) => (
|
|
||||||
<React.Fragment key={step.key}>
|
|
||||||
{idx > 0 && (
|
|
||||||
<div className="xx-clonemodal-step-connector xx-clonemodal-step-connector--done" />
|
|
||||||
)}
|
|
||||||
<div className="xx-clonemodal-step-progress xx-clonemodal-step-progress--done">
|
|
||||||
<div className="xx-clonemodal-step-icon">✓</div>
|
|
||||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="xx-clonemodal-success">
|
|
||||||
<div className="xx-clonemodal-success-icon">🎉</div>
|
|
||||||
<h3 className="xx-clonemodal-success-title">克隆已提交</h3>
|
|
||||||
<p className="xx-clonemodal-success-desc">
|
|
||||||
音色正在生成中,完成后将出现在「我的克隆」列表中
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CloneModal
|
|
||||||
export type { CloneModalProps, ModalPhase } from "./types"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { VoiceClone } from "@/api/voice-clone"
|
|
||||||
|
|
||||||
/** 弹窗阶段 */
|
|
||||||
export type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
|
||||||
|
|
||||||
/** CloneModal 组件属性 */
|
|
||||||
export interface CloneModalProps {
|
|
||||||
/** 弹窗是否可见 */
|
|
||||||
open: boolean
|
|
||||||
/** 关闭弹窗回调 */
|
|
||||||
onClose: () => void
|
|
||||||
/** 克隆成功回调(返回新创建的音色) */
|
|
||||||
onSuccess?: (voice: VoiceClone) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 进度步骤配置 */
|
|
||||||
export interface ProgressStep {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { ACCEPTED_EXTENSIONS, MAX_FILE_SIZE } from "./constants"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证音频文件
|
|
||||||
* @returns 错误信息,null 表示验证通过
|
|
||||||
*/
|
|
||||||
export const validateFile = (file: File): string | null => {
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
|
||||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
|
||||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
|
||||||
}
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
return "文件大小超过 10MB,请压缩后重试"
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 格式化录制时间 mm:ss */
|
|
||||||
export const formatRecordTime = (seconds: number): string => {
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = seconds % 60
|
|
||||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from "react"
|
|
||||||
import type { ModalPhase } from "../types/cloneModal"
|
|
||||||
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
|
||||||
import useAudioRecorder from "./useAudioRecorder"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 克隆弹窗表单状态 Hook
|
|
||||||
* 管理表单字段、录音、文件选择、验证逻辑
|
|
||||||
*/
|
|
||||||
export function useCloneFormState({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
||||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
|
||||||
const [voiceName, setVoiceName] = useState("")
|
|
||||||
const [voiceDescription, setVoiceDescription] = useState("")
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
|
||||||
const [dragActive, setDragActive] = useState(false)
|
|
||||||
const [errorMessage, setErrorMessage] = useState("")
|
|
||||||
|
|
||||||
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
|
||||||
useAudioRecorder()
|
|
||||||
|
|
||||||
/** 默认音色名称计数器 */
|
|
||||||
const cloneCounterRef = useRef(1)
|
|
||||||
|
|
||||||
const getNextDefaultName = useCallback((): string => {
|
|
||||||
const name = `我的声音 ${cloneCounterRef.current}`
|
|
||||||
cloneCounterRef.current += 1
|
|
||||||
return name
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
|
||||||
|
|
||||||
const canSubmit =
|
|
||||||
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
|
||||||
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
|
||||||
hasAudio
|
|
||||||
|
|
||||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
|
||||||
|
|
||||||
/** 重置弹窗状态 */
|
|
||||||
const resetState = useCallback(() => {
|
|
||||||
setPhase("input")
|
|
||||||
setVoiceName(getNextDefaultName())
|
|
||||||
setVoiceDescription("")
|
|
||||||
setSelectedFile(null)
|
|
||||||
setDragActive(false)
|
|
||||||
setErrorMessage("")
|
|
||||||
resetRecording()
|
|
||||||
}, [getNextDefaultName, resetRecording])
|
|
||||||
|
|
||||||
/** 关闭弹窗 */
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
resetState()
|
|
||||||
onClose()
|
|
||||||
}, [resetState, onClose])
|
|
||||||
|
|
||||||
/** 弹窗打开时重置状态 */
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
resetState()
|
|
||||||
}
|
|
||||||
}, [open, resetState])
|
|
||||||
|
|
||||||
/** 选择文件(来自上传或拖拽) */
|
|
||||||
const handleFileSelect = useCallback(
|
|
||||||
(file: File | null, error: string) => {
|
|
||||||
if (error) {
|
|
||||||
setErrorMessage(error)
|
|
||||||
setSelectedFile(null)
|
|
||||||
} else {
|
|
||||||
setErrorMessage("")
|
|
||||||
setSelectedFile(file)
|
|
||||||
// 清除录音
|
|
||||||
resetRecording()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[resetRecording],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 录音切换 */
|
|
||||||
const handleRecordToggle = useCallback(() => {
|
|
||||||
setErrorMessage("")
|
|
||||||
if (isRecording) {
|
|
||||||
toggleRecording()
|
|
||||||
} else {
|
|
||||||
// 开始录制前清除已选文件
|
|
||||||
setSelectedFile(null)
|
|
||||||
toggleRecording()
|
|
||||||
}
|
|
||||||
}, [isRecording, toggleRecording])
|
|
||||||
|
|
||||||
/** 表单验证 */
|
|
||||||
const validateForm = useCallback((): string | null => {
|
|
||||||
const name = voiceName.trim()
|
|
||||||
if (!name) {
|
|
||||||
return "请输入音色名称"
|
|
||||||
}
|
|
||||||
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
|
||||||
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
|
||||||
}
|
|
||||||
if (!hasAudio) {
|
|
||||||
return "请上传音频文件或录制一段声音"
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}, [voiceName, hasAudio])
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
|
||||||
phase,
|
|
||||||
setPhase,
|
|
||||||
voiceName,
|
|
||||||
setVoiceName,
|
|
||||||
voiceDescription,
|
|
||||||
setVoiceDescription,
|
|
||||||
selectedFile,
|
|
||||||
dragActive,
|
|
||||||
setDragActive,
|
|
||||||
errorMessage,
|
|
||||||
setErrorMessage,
|
|
||||||
// 录音
|
|
||||||
isRecording,
|
|
||||||
recordTime,
|
|
||||||
recordedBlob,
|
|
||||||
// 计算属性
|
|
||||||
hasAudio,
|
|
||||||
canSubmit,
|
|
||||||
isProcessing,
|
|
||||||
// handlers
|
|
||||||
handleFileSelect,
|
|
||||||
handleRecordToggle,
|
|
||||||
handleClose,
|
|
||||||
validateForm,
|
|
||||||
resetState,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Executable → Regular
+202
-33
@@ -1,44 +1,213 @@
|
|||||||
import type { CloneModalProps } from "../types/cloneModal"
|
import { useState, useRef, useCallback, useEffect } from "react"
|
||||||
import { useCloneFormState } from "./useCloneFormState"
|
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
||||||
import { useCloneSubmit } from "./useCloneSubmit"
|
import { uploadAsset } from "@/api/assets"
|
||||||
|
import type { ModalPhase, CloneModalProps } from "../types/cloneModal"
|
||||||
|
import { MIN_VOICE_NAME_LENGTH, MAX_VOICE_NAME_LENGTH } from "../constants/cloneModal"
|
||||||
|
import useAudioRecorder from "./useAudioRecorder"
|
||||||
|
|
||||||
|
interface UseCloneModalReturn {
|
||||||
|
phase: ModalPhase
|
||||||
|
voiceName: string
|
||||||
|
voiceDescription: string
|
||||||
|
selectedFile: File | null
|
||||||
|
dragActive: boolean
|
||||||
|
errorMessage: string
|
||||||
|
isRecording: boolean
|
||||||
|
recordTime: number
|
||||||
|
recordedBlob: Blob | null
|
||||||
|
canSubmit: boolean
|
||||||
|
isProcessing: boolean
|
||||||
|
setVoiceName: (value: string) => void
|
||||||
|
setVoiceDescription: (value: string) => void
|
||||||
|
setDragActive: (active: boolean) => void
|
||||||
|
handleFileSelect: (file: File | null, error: string) => void
|
||||||
|
handleRecordToggle: () => void
|
||||||
|
handleClose: () => void
|
||||||
|
handleSubmit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 音色克隆弹窗主业务 Hook
|
* 音色克隆弹窗主业务 Hook
|
||||||
* 组合表单状态 + 提交流程两个子 Hook
|
|
||||||
*/
|
*/
|
||||||
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps) => {
|
const useCloneModal = ({ open, onClose, onSuccess }: CloneModalProps): UseCloneModalReturn => {
|
||||||
const formState = useCloneFormState({ open, onClose })
|
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||||
|
const [voiceName, setVoiceName] = useState("")
|
||||||
|
const [voiceDescription, setVoiceDescription] = useState("")
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||||
|
const [dragActive, setDragActive] = useState(false)
|
||||||
|
const [errorMessage, setErrorMessage] = useState("")
|
||||||
|
|
||||||
const { handleSubmit } = useCloneSubmit({
|
const { isRecording, recordTime, recordedBlob, toggleRecording, resetRecording } =
|
||||||
voiceName: formState.voiceName,
|
useAudioRecorder()
|
||||||
voiceDescription: formState.voiceDescription,
|
|
||||||
selectedFile: formState.selectedFile,
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
recordedBlob: formState.recordedBlob,
|
/** 默认音色名称计数器 */
|
||||||
setPhase: formState.setPhase,
|
const cloneCounterRef = useRef(1)
|
||||||
setErrorMessage: formState.setErrorMessage,
|
|
||||||
validateForm: formState.validateForm,
|
const getNextDefaultName = useCallback((): string => {
|
||||||
|
const name = `我的声音 ${cloneCounterRef.current}`
|
||||||
|
cloneCounterRef.current += 1
|
||||||
|
return name
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
voiceName.trim().length >= MIN_VOICE_NAME_LENGTH &&
|
||||||
|
voiceName.trim().length <= MAX_VOICE_NAME_LENGTH &&
|
||||||
|
hasAudio
|
||||||
|
|
||||||
|
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||||
|
|
||||||
|
/** 重置弹窗状态 */
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setPhase("input")
|
||||||
|
setVoiceName(getNextDefaultName())
|
||||||
|
setVoiceDescription("")
|
||||||
|
setSelectedFile(null)
|
||||||
|
setDragActive(false)
|
||||||
|
setErrorMessage("")
|
||||||
|
resetRecording()
|
||||||
|
}, [getNextDefaultName, resetRecording])
|
||||||
|
|
||||||
|
/** 关闭弹窗 */
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
resetState()
|
||||||
|
onClose()
|
||||||
|
}, [resetState, onClose])
|
||||||
|
|
||||||
|
/** 弹窗打开时重置状态 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
}, [open, resetState])
|
||||||
|
|
||||||
|
/** 组件卸载时清理定时器 */
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 选择文件(来自上传或拖拽) */
|
||||||
|
const handleFileSelect = useCallback(
|
||||||
|
(file: File | null, error: string) => {
|
||||||
|
if (error) {
|
||||||
|
setErrorMessage(error)
|
||||||
|
setSelectedFile(null)
|
||||||
|
} else {
|
||||||
|
setErrorMessage("")
|
||||||
|
setSelectedFile(file)
|
||||||
|
// 清除录音
|
||||||
|
resetRecording()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[resetRecording],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 录音切换 */
|
||||||
|
const handleRecordToggle = useCallback(() => {
|
||||||
|
setErrorMessage("")
|
||||||
|
if (isRecording) {
|
||||||
|
toggleRecording()
|
||||||
|
} else {
|
||||||
|
// 开始录制前清除已选文件
|
||||||
|
setSelectedFile(null)
|
||||||
|
toggleRecording()
|
||||||
|
}
|
||||||
|
}, [isRecording, toggleRecording])
|
||||||
|
|
||||||
|
/** 表单验证 */
|
||||||
|
const validateForm = useCallback((): string | null => {
|
||||||
|
const name = voiceName.trim()
|
||||||
|
if (!name) {
|
||||||
|
return "请输入音色名称"
|
||||||
|
}
|
||||||
|
if (name.length < MIN_VOICE_NAME_LENGTH || name.length > MAX_VOICE_NAME_LENGTH) {
|
||||||
|
return `音色名称需在 ${MIN_VOICE_NAME_LENGTH}-${MAX_VOICE_NAME_LENGTH} 个字符之间`
|
||||||
|
}
|
||||||
|
if (!hasAudio) {
|
||||||
|
return "请上传音频文件或录制一段声音"
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [voiceName, hasAudio])
|
||||||
|
|
||||||
|
/** 提交克隆 */
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const formError = validateForm()
|
||||||
|
if (formError) {
|
||||||
|
setErrorMessage(formError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 阶段 1:上传音频
|
||||||
|
setPhase("uploading")
|
||||||
|
|
||||||
|
let fileToUpload: File
|
||||||
|
if (selectedFile) {
|
||||||
|
fileToUpload = selectedFile
|
||||||
|
} else {
|
||||||
|
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||||
|
type: "audio/webm",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append("file", fileToUpload)
|
||||||
|
const uploadResult = await uploadAsset(formData)
|
||||||
|
|
||||||
|
// 阶段 2:克隆
|
||||||
|
setPhase("cloning")
|
||||||
|
const result = await createVoiceClone({
|
||||||
|
name: voiceName.trim(),
|
||||||
|
description: voiceDescription.trim() || undefined,
|
||||||
|
audio_url: uploadResult.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 阶段 3:完成
|
||||||
|
setPhase("done")
|
||||||
|
|
||||||
|
// 2秒后自动关闭
|
||||||
|
timerRef.current = setTimeout(() => {
|
||||||
|
onSuccess?.(toVoiceClone(result))
|
||||||
|
handleClose()
|
||||||
|
}, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
setPhase("input")
|
||||||
|
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
validateForm,
|
||||||
|
selectedFile,
|
||||||
|
recordedBlob,
|
||||||
|
voiceName,
|
||||||
|
voiceDescription,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onClose: formState.handleClose,
|
handleClose,
|
||||||
})
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase: formState.phase,
|
phase,
|
||||||
voiceName: formState.voiceName,
|
voiceName,
|
||||||
voiceDescription: formState.voiceDescription,
|
voiceDescription,
|
||||||
selectedFile: formState.selectedFile,
|
selectedFile,
|
||||||
dragActive: formState.dragActive,
|
dragActive,
|
||||||
errorMessage: formState.errorMessage,
|
errorMessage,
|
||||||
isRecording: formState.isRecording,
|
isRecording,
|
||||||
recordTime: formState.recordTime,
|
recordTime,
|
||||||
recordedBlob: formState.recordedBlob,
|
recordedBlob,
|
||||||
canSubmit: formState.canSubmit,
|
canSubmit,
|
||||||
isProcessing: formState.isProcessing,
|
isProcessing,
|
||||||
setVoiceName: formState.setVoiceName,
|
setVoiceName,
|
||||||
setVoiceDescription: formState.setVoiceDescription,
|
setVoiceDescription,
|
||||||
setDragActive: formState.setDragActive,
|
setDragActive,
|
||||||
handleFileSelect: formState.handleFileSelect,
|
handleFileSelect,
|
||||||
handleRecordToggle: formState.handleRecordToggle,
|
handleRecordToggle,
|
||||||
handleClose: formState.handleClose,
|
handleClose,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
import { useRef, useCallback, useEffect } from "react"
|
|
||||||
import { createVoiceClone, toVoiceClone } from "@/api/voice-clone"
|
|
||||||
import { uploadAsset, ensureDefaultLibrary } from "@/api/assets"
|
|
||||||
import { getOrCreateDefaultProject } from "@/api/projects"
|
|
||||||
import type { VoiceClone } from "@/api/voice-clone"
|
|
||||||
|
|
||||||
interface UseCloneSubmitOptions {
|
|
||||||
voiceName: string
|
|
||||||
voiceDescription: string
|
|
||||||
selectedFile: File | null
|
|
||||||
recordedBlob: Blob | null
|
|
||||||
setPhase: (phase: "input" | "uploading" | "cloning" | "done") => void
|
|
||||||
setErrorMessage: (msg: string) => void
|
|
||||||
validateForm: () => string | null
|
|
||||||
onSuccess?: (clone: VoiceClone) => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 克隆提交流程 Hook
|
|
||||||
* 封装上传 + 克隆 + 完成的三阶段流程
|
|
||||||
*/
|
|
||||||
export function useCloneSubmit({
|
|
||||||
voiceName,
|
|
||||||
voiceDescription,
|
|
||||||
selectedFile,
|
|
||||||
recordedBlob,
|
|
||||||
setPhase,
|
|
||||||
setErrorMessage,
|
|
||||||
validateForm,
|
|
||||||
onSuccess,
|
|
||||||
onClose,
|
|
||||||
}: UseCloneSubmitOptions) {
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
||||||
|
|
||||||
/** 组件卸载时清理定时器 */
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
const formError = validateForm()
|
|
||||||
if (formError) {
|
|
||||||
setErrorMessage(formError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrorMessage("")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 阶段 1:上传音频
|
|
||||||
setPhase("uploading")
|
|
||||||
|
|
||||||
let fileToUpload: File
|
|
||||||
if (selectedFile) {
|
|
||||||
fileToUpload = selectedFile
|
|
||||||
} else {
|
|
||||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
|
||||||
type: "audio/webm",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取默认项目和素材库(后端 /upload 接口必填)
|
|
||||||
const project = await getOrCreateDefaultProject()
|
|
||||||
const library = await ensureDefaultLibrary({ project_id: project.id, kind: "voice" })
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", fileToUpload)
|
|
||||||
formData.append("project_id", project.id)
|
|
||||||
formData.append("library_id", library.id)
|
|
||||||
const uploadResult = await uploadAsset(formData)
|
|
||||||
|
|
||||||
// 阶段 2:克隆
|
|
||||||
setPhase("cloning")
|
|
||||||
const result = await createVoiceClone({
|
|
||||||
name: voiceName.trim(),
|
|
||||||
description: voiceDescription.trim() || undefined,
|
|
||||||
audio_url: uploadResult.url,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 阶段 3:完成
|
|
||||||
setPhase("done")
|
|
||||||
|
|
||||||
// 2秒后自动关闭
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
onSuccess?.(toVoiceClone(result))
|
|
||||||
onClose()
|
|
||||||
}, 2000)
|
|
||||||
} catch (err) {
|
|
||||||
setPhase("input")
|
|
||||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
validateForm,
|
|
||||||
selectedFile,
|
|
||||||
recordedBlob,
|
|
||||||
voiceName,
|
|
||||||
voiceDescription,
|
|
||||||
setPhase,
|
|
||||||
setErrorMessage,
|
|
||||||
onSuccess,
|
|
||||||
onClose,
|
|
||||||
])
|
|
||||||
|
|
||||||
return { handleSubmit }
|
|
||||||
}
|
|
||||||
@@ -31,8 +31,8 @@ export const useLogin = () => {
|
|||||||
const user = await authApi.getCurrentUser()
|
const user = await authApi.getCurrentUser()
|
||||||
setAuth(user, data.access_token, refreshToken)
|
setAuth(user, data.access_token, refreshToken)
|
||||||
|
|
||||||
// 跳转到登录前页面或仪表盘(与 Login.tsx onFinish 保持一致)
|
// 跳转到登录前页面或首页
|
||||||
const redirect = localStorage.getItem("login_redirect") || "/app/dashboard"
|
const redirect = localStorage.getItem("login_redirect") || "/"
|
||||||
localStorage.removeItem("login_redirect")
|
localStorage.removeItem("login_redirect")
|
||||||
navigate(redirect, { replace: true })
|
navigate(redirect, { replace: true })
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export { useBatchDelete } from "./useBatchDelete"
|
|
||||||
export { useBatchTag } from "./useBatchTag"
|
|
||||||
export { useBatchClassify } from "./useBatchClassify"
|
|
||||||
export { useBatchMark } from "./useBatchMark"
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
|
|
||||||
interface UseBatchClassifyOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchClassify = ({
|
|
||||||
selectedIds,
|
|
||||||
queryClient,
|
|
||||||
showResult,
|
|
||||||
}: UseBatchClassifyOptions) => {
|
|
||||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
|
||||||
const [batchCategory, setBatchCategory] = useState("")
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchClassify = useCallback(async () => {
|
|
||||||
if (!batchCategory) {
|
|
||||||
message.warning("请选择分类")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchClassifyAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
category: batchCategory,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showResult(result, "批量改分类")
|
|
||||||
setClassifyModalOpen(false)
|
|
||||||
setBatchCategory("")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量改分类失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
|
||||||
|
|
||||||
return {
|
|
||||||
classifyModalOpen,
|
|
||||||
setClassifyModalOpen,
|
|
||||||
batchCategory,
|
|
||||||
setBatchCategory,
|
|
||||||
batchLoading,
|
|
||||||
handleBatchClassify,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
|
|
||||||
interface UseBatchDeleteOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
invalidateAssets: () => void
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchDelete = ({
|
|
||||||
selectedIds,
|
|
||||||
invalidateAssets,
|
|
||||||
showResult,
|
|
||||||
}: UseBatchDeleteOptions) => {
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchDelete = useCallback(async () => {
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchDeleteAssets(ids)
|
|
||||||
invalidateAssets()
|
|
||||||
showResult(result, "批量删除")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量删除失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [selectedIds, invalidateAssets, showResult])
|
|
||||||
|
|
||||||
return { batchLoading, handleBatchDelete }
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
|
||||||
import { SMART_VIEW_LABELS } from "../constants"
|
|
||||||
|
|
||||||
interface UseBatchMarkOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
|
||||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
|
||||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchMark = useCallback(async () => {
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchMarkAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
smart_view: batchSmartView,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showResult(result, "批量智能标记")
|
|
||||||
setMarkModalOpen(false)
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(
|
|
||||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量智能标记失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
|
||||||
|
|
||||||
return {
|
|
||||||
markModalOpen,
|
|
||||||
setMarkModalOpen,
|
|
||||||
batchSmartView,
|
|
||||||
setBatchSmartView,
|
|
||||||
batchLoading,
|
|
||||||
handleBatchMark,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
|
|
||||||
interface UseBatchTagOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
|
||||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
|
||||||
const [batchTagInput, setBatchTagInput] = useState("")
|
|
||||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
|
||||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchTag = useCallback(async () => {
|
|
||||||
if (batchTags.length === 0) {
|
|
||||||
message.warning("请至少输入一个标签")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchTagAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
tags: batchTags,
|
|
||||||
mode: tagMode,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showResult(result, "批量打标签")
|
|
||||||
setTagModalOpen(false)
|
|
||||||
setBatchTags([])
|
|
||||||
setBatchTagInput("")
|
|
||||||
setTagMode("add")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量打标签失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
|
||||||
|
|
||||||
const handleTagInputKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
|
||||||
e.preventDefault()
|
|
||||||
const tag = batchTagInput.trim()
|
|
||||||
if (!batchTags.includes(tag)) {
|
|
||||||
setBatchTags([...batchTags, tag])
|
|
||||||
}
|
|
||||||
setBatchTagInput("")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[batchTagInput, batchTags],
|
|
||||||
)
|
|
||||||
|
|
||||||
const removeBatchTag = useCallback(
|
|
||||||
(tag: string) => {
|
|
||||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
|
||||||
},
|
|
||||||
[batchTags],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
tagModalOpen,
|
|
||||||
setTagModalOpen,
|
|
||||||
batchTagInput,
|
|
||||||
setBatchTagInput,
|
|
||||||
batchTags,
|
|
||||||
setBatchTags,
|
|
||||||
tagMode,
|
|
||||||
setTagMode,
|
|
||||||
batchLoading,
|
|
||||||
handleBatchTag,
|
|
||||||
handleTagInputKeyDown,
|
|
||||||
removeBatchTag,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { useBatchDelete } from "./useBatchDelete"
|
|
||||||
export { useBatchTag } from "./useBatchTag"
|
|
||||||
export { useBatchClassify } from "./useBatchClassify"
|
|
||||||
export { useBatchMark } from "./useBatchMark"
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
|
|
||||||
interface UseBatchClassifyOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchClassify = ({
|
|
||||||
selectedIds,
|
|
||||||
queryClient,
|
|
||||||
showResult,
|
|
||||||
}: UseBatchClassifyOptions) => {
|
|
||||||
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
|
||||||
const [batchCategory, setBatchCategory] = useState("")
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchClassify = useCallback(async () => {
|
|
||||||
if (!batchCategory) {
|
|
||||||
message.warning("请选择分类")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchClassifyAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
category: batchCategory,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showResult(result, "批量改分类")
|
|
||||||
setClassifyModalOpen(false)
|
|
||||||
setBatchCategory("")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量改分类失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchCategory, selectedIds, queryClient, showResult])
|
|
||||||
|
|
||||||
return {
|
|
||||||
classifyModalOpen,
|
|
||||||
setClassifyModalOpen,
|
|
||||||
batchCategory,
|
|
||||||
setBatchCategory,
|
|
||||||
batchLoading,
|
|
||||||
handleBatchClassify,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
|
|
||||||
interface UseBatchDeleteOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
invalidateAssets: () => void
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchDelete = ({
|
|
||||||
selectedIds,
|
|
||||||
invalidateAssets,
|
|
||||||
showResult,
|
|
||||||
}: UseBatchDeleteOptions) => {
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchDelete = useCallback(async () => {
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchDeleteAssets(ids)
|
|
||||||
invalidateAssets()
|
|
||||||
showResult(result, "批量删除")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功删除 ${result.success_count} 个素材`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量删除失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [selectedIds, invalidateAssets, showResult])
|
|
||||||
|
|
||||||
return { batchLoading, handleBatchDelete }
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
import type { SmartViewType } from "../../../components/BatchMarkModal"
|
|
||||||
import { SMART_VIEW_LABELS } from "../constants"
|
|
||||||
|
|
||||||
interface UseBatchMarkOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
|
||||||
const [markModalOpen, setMarkModalOpen] = useState(false)
|
|
||||||
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchMark = useCallback(async () => {
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchMarkAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
smart_view: batchSmartView,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showResult(result, "批量智能标记")
|
|
||||||
setMarkModalOpen(false)
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(
|
|
||||||
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量智能标记失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchSmartView, selectedIds, queryClient, showResult])
|
|
||||||
|
|
||||||
return {
|
|
||||||
markModalOpen,
|
|
||||||
setMarkModalOpen,
|
|
||||||
batchSmartView,
|
|
||||||
setBatchSmartView,
|
|
||||||
batchLoading,
|
|
||||||
handleBatchMark,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useState, useCallback } from "react"
|
|
||||||
import { message } from "antd"
|
|
||||||
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
|
|
||||||
|
|
||||||
interface UseBatchTagOptions {
|
|
||||||
selectedIds: Set<string>
|
|
||||||
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
|
||||||
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
|
||||||
const [tagModalOpen, setTagModalOpen] = useState(false)
|
|
||||||
const [batchTagInput, setBatchTagInput] = useState("")
|
|
||||||
const [batchTags, setBatchTags] = useState<string[]>([])
|
|
||||||
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
|
||||||
const [batchLoading, setBatchLoading] = useState(false)
|
|
||||||
|
|
||||||
const handleBatchTag = useCallback(async () => {
|
|
||||||
if (batchTags.length === 0) {
|
|
||||||
message.warning("请至少输入一个标签")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setBatchLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await batchTagAssets({
|
|
||||||
asset_ids: ids,
|
|
||||||
tags: batchTags,
|
|
||||||
mode: tagMode,
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
|
||||||
showResult(result, "批量打标签")
|
|
||||||
setTagModalOpen(false)
|
|
||||||
setBatchTags([])
|
|
||||||
setBatchTagInput("")
|
|
||||||
setTagMode("add")
|
|
||||||
if (result.failure_count === 0) {
|
|
||||||
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
|
||||||
} else {
|
|
||||||
message.warning(
|
|
||||||
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error("批量打标签失败,请重试")
|
|
||||||
} finally {
|
|
||||||
setBatchLoading(false)
|
|
||||||
}
|
|
||||||
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
|
||||||
|
|
||||||
const handleTagInputKeyDown = useCallback(
|
|
||||||
(e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === "Enter" && batchTagInput.trim()) {
|
|
||||||
e.preventDefault()
|
|
||||||
const tag = batchTagInput.trim()
|
|
||||||
if (!batchTags.includes(tag)) {
|
|
||||||
setBatchTags([...batchTags, tag])
|
|
||||||
}
|
|
||||||
setBatchTagInput("")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[batchTagInput, batchTags],
|
|
||||||
)
|
|
||||||
|
|
||||||
const removeBatchTag = useCallback(
|
|
||||||
(tag: string) => {
|
|
||||||
setBatchTags(batchTags.filter((t) => t !== tag))
|
|
||||||
},
|
|
||||||
[batchTags],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
tagModalOpen,
|
|
||||||
setTagModalOpen,
|
|
||||||
batchTagInput,
|
|
||||||
setBatchTagInput,
|
|
||||||
batchTags,
|
|
||||||
setBatchTags,
|
|
||||||
tagMode,
|
|
||||||
setTagMode,
|
|
||||||
batchLoading,
|
|
||||||
handleBatchTag,
|
|
||||||
handleTagInputKeyDown,
|
|
||||||
removeBatchTag,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,238 @@
|
|||||||
/**
|
import { useState, useCallback } from "react"
|
||||||
* @deprecated 请从 ./batch/ 目录导入子模块
|
import { message } from "antd"
|
||||||
* 保持向后兼容,re-export 所有批量操作 Hook
|
import {
|
||||||
*/
|
batchDeleteAssets,
|
||||||
export { useBatchDelete } from "./batch/useBatchDelete"
|
batchTagAssets,
|
||||||
export { useBatchTag } from "./batch/useBatchTag"
|
batchClassifyAssets,
|
||||||
export { useBatchClassify } from "./batch/useBatchClassify"
|
batchMarkAssets,
|
||||||
export { useBatchMark } from "./batch/useBatchMark"
|
type BatchOperationResult,
|
||||||
|
} from "@/api/assets"
|
||||||
|
import type { SmartViewType } from "../../components/BatchMarkModal"
|
||||||
|
import { SMART_VIEW_LABELS } from "./constants"
|
||||||
|
|
||||||
|
/* ── 批量删除 ── */
|
||||||
|
interface UseBatchDeleteOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
invalidateAssets: () => void
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchDelete = ({
|
||||||
|
selectedIds,
|
||||||
|
invalidateAssets,
|
||||||
|
showResult,
|
||||||
|
}: UseBatchDeleteOptions) => {
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchDelete = useCallback(async () => {
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchDeleteAssets(ids)
|
||||||
|
invalidateAssets()
|
||||||
|
showResult(result, "批量删除")
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(`成功删除 ${result.success_count} 个素材`)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量删除失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [selectedIds, invalidateAssets, showResult])
|
||||||
|
|
||||||
|
return { batchLoading, handleBatchDelete }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 批量打标签 ── */
|
||||||
|
interface UseBatchTagOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
|
||||||
|
const [tagModalOpen, setTagModalOpen] = useState(false)
|
||||||
|
const [batchTagInput, setBatchTagInput] = useState("")
|
||||||
|
const [batchTags, setBatchTags] = useState<string[]>([])
|
||||||
|
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchTag = useCallback(async () => {
|
||||||
|
if (batchTags.length === 0) {
|
||||||
|
message.warning("请至少输入一个标签")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchTagAssets({
|
||||||
|
asset_ids: ids,
|
||||||
|
tags: batchTags,
|
||||||
|
mode: tagMode,
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
showResult(result, "批量打标签")
|
||||||
|
setTagModalOpen(false)
|
||||||
|
setBatchTags([])
|
||||||
|
setBatchTagInput("")
|
||||||
|
setTagMode("add")
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(`成功为 ${result.success_count} 个素材打标签`)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量打标签失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
|
||||||
|
|
||||||
|
const handleTagInputKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && batchTagInput.trim()) {
|
||||||
|
e.preventDefault()
|
||||||
|
const tag = batchTagInput.trim()
|
||||||
|
if (!batchTags.includes(tag)) {
|
||||||
|
setBatchTags([...batchTags, tag])
|
||||||
|
}
|
||||||
|
setBatchTagInput("")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[batchTagInput, batchTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeBatchTag = useCallback(
|
||||||
|
(tag: string) => {
|
||||||
|
setBatchTags(batchTags.filter((t) => t !== tag))
|
||||||
|
},
|
||||||
|
[batchTags],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagModalOpen,
|
||||||
|
setTagModalOpen,
|
||||||
|
batchTagInput,
|
||||||
|
setBatchTagInput,
|
||||||
|
batchTags,
|
||||||
|
setBatchTags,
|
||||||
|
tagMode,
|
||||||
|
setTagMode,
|
||||||
|
batchLoading,
|
||||||
|
handleBatchTag,
|
||||||
|
handleTagInputKeyDown,
|
||||||
|
removeBatchTag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 批量改分类 ── */
|
||||||
|
interface UseBatchClassifyOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchClassify = ({
|
||||||
|
selectedIds,
|
||||||
|
queryClient,
|
||||||
|
showResult,
|
||||||
|
}: UseBatchClassifyOptions) => {
|
||||||
|
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
|
||||||
|
const [batchCategory, setBatchCategory] = useState("")
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchClassify = useCallback(async () => {
|
||||||
|
if (!batchCategory) {
|
||||||
|
message.warning("请选择分类")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchClassifyAssets({
|
||||||
|
asset_ids: ids,
|
||||||
|
category: batchCategory,
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
showResult(result, "批量改分类")
|
||||||
|
setClassifyModalOpen(false)
|
||||||
|
setBatchCategory("")
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量改分类失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [batchCategory, selectedIds, queryClient, showResult])
|
||||||
|
|
||||||
|
return {
|
||||||
|
classifyModalOpen,
|
||||||
|
setClassifyModalOpen,
|
||||||
|
batchCategory,
|
||||||
|
setBatchCategory,
|
||||||
|
batchLoading,
|
||||||
|
handleBatchClassify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 批量智能标记 ── */
|
||||||
|
interface UseBatchMarkOptions {
|
||||||
|
selectedIds: Set<string>
|
||||||
|
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
|
||||||
|
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
|
||||||
|
const [markModalOpen, setMarkModalOpen] = useState(false)
|
||||||
|
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
|
||||||
|
const [batchLoading, setBatchLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleBatchMark = useCallback(async () => {
|
||||||
|
const ids = Array.from(selectedIds)
|
||||||
|
setBatchLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await batchMarkAssets({
|
||||||
|
asset_ids: ids,
|
||||||
|
smart_view: batchSmartView,
|
||||||
|
})
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["assets"] })
|
||||||
|
showResult(result, "批量智能标记")
|
||||||
|
setMarkModalOpen(false)
|
||||||
|
if (result.failure_count === 0) {
|
||||||
|
message.success(
|
||||||
|
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}」`,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
message.warning(
|
||||||
|
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error("批量智能标记失败,请重试")
|
||||||
|
} finally {
|
||||||
|
setBatchLoading(false)
|
||||||
|
}
|
||||||
|
}, [batchSmartView, selectedIds, queryClient, showResult])
|
||||||
|
|
||||||
|
return {
|
||||||
|
markModalOpen,
|
||||||
|
setMarkModalOpen,
|
||||||
|
batchSmartView,
|
||||||
|
setBatchSmartView,
|
||||||
|
batchLoading,
|
||||||
|
handleBatchMark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
useBatchTag,
|
useBatchTag,
|
||||||
useBatchClassify,
|
useBatchClassify,
|
||||||
useBatchMark,
|
useBatchMark,
|
||||||
} from "./asset-operations/batch-operations"
|
} from "./asset-operations/batchOperations"
|
||||||
import type { BatchOperationResult } from "@/api/assets"
|
import type { BatchOperationResult } from "@/api/assets"
|
||||||
import type { SmartViewType } from "../components/BatchMarkModal"
|
import type { SmartViewType } from "../components/BatchMarkModal"
|
||||||
|
|
||||||
|
|||||||
Executable → Regular
+184
-36
@@ -1,35 +1,116 @@
|
|||||||
/**
|
/**
|
||||||
* 查重上传页面 — V21 设计系统
|
* 查重上传页面 — V21 设计系统
|
||||||
* 左右分栏:拖拽上传区 + 格式说明
|
* 左右分栏:拖拽上传区 + 格式说明
|
||||||
|
* 零 antd 依赖
|
||||||
*/
|
*/
|
||||||
import React from "react"
|
import React, { useState, useRef, useCallback } from "react"
|
||||||
|
import { useMutation } from "@tanstack/react-query"
|
||||||
|
import { Button, Card, Tag } from "@/components/ui"
|
||||||
|
import { uploadForDuplication } from "@/api/duplication"
|
||||||
import { useNavigate } from "react-router-dom"
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Card } from "@/components/ui"
|
|
||||||
import PageHead from "@/components/layout/PageHead"
|
|
||||||
import UploadZone from "./duplication-upload/UploadZone"
|
|
||||||
import UploadActions from "./duplication-upload/UploadActions"
|
|
||||||
import UploadProgress from "./duplication-upload/UploadProgress"
|
|
||||||
import UploadResultPanel from "./duplication-upload/UploadResultPanel"
|
|
||||||
import InfoSidebar from "./duplication-upload/InfoSidebar"
|
|
||||||
import { useDuplicationUpload } from "./duplication-upload/useDuplicationUpload"
|
|
||||||
import { ACCEPT_FORMATS } from "./duplication-upload/constants"
|
|
||||||
import "./duplication.css"
|
import "./duplication.css"
|
||||||
|
import PageHead from "@/components/layout/PageHead"
|
||||||
|
|
||||||
|
/** 支持的视频格式 */
|
||||||
|
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||||
|
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||||
|
/** 最大文件大小:2GB */
|
||||||
|
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 简易 toast */
|
||||||
|
interface ToastState {
|
||||||
|
message: string
|
||||||
|
type: "success" | "error" | "warning"
|
||||||
|
}
|
||||||
|
|
||||||
const DuplicationUpload: React.FC = () => {
|
const DuplicationUpload: React.FC = () => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const {
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
fileInputRef,
|
const [dragging, setDragging] = useState(false)
|
||||||
dragging,
|
const [uploading, setUploading] = useState(false)
|
||||||
uploading,
|
const [uploadResult, setUploadResult] = useState<{
|
||||||
uploadResult,
|
id: string
|
||||||
toast,
|
message: string
|
||||||
handleDragOver,
|
} | null>(null)
|
||||||
handleDragLeave,
|
const [toast, setToast] = useState<ToastState | null>(null)
|
||||||
handleDrop,
|
|
||||||
handleSelectFile,
|
/** 显示 toast */
|
||||||
handleFileChange,
|
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||||
handleReset,
|
setToast({ message, type })
|
||||||
} = useDuplicationUpload()
|
setTimeout(() => setToast(null), 3000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 上传查重 mutation
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (file: File) => uploadForDuplication(file),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setUploading(false)
|
||||||
|
setUploadResult({ id: data.id, message: data.message })
|
||||||
|
showToast("查重任务已提交", "success")
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setUploading(false)
|
||||||
|
showToast("上传失败,请重试", "error")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 校验并上传文件 */
|
||||||
|
const handleFile = useCallback(
|
||||||
|
(file: File) => {
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
showToast("文件大小不能超过 2GB", "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const ext = file.name.toLowerCase().split(".").pop()
|
||||||
|
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||||
|
if (!allowedExts.includes(ext || "")) {
|
||||||
|
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setUploading(true)
|
||||||
|
setUploadResult(null)
|
||||||
|
uploadMutation.mutate(file)
|
||||||
|
},
|
||||||
|
[uploadMutation, showToast],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 拖拽事件 */
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback(() => {
|
||||||
|
setDragging(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setDragging(false)
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
},
|
||||||
|
[handleFile],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 点击选择文件 */
|
||||||
|
const handleSelectFile = () => {
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (file) handleFile(file)
|
||||||
|
// 重置 input 以便重复选择同一文件
|
||||||
|
e.target.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置状态 */
|
||||||
|
const handleReset = () => {
|
||||||
|
setUploadResult(null)
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dup-page">
|
<div className="dup-page">
|
||||||
@@ -44,14 +125,25 @@ const DuplicationUpload: React.FC = () => {
|
|||||||
<div className="dup-upload-grid">
|
<div className="dup-upload-grid">
|
||||||
{/* 左侧:上传区域 */}
|
{/* 左侧:上传区域 */}
|
||||||
<Card>
|
<Card>
|
||||||
<UploadZone
|
{/* 拖拽上传区 */}
|
||||||
dragging={dragging}
|
<div
|
||||||
uploading={uploading}
|
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={handleSelectFile}
|
onClick={uploading ? undefined : handleSelectFile}
|
||||||
/>
|
>
|
||||||
|
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||||
|
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||||
|
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||||
|
<div className="dup-upload-formats">
|
||||||
|
{FORMAT_LIST.map((fmt) => (
|
||||||
|
<Tag key={fmt} variant="info">
|
||||||
|
{fmt}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 隐藏的文件 input */}
|
{/* 隐藏的文件 input */}
|
||||||
<input
|
<input
|
||||||
@@ -62,21 +154,77 @@ const DuplicationUpload: React.FC = () => {
|
|||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UploadActions uploading={uploading} onSelectFile={handleSelectFile} />
|
{/* 上传按钮 */}
|
||||||
|
<div className="dup-upload-actions">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="md"
|
||||||
|
onClick={handleSelectFile}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
📂 选择文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{uploading && <UploadProgress />}
|
{/* 上传进度 */}
|
||||||
|
{uploading && (
|
||||||
|
<div className="dup-progress">
|
||||||
|
<div className="dup-progress-circle">
|
||||||
|
<span className="dup-progress-icon">⏳</span>
|
||||||
|
<span className="dup-progress-text">查重中...</span>
|
||||||
|
</div>
|
||||||
|
<p>正在分析视频内容,请稍候...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传结果 */}
|
||||||
{uploadResult && !uploading && (
|
{uploadResult && !uploading && (
|
||||||
<UploadResultPanel
|
<div className="dup-result">
|
||||||
result={uploadResult}
|
<div className="dup-result-icon">✅</div>
|
||||||
onViewResult={() => navigate("/app/duplication/results")}
|
<h3>查重任务已提交</h3>
|
||||||
onReset={handleReset}
|
<p>{uploadResult.message}</p>
|
||||||
/>
|
<div className="dup-result-actions">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
buttonSize="md"
|
||||||
|
onClick={() => navigate("/app/duplication/results")}
|
||||||
|
>
|
||||||
|
查看结果
|
||||||
|
</Button>
|
||||||
|
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
|
||||||
|
继续上传
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 右侧:格式说明 + 提示 */}
|
{/* 右侧:格式说明 + 提示 */}
|
||||||
<InfoSidebar />
|
<div className="dup-info-card">
|
||||||
|
<h3>📋 查重说明</h3>
|
||||||
|
<ul className="dup-info-list">
|
||||||
|
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
||||||
|
<li>查重完成后,可查看重复片段的具体位置</li>
|
||||||
|
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
||||||
|
<li>高相似度片段建议进行替换或裁剪</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
||||||
|
<div className="dup-format-tags">
|
||||||
|
{FORMAT_LIST.map((fmt) => (
|
||||||
|
<Tag key={fmt} variant="info">
|
||||||
|
{fmt}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
||||||
|
<ul className="dup-info-list">
|
||||||
|
<li>单个文件不超过 2GB</li>
|
||||||
|
<li>视频时长建议不超过 60 分钟</li>
|
||||||
|
<li>查重结果可在「查重记录」中随时查看</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Executable → Regular
+1
-7
@@ -51,13 +51,7 @@ export const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
|||||||
{ key: "high", label: "高风险" },
|
{ key: "high", label: "高风险" },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 支持的视频格式 */
|
/** Toast 类型 */
|
||||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
|
||||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
|
||||||
/** 最大文件大小:2GB */
|
|
||||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
|
||||||
|
|
||||||
/** 简易 toast */
|
|
||||||
export interface ToastState {
|
export interface ToastState {
|
||||||
message: string
|
message: string
|
||||||
type: "success" | "error" | "warning"
|
type: "success" | "error" | "warning"
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { Tag } from "@/components/ui"
|
|
||||||
import { FORMAT_LIST } from "./constants"
|
|
||||||
|
|
||||||
/** 右侧说明卡 */
|
|
||||||
const InfoSidebar: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="dup-info-card">
|
|
||||||
<h3>📋 查重说明</h3>
|
|
||||||
<ul className="dup-info-list">
|
|
||||||
<li>系统会对比您上传的视频与视频库中的已有视频</li>
|
|
||||||
<li>查重完成后,可查看重复片段的具体位置</li>
|
|
||||||
<li>查重过程通常需要几分钟,取决于视频大小</li>
|
|
||||||
<li>高相似度片段建议进行替换或裁剪</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h3 style={{ marginTop: 24 }}>🎬 支持格式</h3>
|
|
||||||
<div className="dup-format-tags">
|
|
||||||
{FORMAT_LIST.map((fmt) => (
|
|
||||||
<Tag key={fmt} variant="info">
|
|
||||||
{fmt}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 style={{ marginTop: 24 }}>💡 温馨提示</h3>
|
|
||||||
<ul className="dup-info-list">
|
|
||||||
<li>单个文件不超过 2GB</li>
|
|
||||||
<li>视频时长建议不超过 60 分钟</li>
|
|
||||||
<li>查重结果可在「查重记录」中随时查看</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default InfoSidebar
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { Button } from "@/components/ui"
|
|
||||||
|
|
||||||
interface UploadActionsProps {
|
|
||||||
uploading: boolean
|
|
||||||
onSelectFile: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上传按钮区 */
|
|
||||||
const UploadActions: React.FC<UploadActionsProps> = ({ uploading, onSelectFile }) => {
|
|
||||||
return (
|
|
||||||
<div className="dup-upload-actions">
|
|
||||||
<Button buttonType="primary" buttonSize="md" onClick={onSelectFile} disabled={uploading}>
|
|
||||||
📂 选择文件
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UploadActions
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
|
|
||||||
/** 上传进度展示 */
|
|
||||||
const UploadProgress: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="dup-progress">
|
|
||||||
<div className="dup-progress-circle">
|
|
||||||
<span className="dup-progress-icon">⏳</span>
|
|
||||||
<span className="dup-progress-text">查重中...</span>
|
|
||||||
</div>
|
|
||||||
<p>正在分析视频内容,请稍候...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UploadProgress
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { Button } from "@/components/ui"
|
|
||||||
import type { UploadResult } from "./constants"
|
|
||||||
|
|
||||||
interface UploadResultPanelProps {
|
|
||||||
result: UploadResult
|
|
||||||
onViewResult: () => void
|
|
||||||
onReset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上传结果展示 */
|
|
||||||
const UploadResultPanel: React.FC<UploadResultPanelProps> = ({ result, onViewResult, onReset }) => {
|
|
||||||
return (
|
|
||||||
<div className="dup-result">
|
|
||||||
<div className="dup-result-icon">✅</div>
|
|
||||||
<h3>查重任务已提交</h3>
|
|
||||||
<p>{result.message}</p>
|
|
||||||
<div className="dup-result-actions">
|
|
||||||
<Button buttonType="primary" buttonSize="md" onClick={onViewResult}>
|
|
||||||
查看结果
|
|
||||||
</Button>
|
|
||||||
<Button buttonType="secondary" buttonSize="md" onClick={onReset}>
|
|
||||||
继续上传
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UploadResultPanel
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { Tag } from "@/components/ui"
|
|
||||||
import { FORMAT_LIST } from "./constants"
|
|
||||||
|
|
||||||
interface UploadZoneProps {
|
|
||||||
dragging: boolean
|
|
||||||
uploading: boolean
|
|
||||||
onDragOver: (e: React.DragEvent) => void
|
|
||||||
onDragLeave: () => void
|
|
||||||
onDrop: (e: React.DragEvent) => void
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 拖拽上传区 */
|
|
||||||
const UploadZone: React.FC<UploadZoneProps> = ({
|
|
||||||
dragging,
|
|
||||||
uploading,
|
|
||||||
onDragOver,
|
|
||||||
onDragLeave,
|
|
||||||
onDrop,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`dup-upload-zone ${dragging ? "dragging" : ""} ${uploading ? "disabled" : ""}`}
|
|
||||||
onDragOver={onDragOver}
|
|
||||||
onDragLeave={onDragLeave}
|
|
||||||
onDrop={onDrop}
|
|
||||||
onClick={uploading ? undefined : onClick}
|
|
||||||
>
|
|
||||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
|
||||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
|
||||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
|
||||||
<div className="dup-upload-formats">
|
|
||||||
{FORMAT_LIST.map((fmt) => (
|
|
||||||
<Tag key={fmt} variant="info">
|
|
||||||
{fmt}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default UploadZone
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
/** 支持的视频格式 */
|
|
||||||
export const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
|
||||||
export const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
|
||||||
/** 最大文件大小:2GB */
|
|
||||||
export const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
|
||||||
|
|
||||||
/** 简易 toast */
|
|
||||||
export interface ToastState {
|
|
||||||
message: string
|
|
||||||
type: "success" | "error" | "warning"
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 上传结果 */
|
|
||||||
export interface UploadResult {
|
|
||||||
id: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { useState, useRef, useCallback } from "react"
|
|
||||||
import { useMutation } from "@tanstack/react-query"
|
|
||||||
import { uploadForDuplication } from "@/api/duplication"
|
|
||||||
import { ACCEPT_FORMATS, FORMAT_LIST, MAX_FILE_SIZE } from "./constants"
|
|
||||||
import type { ToastState, UploadResult } from "./constants"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 查重上传逻辑 Hook
|
|
||||||
* 封装文件校验、上传 mutation、toast 提示
|
|
||||||
*/
|
|
||||||
export function useDuplicationUpload() {
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const [dragging, setDragging] = useState(false)
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null)
|
|
||||||
const [toast, setToast] = useState<ToastState | null>(null)
|
|
||||||
|
|
||||||
/** 显示 toast */
|
|
||||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
|
||||||
setToast({ message, type })
|
|
||||||
setTimeout(() => setToast(null), 3000)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 上传查重 mutation
|
|
||||||
const uploadMutation = useMutation({
|
|
||||||
mutationFn: (file: File) => uploadForDuplication(file),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setUploading(false)
|
|
||||||
setUploadResult({ id: data.id, message: data.message })
|
|
||||||
showToast("查重任务已提交", "success")
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
setUploading(false)
|
|
||||||
showToast("上传失败,请重试", "error")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
/** 校验并上传文件 */
|
|
||||||
const handleFile = useCallback(
|
|
||||||
(file: File) => {
|
|
||||||
if (file.size > MAX_FILE_SIZE) {
|
|
||||||
showToast("文件大小不能超过 2GB", "error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const ext = file.name.toLowerCase().split(".").pop()
|
|
||||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
|
||||||
if (!allowedExts.includes(ext || "")) {
|
|
||||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setUploading(true)
|
|
||||||
setUploadResult(null)
|
|
||||||
uploadMutation.mutate(file)
|
|
||||||
},
|
|
||||||
[uploadMutation, showToast],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 拖拽事件 */
|
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragging(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDragLeave = useCallback(() => {
|
|
||||||
setDragging(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragging(false)
|
|
||||||
const file = e.dataTransfer.files[0]
|
|
||||||
if (file) handleFile(file)
|
|
||||||
},
|
|
||||||
[handleFile],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 点击选择文件 */
|
|
||||||
const handleSelectFile = useCallback(() => {
|
|
||||||
fileInputRef.current?.click()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) handleFile(file)
|
|
||||||
// 重置 input 以便重复选择同一文件
|
|
||||||
e.target.value = ""
|
|
||||||
},
|
|
||||||
[handleFile],
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 重置状态 */
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
setUploadResult(null)
|
|
||||||
setUploading(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return {
|
|
||||||
// refs
|
|
||||||
fileInputRef,
|
|
||||||
// 状态
|
|
||||||
dragging,
|
|
||||||
uploading,
|
|
||||||
uploadResult,
|
|
||||||
toast,
|
|
||||||
// 事件
|
|
||||||
handleDragOver,
|
|
||||||
handleDragLeave,
|
|
||||||
handleDrop,
|
|
||||||
handleSelectFile,
|
|
||||||
handleFileChange,
|
|
||||||
handleReset,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3531,143 +3531,52 @@
|
|||||||
color: var(--text-tertiary, #9ca3af);
|
color: var(--text-tertiary, #9ca3af);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tts-voice-tabs {
|
.tts-voice-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
gap: 4px;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
background: var(--bg-secondary, #f3f4f6);
|
gap: 8px;
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
padding: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tts-voice-tab {
|
.tts-voice-card {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary, #6b7280);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-tab:hover {
|
|
||||||
color: var(--text-primary, #1f2937);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-tab.active {
|
|
||||||
background: var(--bg-surface, #fff);
|
|
||||||
color: var(--primary-600, #2563eb);
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-tab-count {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
background: var(--primary-100, #dbeafe);
|
|
||||||
color: var(--primary-600, #2563eb);
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-list {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 4px;
|
||||||
padding: 8px 10px;
|
padding: 10px 6px;
|
||||||
border: 1px solid var(--border-primary, #e5e7eb);
|
border: 1px solid var(--border-primary, #e5e7eb);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--radius-md, 8px);
|
||||||
background: var(--bg-surface, #fff);
|
background: var(--bg-surface, #fff);
|
||||||
|
color: var(--text-secondary, #6b7280);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item:hover {
|
|
||||||
border-color: var(--primary-300, #93c5fd);
|
|
||||||
background: var(--primary-50, #eff6ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item.selected {
|
|
||||||
border-color: var(--primary-500, #3b82f6);
|
|
||||||
background: var(--primary-50, #eff6ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--bg-secondary, #f3f4f6);
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-primary, #1f2937);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item-desc {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-tertiary, #9ca3af);
|
|
||||||
margin-top: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item-play {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: none;
|
|
||||||
background: var(--primary-500, #3b82f6);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-item-play:hover {
|
|
||||||
background: var(--primary-600, #2563eb);
|
|
||||||
transform: scale(1.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tts-voice-empty {
|
|
||||||
padding: 20px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-tertiary, #9ca3af);
|
}
|
||||||
background: var(--bg-secondary, #f3f4f6);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
.tts-voice-card:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary-300, #93c5fd);
|
||||||
|
color: var(--primary-600, #2563eb);
|
||||||
|
background: var(--primary-50, #eff6ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-card.active {
|
||||||
|
border-color: var(--primary-500, #3b82f6);
|
||||||
|
color: var(--primary-600, #2563eb);
|
||||||
|
background: var(--primary-50, #eff6ff);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-card:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-card-icon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-voice-card-name {
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 滑块区域 ── */
|
/* ── 滑块区域 ── */
|
||||||
|
|||||||
Executable → Regular
+130
-21
@@ -1,11 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 绿幕抠像配置面板
|
||||||
|
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
||||||
|
*/
|
||||||
import React, { useCallback } from "react"
|
import React, { useCallback } from "react"
|
||||||
import { Drawer, Switch } from "antd"
|
import { Drawer, Switch } from "antd"
|
||||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
||||||
import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_COLORS } from "../types"
|
import {
|
||||||
import { GreenScreenPresets } from "./green-screen/GreenScreenPresets"
|
DEFAULT_CHROMA_KEY_CONFIG,
|
||||||
import { GreenScreenCustomColor } from "./green-screen/GreenScreenCustomColor"
|
CHROMA_KEY_PRESET_LABELS,
|
||||||
import { GreenScreenSliders } from "./green-screen/GreenScreenSliders"
|
CHROMA_KEY_PRESET_COLORS,
|
||||||
import { GreenScreenPreview } from "./green-screen/GreenScreenPreview"
|
} from "../types"
|
||||||
|
|
||||||
interface GreenScreenPanelProps {
|
interface GreenScreenPanelProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -14,6 +18,9 @@ interface GreenScreenPanelProps {
|
|||||||
onChange: (config: ChromaKeyConfig) => void
|
onChange: (config: ChromaKeyConfig) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 预设列表 */
|
||||||
|
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||||
|
|
||||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||||
const update = useCallback(
|
const update = useCallback(
|
||||||
(partial: Partial<ChromaKeyConfig>) => {
|
(partial: Partial<ChromaKeyConfig>) => {
|
||||||
@@ -26,6 +33,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
||||||
}, [config.enabled, onChange])
|
}, [config.enabled, onChange])
|
||||||
|
|
||||||
|
/** 选择颜色预设时同步更新 color 字段 */
|
||||||
const handlePresetSelect = useCallback(
|
const handlePresetSelect = useCallback(
|
||||||
(preset: ChromaKeyColorPreset) => {
|
(preset: ChromaKeyColorPreset) => {
|
||||||
update({
|
update({
|
||||||
@@ -36,9 +44,10 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
[update],
|
[update],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 自定义颜色变化时清除预设标记 */
|
||||||
const handleColorChange = useCallback(
|
const handleColorChange = useCallback(
|
||||||
(color: string) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
update({ color })
|
update({ color: e.target.value })
|
||||||
},
|
},
|
||||||
[update],
|
[update],
|
||||||
)
|
)
|
||||||
@@ -52,6 +61,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="green-screen-panel-drawer"
|
className="green-screen-panel-drawer"
|
||||||
>
|
>
|
||||||
|
{/* 顶部开关 */}
|
||||||
<div className="green-header">
|
<div className="green-header">
|
||||||
<span className="green-header-label">启用绿幕抠像</span>
|
<span className="green-header-label">启用绿幕抠像</span>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -61,24 +71,123 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, conf
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GreenScreenPresets
|
{/* 颜色预设 */}
|
||||||
selectedPreset={config.color_preset}
|
<div className="green-section">
|
||||||
onPresetSelect={handlePresetSelect}
|
<div className="green-section-title">颜色预设</div>
|
||||||
/>
|
<div className="green-presets">
|
||||||
|
{PRESET_LIST.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
className={`green-preset-btn${config.color_preset === p ? " active" : ""}`}
|
||||||
|
onClick={() => handlePresetSelect(p)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="green-preset-dot"
|
||||||
|
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||||
|
/>
|
||||||
|
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<GreenScreenCustomColor color={config.color} onColorChange={handleColorChange} />
|
{/* 自定义颜色 */}
|
||||||
|
<div className="green-section">
|
||||||
|
<div className="green-section-title">自定义颜色</div>
|
||||||
|
<div className="green-color-row">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
className="green-color-picker"
|
||||||
|
value={config.color}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="green-color-hex"
|
||||||
|
value={config.color}
|
||||||
|
onChange={handleColorChange}
|
||||||
|
placeholder="#00FF00"
|
||||||
|
/>
|
||||||
|
<div className="green-color-swatch" style={{ background: config.color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<GreenScreenSliders
|
{/* 参数调节 */}
|
||||||
similarity={config.similarity}
|
<div className="green-section">
|
||||||
blend={config.blend}
|
<div className="green-section-title">参数调节</div>
|
||||||
spill={config.spill}
|
|
||||||
onSimilarityChange={(v) => update({ similarity: v })}
|
|
||||||
onBlendChange={(v) => update({ blend: v })}
|
|
||||||
onSpillChange={(v) => update({ spill: v })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GreenScreenPreview color={config.color} blend={config.blend} />
|
{/* 相似度 */}
|
||||||
|
<div className="green-slider-row">
|
||||||
|
<div className="green-slider-header">
|
||||||
|
<span className="green-slider-label">相似度</span>
|
||||||
|
<span className="green-slider-value">{config.similarity}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="green-slider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={config.similarity}
|
||||||
|
onChange={(e) => update({ similarity: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<div className="green-slider-desc">越大容忍的色差范围越广</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 边缘平滑 */}
|
||||||
|
<div className="green-slider-row">
|
||||||
|
<div className="green-slider-header">
|
||||||
|
<span className="green-slider-label">边缘平滑</span>
|
||||||
|
<span className="green-slider-value">{config.blend}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="green-slider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={config.blend}
|
||||||
|
onChange={(e) => update({ blend: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<div className="green-slider-desc">越大边缘越柔和自然</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 溢色抑制 */}
|
||||||
|
<div className="green-slider-row">
|
||||||
|
<div className="green-slider-header">
|
||||||
|
<span className="green-slider-label">溢色抑制</span>
|
||||||
|
<span className="green-slider-value">{config.spill}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="green-slider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={config.spill}
|
||||||
|
onChange={(e) => update({ spill: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<div className="green-slider-desc">去除边缘颜色溢出</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 预览 */}
|
||||||
|
<div className="green-section">
|
||||||
|
<div className="green-section-title">效果预览</div>
|
||||||
|
<div className="green-preview-box">
|
||||||
|
<div className="green-preview-bg" style={{ background: config.color, opacity: 0.3 }} />
|
||||||
|
<div className="green-preview-subject">
|
||||||
|
<div className="green-preview-circle" />
|
||||||
|
<div className="green-preview-text">主体</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="green-preview-edge"
|
||||||
|
style={{
|
||||||
|
borderColor: config.color,
|
||||||
|
filter: `blur(${config.blend / 10}px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部重置 */}
|
||||||
<div className="green-footer">
|
<div className="green-footer">
|
||||||
<button className="green-reset-btn" onClick={handleReset}>
|
<button className="green-reset-btn" onClick={handleReset}>
|
||||||
重置参数
|
重置参数
|
||||||
|
|||||||
+17
-31
@@ -4,14 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { Drawer, Switch } from "antd"
|
import { Drawer, Switch } from "antd"
|
||||||
import type { PipConfig } from "../../types"
|
import type { PipConfig } from "@/pages/editing-planner/types"
|
||||||
|
import LayerList from "./pip-config/LayerList"
|
||||||
|
import LayerConfig from "./pip-config/LayerConfig"
|
||||||
|
import { usePipLayers } from "@/pages/editing-planner/hooks/usePipLayers"
|
||||||
|
|
||||||
import { usePipConfigPanel } from "./hooks/usePipConfigPanel"
|
interface PipConfigPanelProps {
|
||||||
import { LayerList } from "./components/LayerList"
|
|
||||||
import { PipPreview } from "./components/PipPreview"
|
|
||||||
import { LayerConfig } from "./components/LayerConfig"
|
|
||||||
|
|
||||||
export interface PipConfigPanelProps {
|
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
config: PipConfig
|
config: PipConfig
|
||||||
@@ -38,7 +36,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
|||||||
handleGridClick,
|
handleGridClick,
|
||||||
handleWidthChange,
|
handleWidthChange,
|
||||||
handleHeightChange,
|
handleHeightChange,
|
||||||
} = usePipConfigPanel({ config, onChange })
|
} = usePipLayers({ config, onChange })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -52,9 +50,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
|||||||
{/* ═══ 顶部工具栏 ═══ */}
|
{/* ═══ 顶部工具栏 ═══ */}
|
||||||
<div className="pip-toolbar">
|
<div className="pip-toolbar">
|
||||||
<div className="pip-toolbar-left">
|
<div className="pip-toolbar-left">
|
||||||
<button className="pip-add-btn" onClick={handleAddLayer}>
|
<span style={{ fontSize: 13, color: "#666" }}>共 {config.layers.length} 个图层</span>
|
||||||
+ 添加图层
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="pip-enable-switch">
|
<div className="pip-enable-switch">
|
||||||
<span>启用</span>
|
<span>启用</span>
|
||||||
@@ -64,32 +60,22 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
|||||||
|
|
||||||
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
{/* ═══ 主体:图层列表 + 配置区 ═══ */}
|
||||||
<div className="pip-body">
|
<div className="pip-body">
|
||||||
{/* 左侧图层列表 */}
|
|
||||||
<LayerList
|
<LayerList
|
||||||
layers={config.layers}
|
layers={config.layers}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={setSelectedId}
|
||||||
|
onAdd={handleAddLayer}
|
||||||
onDelete={handleDeleteLayer}
|
onDelete={handleDeleteLayer}
|
||||||
/>
|
/>
|
||||||
|
<LayerConfig
|
||||||
{/* 右侧配置区 */}
|
layer={selectedLayer}
|
||||||
{!selectedLayer ? (
|
layers={config.layers}
|
||||||
<div className="pip-config-area">
|
totalDuration={totalDuration}
|
||||||
<div className="pip-config-empty">选择或添加图层以配置</div>
|
onUpdate={updateLayer}
|
||||||
</div>
|
onGridClick={handleGridClick}
|
||||||
) : (
|
onWidthChange={handleWidthChange}
|
||||||
<>
|
onHeightChange={handleHeightChange}
|
||||||
<PipPreview layers={config.layers} selectedId={selectedId} />
|
/>
|
||||||
<LayerConfig
|
|
||||||
layer={selectedLayer}
|
|
||||||
totalDuration={totalDuration}
|
|
||||||
onUpdate={updateLayer}
|
|
||||||
onGridClick={handleGridClick}
|
|
||||||
onWidthChange={handleWidthChange}
|
|
||||||
onHeightChange={handleHeightChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 底部操作 ── */}
|
{/* ── 底部操作 ── */}
|
||||||
-268
@@ -1,268 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import type { PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../../../types"
|
|
||||||
import { GRID_POSITIONS, ANIM_OPTIONS, SLIDE_DIR_OPTIONS } from "../constants"
|
|
||||||
|
|
||||||
export interface LayerConfigProps {
|
|
||||||
layer: PipLayer
|
|
||||||
totalDuration: number
|
|
||||||
onUpdate: (id: string, partial: Partial<PipLayer>) => void
|
|
||||||
onGridClick: (pos: PipGridPosition) => void
|
|
||||||
onWidthChange: (val: number) => void
|
|
||||||
onHeightChange: (val: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LayerConfig: React.FC<LayerConfigProps> = ({
|
|
||||||
layer,
|
|
||||||
totalDuration,
|
|
||||||
onUpdate,
|
|
||||||
onGridClick,
|
|
||||||
onWidthChange,
|
|
||||||
onHeightChange,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="pip-config-area">
|
|
||||||
{/* ── 素材类型 ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">素材类型</label>
|
|
||||||
<div className="pip-type-btns">
|
|
||||||
<button
|
|
||||||
className={`pip-type-btn${layer.material_type === "image" ? " active" : ""}`}
|
|
||||||
onClick={() => onUpdate(layer.id, { material_type: "image" })}
|
|
||||||
>
|
|
||||||
🖼️ 图片
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`pip-type-btn${layer.material_type === "video" ? " active" : ""}`}
|
|
||||||
onClick={() => onUpdate(layer.id, { material_type: "video" })}
|
|
||||||
>
|
|
||||||
🎬 视频
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 素材 URL ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">
|
|
||||||
{layer.material_type === "image" ? "图片" : "视频"} URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="pip-input"
|
|
||||||
type="text"
|
|
||||||
placeholder={
|
|
||||||
layer.material_type === "image"
|
|
||||||
? "https://example.com/image.png"
|
|
||||||
: "https://example.com/video.mp4"
|
|
||||||
}
|
|
||||||
value={layer.material_url}
|
|
||||||
onChange={(e) => onUpdate(layer.id, { material_url: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">位置</label>
|
|
||||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
|
||||||
<div className="pip-grid">
|
|
||||||
{GRID_POSITIONS.map((pos) => (
|
|
||||||
<button
|
|
||||||
key={pos}
|
|
||||||
className={`pip-grid-btn${layer.grid_position === pos ? " active" : ""}`}
|
|
||||||
onClick={() => onGridClick(pos)}
|
|
||||||
>
|
|
||||||
<span className="pip-grid-dot" />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="pip-field-row" style={{ flex: 1 }}>
|
|
||||||
<div>
|
|
||||||
<label className="pip-field-label">X (%)</label>
|
|
||||||
<input
|
|
||||||
className="pip-number"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={layer.x}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { x: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="pip-field-label">Y (%)</label>
|
|
||||||
<input
|
|
||||||
className="pip-number"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={layer.y}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val) && val >= 0 && val <= 100) onUpdate(layer.id, { y: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 尺寸 ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">尺寸</label>
|
|
||||||
<div className="pip-slider-row">
|
|
||||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
|
||||||
<input
|
|
||||||
className="pip-slider"
|
|
||||||
type="range"
|
|
||||||
min={10}
|
|
||||||
max={80}
|
|
||||||
value={layer.width}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val)) onWidthChange(val)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="pip-slider-value">{layer.width}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
|
||||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
|
||||||
<input
|
|
||||||
className="pip-slider"
|
|
||||||
type="range"
|
|
||||||
min={10}
|
|
||||||
max={80}
|
|
||||||
value={layer.height}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val)) onHeightChange(val)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="pip-slider-value">{layer.height}%</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="pip-lock-row"
|
|
||||||
style={{ marginTop: 6 }}
|
|
||||||
onClick={() => onUpdate(layer.id, { aspect_lock: !layer.aspect_lock })}
|
|
||||||
>
|
|
||||||
<span className="pip-lock-icon">{layer.aspect_lock ? "🔒" : "🔓"}</span>
|
|
||||||
<span>{layer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 圆角 ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">圆角</label>
|
|
||||||
<div className="pip-slider-row">
|
|
||||||
<input
|
|
||||||
className="pip-slider"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={50}
|
|
||||||
value={layer.border_radius}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val)) onUpdate(layer.id, { border_radius: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="pip-slider-value">{layer.border_radius}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 透明度 ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">透明度</label>
|
|
||||||
<div className="pip-slider-row">
|
|
||||||
<input
|
|
||||||
className="pip-slider"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={layer.opacity}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val)) onUpdate(layer.id, { opacity: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="pip-slider-value">{layer.opacity}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 时间 ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">时间</label>
|
|
||||||
<div className="pip-field-row">
|
|
||||||
<div>
|
|
||||||
<label className="pip-field-label">开始 (s)</label>
|
|
||||||
<input
|
|
||||||
className="pip-number"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={totalDuration || 999}
|
|
||||||
step={0.1}
|
|
||||||
value={layer.start_time}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val) && val >= 0 && val <= totalDuration)
|
|
||||||
onUpdate(layer.id, { start_time: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="pip-field-label">持续 (s)</label>
|
|
||||||
<input
|
|
||||||
className="pip-number"
|
|
||||||
type="number"
|
|
||||||
min={0.1}
|
|
||||||
max={totalDuration || 999}
|
|
||||||
step={0.1}
|
|
||||||
value={layer.duration}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = Number(e.target.value)
|
|
||||||
if (!isNaN(val) && val > 0 && val <= totalDuration)
|
|
||||||
onUpdate(layer.id, { duration: val })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── 入场动画 ── */}
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">入场动画</label>
|
|
||||||
<select
|
|
||||||
className="pip-select"
|
|
||||||
value={layer.animation}
|
|
||||||
onChange={(e) => onUpdate(layer.id, { animation: e.target.value as PipAnimType })}
|
|
||||||
>
|
|
||||||
{ANIM_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 滑入方向(仅 slide_in 时显示) */}
|
|
||||||
{layer.animation === "slide_in" && (
|
|
||||||
<div className="pip-field">
|
|
||||||
<label className="pip-field-label">滑入方向</label>
|
|
||||||
<select
|
|
||||||
className="pip-select"
|
|
||||||
value={layer.slide_direction}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdate(layer.id, {
|
|
||||||
slide_direction: e.target.value as PipSlideDirection,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{SLIDE_DIR_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import type { PipLayer } from "../../../types"
|
|
||||||
import { LAYER_COLORS } from "../constants"
|
|
||||||
|
|
||||||
export interface LayerListProps {
|
|
||||||
layers: PipLayer[]
|
|
||||||
selectedId: string
|
|
||||||
onSelect: (id: string) => void
|
|
||||||
onDelete: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LayerList: React.FC<LayerListProps> = ({ layers, selectedId, onSelect, onDelete }) => {
|
|
||||||
if (layers.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="pip-layer-list">
|
|
||||||
<div className="pip-layer-empty">暂无图层,点击上方添加</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pip-layer-list">
|
|
||||||
{layers.map((layer, idx) => (
|
|
||||||
<div
|
|
||||||
key={layer.id}
|
|
||||||
className={`pip-layer-item${selectedId === layer.id ? " active" : ""}`}
|
|
||||||
onClick={() => onSelect(layer.id)}
|
|
||||||
>
|
|
||||||
{layer.thumbnail_url || layer.material_url ? (
|
|
||||||
<img
|
|
||||||
className="pip-layer-thumb"
|
|
||||||
src={layer.thumbnail_url || layer.material_url || undefined}
|
|
||||||
alt={layer.name}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="pip-layer-thumb"
|
|
||||||
style={{
|
|
||||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="pip-layer-name">{layer.name}</span>
|
|
||||||
<button
|
|
||||||
className="pip-layer-delete"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onDelete(layer.id)
|
|
||||||
}}
|
|
||||||
title="删除图层"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import type { PipLayer } from "../../../types"
|
|
||||||
import { LAYER_COLORS } from "../constants"
|
|
||||||
|
|
||||||
export interface PipPreviewProps {
|
|
||||||
layers: PipLayer[]
|
|
||||||
selectedId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PipPreview: React.FC<PipPreviewProps> = ({ layers, selectedId }) => {
|
|
||||||
return (
|
|
||||||
<div className="pip-preview-box">
|
|
||||||
{layers.map((layer, idx) => (
|
|
||||||
<div
|
|
||||||
key={layer.id}
|
|
||||||
className={`pip-preview-layer${selectedId === layer.id ? " selected" : ""}`}
|
|
||||||
style={{
|
|
||||||
left: `${layer.x}%`,
|
|
||||||
top: `${layer.y}%`,
|
|
||||||
width: `${layer.width}%`,
|
|
||||||
height: `${layer.height}%`,
|
|
||||||
background: LAYER_COLORS[idx % LAYER_COLORS.length],
|
|
||||||
opacity: layer.opacity / 100,
|
|
||||||
borderRadius: `${layer.border_radius}%`,
|
|
||||||
zIndex: layer.z_index,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="pip-preview-label">{layer.name}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import type { PipGridPosition, PipAnimType, PipSlideDirection } from "../../types"
|
|
||||||
|
|
||||||
/** 九宫格位置 → 百分比坐标映射 */
|
|
||||||
export const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
|
||||||
top_left: { x: 5, y: 5 },
|
|
||||||
top_center: { x: 37.5, y: 5 },
|
|
||||||
top_right: { x: 70, y: 5 },
|
|
||||||
center_left: { x: 5, y: 37.5 },
|
|
||||||
center: { x: 37.5, y: 37.5 },
|
|
||||||
center_right: { x: 70, y: 37.5 },
|
|
||||||
bottom_left: { x: 5, y: 70 },
|
|
||||||
bottom_center: { x: 37.5, y: 70 },
|
|
||||||
bottom_right: { x: 70, y: 70 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 九宫格位置选项 */
|
|
||||||
export const GRID_POSITIONS: PipGridPosition[] = [
|
|
||||||
"top_left",
|
|
||||||
"top_center",
|
|
||||||
"top_right",
|
|
||||||
"center_left",
|
|
||||||
"center",
|
|
||||||
"center_right",
|
|
||||||
"bottom_left",
|
|
||||||
"bottom_center",
|
|
||||||
"bottom_right",
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 入场动画选项 */
|
|
||||||
export const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
|
||||||
{ value: "none", label: "无" },
|
|
||||||
{ value: "fade_in", label: "淡入" },
|
|
||||||
{ value: "slide_in", label: "滑入" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 滑入方向选项 */
|
|
||||||
export const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
|
||||||
{ value: "left", label: "← 左" },
|
|
||||||
{ value: "right", label: "→ 右" },
|
|
||||||
{ value: "up", label: "↑ 上" },
|
|
||||||
{ value: "down", label: "↓ 下" },
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 预览图层颜色池 */
|
|
||||||
export const LAYER_COLORS = [
|
|
||||||
"rgba(22,119,255,0.5)",
|
|
||||||
"rgba(82,196,26,0.5)",
|
|
||||||
"rgba(250,173,20,0.5)",
|
|
||||||
"rgba(255,77,79,0.5)",
|
|
||||||
"rgba(114,46,209,0.5)",
|
|
||||||
"rgba(19,194,194,0.5)",
|
|
||||||
]
|
|
||||||
|
|
||||||
let layerIdCounter = 0
|
|
||||||
export const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user